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/Email.php
<?php

namespace App\Model;

use Illuminate\Database\Eloquent\Model;
use App\Library\Traits\HasUid;
use App\Library\Contracts\TemplateSubjectInterface;
use App\Library\HtmlHandler\InjectTrackingPixel;
use App\Library\HtmlHandler\AddDoctype;
use App\Library\HtmlHandler\AddPreheader;
use App\Library\HtmlHandler\RemoveTitleTag;
use App\Library\HtmlHandler\AppendHtml;
use App\Library\HtmlHandler\TransformWidgets;
use App\Library\HtmlHandler\MakeInlineCss;
use App\Library\HtmlHandler\ReplaceBareLineFeed;
use App\Library\Lockable;
use Illuminate\Support\Facades\Cache;
use League\Pipeline\PipelineBuilder;
use Soundasleep\Html2Text;
use Exception;
use App\Services\TemplateService;

/**
 * Email — generic content layer shared by Campaign and AutomationEmail.
 *
 * Holds all email composition fields: subject, from/reply headers, HTML/plain content,
 * template, tracking settings, delivery statuses. No scheduling, no subscriber targeting,
 * no automation-specific fields — those belong to Campaign or AutomationEmail respectively.
 *
 * Relations:
 *   - campaign()        → the Campaign that uses this email (if any)
 *   - automationEmail() → the AutomationEmail that uses this email (if any)
 *
 * @property int|null $id
 * @property string $uid
 * @property int|null $template_id
 * @property int|null $signature_id
 * @property string|null $preheader
 * @property string|null $html
 * @property string|null $plain
 * @property string|null $type
 * @property string|null $subject
 * @property string|null $from_email
 * @property string|null $from_name
 * @property string|null $reply_to
 * @property bool $sign_dkim
 * @property bool $track_open
 * @property bool $track_click
 * @property bool $use_default_sending_server_from_email
 * @property bool $skip_failed_message
 * @property int|null $customer_id
 * @property int|null $tracking_domain_id
 */
class Email extends Model implements TemplateSubjectInterface
{
    use HasUid;

    public const TYPE_REGULAR    = 'regular';
    public const TYPE_PLAIN_TEXT = 'plain-text';

    protected $fillable = [
        'type',
        'subject', 'from_email', 'from_name', 'reply_to',
        'html', 'plain', 'preheader',
        'sign_dkim', 'track_open', 'track_click',
        'use_default_sending_server_from_email', 'skip_failed_message',
        'delivery_statuses', 'template_id',
        'tracking_domain_id', 'signature_id',
        'customer_id',
    ];

    protected $casts = [
        'track_open'  => 'boolean',
        'track_click' => 'boolean',
        'sign_dkim'   => 'boolean',
        'use_default_sending_server_from_email' => 'boolean',
        'skip_failed_message' => 'boolean',
    ];

    // -------------------------------------------------------------------------
    // Relations
    // -------------------------------------------------------------------------

    public function customer()
    {
        return $this->belongsTo(Customer::class);
    }

    public function campaign()
    {
        return $this->hasOne(Campaign::class);
    }

    public function automationEmail()
    {
        return $this->hasOne(AutomationEmail::class);
    }

    /**
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<Template, $this>
     */
    public function template(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(Template::class);
    }

    public function trackingDomain()
    {
        return $this->belongsTo(TrackingDomain::class, 'tracking_domain_id');
    }

    public function signature()
    {
        return $this->belongsTo(Signature::class, 'signature_id');
    }

    // -------------------------------------------------------------------------
    // Template management
    // -------------------------------------------------------------------------

    public function hasTemplate(): bool
    {
        return $this->template()->exists();
    }

    public function getThumbUrl(): string
    {
        return $this->template
            ? $this->template->getThumbUrl()
            : url('images/placeholder.jpg');
    }

    /**
     * Legacy convenience wrapper around TemplateService.
     */
    public function setTemplate($template, string $name): void
    {
        TemplateService::for($this)->setTemplate($template, $name);
        $this->updatePlainFromHtml();
    }

    public function removeTemplate(): void
    {
        TemplateService::for($this)->removeTemplate();
    }

    /**
     * Replace this email's content with raw uploaded HTML.
     *
     * Creates a new private Template with source='uploaded' and assigns it. If an
     * existing template is attached, it gets cleaned up first — same lifecycle as
     * setTemplate(). The send pipeline treats uploaded templates identically to
     * builder templates; only the editing UX differs.
     */
    public function setCustomHtml(string $html, string $name): void
    {
        TemplateService::for($this)->setCustomHtml($html, $name);
        $this->updatePlainFromHtml();
    }

    public function isCustomHtml(): bool
    {
        return $this->template?->isUploaded() ?? false;
    }

    public function setTemplateContent($content, $json): void
    {
        if (!$this->template) {
            throw new Exception('Cannot set content: email does not have template!');
        }

        $this->template->updateContent($json, $content);
    }

    public function getTemplateContent(): string
    {
        if (!$this->template) {
            throw new Exception('Cannot get content: email does not have template!');
        }

        return $this->template->content;
    }

    // -------------------------------------------------------------------------
    // HTML / plain generation
    // -------------------------------------------------------------------------

    public function updatePlainFromHtml(): void
    {
        if (!$this->plain) {
            $this->plain = preg_replace('/\s+/', ' ', preg_replace('/\r\n/', ' ', strip_tags($this->getTemplateContent())));
            $this->save();
        }
    }

    public function getCachedHtmlId(): string
    {
        return "{$this->uid}-html";
    }

    public function clearTemplateCache(): void
    {
        Cache::forget($this->getCachedHtmlId());
    }

    /**
     * Return HTML that has been processed through base handlers (no subscriber binding).
     */
    public function getBaseHtmlContent(bool $fromCache = false, int $expiresInSeconds = 600): string
    {
        if (!$this->template) {
            throw new Exception('No template available');
        }

        $cacheId         = $this->getCachedHtmlId();
        $updateCacheFlag = $fromCache && !Cache::has($cacheId);
        $html            = null;

        if (!$fromCache || $updateCacheFlag) {
            $pipeline = new PipelineBuilder();
            $pipeline->add(new AddDoctype());
            $pipeline->add(new AddPreheader($this->preheader));
            $pipeline->add(new RemoveTitleTag());
            $pipeline->add(new AppendHtml($this->getHtmlFooter()));
            $pipeline->add(new TransformWidgets());
            $pipeline->add(new MakeInlineCss($this->template->findCssFiles()));
            $html = $pipeline->build()->process($this->getTemplateContent() ?? '');
        }

        if ($updateCacheFlag) {
            $lockfile = storage_path('locks/campaign-cache-' . $this->uid);
            $lock     = new Lockable($lockfile);

            $lock->getExclusiveLock(function ($f) use ($cacheId, $html, $expiresInSeconds) {
                Cache::put($cacheId, $html, $expiresInSeconds);
            }, $timeoutSeconds = 3, $timeoutCallback = function () {
                // just quit, do not throw exception
            });
        }

        return $html ?: Cache::get($cacheId);
    }

    public function getPlainContentFromHtml(string $html): string
    {
        $plain    = Html2Text::convert($html, ['ignore_errors' => true]);
        $pipeline = new PipelineBuilder();
        $pipeline->add(new ReplaceBareLineFeed());
        return $pipeline->build()->process($plain);
    }

    // -------------------------------------------------------------------------
    // Customer-quota helpers (footer, sending domain)
    // -------------------------------------------------------------------------

    public function footerEnabled(): ?bool
    {
        if (is_null($this->customer)) {
            return null;
        }
        return \App\Services\Plans\Gates\EntitlementGate::allows(
            $this->customer,
            \App\Services\Plans\Entitlements\EntitlementKey::HAS_CUSTOM_FOOTER
        );
    }

    public function getHtmlFooter(): ?string
    {
        return $this->getFooterFromPlan('footer_html');
    }

    public function getPlainTextFooter(): ?string
    {
        return $this->getFooterFromPlan('footer_text');
    }

    private function getFooterFromPlan(string $column): ?string
    {
        if (is_null($this->customer)) {
            return null;
        }
        $sub = $this->customer->getCurrentActiveSubscription();
        return $sub?->plan->{$column};
    }

    /**
     * Find a customer-owned LOCAL_DKIM identity for the From: address's
     * domain. Used by HasEmailTemplate's DKIM signing path: only LOCAL_DKIM
     * rows have populated dkim_* keys for client-side signing.
     */
    public function findSendingIdentity(string $email): ?\App\Model\SendingIdentity
    {
        $domainName = substr(strrchr($email, '@'), 1);
        if ($domainName === false || $domainName === '') {
            return null;
        }
        return \App\Model\SendingIdentity::query()
            ->where('customer_id', $this->customer->id)
            ->where('management_mode', \App\SendingServers\Identities\ManagementMode::LOCAL_DKIM->value)
            ->where('signing_enabled', true)
            ->where('value', $domainName)
            ->first();
    }

    // -------------------------------------------------------------------------
    // Content helpers
    // -------------------------------------------------------------------------

    public function isSetup(): bool
    {
        return !empty($this->subject)
            && !empty($this->from_email)
            && !empty($this->from_name)
            && ($this->use_default_sending_server_from_email || !empty($this->reply_to))
            && ($this->template || $this->html);
    }

    public function useSendingServerDefaultFromEmailAddress(): bool
    {
        return $this->use_default_sending_server_from_email == true;
    }

    public function isStageExcluded(string $name): bool
    {
        if ($name === InjectTrackingPixel::class) {
            return !$this->track_open;
        }
        return false;
    }

    public function deleteAndCleanup(): void
    {
        TemplateService::for($this)->deleteSubjectAndTemplate();
    }

    public function copy(): self
    {
        $copy              = $this->replicate();
        $copy->template_id = null;
        $copy->generateUid();
        $copy->save();

        if ($this->template) {
            $copy->setTemplate($this->template, $this->template->name);
        }

        return $copy;
    }

    // -------------------------------------------------------------------------
    // Delivery statuses
    // -------------------------------------------------------------------------

    public function fillDeliveryStatuses(array $array): void
    {
        $array = array_map(function ($status) {
            $status = trim($status);

            if (empty($status)) {
                throw new Exception("Status can not be empty");
            }

            if (!in_array($status, [
                Subscriber::VERIFICATION_STATUS_DELIVERABLE,
                Subscriber::VERIFICATION_STATUS_UNDELIVERABLE,
                Subscriber::VERIFICATION_STATUS_UNKNOWN,
                Subscriber::VERIFICATION_STATUS_RISKY,
                Subscriber::VERIFICATION_STATUS_UNVERIFIED,
            ])) {
                throw new Exception("Status $status is invalid!");
            }

            return $status;
        }, $array);

        $this->delivery_statuses = json_encode($array);
    }

    public function setDeliveryStatuses(array $array): void
    {
        $this->fillDeliveryStatuses($array);
        $this->save();
    }

    public function getDefaultDeliveryStatuses(): array
    {
        return [
            Subscriber::VERIFICATION_STATUS_DELIVERABLE,
            Subscriber::VERIFICATION_STATUS_UNVERIFIED,
        ];
    }

    public function getDeliveryStatuses(): array
    {
        if ($this->delivery_statuses === null) {
            return $this->getDefaultDeliveryStatuses();
        }

        return json_decode($this->delivery_statuses, true);
    }

    // -------------------------------------------------------------------------
    // Factory
    // -------------------------------------------------------------------------

    public static function newDefault(): self
    {
        $email = new self([
            'sign_dkim'   => true,
            'track_open'  => true,
            'track_click' => true,
        ]);

        $email->skip_failed_message = Setting::isYes('email.default.skip_failed_message');
        $email->fillDeliveryStatuses($email->getDefaultDeliveryStatuses());

        return $email;
    }
}