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;
}
}