HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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();
    }
}