design_template ? $model->design_template : Helper::getDefaultEmailTemplate(); $model->email_body = $model->email_body ?: ''; $model->status = $model->status ?: 'draft'; $model->type = static::$type; $model->design_template = $defaultTemplate; $model->slug = $model->slug ?: sanitize_title($model->title, '', 'preview'); $model->created_by = $model->created_by ?: get_current_user_id(); $model->settings = $model->settings ?: [ 'mailer_settings' => [ 'from_name' => '', 'from_email' => '', 'reply_to_name' => '', 'reply_to_email' => '', 'is_custom' => 'no' ], 'subscribers' => [ [ 'list' => 'all', 'tag' => 'all' ] ], 'excludedSubscribers' => [ [ 'list' => null, 'tag' => null ] ], 'sending_filter' => 'list_tag', 'dynamic_segment' => [ 'id' => '', 'slug' => '' ], 'advanced_filters' => [[]], 'template_config' => Helper::getTemplateConfig($defaultTemplate), 'sending_type' => 'instant', 'is_transactional' => 'no' ]; }); static::addGlobalScope('type', function ($builder) { $builder->where('type', '=', static::$type); }); } public function setSlugAttribute($slug) { $this->attributes['slug'] = \sanitize_title($slug, '', 'preview'); } public function setSettingsAttribute($settings) { $this->attributes['settings'] = \maybe_serialize($settings); } public function getSettingsAttribute($settings) { $settings = \maybe_unserialize($settings); $settings = is_array($settings) ? $settings : []; $templateConfig = Arr::get($settings, 'template_config', []); $defaultConfig = Helper::getTemplateConfig($this->design_template, false); $templateConfig = wp_parse_args($templateConfig, $defaultConfig); $templateConfig['design_template'] = $this->design_template; $footerDefaults = [ 'disable_footer' => 'no', 'custom_footer' => 'no', 'footer_content' => '', 'font_size' => 13, 'font_color' => '#202020', 'background_color' => 'transparent', 'footer_padding' => 20 ]; $footerSettings = Arr::get($settings, 'footer_settings', []); $footerSettings = is_array($footerSettings) ? $footerSettings : []; // Backward compatibility: older imports may only carry disable_footer in template_config. if (!isset($footerSettings['disable_footer'])) { $legacyDisable = Arr::get($templateConfig, 'disable_footer'); if ($legacyDisable === 'yes' || $legacyDisable === 'no') { $footerSettings['disable_footer'] = $legacyDisable; } } if (!isset($footerSettings['custom_footer'])) { $legacyFooterContent = Arr::get($footerSettings, 'footer_content', ''); if (is_string($legacyFooterContent) && trim(wp_strip_all_tags($legacyFooterContent))) { $footerSettings['custom_footer'] = 'yes'; } } $footerSettings = wp_parse_args($footerSettings, $footerDefaults); $footerSettings['disable_footer'] = ($footerSettings['disable_footer'] === 'yes') ? 'yes' : 'no'; $footerSettings['custom_footer'] = ($footerSettings['custom_footer'] === 'yes') ? 'yes' : 'no'; $settings['footer_settings'] = $footerSettings; $templateConfig['disable_footer'] = $footerSettings['disable_footer']; $settings['template_config'] = $templateConfig; $mailerDefaults = [ 'from_name' => '', 'from_email' => '', 'reply_to_name' => '', 'reply_to_email' => '', 'is_custom' => 'no' ]; $mailerSettings = Arr::get($settings, 'mailer_settings', []); $mailerSettings = wp_parse_args($mailerSettings, $mailerDefaults); $settings['mailer_settings'] = $mailerSettings; return $settings; } public function getRecipientsCountAttribute($recipientsCount) { return (int)$recipientsCount; } public function getRenderedBodyAttribute() { return (new Template)->render($this->body); } // Now using a single subject, get the first one public function getSubjectAttribute() { if ($firstSubject = $this->subjects()->first()) { return $firstSubject->value; } return $this->email_subject; } public function syncSubjects($subjects) { $validSubjectIds = []; foreach ($subjects as $subject) { if (empty($subject['value']) || empty($subject['key'])) { continue; } if (empty($subject['id'])) { $data = Arr::only($subject, ['key', 'value']); $data['object_id'] = $this->id; $inserted = Subject::create($data); $validSubjectIds[] = $inserted->id; } else { $subjectItem = Subject::where('id', intval($subject['id'])) ->where('object_id', $this->id) ->first(); if ($subjectItem) { $subjectItem->fill(Arr::only($subject, ['key', 'value']))->save(); $validSubjectIds[] = $subjectItem->id; } } } if ($validSubjectIds) { // remove old subjects Subject::whereNotIn('id', $validSubjectIds) ->where('object_id', $this->id) ->delete(); } else { Subject::where('object_id', $this->id) ->delete(); } return $this->subjects(); } public function duplicateSubjects(Campaign $campaign) { $subjects = $campaign->subjects; if (!$subjects) { return; } $formattedSubjects = []; foreach ($subjects as $subject) { $formattedSubjects[] = [ 'key' => $subject->key, 'value' => $subject['value'] ]; } if ($formattedSubjects) { $this->syncSubjects($formattedSubjects); } } public function scopeOfType($query, $status) { return $query->where('status', $status); } public function scopeArchived($query) { return $query->where('status', 0); } /** * @return \FluentCrm\Framework\Database\Orm\Relations\BelongsTo */ public function template() { return $this->belongsTo(__NAMESPACE__ . '\Template', 'template_id', 'ID'); } /** * One2Many: Campaign has many emails * * @return \FluentCrm\Framework\Database\Orm\Relations\hasMany */ public function emails() { return $this->hasMany( __NAMESPACE__ . '\CampaignEmail', 'campaign_id', 'id' ); } /** * TODO: emails should be filtered by status (draft, queue e.t.c.) * One2Many: Campaign has many emails * @return \FluentCrm\Framework\Database\Orm\Relations\hasMany */ public function campaign_emails() { return $this->hasMany( __NAMESPACE__ . '\CampaignEmail', 'campaign_id', 'id' )->where('email_type', 'campaign'); } /** * One2Many: Campaign has many subjects * @return \FluentCrm\Framework\Database\Orm\Relations\hasMany */ public function subjects() { return $this->hasMany(__NAMESPACE__ . '\Subject', 'object_id', 'id'); } /** * Add one or more subscribers to the campaign by list with filtering * @param $settings * @param bool $limit * @param int $offset * @return array */ public function subscribeBySegment($settings, $limit = false, $offset = 0) { $model = $this->getSubscribersModel($settings); $totalCount = $model->count(); if ($limit) { $model->limit($limit); } if ($offset) { $model->offset($offset); } $result = $this->subscribe($model, [], true); return [ 'result' => ($result) ? $result : 0, 'total_subscribed' => count($result), 'total_items' => $totalCount ]; } public function getSubscribersModel($settings = false) { if (!$settings) { $settings = $this->settings; } $filterType = Arr::get($settings, 'sending_filter', 'list_tag'); if ($filterType == 'list_tag') { $subscriberModel = $this->getSubscribeIdsByListModel($settings['subscribers'], 'subscribed'); if ($excludeItems = Arr::get($settings, 'excludedSubscribers')) { $formattedExcludedItems = []; foreach ($excludeItems as $item) { if (empty($item['list']) && empty($item['tag'])) { continue; } $formattedExcludedItems[] = $item; } if ($formattedExcludedItems) { $excludedModel = $this->getSubscribeIdsByListModel($excludeItems, 'subscribed'); $excludedModel->select('id'); $subscriberModel->whereNotIn('id', $excludedModel->getQuery()); } } return $subscriberModel; } if ($filterType == 'dynamic_segment') { $segmentSettings = Arr::get($settings, 'dynamic_segment', []); $segmentSettings['offset'] = 0; $segmentSettings['limit'] = false; /** * Filter the dynamic segment details based on the segment slug. * * This filter allows you to modify the details of a dynamic segment. * * @param array The details of the dynamic segment. * @param int $segmentSettings ['id'] The ID of the segment. * @param array { * Additional context for the segment. * * @type bool Whether to include the model in the context. * } * * @return array Modified segment details. * @since 2.5.93 * */ $segmentDetails = apply_filters('fluentcrm_dynamic_segment_' . $segmentSettings['slug'], [], $segmentSettings['id'], [ 'model' => true ]); if (!empty($segmentDetails['model'])) { $model = $segmentDetails['model']; $model->where('status', 'subscribed'); return $model; } return null; } if ($filterType == 'advanced_filters') { $query = new ContactsQuery([ 'with' => [], 'filter_type' => 'advanced', 'contact_status' => 'subscribed', 'filters_groups_raw' => $settings['advanced_filters'] ]); return $query->getModel(); } return null; } public function getSubscriberIdsBySegmentSettings($settings, $limit = false, $offset = 0) { $model = $this->getSubscribersModel($settings); if (!$model) { return [ 'subscriber_ids' => [], 'total_count' => 0 ]; } $totalCount = $model->count(); if ($limit) { $model->limit($limit); } if ($offset) { $model->offset($offset); } return [ 'subscriber_ids' => $model->get()->pluck('id')->toArray(), 'total_count' => $totalCount ]; } public function getSubscriberIdsCountBySegmentSettings($settings, $status = 'subscribed') { $model = $this->getSubscribersModel($settings); if ($model) { return $model->count(); } return 0; } /** * @param $query * @param $ids * @param $table * @param $objectType * @return mixed */ private function getSubQueryForLisTorTagFilter($query, $ids, $table, $objectType) { $prefix = 'fc_'; return $query->from($prefix . $table) ->join( $prefix . 'subscriber_pivot', $prefix . 'subscriber_pivot.object_id', '=', $prefix . $table . '.id' ) ->where($prefix . 'subscriber_pivot.object_type', $objectType) ->whereIn($prefix . $table . '.id', $ids) ->groupBy($prefix . 'subscriber_pivot.subscriber_id') ->select($prefix . 'subscriber_pivot.subscriber_id'); } /** * Get subscribers ids to by list with tag filtering * @param array $items * @param string $status contact status * @param int|boolean $limit limit * @param int $offset contact offset * @return array */ public function getSubscribeIdsByList($items, $status = 'subscribed', $limit = false, $offset = 0) { $model = $this->getSubscribeIdsByListModel($items, $status, $limit, $offset); $results = $model->get(); $ids = []; foreach ($results as $result) { $ids[] = $result->id; } return $ids; } /** * Get subscribers count to by list with tag filtering * @param array $items * @param string $status contact status * @param int|boolean $limit limit * @param int $offset contact offset * @return int */ public function getSubscribeIdsByListCount($items, $status = 'subscribed', $limit = false, $offset = 0) { $model = $this->getSubscribeIdsByListModel($items, $status, $limit, $offset); return $model->count(); } public function getSubscribeIdsByListModel($items, $status = 'subscribed', $limit = false, $offset = 0) { $query = Subscriber::where('status', $status); $queryGroups = []; $willSkip = false; $hasListFilter = false; $tagIds = []; foreach ($items as $item) { $listId = $item['list']; $tagId = $item['tag']; if (!$listId || !$tagId) { continue; } if ($listId == 'all' && $tagId == 'all') { $willSkip = true; } else if ($listId == 'all') { $queryGroups[] = ['tag_id' => $tagId]; $tagIds[] = $tagId; } else if ($tagId == 'all') { $hasListFilter = true; $queryGroups[] = ['list_id' => $listId]; } else { $hasListFilter = true; $tagIds[] = $tagId; $queryGroups[] = [ 'list_id' => $listId, 'tag_id' => $tagId ]; } } if (!$willSkip && !$hasListFilter && $tagIds) { $query->filterByTags($tagIds); } else if (!$willSkip && $queryGroups) { $query->where(function ($innerQuery) use ($queryGroups) { $type = 'where'; foreach ($queryGroups as $queryGroup) { $innerQuery->{$type}(function ($q) use ($queryGroup, $innerQuery) { foreach ($queryGroup as $type => $id) { if ($type == 'tag_id') { $q->whereIn('id', function ($query) use ($id) { return $this->getSubQueryForLisTorTagFilter($query, [$id], 'tags', 'FluentCrm\App\Models\Tag'); }); } else if ($type == 'list_id') { $q->whereIn('id', function ($query) use ($id) { return $this->getSubQueryForLisTorTagFilter($query, [$id], 'lists', 'FluentCrm\App\Models\Lists'); }); } } }); $type = 'orWhere'; } }); } if ($limit) { $query->limit($limit)->offset($offset); } return $query; } /** * Add one or more subscribers to the campaign * @param array $subscriberIds * @param array $emailArgs extra campaign_email args * @param bool $isModel if the $subscriberIds is collection or not * @return array */ public function subscribe($subscriberIds, $emailArgs = [], $isModel = false) { $updateIds = []; $mailHeaders = Helper::getMailHeadersFromSettings(Arr::get($this->settings, 'mailer_settings', [])); if ($isModel) { $subscribers = $subscriberIds; } else { $subscribers = Subscriber::whereIn('id', $subscriberIds)->get(); } $validStatuses = ['subscribed', 'transactional']; foreach ($subscribers as $subscriber) { if (!in_array($subscriber->status, $validStatuses)) { continue; // We don't want to send emails to non-subscribed members } $time = fluentCrmTimestamp(); $email = [ 'campaign_id' => $this->id, 'status' => $this->status, 'subscriber_id' => $subscriber->id, 'email_address' => $subscriber->email, 'email_headers' => $mailHeaders, 'email_hash' => Helper::generateEmailHash(), 'created_at' => $time, 'updated_at' => $time ]; $subjectItem = $this->guessEmailSubject(); $emailSubject = $this->email_subject; if ($subjectItem && !empty($subjectItem->value)) { $emailSubject = $subjectItem->value; $email['email_subject_id'] = $subjectItem->id; } /** * Filter the campaign email subject text. * * This filter allows you to modify the email subject text for a campaign. * * @param string $emailSubject The original email subject text. * @param object $subscriber The subscriber object. * * @return string The filtered email subject text. */ $email['email_subject'] = apply_filters('fluent_crm/parse_campaign_email_text', $emailSubject, $subscriber); $email['email_body'] = $this->email_body; if ($emailArgs) { $email = wp_parse_args($emailArgs, $email); } $inserted = CampaignEmail::create($email); $subscriber->campaign_id = $this->id; $subscriber->email_id = $inserted->id; $updateIds[] = $inserted->id; } $emailCount = $this->getEmailCount(); if ($emailCount != $this->recipients_count) { $this->recipients_count = $emailCount; $this->save(); } return $updateIds; } /** * Remove one or more subscribers from the campaign * @param array $subscriberIds * @return bool */ public function unsubscribe($subscriberIds) { $result = $this->emails()->whereIn('subscriber_id', $subscriberIds)->delete(); $this->recipients_count = $this->emails()->count(); $this->save(); return $result; } /** * Guess the subject by probability formula * @return Model Object or null */ public function guessEmailSubject() { // Cache subjects per campaign to avoid repeated DB queries during batch processing. // The weighted random selection still runs per call for proper A/B distribution. static $subjectsCache = []; if (isset($subjectsCache[$this->id])) { $subjects = $subjectsCache[$this->id]; } else { $subjects = $this->subjects()->get(); $subjectsCache[$this->id] = $subjects; } if ($subjects->isEmpty()) { return null; } $priorities = $subjects->pluck('key')->toArray(); $count = count($priorities); $num = wp_rand(0, array_sum($priorities)); $i = $n = 0; while ($i < $count) { $n += $priorities[$i]; if ($n >= $num) break; $i++; } return isset($subjects[$i]) ? $subjects[$i] : null; } public function getParsedText($text, $subscriber) { return Parser::parse($text, $subscriber); } public function filterDuplicateSubscribers($subscriberIds, $subscribers) { $existingIds = CampaignEmail::where('campaign_id', $this->id) ->whereIn('subscriber_id', $subscribers->pluck('id')->toArray()) ->get()->pluck('subscriber_id')->toArray(); return $subscribers->filter(function ($subscriber) use ($existingIds) { return !in_array($subscriber->id, $existingIds); }); } public function archive() { $this->status = 0; $this->save(); return $this; } public function getUtmParams() { if ($this->utm_status) { return array_filter([ 'utm_source' => $this->utm_source, 'utm_medium' => $this->utm_medium, 'utm_campaign' => $this->utm_campaign, 'utm_term' => $this->utm_term, 'utm_content' => $this->utm_content ]); } return []; } public function stats() { $totalEmails = CampaignEmail::where('campaign_id', $this->id) ->count(); $totalSent = CampaignEmail::where('campaign_id', $this->id) ->where('status', 'sent') ->count(); if ($this->getOpenTrackingStatus(false) === 'anonymous') { $views = fluentcrm_get_campaign_meta($this->id, '_ano_open_count', true); if (!$views) { $views = 0; } } else { $views = CampaignEmail::where('campaign_id', $this->id) ->where('is_open', 1) ->count(); } if ($this->getClickTrackingStatus(false) === 'anonymous') { $clickItems = fluentcrm_get_campaign_meta($this->id, '_ano_url_clicks', true); $clicks = 0; if ($clickItems && is_array($clickItems)) { $clicks = array_sum($clickItems); } } else { $clicks = CampaignEmail::where('campaign_id', $this->id) ->whereNotNull('click_counter') ->count(); } $unSubscribed = CampaignUrlMetric::where('campaign_id', $this->id) ->where('type', 'unsubscribe') ->distinct() ->count('subscriber_id'); $revenue = fluentcrm_get_campaign_meta($this->id, '_campaign_revenue'); $stats = [ 'total' => $totalEmails, 'sent' => $totalSent, 'clicks' => $clicks, 'views' => $views, 'unsubscribers' => $unSubscribed ]; if ($revenue && $revenue->value) { $data = (array)$revenue->value; foreach ($data as $currency => $cents) { if ($cents && $currency !== 'orderIds') { $stats['revenue'] = [ 'label' => __('Revenue', 'fluent-crm') . ' (' . $currency . ')', 'total' => number_format($cents / 100, 2), 'currency' => $currency ]; } } } return $stats; } public function getEmailCount() { return fluentCrmDb()->table('fc_campaign_emails') ->where('campaign_id', $this->id) ->count(); } public function maybeDeleteDuplicates() { global $wpdb; $table = $wpdb->prefix . 'fc_campaign_emails'; // Quick check: do any duplicates exist? Most campaigns won't have any. // Exclude NULL subscriber_ids — SQL NULL != NULL so the self-join can't match them. $hasDuplicates = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$table} WHERE campaign_id = %d AND subscriber_id IS NOT NULL GROUP BY subscriber_id HAVING COUNT(*) > 1 LIMIT 1", $this->id )); if (!$hasDuplicates) { return $this; } // Delete duplicates, keeping the row with the lowest id per subscriber. $deleted = $wpdb->query($wpdb->prepare( "DELETE e1 FROM {$table} e1 INNER JOIN {$table} e2 ON e1.campaign_id = e2.campaign_id AND e1.subscriber_id = e2.subscriber_id AND e1.id > e2.id WHERE e1.campaign_id = %d AND e1.subscriber_id IS NOT NULL", $this->id )); if ($deleted) { $emailCount = $this->getEmailCount(); if ($emailCount != $this->recipients_count) { $this->recipients_count = $emailCount; $this->save(); } } return $this; } public function getHash() { $hash = fluentcrm_get_campaign_meta($this->id, '_campaign_hash', true); if ($hash) { return $hash; } $hash = md5(wp_rand(100, 10000) . '_' . $this->id . '_' . $this->title . '_' . time() . '_' . wp_generate_uuid4()); $hash = str_replace('e', 'd', $hash); fluentcrm_update_campaign_meta($this->id, '_campaign_hash', $hash); return $hash; } public function deleteCampaignData() { CampaignEmail::where('campaign_id', $this->id)->delete(); CampaignUrlMetric::where('campaign_id', $this->id)->delete(); Meta::where('object_id', $this->id) ->where('object_type', 'FluentCrm\App\Models\Campaign') ->delete(); return $this; } public function rangedScheduleDates() { $settings = $this->settings; if (Arr::get($settings, 'sending_type') != 'range_schedule') { return null; } $ranges = Arr::get($settings, 'schedule_range', ['', '']); if (!$ranges) { return null; } return [ 'start' => gmdate('Y-m-d H:i:s', $ranges[0]), 'end' => gmdate('Y-m-d H:i:s', $ranges[1]) ]; } public function getEmailScheduleAt() { $settings = $this->settings; if (Arr::get($settings, 'sending_type') != 'range_schedule') { return $this->scheduled_at; } // this is a range selector $ranges = Arr::get($settings, 'schedule_range', [$this->scheduled_at, $this->scheduled_at]); $timeStamp = random_int($ranges[0], $ranges[1]); if ($timeStamp < current_time('timestamp')) { $timeStamp = current_time('timestamp') + 60; } return gmdate('Y-m-d H:i:s', $timeStamp); } public function getShareableUrl() { $shareId = fluentcrm_get_campaign_meta($this->id, '_campaign_share_id', true); if (!$shareId) { $shareId = md5($this->getHash() . '_' . $this->id . '_' . time()); fluentcrm_update_campaign_meta($this->id, '_campaign_share_id', $shareId); } return add_query_arg([ 'fluentcrm' => 1, 'route' => 'email_preview', 'fc_newsletter' => $shareId ], site_url()); } public function labelsTerm() { return $this->belongsToMany(Label::class, 'fc_term_relations', 'object_id', 'term_id') ->wherePivot('object_type', __CLASS__); } public function labels() { $labelIds = TermRelation::where('object_id', $this->id) ->where('object_type', __CLASS__) ->pluck('term_id') ->toArray(); return Label::whereIn('id', $labelIds)->get(); } public function getFormattedLabels() { $labels = $this->labels(); return $labels->map(function ($label) { return [ 'id' => $label->id, 'slug' => $label->slug, 'title' => $label->title, 'color' => $label->settings['color'] ?? '' ]; }); } public function attachLabels($labelIds) { $labelIds = is_array($labelIds) ? $labelIds : [$labelIds]; if (empty($labelIds)) { return $this; } $existingLabelIds = TermRelation::where('object_id', $this->id) ->where('object_type', __CLASS__) ->pluck('term_id') ->toArray(); $newLabelIds = array_diff($labelIds, $existingLabelIds); if (!empty($newLabelIds)) { foreach ($newLabelIds as $labelId) { TermRelation::create([ 'object_id' => $this->id, 'object_type' => __CLASS__, 'term_id' => $labelId ]); } } return $this; } public function detachLabels($labelIds) { $labelIds = is_array($labelIds) ? $labelIds : [$labelIds]; if (empty($labelIds)) { return $this; } TermRelation::where('object_id', $this->id) ->where('object_type', __CLASS__) ->whereIn('term_id', $labelIds) ->delete(); return $this; } public function getOpenTrackingStatus($globalFallback = true) { $settings = $this->settings; if (isset($settings['open_tracker'])) { $status = $settings['open_tracker']; return $status; } if ($globalFallback) { $status = fluentcrmTrackEmailOpen(); return $status; } return null; } public function getClickTrackingStatus($globalFallback = true) { $settings = $this->settings; if (isset($settings['click_tracker'])) { $status = $settings['click_tracker']; return $status; } if ($globalFallback) { $status = fluentcrmTrackClicking(); return $status; } return null; } }