File: /home/xedaptot/ai.naniguide.com/app/Model/AutomationEmail.php
<?php
namespace App\Model;
use App\Library\Cache\AppCache;
use App\Library\Cache\Cacheable;
use App\Library\Traits\HasModelCacheIdentity;
use DB;
use Illuminate\Database\Eloquent\Model;
use Validator;
use ZipArchive;
use KubAT\PhpSimple\HtmlDomParser;
use App\Model\Setting;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use App\Jobs\SendSingleMessage;
use App\Library\Traits\HasEmailTemplate;
use App\Library\Traits\HasUid;
use App\Library\Contracts\TagResolverInterface;
use App\Library\HtmlHandler\InjectTrackingPixel;
use App\Library\HtmlHandler\TransformUrl;
use App\Library\RouletteWheel;
use Closure;
use Exception;
/**
* AutomationEmail — an email step within an Automation2 workflow.
*
* Stores automation-specific context (automation2_id, action_id) and delegates
* all content fields (subject, from, html, template, tracking settings) to its `email()` relation.
*/
class AutomationEmail extends Model implements TagResolverInterface, Cacheable
{
use HasEmailTemplate;
use HasModelCacheIdentity;
use HasUid;
protected $table = 'automation_emails';
protected $fillable = [
'action_id', 'email_id',
// EMAIL_ATTRIBUTES are kept fillable so fill() passes them through to
// setAttribute(), which delegates them to the Email model.
'type', 'subject', 'from_email', 'from_name', 'reply_to',
'html', 'plain', 'sign_dkim', 'track_open', 'track_click',
'use_default_sending_server_from_email', 'skip_failed_message',
'preheader', 'delivery_statuses', 'template_id',
'tracking_domain_id', 'signature_id',
];
// Content fields delegated to email() relation when email_id is set.
protected const EMAIL_ATTRIBUTES = [
'type',
'subject', 'from_email', 'from_name', 'reply_to',
'html', 'plain', 'sign_dkim', 'track_open', 'track_click',
'use_default_sending_server_from_email', 'skip_failed_message',
'preheader', 'delivery_statuses', 'template_id',
'tracking_domain_id', 'signature_id',
];
// Cached HTML content
protected $parsedContent = null;
protected $serversPool = null;
public function getAttribute($key)
{
if (in_array($key, static::EMAIL_ATTRIBUTES) && !is_null($this->email_id)) {
return optional($this->getRelationValue('email') ?? $this->email)->getAttribute($key);
}
return parent::getAttribute($key);
}
public function setAttribute($key, $value)
{
if (in_array($key, static::EMAIL_ATTRIBUTES) && !is_null($this->email_id)) {
$email = $this->getRelationValue('email') ?? $this->email;
if ($email) {
$email->setAttribute($key, $value);
return $this;
}
}
return parent::setAttribute($key, $value);
}
// -------------------------------------------------------------------------
// Relations
// -------------------------------------------------------------------------
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<Email, $this>
*/
public function email(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Email::class);
}
public function getResolvedTags(): array
{
return [
'automation_email' => [
'uid' => $this->uid,
'from_email' => $this->from_email,
'from_name' => $this->from_name,
'subject' => $this->subject,
'reply_to' => $this->reply_to,
],
];
}
public function save(array $options = []): bool
{
$pending = array_intersect_key($this->attributes, array_flip(static::EMAIL_ATTRIBUTES));
if (!empty($pending)) {
if (is_null($this->attributes['email_id'] ?? null)) {
$email = Email::newDefault();
$email->customer_id = $this->attributes['customer_id'] ?? null;
foreach ($pending as $key => $value) {
$email->setAttribute($key, $value);
}
$email->save();
$this->attributes['email_id'] = $email->id;
$this->setRelation('email', $email);
} else {
$email = $this->getRelationValue('email') ?? $this->email;
foreach ($pending as $key => $value) {
$email->setAttribute($key, $value);
}
}
foreach (array_keys($pending) as $key) {
unset($this->attributes[$key]);
}
}
$result = parent::save($options);
if ($this->relationLoaded('email') && $this->email && $this->email->isDirty()) {
$this->email->save();
}
return $result;
}
public function automation()
{
return $this->belongsTo('App\Model\Automation2', 'automation2_id');
}
public function customer()
{
return $this->belongsTo('App\Model\Customer');
}
public function emailLinks()
{
return $this->hasMany('App\Model\EmailLink');
}
public function trackingLogs()
{
return $this->hasMany('App\Model\TrackingLog');
}
public function bounceLogs()
{
return BounceLog::select('bounce_logs.*')
->leftJoin('tracking_logs', 'tracking_logs.id', '=', 'bounce_logs.tracking_log_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
}
public function openLogs()
{
return OpenLog::select('open_logs.*')
->leftJoin('tracking_logs', 'tracking_logs.message_id', '=', 'open_logs.message_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
}
public function clickLogs()
{
return ClickLog::select('click_logs.*')
->leftJoin('tracking_logs', 'tracking_logs.message_id', '=', 'click_logs.message_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
}
public function feedbackLogs()
{
return FeedbackLog::select('feedback_logs.*')
->leftJoin('tracking_logs', 'tracking_logs.id', '=', 'feedback_logs.tracking_log_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
}
public function unsubscribeLogs()
{
return UnsubscribeLog::select('unsubscribe_logs.*')
->leftJoin('tracking_logs', 'tracking_logs.message_id', '=', 'unsubscribe_logs.message_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
}
public function openLocations()
{
return IpLocation::select(
'ip_locations.*',
'open_logs.created_at as open_at',
'subscribers.id as subscriber_id',
'subscribers.uid as subscriber_uid',
'subscribers.email as email',
'mail_lists.uid as list_uid'
)
->leftJoin('open_logs', 'open_logs.ip_address', '=', 'ip_locations.ip_address')
->leftJoin('tracking_logs', 'open_logs.message_id', '=', 'tracking_logs.message_id')
->leftJoin('subscribers', 'subscribers.id', '=', 'tracking_logs.subscriber_id')
->leftJoin('mail_lists', 'mail_lists.id', '=', 'subscribers.mail_list_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
}
public function clickLocations()
{
return IpLocation::select(
'ip_locations.*',
'click_logs.created_at as click_at',
'click_logs.url as url',
'subscribers.id as subscriber_id',
'subscribers.uid as subscriber_uid',
'subscribers.email as email',
'mail_lists.uid as list_uid'
)
->leftJoin('click_logs', 'click_logs.ip_address', '=', 'ip_locations.ip_address')
->leftJoin('tracking_logs', 'click_logs.message_id', '=', 'tracking_logs.message_id')
->leftJoin('subscribers', 'subscribers.id', '=', 'tracking_logs.subscriber_id')
->leftJoin('mail_lists', 'mail_lists.id', '=', 'subscribers.mail_list_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
}
public function emailWebhooks()
{
return $this->hasMany('App\Model\EmailWebhook');
}
public function trackingDomain()
{
return $this->belongsTo('App\Model\TrackingDomain', 'tracking_domain_id');
}
public function signature()
{
return $this->belongsTo('App\Model\Signature', 'signature_id');
}
public function attachments()
{
return $this->hasMany('App\Model\Attachment');
}
// -------------------------------------------------------------------------
// Reporting / cache helpers
// -------------------------------------------------------------------------
protected function reportCustomer()
{
return $this->getRelationValue('customer')
?? $this->customer
?? optional($this->getRelationValue('automation') ?? $this->automation)->customer;
}
protected function reportCustomerId(): ?int
{
return $this->reportCustomer()?->id;
}
protected function reportTimezone(): string
{
return $this->reportCustomer()?->getTimezone() ?? config('app.timezone');
}
public function cacheManifest(): array
{
return [
'ActiveSubscriberCount' => fn () => $this->activeSubscribersCount(),
'SubscriberCount' => fn () => $this->subscribersCount(),
'DeliveredRate' => fn () => $this->deliveredRate(),
'DeliveredCount' => fn () => $this->deliveredCount(),
'FailedDeliveredRate' => fn () => $this->failedRate(),
'FailedDeliveredCount' => fn () => $this->failedCount(),
'NotDeliveredRate' => fn () => $this->notDeliveredRate(),
'NotDeliveredCount' => fn () => $this->notDeliveredCount(),
'PendingCount' => fn () => 0,
'SkippedCount' => fn () => 0,
'ClickCount' => fn () => $this->clickCount(),
'ClickedRate' => fn () => $this->clickRate(),
'UniqueClickCount' => fn () => $this->uniqueClickCount(),
'UniqOpenRate' => fn () => $this->openRate(),
'UniqOpenCount' => fn () => $this->openUniqCount(),
'NotOpenRate' => fn () => $this->notOpenRate(),
'NotOpenCount' => fn () => $this->notOpenCount(),
'BounceCount' => fn () => $this->bounceCount(),
'BounceRate' => fn () => $this->bounceRate(),
'UnsubscribeCount' => fn () => $this->unsubscribeCount(),
'UnsubscribeRate' => fn () => $this->unsubscribeRate(),
'FeedbackCount' => fn () => $this->feedbackCount(),
'FeedbackRate' => fn () => $this->feedbackRate(),
'AbuseFeedbackCount' => fn () => $this->abuseFeedbackCount(),
// Compound: every "top X" block on the deep-dive overview. Stored
// as one cache entry so a single read in the controller hydrates
// the whole top-cards row. Mirrors Campaign::cacheManifest's
// OverviewTopBlocks shape so the deep-dive blade can stay generic.
'OverviewTopBlocks' => [
'compute' => function () {
return [
'top_links' => $this->getTopLinks(5)->get(),
'top_open_countries' => $this->topOpenCountries(7)->get(),
'top_click_countries' => $this->topClickCountries(7)->get(),
'top_active_subscribers' => $this->getTopActiveSubscribers(5)->get(),
'top_locations' => $this->topLocations(5)->get(),
'last_open_at' => optional($this->lastOpen())->created_at,
'last_click_at' => optional($this->lastClick())->created_at,
'first_sent_at' => optional(\DB::table('tracking_logs')
->where('automation_email_id', $this->id)
->orderBy('created_at')->limit(1)->first())->created_at,
'last_sent_at' => optional(\DB::table('tracking_logs')
->where('automation_email_id', $this->id)
->orderByDesc('created_at')->limit(1)->first())->created_at,
];
},
'ttl' => 3600,
],
];
}
/**
* Oldest write timestamp among the cache entries that back the deep-dive
* email-report page. Drives the "Last updated X minutes ago" freshness
* strip + Refresh button. Mirrors Campaign::getOverviewComputedAt.
*/
public function getOverviewComputedAt(): ?\Carbon\Carbon
{
$scope = \App\Library\Cache\AppCache::for($this);
$timestamps = array_filter([
$scope->lastUpdatedAt('SubscriberCount'),
$scope->lastUpdatedAt('DeliveredCount'),
$scope->lastUpdatedAt('UniqOpenCount'),
$scope->lastUpdatedAt('ClickCount'),
$scope->lastUpdatedAt('OverviewTopBlocks'),
]);
return $timestamps ? min($timestamps) : null;
}
public function subscribers()
{
return Subscriber::select('subscribers.*')
->join('tracking_logs', 'tracking_logs.subscriber_id', '=', 'subscribers.id')
->where('tracking_logs.automation_email_id', '=', $this->id)
->groupBy('subscribers.id');
}
public function subscribersCount($cache = false): int
{
if ($cache) {
return (int) AppCache::for($this)->read('SubscriberCount', 0);
}
return (int) $this->trackingLogs()->distinct('subscriber_id')->count('subscriber_id');
}
public function activeSubscribersCount(): int
{
return (int) $this->trackingLogs()
->sent()
->join('subscribers', 'subscribers.id', '=', 'tracking_logs.subscriber_id')
->where('subscribers.status', Subscriber::STATUS_SUBSCRIBED)
->distinct('tracking_logs.subscriber_id')
->count('tracking_logs.subscriber_id');
}
public function deliveredCount(): int
{
return (int) $this->trackingLogs()->sent()->distinct('subscriber_id')->count('subscriber_id');
}
public function failedCount(): int
{
return (int) $this->trackingLogs()->failed()->distinct('subscriber_id')->count('subscriber_id');
}
public function notDeliveredCount(): int
{
return max(0, $this->subscribersCount() - $this->deliveredCount() - $this->failedCount());
}
public function deliveredRate(): float
{
$total = $this->subscribersCount(true);
return $total === 0 ? 0.0 : $this->deliveredCount() / $total;
}
public function failedRate(): float
{
$total = $this->subscribersCount(true);
return $total === 0 ? 0.0 : $this->failedCount() / $total;
}
public function notDeliveredRate(): float
{
$total = $this->subscribersCount(true);
return $total === 0 ? 0.0 : $this->notDeliveredCount() / $total;
}
public function clickCount($start = null, $end = null): int
{
$query = $this->clickLogs();
if (isset($start)) {
$query = $query->where('click_logs.created_at', '>=', $start);
}
if (isset($end)) {
$query = $query->where('click_logs.created_at', '<=', $end);
}
return (int) $query->count();
}
public function uniqueClickCount(): int
{
return (int) $this->clickLogs()->distinct('tracking_logs.subscriber_id')->count('tracking_logs.subscriber_id');
}
public function clickRate(): float
{
$deliveredCount = $this->deliveredCount();
return $deliveredCount === 0 ? 0.0 : $this->uniqueClickCount() / $deliveredCount;
}
public function abuseFeedbackCount(): int
{
return (int) $this->feedbackLogs()->where('feedback_type', '=', FeedbackLog::FEEDBACK_TYPE_ABUSE)->count();
}
public function uniqueOpenCount(): int
{
return (int) $this->openLogs()->distinct('tracking_logs.subscriber_id')->count('tracking_logs.subscriber_id');
}
public function notOpenCount(): int
{
return max(0, $this->deliveredCount() - $this->uniqueOpenCount());
}
public function openUniqCount($start = null, $end = null): int
{
$query = $this->openLogs();
if (isset($start)) {
$query = $query->where('open_logs.created_at', '>=', $start);
}
if (isset($end)) {
$query = $query->where('open_logs.created_at', '<=', $end);
}
return (int) $query->distinct('tracking_logs.subscriber_id')->count('tracking_logs.subscriber_id');
}
public function openRate(): float
{
$deliveredCount = $this->deliveredCount();
return $deliveredCount === 0 ? 0.0 : $this->uniqueOpenCount() / $deliveredCount;
}
public function notOpenRate(): float
{
return 1.0 - $this->openRate();
}
public function feedbackCount(): int
{
return (int) $this->feedbackLogs()->distinct('tracking_logs.subscriber_id')->count('tracking_logs.subscriber_id');
}
public function feedbackRate(): float
{
$deliveredCount = $this->deliveredCount();
return $deliveredCount === 0 ? 0.0 : $this->feedbackCount() / $deliveredCount;
}
public function bounceCount(): int
{
return (int) $this->bounceLogs()->distinct('tracking_logs.subscriber_id')->count('tracking_logs.subscriber_id');
}
public function bounceRate(): float
{
$deliveredCount = $this->deliveredCount();
return $deliveredCount === 0 ? 0.0 : $this->bounceCount() / $deliveredCount;
}
public function unsubscribeCount(): int
{
return (int) $this->unsubscribeLogs()->distinct('tracking_logs.subscriber_id')->count('tracking_logs.subscriber_id');
}
public function unsubscribeRate(): float
{
$deliveredCount = $this->deliveredCount();
return $deliveredCount === 0 ? 0.0 : $this->unsubscribeCount() / $deliveredCount;
}
public function lastClick()
{
return $this->clickLogs()->orderBy('created_at', 'desc')->first();
}
public function lastOpen()
{
return $this->openLogs()->orderBy('created_at', 'desc')->first();
}
public function getTopLinks($number = 5)
{
return $this->clickLogs()
->select('click_logs.url')
->addSelect(DB::raw('count(*) as aggregate'))
->groupBy('click_logs.url')
->orderBy('aggregate', 'desc')
->take($number);
}
/**
* Top subscribers by combined engagement (opens + clicks) for this
* automation email. See Campaign::getTopActiveSubscribers() for query
* shape rationale (subqueries vs cartesian explosion).
*
* @return \Illuminate\Database\Query\Builder
*/
public function getTopActiveSubscribers($number = 5)
{
$opensSub = DB::table('open_logs as ol')
->select('t.subscriber_id', DB::raw('COUNT(*) AS opens'))
->join('tracking_logs as t', 't.id', '=', 'ol.tracking_log_id')
->where('t.automation_email_id', $this->id)
->groupBy('t.subscriber_id');
$clicksSub = DB::table('click_logs as cl')
->select('t.subscriber_id', DB::raw('COUNT(*) AS clicks'))
->join('tracking_logs as t', 't.id', '=', 'cl.tracking_log_id')
->where('t.automation_email_id', $this->id)
->groupBy('t.subscriber_id');
return DB::table('subscribers')
->select('subscribers.id', 'subscribers.uid', 'subscribers.email')
->selectRaw('COALESCE(o.opens, 0) AS opens')
->selectRaw('COALESCE(c.clicks, 0) AS clicks')
->leftJoinSub($opensSub, 'o', 'o.subscriber_id', '=', 'subscribers.id')
->leftJoinSub($clicksSub, 'c', 'c.subscriber_id', '=', 'subscribers.id')
->whereRaw('(COALESCE(o.opens, 0) + COALESCE(c.clicks, 0)) > 0')
->orderByRaw('(COALESCE(o.opens, 0) + COALESCE(c.clicks, 0)) DESC')
->orderByDesc('opens')
->orderByDesc('clicks')
->when($number !== null, fn ($q) => $q->limit($number));
}
public function topLocations($number = 5)
{
$query = IpLocation::select('ip_locations.ip_address', 'ip_locations.country_code', 'ip_locations.country_name', 'ip_locations.region_name')
->addSelect(DB::raw('count(*) as aggregate'))
->join('open_logs', 'open_logs.ip_address', '=', 'ip_locations.ip_address')
->join('tracking_logs', 'open_logs.message_id', '=', 'tracking_logs.message_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
if (!is_null($this->reportCustomerId())) {
$query = $query->where('tracking_logs.customer_id', '=', $this->reportCustomerId());
}
return $query
->groupBy('ip_locations.ip_address', 'ip_locations.country_code', 'ip_locations.country_name', 'ip_locations.region_name')
->orderBy('aggregate', 'desc')
->take($number);
}
public function topClickLocations($number = 5)
{
$query = IpLocation::select('ip_locations.ip_address', 'ip_locations.country_code', 'ip_locations.country_name', 'ip_locations.region_name')
->addSelect(DB::raw('count(*) as aggregate'))
->join('click_logs', 'click_logs.ip_address', '=', 'ip_locations.ip_address')
->join('tracking_logs', 'click_logs.message_id', '=', 'tracking_logs.message_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
if (!is_null($this->reportCustomerId())) {
$query = $query->where('tracking_logs.customer_id', '=', $this->reportCustomerId());
}
return $query
->groupBy('ip_locations.ip_address', 'ip_locations.country_code', 'ip_locations.country_name', 'ip_locations.region_name')
->orderBy('aggregate', 'desc')
->take($number);
}
public function topOpenCountries($number = 5)
{
$query = IpLocation::select('ip_locations.country_name', 'ip_locations.country_code')
->addSelect(DB::raw('count(*) as aggregate'))
->join('open_logs', 'open_logs.ip_address', '=', 'ip_locations.ip_address')
->join('tracking_logs', 'open_logs.message_id', '=', 'tracking_logs.message_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
if (!is_null($this->reportCustomerId())) {
$query = $query->where('tracking_logs.customer_id', '=', $this->reportCustomerId());
}
return $query
->groupBy('ip_locations.country_name', 'ip_locations.country_code')
->orderBy('aggregate', 'desc')
->take($number);
}
public function topClickCountries($number = 5)
{
$query = IpLocation::select('ip_locations.country_name', 'ip_locations.country_code')
->addSelect(DB::raw('count(*) as aggregate'))
->join('click_logs', 'click_logs.ip_address', '=', 'ip_locations.ip_address')
->join('tracking_logs', 'click_logs.message_id', '=', 'tracking_logs.message_id')
->where('tracking_logs.automation_email_id', '=', $this->id);
if (!is_null($this->reportCustomerId())) {
$query = $query->where('tracking_logs.customer_id', '=', $this->reportCustomerId());
}
return $query
->groupBy('ip_locations.country_name', 'ip_locations.country_code')
->orderBy('aggregate', 'desc')
->take($number);
}
public function openUniqHours($start = null, $end = null)
{
$query = $this->openLogs()->select('open_logs.created_at');
$currentTimezone = $this->reportTimezone();
if (isset($start)) {
$query = $query->where('open_logs.created_at', '>=', $start);
}
if (isset($end)) {
$query = $query->where('open_logs.created_at', '<=', $end);
}
return $query->orderBy('open_logs.created_at', 'asc')->get()->groupBy(function ($date) use ($currentTimezone) {
return $date->created_at->timezone($currentTimezone)->format('H');
});
}
public function clickHours($start = null, $end = null)
{
$query = $this->clickLogs()->select('click_logs.created_at', 'tracking_logs.subscriber_id');
$currentTimezone = $this->reportTimezone();
if (isset($start)) {
$query = $query->where('click_logs.created_at', '>=', $start);
}
if (isset($end)) {
$query = $query->where('click_logs.created_at', '<=', $end);
}
return $query->orderBy('click_logs.created_at', 'asc')->get()->groupBy(function ($date) use ($currentTimezone) {
return $date->created_at->timezone($currentTimezone)->format('H');
});
}
public function openUniqDays($start = null, $end = null)
{
$query = $this->openLogs()->select('open_logs.created_at');
$currentTimezone = $this->reportTimezone();
if (isset($start)) {
$query = $query->where('open_logs.created_at', '>=', $start);
}
if (isset($end)) {
$query = $query->where('open_logs.created_at', '<=', $end);
}
return $query->orderBy('open_logs.created_at', 'asc')->get()->groupBy(function ($date) use ($currentTimezone) {
return $date->created_at->timezone($currentTimezone)->format('Y-m-d');
});
}
public function clickDays($start = null, $end = null)
{
$query = $this->clickLogs()->select('click_logs.created_at', 'tracking_logs.subscriber_id');
$currentTimezone = $this->reportTimezone();
if (isset($start)) {
$query = $query->where('click_logs.created_at', '>=', $start);
}
if (isset($end)) {
$query = $query->where('click_logs.created_at', '<=', $end);
}
return $query->orderBy('click_logs.created_at', 'asc')->get()->groupBy(function ($date) use ($currentTimezone) {
return $date->created_at->timezone($currentTimezone)->format('Y-m-d');
});
}
// -------------------------------------------------------------------------
// Domain logic (Automation-specific)
// -------------------------------------------------------------------------
/**
* Get email's default mail list (via automation).
*/
public function defaultMailList()
{
return $this->automation->mailList();
}
/**
* Validation rules.
*/
public function rules($request = null)
{
$rules = [
'subject' => 'required',
'from_email' => 'required|email',
'from_name' => 'required',
];
if ($this->use_default_sending_server_from_email) {
$rules['from_email'] = 'nullable|email';
$rules['reply_to'] = 'nullable|email';
} else {
$rules['from_email'] = 'required|email';
$rules['reply_to'] = 'required|email';
}
if (isset($request) && $request->custom_tracking_domain) {
$rules['tracking_domain_uid'] = 'required';
}
return $rules;
}
/**
* Find and update email links.
*/
public function updateLinks()
{
if (!$this->getTemplateContent()) {
return false;
}
$links = [];
defined('MAX_FILE_SIZE') || define('MAX_FILE_SIZE', 10000000);
$document = HtmlDomParser::str_get_html($this->getTemplateContent());
foreach ($document->find('a') as $element) {
if (preg_match('/^http/', $element->href) != 0) {
$links[] = trim($element->href);
}
}
$this->emailLinks()->whereNotIn('link', $links)->delete();
foreach ($links as $link) {
$exist = $this->emailLinks()->where('link', '=', $link)->count();
if (!$exist) {
$emailLink = $this->emailLinks()->make([
'link' => $link,
]);
$emailLink->customer_id = $this->customer_id;
$emailLink->save();
}
}
}
public function getServersPool()
{
if (is_null($this->serversPool)) {
$serversAndWeights = $this->automation->customer->getSendingServerPool();
if (empty($serversAndWeights)) {
throw new Exception('No sending server available');
}
$pool = new RouletteWheel();
foreach ($serversAndWeights as $serverId => $weight) {
$server = SendingServer::find($serverId);
$pool->add($server, $weight);
}
$this->serversPool = $pool;
}
return $this->serversPool;
}
public function queueDeliverTo($subscriber, $autoTriggerId, $actionId, $queue)
{
$serversPool = $this->getServersPool();
$subscription = $this->automation->customer->getCurrentActiveSubscription();
if (is_null($subscription)) {
throw new Exception("Customer {$this->customer->name} has no active subscription, quit sending");
}
$job = new SendSingleMessage(
$this,
$subscriber,
$serversPool,
$subscription,
$autoTriggerId,
$actionId,
);
$connection = config('custom.automation_queue_connection');
if (!is_null($this->customer->custom_queue_name)) {
$queue = $this->customer->custom_queue_name;
} elseif (is_null($queue)) {
$queue = ACM_QUEUE_TYPE_SINGLE;
}
if ($connection) {
$job->onConnection($connection)->onQueue($queue);
} else {
$job->onQueue($queue);
}
$jobId = safe_dispatch($job);
return $jobId;
}
/**
* Send a test email for this automation step.
*
* Credit accounting: a successful test send decrements 1 send_email credit
* (same key as a real automation step send). Test sends bypass SendPipeline
* so the usual credit gate doesn't fire — we check + consume here directly.
* Mirrors Campaign::sendTestEmail behavior (one credit per successful test,
* pre-block when exhausted, no consume on send failure).
*/
public function sendTestEmail($emailAddress)
{
$validator = Validator::make([ 'email' => $emailAddress ], [
'email' => 'required|email',
]);
if ($validator->fails()) {
$firstErrorMsg = $validator->errors()->first();
throw new Exception($firstErrorMsg);
}
$credits = app(\App\Services\Plans\Credits\CreditsService::class);
$sendKey = \App\Services\Plans\Credits\CreditKey::SEND_EMAIL;
$sub = $this->customer?->getCurrentActiveSubscription();
if ($sub === null) {
throw new Exception(trans('messages.campaign.test_no_subscription'));
}
if (!$credits->canUse($sub, $sendKey, 1)) {
throw new Exception(trans('messages.campaign.test_credit_exhausted'));
}
$server = $this->automation->customer->pickSendingServer();
if (!$server) {
throw new Exception('No sending server available for automation test send (customer pool empty)');
}
$subscriber = $this->mockSubscriber($emailAddress);
list($message, $msgId) = $this->prepareEmail($subscriber, $server);
$server->send($message);
// Consume + push to snapshot only after a successful send so a failed
// SMTP attempt doesn't penalize the customer's balance.
$credits->consume($sub, $sendKey, 1);
$credits->syncSnapshotFromTracker($sub);
}
/**
* Log delivery message.
*/
public function trackMessage(
\App\SendingServers\Drivers\SendResult $response,
$subscriber,
$server,
$msgId,
$triggerId = null
) {
$this->trackingLogs()->create([
'automation_email_id' => $this->id,
'message_id' => $msgId,
'subscriber_id' => $subscriber->id,
'sending_server_id' => $server->id,
'customer_id' => $this->automation->customer->id,
'auto_trigger_id' => $triggerId,
'status' => $response->getStatus()->value,
'runtime_message_id' => $response->getRuntimeMessageId() ?? $msgId,
'error' => $response->getError(),
]);
}
public function isOpened($subscriber)
{
if (is_null($subscriber)) {
return false;
}
return $this->trackingLogs()->where('subscriber_id', $subscriber->id)
->join('open_logs', 'open_logs.message_id', '=', 'tracking_logs.message_id')->exists();
}
public function isClicked($subscriber)
{
if (is_null($subscriber)) {
return false;
}
return $this->trackingLogs()->where('subscriber_id', $subscriber->id)
->join('click_logs', 'click_logs.message_id', '=', 'tracking_logs.message_id')->exists();
}
public function fillAttributes($params)
{
$this->fill($params);
if (isset($params['custom_tracking_domain']) && $params['custom_tracking_domain'] && isset($params['tracking_domain_uid'])) {
$tracking_domain = \App\Model\TrackingDomain::findByUid($params['tracking_domain_uid']);
if ($tracking_domain) {
$this->tracking_domain_id = $tracking_domain->id;
} else {
$this->tracking_domain_id = null;
}
} else {
$this->tracking_domain_id = null;
}
}
public function isSetup()
{
return $this->subject && $this->reply_to && $this->from_email && $this->hasTemplate();
}
public function deleteAndCleanup()
{
if ($this->email) {
$this->email->deleteAndCleanup();
}
$this->delete();
}
public function logger()
{
return $this->automation->logger();
}
public function newWebhook()
{
$webhook = new \App\Model\EmailWebhook();
$webhook->automation_email_id = $this->id;
return $webhook;
}
public function isStageExcluded(string $name): bool
{
switch ($name) {
case InjectTrackingPixel::class:
return !$this->track_open;
default:
return false;
}
}
public function copy()
{
$copy = $this->replicate();
$copy->generateUid();
$copy->email_id = null;
$copy->save();
if ($this->email) {
$newEmail = $this->email->copy();
$copy->email_id = $newEmail->id;
$copy->save();
}
return $copy;
}
public static function newDefault()
{
$automationEmail = new self([
'sign_dkim' => true,
'track_open' => true,
'track_click' => true,
]);
$automationEmail->skip_failed_message = Setting::isYes('email.default.skip_failed_message');
$automationEmail->fillDeliveryStatuses($automationEmail->getDefaultDeliveryStatuses());
return $automationEmail;
}
public function setSignature($signature)
{
if ($this->email) {
$this->email->signature_id = $signature ? $signature->id : null;
$this->email->save();
}
}
public function setPreheader($preheader)
{
if ($this->email) {
$this->email->preheader = $preheader;
$this->email->save();
}
}
public function removePreheader()
{
if ($this->email) {
$this->email->preheader = null;
$this->email->save();
}
}
public function checkDelayFlag()
{
return false; // not supported
}
public function debug(Closure $callback = null)
{
// not supported for AutomationEmail
}
// SendPipeline::preflight calls this on every send to write a heartbeat
// for stuck-job audits. Campaign maintains a per-uid heartbeat key; for
// automation emails the equivalent signal lives on `AutoTrigger`
// (executed_at / wait_until), so this is a no-op shim for parity.
public function touchActivity(): void
{
// intentionally empty
}
public function useSendingServerDefaultFromEmailAddress()
{
return $this->use_default_sending_server_from_email == true;
}
public function fillDeliveryStatuses(array $array)
{
if ($this->email) {
$this->email->fillDeliveryStatuses($array);
}
}
public function setDeliveryStatuses(array $array)
{
if ($this->email) {
$this->email->setDeliveryStatuses($array);
}
}
public function getDefaultDeliveryStatuses()
{
return [
\App\Model\Subscriber::VERIFICATION_STATUS_DELIVERABLE,
\App\Model\Subscriber::VERIFICATION_STATUS_UNVERIFIED,
];
}
public function getDeliveryStatuses()
{
if ($this->email) {
return $this->email->getDeliveryStatuses();
}
return $this->getDefaultDeliveryStatuses();
}
}