> */ public static function getEmailTypeAliases() { return [ 'funnel_email_campaign' => ['funnel_email_campaign', 'automation'], 'recurring_campaign' => ['recurring_campaign', 'recurring_email_campaign'], 'custom_email_campaign' => ['custom_email_campaign', 'custom_email'], 'campaign' => ['campaign'], 'sequence' => ['sequence', 'email_sequence', 'sequence_email'], ]; } /** * Map canonical email types to the user-facing labels used in reporting. * * @return array */ public static function getEmailTypeLabels() { return [ 'funnel_email_campaign' => __('Automation', 'fluent-crm'), 'recurring_campaign' => __('Recurring Campaign', 'fluent-crm'), 'custom_email_campaign' => __('Custom Email', 'fluent-crm'), 'campaign' => __('Campaign', 'fluent-crm'), 'sequence' => __('Sequence', 'fluent-crm'), ]; } /** * Resolve the canonical email type key for filtering and reporting. * * @param string|null $emailType * @return string */ public static function normalizeEmailType($emailType) { $emailType = sanitize_text_field((string)$emailType); foreach (static::getEmailTypeAliases() as $canonicalType => $aliases) { if (in_array($emailType, $aliases, true)) { return $canonicalType; } } return $emailType ?: 'campaign'; } /** * Expand selected canonical types into the raw slugs stored in email rows. * * @param array $selectedTypes * @return array */ public static function expandEmailTypes(array $selectedTypes) { $expandedTypes = []; $aliases = static::getEmailTypeAliases(); foreach ($selectedTypes as $selectedType) { $canonicalType = static::normalizeEmailType($selectedType); $expandedTypes = array_merge($expandedTypes, $aliases[$canonicalType] ?? [$canonicalType]); } return array_values(array_unique($expandedTypes)); } /** * Resolve the human-readable email type label for reports and tables. * * @return string */ public function getEmailTypeLabelAttribute() { return static::resolveEmailTypeLabel($this->email_type); } /** * Convert a stored email type slug into a stable UI label. * * @param string|null $emailType * @return string */ public static function resolveEmailTypeLabel($emailType) { $emailType = static::normalizeEmailType($emailType); $labels = static::getEmailTypeLabels(); if (isset($labels[$emailType])) { return $labels[$emailType]; } return ucwords(str_replace(['_', '-'], ' ', $emailType ?: 'campaign')); } /** * One2One: CampaignEmail belongs to one Campaign * @return Model */ public function campaign() { return $this->belongsTo( __NAMESPACE__ . '\Campaign', 'campaign_id', 'id' )->withoutGlobalScope('type'); } /** * One2One: CampaignEmail belongs to one Subscriber * @return Model */ public function subscriber() { return $this->belongsTo( __NAMESPACE__ . '\Subscriber', 'subscriber_id', 'id' ); } /** * One2One: CampaignEmail belongs to one Subject * * Note: The email_subject_id will be inserted by calculating the prioroty * from subjects table where the subjects are related to a parent Campaign. * So, when creating a campaign email, there will be an option to select a * subject from a list and that list will contain subjects related to the * parent campaign because a campaign can have many subjects and the campaign * email will get only one from that list by calculating the priority from subjects. * * @return Model */ public function subject() { return $this->belongsTo( __NAMESPACE__ . '\Subject', 'email_subject_id', 'id' ); } public function markAs($status) { $this->status = $status; $this->save(); return $this; } public function markAsSent($status = 'sent') { return $this->markAs($status); } public function markAsFailed($status = 'failed') { return $this->markAs($status); } /** * Data for the email to be sent * @return array */ public function data() { $email_subject = $this->getEmailSubject(); $email_body = $this->getEmailBody(); $headers = Helper::getMailHeader($this->email_headers); return [ 'to' => [ 'email' => $this->email_address, 'name' => ($this->subscriber) ? $this->subscriber->full_name : '' ], 'headers' => $headers, 'subject' => $email_subject, 'body' => $email_body, 'campaign_id' => $this->campaign_id, 'id' => $this->id, 'subscriber_id' => $this->subscriber_id ]; } /** * Build preview data for one queued/sent email row. * * Route: GET /campaigns/emails/{email_id}/preview via CampaignController::previewEmail(). * This is the contact/campaign/all-emails history preview, not the draft editor preview * route (POST /campaigns/email-preview-html). Keep the body rendering rules aligned with * CampaignController::getEmailPreviewBody(): raw/classic-builder templates must bypass * BlockParser, while block-editor templates should continue through BlockParser. * * @return array */ public function previewData() { $emailSettings = fluentcrmGetGlobalSettings('email_settings', []); $campaign = $this->campaign; $subscriber = $this->subscriber; $emailBody = ($this->email_body) ? $this->email_body : (($campaign) ? $campaign->email_body : ''); $designTemplate = ($campaign) ? $campaign->design_template : ''; if (!$designTemplate) { $designTemplate = 'plain'; } $rawTemplates = [ 'raw_html', 'visual_builder', 'raw_classic' ]; if (in_array($designTemplate, $rawTemplates, true) || ($this->is_parsed && $this->email_body)) { $emailBody = wp_unslash($emailBody); } else { $emailBody = (new BlockParser($subscriber))->parse($emailBody); } /** * Determine the campaign email body content text. * * This filter allows you to modify the email body content before it is sent to the subscriber. * * @param string $emailBody The email body content. * @param object $this->subscriber The subscriber object. * @since 2.7.0 * */ $emailBody = apply_filters('fluent_crm/parse_campaign_email_text', $emailBody, $subscriber); $templateConfig = wp_parse_args( ($campaign) ? Arr::get($campaign->settings, 'template_config', []) : [], Helper::getTemplateConfig($designTemplate) ); $emailFooterConfig = ($campaign) ? Helper::getFooterConfig($campaign) : []; $footerText = Arr::get($emailFooterConfig, 'footer_content', ''); if ($subscriber) { $subscriber->campaign_id = $this->campaign_id; /** * Determine the footer text of a campaign email for previewing. * * This filter allows you to modify the footer text of a campaign email before it is sent to the subscriber. * * @param string $footerText The footer text of the campaign email. * @param object $subscriber The subscriber object. * @since 2.7.0 * */ $footerText = apply_filters('fluent_crm/parse_campaign_email_text', $footerText, $subscriber); } $preHeader = ($campaign) ? $campaign->email_pre_header : ''; if ($preHeader && $subscriber) { /** * Filter the pre-header text of a campaign email for previewing. * * @param string $preHeader The pre-header text of the campaign email. * @param object $subscriber The subscriber object. * @since 2.7.0 * */ $preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber); } $emailFooterConfig['footer_content'] = $footerText; /** * Filter the email body content using a specific email design template. * * This filter allows customization of the email body content by applying a specific design template. * * @param string $emailBody The original email body content before applying the design template. * @param array { * Contextual information for the email design template. * * @type string $preHeader The pre-header text for the email, if available. * @type string $email_body The original email body content. * @type string $footer_text The footer text for the email, if any. * @type array $config Configuration settings for the email template. * } * @param object|null $this->campaign The campaign object, if available. * @param object|null $this->subscriber The subscriber object, if available. * @since 1.0.0 * */ $email_body = apply_filters( 'fluent_crm/email-design-template-' . $designTemplate, $emailBody, [ 'preHeader' => $preHeader, 'email_body' => $emailBody, 'footer_text' => $footerText, 'footer_config' => $emailFooterConfig, 'config' => $templateConfig ], $campaign, $subscriber ); if (Str::contains($email_body, ['##crm.', '{{crm.'])) { /** * Filter the email body content for a campaign email and parse SmartCodes. * * This filter allows customization of the email body content before it is sent to the subscriber. There are FluentCRM-specific SmartCodes. * * @param string $email_body The email body content to be filtered. * @param object $this ->subscriber The subscriber object containing subscriber details. * @since 2.7.0 * */ $email_body = apply_filters('fluent_crm/parse_extended_crm_text', $email_body, $subscriber); } $preViewUrl = site_url('?fluentcrm=1&route=email_preview&_e_hash=' . $this->email_hash); $email_body = str_replace(['##web_preview_url##', '{{crm_global_email_footer}}', '{{crm_preheader_text}}'], [$preViewUrl, $footerText, $preHeader], $email_body); $email_body = str_replace(['https://fonts.googleapis.com/css2', 'https://fonts.googleapis.com/css'], 'https://fonts.bunny.net/css', $email_body); return [ 'to' => [ 'email' => $this->email_address, 'name' => ($subscriber) ? $subscriber->full_name : '' ], 'from' => [ 'name' => Arr::get($emailSettings, 'from_name'), 'email' => Arr::get($emailSettings, 'from_email') ], 'reply' => null, 'subject' => $this->email_subject, 'body' => $email_body, 'campaign_id' => $this->campaign_id, 'id' => $this->id, 'subscriber_id' => $this->subscriber_id ]; } public function getEmailSubject() { return $this->email_subject; } public function getEmailBody() { $subscriber = $this->subscriber; if ($subscriber) { $subscriber->email_id = $this->id; } $designTemplate = 'classic'; $campaign = $this->campaign; if ($campaign) { $designTemplate = $campaign->design_template; } if ($this->is_parsed && !$this->email_body && $campaign && $campaign->email_body) { // Recover unsent queue rows that were marked parsed after their body was cleared. $this->is_parsed = 0; } if (!$this->is_parsed) { $rawTemplates = [ 'raw_html', 'visual_builder', 'raw_classic' ]; $emailBody = ($campaign) ? $campaign->email_body : $this->email_body; // Don't cache URL map if body has conditional blocks or merge tags inside href URLs $canCache = !Helper::hasConditionOnString($emailBody) && !preg_match('/href=["\'][^"\']*\{\{/', $emailBody); if (in_array($designTemplate, $rawTemplates)) { $emailBody = $this->campaign->email_body; } else { $emailBody = $this->getParsedEmailBody(); } $emailBody = str_replace(['https://fonts.googleapis.com/css2', 'https://fonts.googleapis.com/css'], 'https://fonts.bunny.net/css', $emailBody); $emailBody = apply_filters('fluent_crm/parse_campaign_email_text', $emailBody, $subscriber); $emailBody = apply_filters('fluentcrm_email_body_text', $emailBody, $subscriber, $this); if ($campaign && $trackingType = $campaign->getClickTrackingStatus()) { $campaignUrls = $this->getCampaignUrls($emailBody, $canCache); if ($campaignUrls) { if ($trackingType === 'anonymous') { $emailBody = Helper::attachAnonymousUrls($emailBody, $campaignUrls, $this->id, $this->email_hash); } else { $emailBody = Helper::attachUrls($emailBody, $campaignUrls, $this->id, $this->email_hash); } } } $this->email_body = $emailBody; $this->is_parsed = 1; // Not saved to DB here — the parsed body is kept in memory for Mailer::send(). // BaseHandler's mark-as-sent UPDATE persists is_parsed=1 and clears email_body. // On rare retry (process crash), the email re-parses from campaign body which // is correct. This avoids a ~15ms LONGTEXT write per email during bulk sends. } $emailFooterConfig = []; $footerText = ''; if ($subscriber) { $subscriber->campaign_id = $this->campaign_id; static $footerConfigCache = []; $cacheKey = $this->campaign_id ?: 0; if (isset($footerConfigCache[$cacheKey])) { $emailFooterConfig = $footerConfigCache[$cacheKey]; } else { $emailFooterConfig = Helper::getFooterConfig($campaign); $footerConfigCache[$cacheKey] = $emailFooterConfig; } $footerText = Arr::get($emailFooterConfig, 'footer_content', ''); if ($footerText) { /** * Filter the footer text of the campaign email. * * This filter allows you to modify the footer text of the campaign email before it is sent to the subscriber. * * @param string $footerText The footer text of the campaign email. * @param object $subscriber The subscriber object. * @since 2.7.0 * */ $footerText = apply_filters('fluent_crm/parse_campaign_email_text', $footerText, $subscriber); $preViewUrl = site_url('?fluentcrm=1&route=email_preview&_e_hash=' . $this->email_hash); $footerText = str_replace('##web_preview_url##', $preViewUrl, $footerText); $emailFooterConfig['footer_content'] = $footerText; } } static $templateConfigCache = []; $templateCacheKey = $this->campaign_id ?: 0; if (isset($templateConfigCache[$templateCacheKey])) { $templateConfig = $templateConfigCache[$templateCacheKey]; } else { if ($this->campaign && Arr::get($this->campaign->settings, 'template_config')) { $templateConfig = wp_parse_args($this->campaign->settings['template_config'], Helper::getTemplateConfig($this->campaign->design_template)); } else { $templateConfig = Helper::getTemplateConfig(); } $templateConfigCache[$templateCacheKey] = $templateConfig; } $preHeader = ($this->campaign) ? $this->campaign->email_pre_header : ''; if ($preHeader && $subscriber) { /** * Filter the pre-header text of a campaign email. * * This filter allows you to modify the pre-header text of a campaign email before it is sent. * * @param string $preHeader The pre-header text of the campaign email. * @param object $subscriber The subscriber object containing subscriber details. * @since 2.7.0 * */ $preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber); } $footerUrls = $this->getCampaignUrls($footerText, false); if ($footerUrls) { $trackingType = $campaign ? $campaign->getClickTrackingStatus() : null; if ($trackingType === 'anonymous') { $footerText = Helper::attachAnonymousUrls($footerText, $footerUrls, $this->id, $this->email_hash); } else { $footerText = Helper::attachUrls($footerText, $footerUrls, $this->id, $this->email_hash); } } $templateData = [ 'preHeader' => $preHeader, 'email_body' => $this->email_body, 'footer_text' => $footerText, 'config' => $templateConfig, 'footer_config' => $emailFooterConfig, ]; /** * Filter the email design template content. * * This filter allows customization of the email design template content based on the template type. * * @param string $this ->email_body The original email body content. * @param array $templateData The data used for the template. * @param object $this ->campaign The campaign object. * @param object $this ->subscriber The subscriber object. * @since 1.0.0 * */ $content = apply_filters( 'fluent_crm/email-design-template-' . $designTemplate, $this->email_body, $templateData, $this->campaign, $this->subscriber ); $preViewUrl = site_url('?fluentcrm=1&route=email_preview&_e_hash=' . $this->email_hash); $content = str_replace(['##web_preview_url##', '{{crm_global_email_footer}}', '{{crm_preheader_text}}'], [$preViewUrl, $footerText, $preHeader], $content); if (Str::contains($content, ['##crm.', '{{crm'])) { /** * Filter the content to parse extended CRM text such as SmartCodes. * * This filter allows you to modify the content by parsing extended CRM text. There are FluentCRM-specific SmartCodes available. * * @param string $content The content to be filtered. * @param object $subscriber The subscriber object. * @since 2.7.0 * */ $content = apply_filters('fluent_crm/parse_extended_crm_text', $content, $subscriber); } return Helper::injectTrackerPixel($content, $this->email_hash, $this->id); } private function getParsedEmailBody() { if (!$this->campaign_id || !$this->campaign) { // return (new BlockParser($this->subscriber))->parse($this->email_body); return (new BlockParser($this->subscriber))->parse($this->email_body); } static $parsedEmailBody = []; $originalBody = $this->campaign->email_body; $hasConditions = Helper::hasConditionOnString($originalBody); if (isset($parsedEmailBody[$this->campaign_id]) && !$hasConditions) { return $parsedEmailBody[$this->campaign_id]; } if ($this->campaign->status == 'archived' && !$hasConditions) { $cachedEmailBody = fluentcrm_get_campaign_meta($this->campaign_id, '_cached_email_body', true); if ($cachedEmailBody) { $parsedEmailBody[$this->campaign_id] = $cachedEmailBody; return $parsedEmailBody[$this->campaign_id]; } } $rawTemplates = [ 'raw_html', 'visual_builder', 'raw_classic' ]; if (in_array($this->campaign->design_template, $rawTemplates)) { $emailBody = $originalBody; } else { // $emailBody = (new BlockParser($this->subscriber))->parse($originalBody); $emailBody = (new BlockParser($this->subscriber))->parse($originalBody); } if ($hasConditions) { return $emailBody; } $parsedEmailBody[$this->campaign_id] = $emailBody; return $emailBody; } public function getCampaignUrls($emailBody, $cached = false) { $trackingType = fluentcrmTrackClicking(); if (!$trackingType) { return []; } if (!$cached || !$this->campaign_id) { return Helper::urlReplaces($emailBody); } static $campaignUrls = []; if (isset($campaignUrls[$this->campaign_id])) { return $campaignUrls[$this->campaign_id]; } $campaignUrls[$this->campaign_id] = Helper::urlReplaces($emailBody); return $campaignUrls[$this->campaign_id]; } public function getClicks() { return fluentCrmDb()->table('fc_campaign_url_metrics') ->select(['fc_campaign_url_metrics.counter', 'fc_url_stores.url', 'fc_campaign_url_metrics.id']) ->where('type', 'click') ->where('fc_campaign_url_metrics.subscriber_id', $this->subscriber_id) ->where('fc_campaign_url_metrics.campaign_id', $this->campaign_id) ->join('fc_url_stores', 'fc_url_stores.id', '=', 'fc_campaign_url_metrics.url_id') ->get(); } public function getSubjectCount($campaignId) { return static::select( 'fc_campaign_emails.email_subject_id', fluentCrmDb()->raw('count(*) as total'), 'fc_meta.value', 'fc_meta.key' ) ->where('fc_campaign_emails.campaign_id', $campaignId) ->groupBy('fc_campaign_emails.email_subject_id') ->join('fc_meta', 'fc_meta.id', '=', 'fc_campaign_emails.email_subject_id') ->get(); } public function getOpenCount($subjectId) { return static::where('email_subject_id', $subjectId) ->where('is_open', '>', 0) ->count(); } public function setEmailHeadersAttribute($headers) { $this->attributes['email_headers'] = \maybe_serialize($headers); } public function getEmailHeadersAttribute($settings) { return \maybe_unserialize($settings); } }