1811 lines
69 KiB
PHP
1811 lines
69 KiB
PHP
<?php
|
|
|
|
namespace FluentCrm\App\Http\Controllers;
|
|
|
|
use FluentCrm\App\Hooks\Handlers\Scheduler;
|
|
use FluentCrm\App\Models\Campaign;
|
|
use FluentCrm\App\Models\CampaignEmail;
|
|
use FluentCrm\App\Models\CampaignUrlMetric;
|
|
use FluentCrm\App\Models\Subscriber;
|
|
use FluentCrm\App\Models\Template;
|
|
use FluentCrm\App\Services\BlockParser;
|
|
use FluentCrm\App\Services\BlockParserHelper;
|
|
use FluentCrm\App\Services\CampaignProcessor;
|
|
use FluentCrm\App\Services\Helper;
|
|
use FluentCrm\App\Services\Libs\Mailer\Handler;
|
|
use FluentCrm\App\Services\Libs\Mailer\Mailer;
|
|
use FluentCrm\App\Services\Sanitize;
|
|
use FluentCrm\Framework\Http\Request\Request;
|
|
use FluentCrm\Framework\Support\Arr;
|
|
use FluentCrm\Framework\Support\Str;
|
|
|
|
/**
|
|
* CampaignController - REST API Handler Class
|
|
*
|
|
* REST API Handler
|
|
*
|
|
* @package FluentCrm\App\Http
|
|
*
|
|
* @version 1.0.0
|
|
*/
|
|
class CampaignController extends Controller
|
|
{
|
|
public function campaigns(Request $request)
|
|
{
|
|
$search = sanitize_text_field($request->get('searchBy', ''));
|
|
$status = $request->get('statuses', []);
|
|
$status = is_array($status) ? array_map('sanitize_key', $status) : [];
|
|
|
|
$order = strtoupper($request->get('sort_type', ''));
|
|
$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'DESC';
|
|
|
|
$orderBy = sanitize_key($request->get('sort_by', ''));
|
|
// Re-key `with` to a flat, integer-indexed list and sanitize each value.
|
|
// Legitimate callers always send a plain list of names (e.g. with[]=stats);
|
|
// discarding any caller-supplied string keys closes the relation-name
|
|
// injection (a request shaped like with[<html>]=stats) without restricting
|
|
// which names are allowed, so no existing core/add-on caller is affected.
|
|
$with = array_values(array_map('sanitize_key', (array) $request->get('with', [])));
|
|
|
|
$labels = $request->get('labels', []);
|
|
$labels = is_array($labels) ? array_map('intval', $labels) : [];
|
|
$labels = array_filter($labels); // labels are id
|
|
|
|
if (empty($orderBy)) {
|
|
$orderBy = 'created_at';
|
|
}
|
|
|
|
$campaignQuery = Campaign::when($status, function ($query) use ($status) {
|
|
return $query->whereIn('status', $status);
|
|
})->when($search, function ($query) use ($search) {
|
|
return $query->where('title', 'LIKE', "%$search%");
|
|
})
|
|
->orderBy($orderBy, ($order == 'ASC') ? 'ASC' : 'DESC');
|
|
|
|
if (!empty($labels)) {
|
|
$campaignQuery->whereHas('labelsTerm', function ($query) use ($labels) {
|
|
$query->whereIn('term_id', $labels);
|
|
});
|
|
}
|
|
|
|
$campaigns = $campaignQuery->paginate();
|
|
if (in_array('stats', $with)) {
|
|
$campaignIds = $campaigns->pluck('id')->toArray();
|
|
|
|
if ($campaignIds) {
|
|
// Batch email stats in a single GROUP BY query
|
|
$emailStats = fluentCrmDb()->table('fc_campaign_emails')
|
|
->select('campaign_id')
|
|
->selectRaw('COUNT(*) as total')
|
|
->selectRaw("SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) as sent")
|
|
->selectRaw("SUM(CASE WHEN is_open = 1 THEN 1 ELSE 0 END) as views")
|
|
->selectRaw("SUM(CASE WHEN click_counter IS NOT NULL THEN 1 ELSE 0 END) as clicks")
|
|
->whereIn('campaign_id', $campaignIds)
|
|
->groupBy('campaign_id')
|
|
->get()
|
|
->keyBy('campaign_id');
|
|
|
|
// Batch unsubscribe counts
|
|
$unsubCounts = fluentCrmDb()->table('fc_campaign_url_metrics')
|
|
->select('campaign_id')
|
|
->selectRaw('COUNT(DISTINCT subscriber_id) as total')
|
|
->where('type', 'unsubscribe')
|
|
->whereIn('campaign_id', $campaignIds)
|
|
->groupBy('campaign_id')
|
|
->get()
|
|
->keyBy('campaign_id');
|
|
|
|
// Batch meta (next_step, revenue, anonymous tracking)
|
|
$metaItems = fluentCrmDb()->table('fc_meta')
|
|
->whereIn('object_id', $campaignIds)
|
|
->where('object_type', 'FluentCrm\App\Models\Campaign')
|
|
->whereIn('key', ['_next_config_step', '_campaign_revenue', '_ano_open_count', '_ano_url_clicks'])
|
|
->get();
|
|
|
|
$metaMap = [];
|
|
foreach ($metaItems as $meta) {
|
|
$metaMap[$meta->object_id][$meta->key] = $meta->value;
|
|
}
|
|
|
|
// Batch labels: get relations then load all labels at once
|
|
$labelRelations = fluentCrmDb()->table('fc_term_relations')
|
|
->whereIn('object_id', $campaignIds)
|
|
->where('object_type', 'FluentCrm\App\Models\Campaign')
|
|
->get();
|
|
|
|
$labelIdsByCampaign = [];
|
|
$allLabelIds = [];
|
|
foreach ($labelRelations as $rel) {
|
|
$labelIdsByCampaign[$rel->object_id][] = $rel->term_id;
|
|
$allLabelIds[] = $rel->term_id;
|
|
}
|
|
|
|
$allLabels = [];
|
|
if ($allLabelIds) {
|
|
$allLabels = fluentCrmDb()->table('fc_terms')
|
|
->whereIn('id', array_unique($allLabelIds))
|
|
->where('taxonomy_name', 'global_label')
|
|
->get()
|
|
->keyBy('id');
|
|
}
|
|
|
|
foreach ($campaigns as $campaign) {
|
|
$stat = $emailStats[$campaign->id] ?? null;
|
|
$unsub = $unsubCounts[$campaign->id] ?? null;
|
|
$campMeta = $metaMap[$campaign->id] ?? [];
|
|
|
|
// Views: use anonymous count if open tracking is anonymous
|
|
if ($campaign->getOpenTrackingStatus(false) === 'anonymous') {
|
|
$views = (int) ($campMeta['_ano_open_count'] ?? 0);
|
|
} else {
|
|
$views = $stat ? (int) $stat->views : 0;
|
|
}
|
|
|
|
// Clicks: use anonymous aggregated clicks if click tracking is anonymous
|
|
if ($campaign->getClickTrackingStatus(false) === 'anonymous') {
|
|
$clickData = $campMeta['_ano_url_clicks'] ?? null;
|
|
$clicks = 0;
|
|
if ($clickData) {
|
|
$clickItems = maybe_unserialize($clickData);
|
|
if (is_array($clickItems)) {
|
|
$clicks = array_sum($clickItems);
|
|
}
|
|
}
|
|
} else {
|
|
$clicks = $stat ? (int) $stat->clicks : 0;
|
|
}
|
|
|
|
$stats = [
|
|
'total' => $stat ? (int) $stat->total : 0,
|
|
'sent' => $stat ? (int) $stat->sent : 0,
|
|
'views' => $views,
|
|
'clicks' => $clicks,
|
|
'unsubscribers' => $unsub ? (int) $unsub->total : 0,
|
|
];
|
|
|
|
// Revenue from batch meta
|
|
$revenueRaw = $campMeta['_campaign_revenue'] ?? null;
|
|
if ($revenueRaw) {
|
|
$data = (array) maybe_unserialize($revenueRaw);
|
|
foreach ($data as $currency => $cents) {
|
|
if ($cents && $currency !== 'orderIds') {
|
|
$stats['revenue'] = [
|
|
'label' => __('Revenue', 'fluent-crm') . ' (' . $currency . ')',
|
|
'total' => number_format($cents / 100, 2),
|
|
'currency' => $currency
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$campaign->stats = $stats;
|
|
$campaign->next_step = $campMeta['_next_config_step'] ?? false;
|
|
|
|
// Labels from batch
|
|
$campaignLabelIds = $labelIdsByCampaign[$campaign->id] ?? [];
|
|
$campaign->labels = array_values(array_filter(array_map(function ($labelId) use ($allLabels) {
|
|
$label = $allLabels[$labelId] ?? null;
|
|
if (!$label) {
|
|
return null;
|
|
}
|
|
$settings = maybe_unserialize($label->settings);
|
|
return [
|
|
'id' => $label->id,
|
|
'slug' => $label->slug,
|
|
'title' => $label->title,
|
|
'color' => is_array($settings) ? ($settings['color'] ?? '') : ''
|
|
];
|
|
}, $campaignLabelIds)));
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'campaigns' => $campaigns
|
|
];
|
|
}
|
|
|
|
public function create(Request $request)
|
|
{
|
|
$title = $request->get('title');
|
|
if ($title !== null && $title !== '') {
|
|
$data = $this->validate($request->only('title'), [
|
|
'title' => 'required|unique:fc_campaigns',
|
|
]);
|
|
$data['title'] = sanitize_text_field($data['title']);
|
|
} else {
|
|
$defaultTitle = __('Untitled', 'fluent-crm');
|
|
$data['title'] = $this->ensureUniqueDefaultTitle($defaultTitle);
|
|
}
|
|
|
|
$campaign = Campaign::create($data)->load([
|
|
'template', 'subjects'
|
|
]);
|
|
|
|
do_action('fluent_crm/campaign_created', $campaign);
|
|
|
|
return $this->sendSuccess(
|
|
$campaign
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return a unique title for default "Untitled" (e.g. Untitled, Untitled 2, ...).
|
|
*/
|
|
protected function ensureUniqueDefaultTitle($baseTitle)
|
|
{
|
|
$title = sanitize_text_field($baseTitle);
|
|
if (!Campaign::where('title', $title)->exists()) {
|
|
return $title;
|
|
}
|
|
$count = 2;
|
|
while (Campaign::where('title', $title . ' ' . $count)->exists()) {
|
|
$count++;
|
|
}
|
|
return $title . ' ' . $count;
|
|
}
|
|
|
|
public function campaign(Request $request, $id)
|
|
{
|
|
if ($request->exists('viewCampaign')) {
|
|
$campaign = Campaign::findOrFail($id);
|
|
$emails = $campaign->emails()->with('subscriber')->paginate();
|
|
return $this->sendSuccess(['campaign' => $campaign, 'emails' => $emails]);
|
|
}
|
|
|
|
// Re-key `with` to a flat, integer-indexed list and sanitize each value
|
|
// before it reaches Campaign::with(). The vulnerability was that a request
|
|
// shaped like with[<html>]=subjects put attacker input in the array KEY,
|
|
// which array_map('sanitize_key', ...) never touched, so it flowed into
|
|
// with() as a relation name and surfaced verbatim in the reflected
|
|
// "relationship not found" exception (XSS). Discarding keys with array_values
|
|
// closes that path, and sanitize_key keeps each value to [a-z0-9_-] so a
|
|
// value can't carry markup either. This keeps the exact value-sanitization the
|
|
// endpoint already had and does not restrict which relations are allowed, so
|
|
// no core/add-on caller is broken.
|
|
$with = array_values(array_map('sanitize_key', (array) $request->get('with', [])));
|
|
if ($with) {
|
|
$campaign = Campaign::with($with)->find($id);
|
|
} else {
|
|
$campaign = Campaign::findOrFail($id);
|
|
}
|
|
|
|
/**
|
|
* Determine the email campaign data in FluentCRM.
|
|
*
|
|
* This filter allows modification of the email campaign data before it is used.
|
|
*
|
|
* @param array $campaign The email campaign data.
|
|
* @since 2.6.51
|
|
*
|
|
*/
|
|
$campaign = apply_filters('fluent_crm/campaign_data', $campaign);
|
|
|
|
$templates = Template::emailTemplates()
|
|
->select(['ID', 'post_title'])
|
|
->orderBy('ID', 'desc')
|
|
->get();
|
|
|
|
$campaign->server_time = current_time('mysql');
|
|
|
|
return $this->sendSuccess(compact('campaign', 'templates'));
|
|
}
|
|
|
|
public function campaignEmails(Request $request, $campaignId)
|
|
{
|
|
$filterType = $request->get('filter_type');
|
|
$search = $request->getSafe('search', 'sanitize_text_field');
|
|
|
|
$campaign = Campaign::withoutGlobalScope('type')->findOrFail($campaignId);
|
|
|
|
$emailsQuery = CampaignEmail::with(['subscriber'])->where('campaign_id', $campaign->id);
|
|
|
|
if ($search) {
|
|
$emailsQuery->whereHas('subscriber', function ($q) use ($search) {
|
|
$q->searchBy($search);
|
|
});
|
|
}
|
|
|
|
$filterType = in_array($filterType, ['click', 'view', 'unopened', 'failed']) ? $filterType : '';
|
|
|
|
if ($filterType == 'click') {
|
|
$emailsQuery = $emailsQuery->whereNotNull('click_counter')
|
|
->orderBy('click_counter', 'DESC');
|
|
} else if ($filterType == 'view') {
|
|
$emailsQuery = $emailsQuery->where('is_open', '>', 0)
|
|
->orderBy('is_open', 'DESC');
|
|
} else if ($filterType == 'unopened') {
|
|
$emailsQuery = $emailsQuery->where('is_open', '==', 0)
|
|
->orderBy('is_open', 'DESC');
|
|
} else if ($filterType == 'failed') {
|
|
$emailsQuery = $emailsQuery->where('status', 'failed')
|
|
->orderBy('id', 'DESC');
|
|
}
|
|
|
|
$emails = $emailsQuery->paginate();
|
|
|
|
$data = [
|
|
'emails' => $emails,
|
|
'failed_counts' => CampaignEmail::where('campaign_id', $campaign->id)->where('status', 'failed')->count()
|
|
];
|
|
|
|
if ($request->get('with_campaign')) {
|
|
$campaign->open_tracking_status = $campaign->getOpenTrackingStatus();
|
|
$campaign->click_tracking_status = $campaign->getClickTrackingStatus();
|
|
$data['campaign'] = $campaign;
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
public function updateSingleCampaignSimulate(Request $request)
|
|
{
|
|
$id = intval($request->get('campaign_id'));
|
|
return $this->update($request, $id);
|
|
}
|
|
|
|
public function update(Request $request, $id)
|
|
{
|
|
$data = $this->validate($this->request->except(['action']), [
|
|
"title" => "required|unique:fc_campaigns,title,{$id},id",
|
|
]);
|
|
|
|
$updateData = Arr::only($data, [
|
|
'title',
|
|
'slug',
|
|
'template_id',
|
|
'email_subject',
|
|
'email_pre_header',
|
|
'email_body',
|
|
'utm_status',
|
|
'utm_source',
|
|
'utm_medium',
|
|
'utm_campaign',
|
|
'utm_term',
|
|
'utm_content',
|
|
'scheduled_at',
|
|
'design_template'
|
|
]);
|
|
|
|
if (!empty($data['settings'])) {
|
|
$updateData['settings'] = $data['settings'];
|
|
|
|
if (!empty($data['settings']['template_config']['design_template'])) {
|
|
$updateData['design_template'] = $data['settings']['template_config']['design_template'];
|
|
}
|
|
}
|
|
|
|
$updateData = Sanitize::campaign($updateData);
|
|
|
|
$campaign = Campaign::findOrFail($id);
|
|
|
|
$campaignSubjects = [];
|
|
|
|
if (isset($data['update_subjects'])) {
|
|
$campaignSubjects = Arr::get($data, 'subjects', []);
|
|
|
|
// Validate A/B subjects before saving campaign fields to avoid partial step updates.
|
|
$validSubjects = array_filter((array)$campaignSubjects, function ($subject) {
|
|
return !empty($subject['key']) && trim((string)Arr::get($subject, 'value', ''));
|
|
});
|
|
|
|
if (!empty($campaignSubjects) && count($validSubjects) < 2) {
|
|
return $this->sendError([
|
|
'message' => __('Please provide at least two Subject Lines for A/B Test.', 'fluent-crm')
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
$campaign->fill($updateData)->save();
|
|
|
|
if (isset($data['update_subjects'])) {
|
|
$campaign->syncSubjects($campaignSubjects);
|
|
$campaign = Campaign::with(['subjects'])->find($id);
|
|
} else {
|
|
$campaign = Campaign::findOrFail($id);
|
|
}
|
|
|
|
$nextStep = Arr::get($data, 'next_step');
|
|
|
|
if ($nextStep) {
|
|
|
|
if ($nextStep == 1) {
|
|
do_action('fluent_crm/update_campaign_compose', $data, $campaign);
|
|
} else if ($nextStep == 2) {
|
|
$footerDisabled = Arr::get(Helper::getFooterConfig($campaign), 'disable_footer') === 'yes';
|
|
if (($footerDisabled || $campaign->design_template === 'visual_builder') && !Helper::hasComplianceText($campaign->email_body)) {
|
|
return $this->sendError([
|
|
'compliance_failed' => true,
|
|
'message' => '##crm.manage_subscription_url## or ##crm.unsubscribe_url## or {{crm_global_email_footer}} string is required for compliance. Please include unsubscription or manage subscription link. <br />Please go to the previous screen and add the unsubscribe link'
|
|
]);
|
|
}
|
|
do_action('fluent_crm/update_campaign_subjects', $data, $campaign);
|
|
}
|
|
|
|
fluentcrm_update_campaign_meta($id, '_next_config_step', $nextStep);
|
|
}
|
|
|
|
do_action('fluent_crm/campaign_data_updated', $campaign, $data);
|
|
|
|
return $this->sendSuccess([
|
|
'campaign' => $campaign
|
|
]);
|
|
}
|
|
|
|
public function updateStep(Request $request, $id)
|
|
{
|
|
$step = intval($request->get('next_step'));
|
|
fluentcrm_update_campaign_meta($id, '_next_config_step', $step);
|
|
return [
|
|
'message' => __('step saved', 'fluent-crm')
|
|
];
|
|
}
|
|
|
|
public function validateRecipientsSelection(Request $request)
|
|
{
|
|
$items = $request->get('items');
|
|
$campaignId = absint($request->get('campaign_id'));
|
|
$campaign = Campaign::findOrFail($campaignId);
|
|
$subscribersIds = $campaign->getSubscribeIdsByList($items);
|
|
|
|
if (!$subscribersIds) {
|
|
return $this->sendError([
|
|
'message' => __('Sorry! No subscribers found based on your selection', 'fluent-crm'),
|
|
'count' => 0
|
|
]);
|
|
}
|
|
|
|
$settings = $campaign->settings;
|
|
$settings['subscribers'] = $items;
|
|
$campaign->settings = $settings;
|
|
$campaign->save();
|
|
|
|
return $this->sendSuccess([
|
|
'count' => count($subscribersIds)
|
|
]);
|
|
}
|
|
|
|
public function draftRecipients(Request $request, $campaignId)
|
|
{
|
|
$campaign = Campaign::findOrFail($campaignId);
|
|
|
|
|
|
$subscribersSettings = [
|
|
'subscribers' => $request->get('subscribers'),
|
|
'excludedSubscribers' => $request->get('excludedSubscribers'),
|
|
'sending_filter' => $request->get('sending_filter', 'list_tag'),
|
|
'dynamic_segment' => $request->get('dynamic_segment'),
|
|
'advanced_filters' => Helper::parseArrayOrJson($request->get('advanced_filters'))
|
|
];
|
|
|
|
// Sanitize the inputs
|
|
$sanitizedSubscribersSettings['sending_filter'] = sanitize_text_field($subscribersSettings['sending_filter']);
|
|
|
|
|
|
$count = (new Campaign())->getSubscriberIdsCountBySegmentSettings($subscribersSettings);
|
|
|
|
if (!$count) {
|
|
return $this->sendError([
|
|
'message' => __('Sorry no subscribers found based on your selection', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
$campaign->campaign_emails()->delete();
|
|
$campaign->status = 'draft';
|
|
$campaign->recipients_count = $count;
|
|
$campaign->settings = wp_parse_args($subscribersSettings, $campaign->settings);
|
|
$campaign->save();
|
|
fluentcrm_update_campaign_meta($campaign->id, '_recipient_processed', 0);
|
|
fluentcrm_update_campaign_meta($campaign->id, '_last_recipient_id', 0);
|
|
|
|
do_action('fluent_crm/campaign_recipients_query_updated', $campaign);
|
|
|
|
return [
|
|
'message' => __('Recipient settings has been updated', 'fluent-crm'),
|
|
'count' => $count
|
|
];
|
|
}
|
|
|
|
public function recipientsCount(Request $request, $campaignId)
|
|
{
|
|
$campaign = Campaign::withoutGlobalScope('type')->findOrFail($campaignId);
|
|
$preProcessedStatuses = [
|
|
'draft',
|
|
'processing',
|
|
'pending-scheduled'
|
|
];
|
|
|
|
if (in_array($campaign->status, $preProcessedStatuses)) {
|
|
$count = $campaign->getSubscribersModel()->count();
|
|
} else {
|
|
$count = $campaign->recipients_count;
|
|
}
|
|
|
|
return [
|
|
'estimated_count' => $count
|
|
];
|
|
}
|
|
|
|
|
|
/*
|
|
* TODO: This method is currently not in use. We will keep it for reference for now. We will remove in the immediate next version
|
|
* Found since v3.0.0
|
|
*/
|
|
// public function subscribe(Request $request, $campaignId)
|
|
// {
|
|
// $startTime = microtime(true);
|
|
// $campaign = Campaign::findOrFail($campaignId);
|
|
|
|
// do_action('fluentcrm_campaign_status_active', $campaign);
|
|
|
|
// $page = (int)$this->request->get('page', 1);
|
|
// /**
|
|
// * Determine the number of subscribers to process per request in FluentCRM Email Campaign.
|
|
// *
|
|
// * This filter allows you to modify the number of subscribers that are processed
|
|
// * in a single request when handling email campaigns.
|
|
// *
|
|
// * @param int The number of subscribers to process per request. Default is 90.
|
|
// * @since 2.7.0
|
|
// *
|
|
// */
|
|
// $limit = (int)apply_filters('fluent_crm/process_subscribers_per_request', 90);
|
|
|
|
// $subscribersSettings = [
|
|
// 'subscribers' => $request->get('subscribers'),
|
|
// 'excludedSubscribers' => $request->get('excludedSubscribers'),
|
|
// 'sending_filter' => $request->get('sending_filter', 'list_tag'),
|
|
// 'dynamic_segment' => $request->get('dynamic_segment'),
|
|
// 'advanced_filters' => Helper::parseArrayOrJson($request->get('advanced_filters'))
|
|
// ];
|
|
|
|
// $offset = 0;
|
|
// $runTime = 40;
|
|
|
|
// if ($page == 1) {
|
|
// $runTime = 15;
|
|
// $campaign->campaign_emails()->delete();
|
|
// $campaign->settings = wp_parse_args($subscribersSettings, $campaign->settings);
|
|
// $campaign->save();
|
|
// fluentcrm_update_campaign_meta($campaign->id, '_recipient_processed', 0);
|
|
// } else {
|
|
// $offset = (int)fluentcrm_get_campaign_meta($campaign->id, '_recipient_processed', true);
|
|
// }
|
|
|
|
// $subscribeStatus = $campaign->subscribeBySegment($subscribersSettings, $limit, $offset);
|
|
// fluentcrm_update_campaign_meta($campaign->id, '_recipient_processed', $campaign->recipients_count);
|
|
|
|
// $willRun = true;
|
|
|
|
// while ($willRun && ((microtime(true) - $startTime) < $runTime) && !fluentCrmIsMemoryExceeded()) {
|
|
// $campaign = Campaign::findOrFail($campaignId);
|
|
// $willRun = !!$subscribeStatus['result'];
|
|
|
|
// if ($willRun) {
|
|
// $subscribeStatus = $campaign->subscribeBySegment($subscribersSettings, $limit, $campaign->recipients_count);
|
|
// fluentcrm_update_campaign_meta($campaign->id, '_recipient_processed', $campaign->recipients_count);
|
|
// }
|
|
// }
|
|
|
|
// $hasMore = !!$subscribeStatus['result'];
|
|
|
|
// if (!$hasMore) {
|
|
// $campaign = Campaign::findOrFail($campaignId);
|
|
// fluentcrm_update_campaign_meta($campaign->id, '_recipient_processed', 0);
|
|
// if (!$campaign->recipients_count) {
|
|
// return $this->sendError([
|
|
// 'message' => __('Sorry, No subscribers found based on your filters', 'fluent-crm')
|
|
// ]);
|
|
// }
|
|
// $campaign->maybeDeleteDuplicates();
|
|
// }
|
|
|
|
// if ($subscribeStatus['total_items']) {
|
|
// return $this->sendSuccess([
|
|
// 'has_more' => $hasMore,
|
|
// 'count' => $campaign->recipients_count,
|
|
// 'total_items' => $subscribeStatus['total_items'],
|
|
// 'page_total' => ceil($subscribeStatus['total_items'] / $limit),
|
|
// 'next_page' => $page + 1,
|
|
// 'execution_time' => microtime(true) - $startTime,
|
|
// 'memory' => fluentCrmIsMemoryExceeded(),
|
|
// 'memory_limit' => fluentCrmGetMemoryLimit(),
|
|
// 'memory_usage' => memory_get_usage(true)
|
|
// ]);
|
|
// }
|
|
|
|
// if ($campaign->recipients_count) {
|
|
// return [
|
|
// 'has_more' => false,
|
|
// 'count' => $campaign->recipients_count
|
|
// ];
|
|
// }
|
|
|
|
// return $this->sendError([
|
|
// 'message' => __('Sorry, No subscribers found based on your filters', 'fluent-crm')
|
|
// ]);
|
|
// }
|
|
|
|
public function getContactEstimation(Request $request)
|
|
{
|
|
$start_time = microtime(true);
|
|
|
|
$filterType = $request->get('sending_filter', 'list_tag');
|
|
|
|
$subscribersSettings = [
|
|
'sending_filter' => $filterType
|
|
];
|
|
|
|
if ($filterType == 'list_tag') {
|
|
$subscribersSettings['subscribers'] = $request->get('subscribers', []);
|
|
$subscribersSettings['excludedSubscribers'] = $request->get('excludedSubscribers', []);
|
|
} else if ($filterType == 'dynamic_segment') {
|
|
$subscribersSettings['dynamic_segment'] = $request->get('dynamic_segment', []);
|
|
} else if ($filterType == 'advanced_filters') {
|
|
$subscribersSettings['advanced_filters'] = Helper::parseArrayOrJson($request->get('advanced_filters'));
|
|
} else {
|
|
return [
|
|
'count' => 0
|
|
];
|
|
}
|
|
|
|
$count = (new Campaign())->getSubscriberIdsCountBySegmentSettings($subscribersSettings);
|
|
|
|
return [
|
|
'count' => $count,
|
|
'execution_time' => microtime(true) - $start_time
|
|
];
|
|
}
|
|
|
|
public function deleteCampaignEmails(Request $request, $campaignId)
|
|
{
|
|
$selectionIds = array_filter($request->get('email_ids'), 'intval');
|
|
|
|
if ($selectionIds) {
|
|
CampaignEmail::where('campaign_id', $campaignId)
|
|
->whereIn('id', $selectionIds)
|
|
->delete();
|
|
}
|
|
|
|
$newCount = CampaignEmail::where('campaign_id', $campaignId)
|
|
->count();
|
|
|
|
Campaign::where('id', $campaignId)->update([
|
|
'recipients_count' => $newCount
|
|
]);
|
|
|
|
return $this->sendSuccess([
|
|
'message' => __('Selected emails are deleted', 'fluent-crm'),
|
|
'recipients_count' => $newCount
|
|
]);
|
|
}
|
|
|
|
public function schedule(Request $request, $campaignId)
|
|
{
|
|
$scheduleAt = $request->get('scheduled_at');
|
|
$campaign = Campaign::findOrFail($campaignId);
|
|
|
|
if ($campaign->status != 'draft') {
|
|
return $this->sendError([
|
|
'message' => __('Campaign status is not in draft status. Please reload the page', 'fluent-crm')
|
|
], 422);
|
|
}
|
|
|
|
if (!$campaign->recipients_count) {
|
|
return $this->sendError([
|
|
'message' => __('No recipients found for this campaign. Please add recipients first.', 'fluent-crm')
|
|
], 422);
|
|
}
|
|
|
|
// Wrap email deletion + campaign update in a transaction so a crash
|
|
// between the two doesn't leave the campaign in an inconsistent state
|
|
fluentCrmDb()->beginTransaction();
|
|
|
|
try {
|
|
// Remove Emails if there has any pre-processed
|
|
CampaignEmail::where('campaign_id', $campaignId)->delete();
|
|
fluentcrm_update_campaign_meta($campaign->id, '_recipient_processed', 0);
|
|
fluentcrm_update_campaign_meta($campaign->id, '_last_recipient_id', 0);
|
|
|
|
if ($scheduleAt) {
|
|
$sendingType = $request->get('sending_type', 'schedule');
|
|
if ($sendingType == 'range_schedule') {
|
|
$isInvalid = true;
|
|
if (is_array($scheduleAt) && count($scheduleAt) == 2) {
|
|
$scheduleStartAt = sanitize_text_field($scheduleAt[0]);
|
|
|
|
if ($scheduleStartAt && strtotime($scheduleStartAt) < current_time('timestamp')) {
|
|
$scheduleStartAt = current_time('mysql');
|
|
}
|
|
|
|
$scheduleEndAt = sanitize_text_field($scheduleAt[1]);
|
|
|
|
if ($scheduleEndAt && $scheduleEndAt && strtotime($scheduleStartAt) < strtotime($scheduleEndAt)) {
|
|
$isInvalid = false;
|
|
}
|
|
|
|
$scheduleAt = [$scheduleStartAt, $scheduleEndAt];
|
|
}
|
|
|
|
if ($isInvalid) {
|
|
fluentCrmDb()->rollBack();
|
|
return $this->sendError([
|
|
'message' => __('Invalid schedule date range', 'fluent-crm')
|
|
], 422);
|
|
}
|
|
|
|
$settings = $campaign->settings;
|
|
$settings['sending_type'] = 'range_schedule';
|
|
$settings['schedule_range'] = [strtotime($scheduleAt[0]), strtotime($scheduleAt[1])];
|
|
|
|
$data = [
|
|
'status' => 'pending-scheduled',
|
|
'updated_at' => fluentCrmTimestamp(),
|
|
'scheduled_at' => $scheduleAt[0],
|
|
'recipients_count' => 0,
|
|
'settings' => $settings
|
|
];
|
|
|
|
} else {
|
|
$scheduleAt = sanitize_text_field($scheduleAt);
|
|
if (!$scheduleAt) {
|
|
fluentCrmDb()->rollBack();
|
|
return $this->sendError([
|
|
'message' => __('Invalid schedule date', 'fluent-crm')
|
|
], 422);
|
|
}
|
|
|
|
$settings = $campaign->settings;
|
|
$settings['sending_type'] = 'schedule';
|
|
|
|
$data = [
|
|
'status' => 'pending-scheduled',
|
|
'updated_at' => fluentCrmTimestamp(),
|
|
'scheduled_at' => $scheduleAt,
|
|
'recipients_count' => 0,
|
|
'settings' => $settings
|
|
];
|
|
}
|
|
|
|
$message = __('Your campaign email has been scheduled', 'fluent-crm');
|
|
|
|
} else {
|
|
$message = __('Email Sending will be started soon', 'fluent-crm');
|
|
$settings = $campaign->settings;
|
|
$settings['sending_type'] = 'instant';
|
|
$data = [
|
|
'status' => 'processing',
|
|
'updated_at' => fluentCrmTimestamp(),
|
|
'scheduled_at' => fluentCrmTimestamp(),
|
|
'recipients_count' => 0,
|
|
'settings' => $settings
|
|
];
|
|
}
|
|
|
|
$data['settings']['click_tracker'] = fluentcrmTrackClicking();
|
|
$data['settings']['open_tracker'] = fluentcrmTrackEmailOpen();
|
|
|
|
$data['settings'] = maybe_serialize($data['settings']);
|
|
|
|
// Guard: only transition from 'draft' to prevent race conditions
|
|
$updated = Campaign::where('id', $campaignId)
|
|
->where('status', 'draft')
|
|
->update($data);
|
|
|
|
if (!$updated) {
|
|
fluentCrmDb()->rollBack();
|
|
return $this->sendError([
|
|
'message' => __('Campaign is no longer in draft status. Please reload the page.', 'fluent-crm')
|
|
], 422);
|
|
}
|
|
|
|
fluentCrmDb()->commit();
|
|
} catch (\Throwable $e) {
|
|
fluentCrmDb()->rollBack();
|
|
return $this->sendError([
|
|
'message' => __('Failed to schedule campaign. Please try again.', 'fluent-crm')
|
|
], 500);
|
|
}
|
|
|
|
if (!$scheduleAt) {
|
|
$url = add_query_arg([
|
|
'action' => 'fluentcrm-post-campaigns-emails-processing',
|
|
'campaign_id' => $campaignId,
|
|
'time' => time()
|
|
], admin_url('admin-ajax.php'));
|
|
|
|
\FluentCrm\App\Services\Libs\Mailer\Handler::fireNonBlockingRequest($url, [
|
|
'retry' => 1
|
|
]);
|
|
}
|
|
|
|
$campaign = Campaign::findOrFail($campaignId);
|
|
|
|
fluentcrm_update_campaign_meta($campaign->id, '_campaign_sent_by', get_current_user_id());
|
|
|
|
if ($scheduleAt) {
|
|
do_action('fluent_crm/campaign_scheduled', $campaign, $campaign->scheduled_at);
|
|
} else {
|
|
do_action('fluent_crm/campaign_set_send_now', $campaign);
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'campaign' => $campaign,
|
|
'message' => $message,
|
|
'current_timestamp' => fluentCrmTimestamp()
|
|
]);
|
|
}
|
|
|
|
public function processingStat(Request $request, $campaignId)
|
|
{
|
|
$campaign = Campaign::withoutGlobalScope('type')
|
|
->with(['subjects'])
|
|
->whereIn('type', fluentCrmAutoProcessCampaignTypes())
|
|
->findOrFail($campaignId);
|
|
|
|
if ($campaign->status == 'pending-scheduled' && (strtotime($campaign->scheduled_at) - current_time('timestamp')) < 360) {
|
|
$campaign->status = 'processing';
|
|
$campaign->recipients_count = 0;
|
|
$campaign->save();
|
|
do_action('fluent_crm/campaign_processing_start', $campaign);
|
|
}
|
|
|
|
if ($campaign->status != 'processing') {
|
|
if ($campaign->status == 'scheduled' && current_time('timestamp') - strtotime($campaign->scheduled_at) > 300) {
|
|
if (Scheduler::markArchiveCampaigns()) {
|
|
$campaign = Campaign::withoutGlobalScope('type')
|
|
->with(['subjects'])
|
|
->findOrFail($campaignId);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'reload' => true,
|
|
'campaign' => $campaign
|
|
];
|
|
}
|
|
|
|
// This is the processing status
|
|
$processor = (new CampaignProcessor($campaign->id));
|
|
$processingChunk = (int)apply_filters('fluent_crm/campaign_processing_stat_chunk', 30, $campaign);
|
|
if ($processingChunk < 1) {
|
|
$processingChunk = 1;
|
|
}
|
|
|
|
$processingRunTime = (int)apply_filters('fluent_crm/campaign_processing_stat_runtime_seconds', 10, $campaign);
|
|
if ($processingRunTime < 1) {
|
|
$processingRunTime = 1;
|
|
}
|
|
|
|
$processedCampaign = $processor->processEmails($processingChunk, $processingRunTime);
|
|
|
|
$didRun = false;
|
|
if ($processedCampaign) {
|
|
$campaign = $processedCampaign;
|
|
$campaign->load('subjects');
|
|
$didRun = true;
|
|
}
|
|
|
|
$campaign->scheduling_range = $campaign->rangedScheduleDates();
|
|
|
|
return [
|
|
'campaign' => $campaign,
|
|
'didRun' => $didRun,
|
|
'scheduling_method' => $processor->getSchedulingMethod()
|
|
];
|
|
}
|
|
|
|
public function sendTestEmail()
|
|
{
|
|
$isTest = $this->request->get('test_campaign') == 'yes';
|
|
|
|
add_action('wp_mail_failed', function ($wpError) {
|
|
Helper::debugLog(
|
|
'Test Email failed',
|
|
$wpError->get_error_message(),
|
|
'error'
|
|
);
|
|
}, 10, 1);
|
|
|
|
if ($isTest) {
|
|
$campaign = (object)$this->request->get('campaign');
|
|
$emailSubject = $campaign->email_subject;
|
|
|
|
if (empty($campaign->settings)) {
|
|
$campaign->settings = [
|
|
'template_config' => []
|
|
];
|
|
}
|
|
|
|
if (!empty($campaign->subjects) && is_array($campaign->subjects)) {
|
|
$validSubjects = array_filter($campaign->subjects, function ($subject) {
|
|
return trim((string)Arr::get((array)$subject, 'value', ''));
|
|
});
|
|
|
|
if ($validSubjects) {
|
|
// Quick Test only verifies deliverability, so use the first configured A/B subject.
|
|
$subject = reset($validSubjects);
|
|
$emailSubject = Arr::get((array)$subject, 'value', $emailSubject);
|
|
}
|
|
}
|
|
|
|
$campaignEmail = (object)[
|
|
'email_subject' => $emailSubject,
|
|
'email_pre_header' => $campaign->email_pre_header,
|
|
'email_body' => $campaign->email_body
|
|
];
|
|
} else {
|
|
$campaignId = $this->request->get('campaign_id');
|
|
$campaignEmail = CampaignEmail::where('campaign_id', $campaignId)->first();
|
|
$campaign = Campaign::findOrFail($campaignId);
|
|
if (!$campaignEmail) {
|
|
$campaignEmail = (object)[
|
|
'email_subject' => $campaign->email_subject,
|
|
'email_pre_header' => $campaign->email_pre_header,
|
|
'email_body' => $campaign->email_body
|
|
];
|
|
}
|
|
}
|
|
|
|
$email = $this->request->getSafe('email', 'sanitize_email', '');
|
|
|
|
if (!$email) {
|
|
$user = get_user_by('ID', get_current_user_id());
|
|
$email = $user->user_email;
|
|
}
|
|
|
|
$emailBody = $campaignEmail->email_body;
|
|
|
|
$subscriber = Subscriber::where('email', $email)->first();
|
|
if (!$subscriber) {
|
|
$subscriber = Subscriber::where('status', 'subscribed')->first();
|
|
}
|
|
|
|
if (!$subscriber) {
|
|
return $this->sendError([
|
|
'message' => __('No subscriber found to send test. Please add atleast one contact as subscribed status', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
$designTemplate = $campaign->design_template;
|
|
|
|
$rawTemplates = [
|
|
'raw_html',
|
|
'visual_builder',
|
|
'raw_classic'
|
|
];
|
|
|
|
if (in_array($designTemplate, $rawTemplates)) {
|
|
$emailBody = $campaign->email_body;
|
|
} else {
|
|
$emailBody = (new BlockParser($subscriber))->parse($emailBody);
|
|
}
|
|
|
|
$emailFooterConfig = Helper::getFooterConfig($campaign);
|
|
$emailFooter = Arr::get($emailFooterConfig, 'footer_content', '');
|
|
|
|
$emailSubject = $campaignEmail->email_subject;
|
|
|
|
$preHeader = (!empty($campaign->email_pre_header)) ? $campaign->email_pre_header : '';
|
|
|
|
if ($subscriber) {
|
|
/**
|
|
* Determine the email campaign body text before it is sent.
|
|
*
|
|
* This filter allows you to modify the email body content for a campaign before it is sent to the subscriber.
|
|
*
|
|
* @param string $emailBody The email body content.
|
|
* @param object $subscriber The subscriber object.
|
|
*
|
|
* @return string The filtered email body content.
|
|
* @since 2.7.0
|
|
*
|
|
*/
|
|
$emailBody = apply_filters('fluent_crm/parse_campaign_email_text', $emailBody, $subscriber);
|
|
/**
|
|
* Determine the email footer text for a campaign.
|
|
*
|
|
* This filter allows you to modify the email footer text for a campaign before it is sent to a subscriber.
|
|
*
|
|
* @param string $emailFooter The email footer text.
|
|
* @param object $subscriber The subscriber object.
|
|
* @since 2.7.0
|
|
*
|
|
*/
|
|
$emailFooter = apply_filters('fluent_crm/parse_campaign_email_text', $emailFooter, $subscriber);
|
|
|
|
$emailFooterConfig['footer_content'] = $emailFooter;
|
|
|
|
/**
|
|
* Determine the email campaign subject text.
|
|
*
|
|
* This filter allows you to modify the email subject text for a campaign.
|
|
*
|
|
* @param string $emailSubject The email subject text.
|
|
* @param object $subscriber The subscriber object.
|
|
* @since 2.7.0
|
|
*
|
|
*/
|
|
$emailSubject = apply_filters('fluent_crm/parse_campaign_email_text', $emailSubject, $subscriber);
|
|
/**
|
|
* Determine the pre-header text of the campaign email.
|
|
*
|
|
* This filter allows you to modify the pre-header text of the campaign email before it is sent to the subscriber.
|
|
*
|
|
* @param string $preHeader The pre-header text of the campaign email.
|
|
* @param object $subscriber The subscriber object containing subscriber details.
|
|
*
|
|
* @return string The filtered pre-header text.
|
|
* @since 2.7.0
|
|
*
|
|
*/
|
|
$preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber);
|
|
}
|
|
|
|
$templateData = [
|
|
'preHeader' => $preHeader,
|
|
'email_body' => $emailBody,
|
|
'footer_text' => $emailFooter,
|
|
'footer_config' => $emailFooterConfig,
|
|
'config' => wp_parse_args(Arr::get($campaign->settings, 'template_config', []), Helper::getTemplateConfig($campaign->design_template))
|
|
];
|
|
|
|
/**
|
|
* Determine the email body content based on the design template type.
|
|
*
|
|
* This filter allows modification of the email body content based on the specified design template.
|
|
*
|
|
* @param string $emailBody The email body content.
|
|
* @param array $templateData The data used for the email template.
|
|
* @param object $campaign The campaign object.
|
|
* @param object $subscriber The subscriber object.
|
|
* @since 2.5.1
|
|
*
|
|
*/
|
|
$emailBody = apply_filters(
|
|
'fluent_crm/email-design-template-' . $campaign->design_template,
|
|
$emailBody,
|
|
$templateData,
|
|
$campaign,
|
|
$subscriber
|
|
);
|
|
|
|
|
|
$emailBody = str_replace('{{crm_global_email_footer}}', $emailFooter, $emailBody);
|
|
$emailBody = str_replace('{{crm_preheader_text}}', $preHeader, $emailBody);
|
|
|
|
$data = [
|
|
'to' => [
|
|
'email' => $email,
|
|
'name' => $subscriber->full_name
|
|
],
|
|
'subject' => 'TEST: ' . $emailSubject,
|
|
'body' => $emailBody,
|
|
'headers' => Helper::getMailHeadersFromSettings(Arr::get($campaign->settings, 'mailer_settings', []))
|
|
];
|
|
|
|
Helper::maybeDisableEmojiOnEmail();
|
|
$result = Mailer::send($data, $subscriber, null, true);
|
|
|
|
return [
|
|
'message' => sprintf(
|
|
__('Test email successfully sent to %1$s, The dynamic tags may not be replaced in the test email', 'fluent-crm'),
|
|
$email
|
|
),
|
|
'result' => $result
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Render the editor/draft email preview iframe.
|
|
*
|
|
* Route: POST /campaigns/email-preview-html
|
|
* Used by resources/admin/Pieces/EmailElements/EmailPreview.vue.
|
|
* For sent email-history previews, see previewEmail().
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getEmailPreviewBody()
|
|
{
|
|
if (!defined('FLUENTCRM_PREVIEWING_EMAIL')) {
|
|
define('FLUENTCRM_PREVIEWING_EMAIL', true);
|
|
}
|
|
|
|
$campaignId = $this->request->get('campaign_id');
|
|
|
|
if ($campaignId) {
|
|
$campaignId = (int)$campaignId;
|
|
$campaign = Campaign::withoutGlobalScope('type')->with(['subjects'])->findOrfail($campaignId);
|
|
} else {
|
|
$campaign = $this->request->get('campaign', []);
|
|
if (isset($campaign['post_content'])) {
|
|
$campaign['email_body'] = $campaign['post_content'];
|
|
}
|
|
|
|
if (isset($campaign['post_excerpt'])) {
|
|
$campaign['email_pre_header'] = sanitize_text_field($campaign['post_excerpt']);
|
|
}
|
|
|
|
$campaign = (object)$campaign;
|
|
}
|
|
|
|
$emailBody = $campaign->email_body;
|
|
|
|
if ($this->request->get('contact_id')) {
|
|
$subscriber = Subscriber::find($this->request->getSafe('contact_id', 'sanitize_text_field'));
|
|
} else {
|
|
$subscriber = fluentcrm_get_current_contact();
|
|
}
|
|
|
|
if (!$subscriber) {
|
|
$subscriber = Subscriber::where('status', 'subscribed')->first();
|
|
}
|
|
|
|
if ($this->request->get('disable_subscriber') == 'yes') {
|
|
$subscriber = null;
|
|
}
|
|
|
|
$designTemplate = $campaign->design_template;
|
|
|
|
if (!$designTemplate) {
|
|
$designTemplate = 'plain';
|
|
}
|
|
|
|
$rawTemplates = [
|
|
'raw_html',
|
|
'visual_builder',
|
|
'raw_classic'
|
|
];
|
|
|
|
if (in_array($designTemplate, $rawTemplates)) {
|
|
$emailBody = wp_unslash($campaign->email_body);
|
|
} else {
|
|
$emailBody = (new BlockParser($subscriber))->parse($emailBody);
|
|
}
|
|
|
|
if (empty($campaign->settings)) {
|
|
$campaign->settings = [];
|
|
}
|
|
|
|
$emailFooterConfig = Helper::getFooterConfig($campaign);
|
|
$emailFooter = Arr::get($emailFooterConfig, 'footer_content', '');
|
|
|
|
if ($subscriber) {
|
|
/**
|
|
* 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 original email body content.
|
|
* @param object $subscriber The subscriber object containing subscriber details.
|
|
*
|
|
* @return string The filtered email body content.
|
|
* @since 2.7.0
|
|
*
|
|
*/
|
|
$emailBody = apply_filters('fluent_crm/parse_campaign_email_text', $emailBody, $subscriber);
|
|
/**
|
|
* Determine the campaign email footer text.
|
|
*
|
|
* This filter allows you to modify the email footer content before it is sent to the subscriber.
|
|
*
|
|
* @param string $emailFooter The original email footer content.
|
|
* @param object $subscriber The subscriber object containing subscriber details.
|
|
*
|
|
* @return string The filtered email footer content.
|
|
* @since 2.7.0
|
|
*
|
|
*/
|
|
$emailFooter = apply_filters('fluent_crm/parse_campaign_email_text', $emailFooter, $subscriber);
|
|
}
|
|
|
|
$preHeader = (!empty($campaign->email_pre_header)) ? $campaign->email_pre_header : '';
|
|
|
|
if ($preHeader && $subscriber) {
|
|
/**
|
|
* Determine the campaign email Pre-header text before sending.
|
|
*
|
|
* This filter allows you to modify the email pre-header content for a campaign before it is sent to the subscriber.
|
|
*
|
|
* @param string $emailBody The email body content.
|
|
* @param object $subscriber The subscriber object.
|
|
* @since 2.7.0
|
|
*
|
|
*/
|
|
$preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber);
|
|
}
|
|
|
|
$emailFooterConfig['footer_content'] = $emailFooter;
|
|
|
|
$templateData = [
|
|
'preHeader' => $preHeader,
|
|
'email_body' => $emailBody,
|
|
'footer_text' => $emailFooter,
|
|
'footer_config' => $emailFooterConfig,
|
|
'config' => wp_parse_args(Arr::get($campaign->settings, 'template_config', []), Helper::getTemplateConfig($campaign->design_template))
|
|
];
|
|
|
|
/**
|
|
* Determine the email body content based on the design template type.
|
|
*
|
|
* This filter allows modification of the email body content based on the specified design template.
|
|
*
|
|
* @param string $emailBody The email body content.
|
|
* @param array $templateData The data used for the email template.
|
|
* @param object $campaign The campaign object.
|
|
* @param object $subscriber The subscriber object.
|
|
* @since 2.5.1
|
|
*
|
|
*/
|
|
$emailBody = apply_filters(
|
|
'fluent_crm/email-design-template-' . $designTemplate,
|
|
$emailBody,
|
|
$templateData,
|
|
$campaign,
|
|
$subscriber
|
|
);
|
|
|
|
if (Str::contains($emailBody, ['{{crm', '##crm'])) {
|
|
$emailBody = str_replace(['{{crm_global_email_footer}}', '{{crm_preheader_text}}'], [$emailFooter, $preHeader], $emailBody);
|
|
if (Str::contains($emailBody, ['##crm.', '{{crm.'])) {
|
|
/**
|
|
* Determine the email body content including a specific Smartcode for a subscriber.
|
|
*
|
|
* This filter allows modification of the email body content including a smartcode before it is sent to a subscriber.
|
|
*
|
|
* @param string $emailBody The email body content.
|
|
* @param object $subscriber The subscriber object.
|
|
*
|
|
* @return string The filtered email body content.
|
|
* @since 2.7.0
|
|
*
|
|
*/
|
|
$emailBody = apply_filters('fluent_crm/parse_extended_crm_text', $emailBody, $subscriber);
|
|
}
|
|
}
|
|
|
|
$emailBody = str_replace(['https://fonts.googleapis.com/css2', 'https://fonts.googleapis.com/css'], 'https://fonts.bunny.net/css', $emailBody);
|
|
|
|
return [
|
|
'preview_html' => $emailBody,
|
|
'subjects' => !empty($campaign->subjects) ? $campaign->subjects : []
|
|
];
|
|
}
|
|
|
|
public function unsubscribe()
|
|
{
|
|
$campaignId = $this->request->get('campaign_id');
|
|
|
|
$subscriberIds = (array)$this->request->get('subscriber_ids');
|
|
|
|
$campaign = Campaign::findOrFail($campaignId);
|
|
|
|
$campaign->unsubscribe($subscriberIds);
|
|
|
|
return $this->sendSuccess(compact('campaign'));
|
|
}
|
|
|
|
public function delete(Request $request, $campaignId)
|
|
{
|
|
$campaign = Campaign::findOrFail($campaignId);
|
|
|
|
$campaign->deleteCampaignData();
|
|
$campaign->delete();
|
|
do_action('fluent_crm/campaign_deleted', $campaignId);
|
|
|
|
return $this->send(['success' => true]);
|
|
}
|
|
|
|
public function handleBulkAction(Request $request)
|
|
{
|
|
$actionName = $request->getSafe('action_name', 'sanitize_text_field', '');
|
|
$campaignIds = array_map('intval', (array)$request->get('campaign_ids', []));
|
|
$selectAllCampaigns = filter_var($request->get('select_all'), FILTER_VALIDATE_BOOLEAN);
|
|
$campaignIds = array_map(function ($id) {
|
|
return (int)$id;
|
|
}, $campaignIds);
|
|
|
|
$campaignIds = array_unique(array_filter($campaignIds));
|
|
|
|
if ($selectAllCampaigns) {
|
|
$campaignIds = Campaign::pluck('id')->toArray();
|
|
}
|
|
|
|
if (!$campaignIds) {
|
|
return $this->sendError([
|
|
'message' => __('Please provide campaign IDs', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
if ($actionName == 'apply_labels') {
|
|
// labels are coming as array of ids from request
|
|
$newLabelIds = (array)$request->get('labels', []);
|
|
$newLabelIds = array_map('intval', $newLabelIds);
|
|
$newLabelIds = array_unique(array_filter($newLabelIds));
|
|
|
|
if (!$newLabelIds) {
|
|
return $this->sendError([
|
|
'message' => __('Please provide labels', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
$campaigns = Campaign::whereIn('id', $campaignIds)->get();
|
|
|
|
foreach ($campaigns as $campaign) {
|
|
$campaign->attachLabels($newLabelIds);
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'message' => __('Labels has been applied successfully', 'fluent-crm'),
|
|
]);
|
|
}
|
|
|
|
if ($actionName == 'delete_campaigns') {
|
|
$campaigns = Campaign::whereIn('id', $campaignIds)->get();
|
|
foreach ($campaigns as $campaign) {
|
|
$campaignId = $campaign->id;
|
|
$campaign->deleteCampaignData();
|
|
$campaign->delete();
|
|
do_action('fluent_crm/campaign_deleted', $campaignId);
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'message' => __('Selected Campaigns have been deleted permanently', 'fluent-crm'),
|
|
]);
|
|
}
|
|
|
|
return $this->sendError([
|
|
'message' => __('invalid bulk action', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
public function createTemplate()
|
|
{
|
|
$templateId = $this->request->get('template_id');
|
|
$campaignId = $this->request->get('campaign_id');
|
|
$campaign = Campaign::findOrFail($campaignId);
|
|
|
|
$template = Template::emailTemplates()->find($templateId);
|
|
if (!$template) {
|
|
return $this->sendError([
|
|
'message' => __('Template not found', 'fluent-crm')
|
|
], 404);
|
|
}
|
|
|
|
$template = Template::emailTemplates()->find($templateId);
|
|
|
|
if (!$template) {
|
|
return $this->sendError([
|
|
'message' => __('Template not found', 'fluent-crm')
|
|
], 404);
|
|
}
|
|
|
|
return $this->send([
|
|
'id' => Template::create([
|
|
'post_type' => fluentcrmCampaignTemplateCPTSlug(),
|
|
'post_content' => $template->post_content
|
|
])->ID
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Render a sent/scheduled email-history preview with metadata and click stats.
|
|
*
|
|
* Route: GET /campaigns/emails/{email_id}/preview
|
|
* Used by contact profile email history, campaign email rows, and all-emails preview.
|
|
* The body renderer is CampaignEmail::previewData(); keep its template handling aligned
|
|
* with getEmailPreviewBody() so raw/classic and Gutenberg previews stay consistent.
|
|
*
|
|
* @param Request $request
|
|
* @param int $emailId
|
|
* @return array
|
|
*/
|
|
public function previewEmail(Request $request, $emailId)
|
|
{
|
|
if (!defined('FLUENTCRM_PREVIEWING_EMAIL')) {
|
|
define('FLUENTCRM_PREVIEWING_EMAIL', true);
|
|
}
|
|
|
|
$email = CampaignEmail::findOrFail($emailId);
|
|
|
|
$emailData = $email->previewData();
|
|
$emailData['clicks'] = $email->getClicks();
|
|
|
|
return $this->sendSuccess([
|
|
'info' => $email,
|
|
'email' => $emailData
|
|
]);
|
|
}
|
|
|
|
public function getCampaignStatus(Request $request, $campaignId)
|
|
{
|
|
$campaign = Campaign::withoutGlobalScope('type')
|
|
->with(['subjects'])
|
|
->whereIn('type', fluentCrmAutoProcessCampaignTypes())
|
|
->findOrFail($campaignId);
|
|
|
|
if ($campaign->status == 'processing' || $campaign->status == 'pending-scheduled') {
|
|
return [
|
|
'current_timestamp' => fluentCrmTimestamp(),
|
|
'stat' => [],
|
|
'campaign' => $campaign,
|
|
'sent_count' => 0,
|
|
'analytics' => (object)[],
|
|
'subject_analytics' => (object)[]
|
|
];
|
|
}
|
|
|
|
if ($campaign->status == 'scheduled' && $campaign->scheduled_at) {
|
|
if (strtotime($campaign->scheduled_at) < strtotime(current_time('mysql'))) {
|
|
$campaign->status = 'working';
|
|
$campaign->save();
|
|
}
|
|
}
|
|
|
|
$ranged = null;
|
|
|
|
if ($campaign->status == 'working') {
|
|
$ranged = $campaign->rangedScheduleDates();
|
|
if (!$ranged) {
|
|
// Keep this status-polling GET endpoint read-mostly. It used to
|
|
// reset stale 'processing' campaign emails here, but this route is
|
|
// called every few seconds from the campaign screen. Doing queue
|
|
// recovery from this hot polling path can collide with the sender's
|
|
// row claims/updates and produce avoidable InnoDB deadlocks. Stale
|
|
// email recovery now stays in Scheduler::resetStaleProcessingEmails().
|
|
|
|
// Detect a stalled send cycle. Do NOT key this off the sending
|
|
// lock: on sites with an external object cache the lock lives in
|
|
// the fc_instant_options cache group (not wp_options, so the old
|
|
// get_option() read missed it entirely) and auto-expires after
|
|
// ~80s, so a 140s threshold would never see it. Instead key off
|
|
// _last_called, which Handler::isSystemOk() stamps in the fc_meta
|
|
// "option" store on every lock-winning run — persistent, has no
|
|
// TTL, and is identical on object-cache and DB-only sites.
|
|
$lastCalled = (int)fluentcrm_get_option('fluentcrm_is_sending_emails_last_called');
|
|
if (!$lastCalled || (time() - $lastCalled) > 140) {
|
|
// No send activity for >140s while the campaign is still
|
|
// 'working' — treat as stuck. Throttle the re-fire so frequent
|
|
// status polling doesn't spam loopback requests; one kick per
|
|
// 60s is enough for a healthy run to resume and refresh
|
|
// _last_called (which then clears this condition).
|
|
$lastRefire = fluentCrmGetOptionCache('_fcrm_last_stuck_refire', 200);
|
|
if (!$lastRefire || (time() - $lastRefire) > 60) {
|
|
fluentCrmSetOptionCache('_fcrm_last_stuck_refire', time(), 200);
|
|
|
|
// Deliberately do NOT force-clear the lock here. If sending
|
|
// has truly stalled for >140s the lock is already well past
|
|
// its ~80s timeout, so the re-fired handler's acquireLock()
|
|
// steals it on its own (DB: expired-timestamp UPDATE; object
|
|
// cache: the key has already TTL'd away). Clearing it
|
|
// explicitly would only change anything while a sender is
|
|
// still holding a *fresh* lock — i.e. a long run under a
|
|
// raised maximumProcessingTime — and releasing it there would
|
|
// let a second sender run concurrently (rate-limit overshoot).
|
|
// Let acquireLock() be the single arbiter of ownership.
|
|
wp_remote_post(admin_url('admin-ajax.php'), [
|
|
'sslverify' => false,
|
|
'blocking' => false,
|
|
'cookies' => array(),
|
|
'body' => [
|
|
'campaign_id' => $campaignId,
|
|
'retry' => 1,
|
|
'time' => time(),
|
|
'action' => 'fluentcrm-post-campaigns-send-now'
|
|
]
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$analytics = [];
|
|
$subjectsAnalytics = [];
|
|
|
|
$sentCount = CampaignEmail::select('id')
|
|
->where('campaign_id', $campaignId)
|
|
->where('status', 'sent')
|
|
->count();
|
|
|
|
if ($campaign->status == 'working') {
|
|
|
|
$campaign->scheduling_range = $ranged;
|
|
|
|
$processingCount = CampaignEmail::select('id')
|
|
->where('campaign_id', $campaignId)
|
|
->where('status', 'processing')
|
|
->count();
|
|
|
|
// Do not reset stale 'processing' rows from campaign status polling.
|
|
// If there are still rows in processing, report that state and let the
|
|
// scheduler-owned recovery path decide when it is safe to requeue them.
|
|
if (!$processingCount && $sentCount) {
|
|
$futureCount = CampaignEmail::select('id')
|
|
->where('campaign_id', $campaignId)
|
|
->whereIn('status', ['pending', 'scheduled', 'paused', 'processing', 'draft'])
|
|
->count();
|
|
|
|
if (!$futureCount) {
|
|
Campaign::withoutGlobalScope('type')->where('id', $campaign->id)->update([
|
|
'status' => 'archived',
|
|
'updated_at' => current_time('mysql')
|
|
]);
|
|
$campaign = Campaign::withoutGlobalScope('type')->with(['subjects'])->findOrFail($campaignId);
|
|
|
|
do_action('fluent_crm/campaign_archived', $campaign);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($campaign->status == 'archived') {
|
|
$campaignUrlMetric = new CampaignUrlMetric();
|
|
$analytics = $campaignUrlMetric->getCampaignAnalytics($campaign);
|
|
|
|
if (isset($analytics['open']) && $analytics['open']['total'] > $sentCount) {
|
|
$analytics['open']['total'] = $sentCount;
|
|
}
|
|
|
|
if (isset($analytics['click']) && $analytics['click']['total'] > $sentCount) {
|
|
$analytics['click']['total'] = $sentCount;
|
|
}
|
|
|
|
$subjectsAnalytics = $campaignUrlMetric->getSubjectStats($campaign);
|
|
|
|
$campaign->open_tracking_status = $campaign->getOpenTrackingStatus();
|
|
$campaign->click_tracking_status = $campaign->getClickTrackingStatus();
|
|
}
|
|
|
|
$stat = CampaignEmail::select('status', fluentCrmDb()->raw('count(*) as total'))
|
|
->where('campaign_id', $campaignId)
|
|
->groupBy('status')
|
|
->get();
|
|
|
|
//attaching who sent the campaign
|
|
$campaignSentBy = $this->getCampaignSentData($campaign->id);
|
|
$campaign->sent_by = $campaignSentBy;
|
|
|
|
return $this->sendSuccess([
|
|
'current_timestamp' => fluentCrmTimestamp(),
|
|
'stat' => $stat,
|
|
'campaign' => $campaign,
|
|
'sent_count' => $sentCount,
|
|
'analytics' => (object)$analytics,
|
|
'subject_analytics' => (object)$subjectsAnalytics
|
|
], 200);
|
|
}
|
|
|
|
public function getOverviewStats(Request $request, CampaignUrlMetric $campaignUrlMetric, $campaignId)
|
|
{
|
|
$campaign = Campaign::withoutGlobalScope('type')
|
|
->whereIn('type', fluentCrmAutoProcessCampaignTypes())
|
|
->findOrFail($campaignId);
|
|
|
|
$sentCount = CampaignEmail::select('id')
|
|
->where('campaign_id', $campaignId)
|
|
->where('status', 'sent')
|
|
->count();
|
|
|
|
|
|
$analytics = $campaignUrlMetric->getCampaignAnalytics($campaignId);
|
|
|
|
if (isset($analytics['open']) && $analytics['open']['total'] > $sentCount) {
|
|
$analytics['open']['total'] = $sentCount;
|
|
}
|
|
|
|
if (isset($analytics['click']) && $analytics['click']['total'] > $sentCount) {
|
|
$analytics['click']['total'] = $sentCount;
|
|
}
|
|
|
|
$stat = CampaignEmail::select('status', fluentCrmDb()->raw('count(*) as total'))
|
|
->where('campaign_id', $campaignId)
|
|
->groupBy('status')
|
|
->get();
|
|
|
|
return [
|
|
'sent_count' => $sentCount,
|
|
'stat' => $stat,
|
|
'analytics' => $analytics
|
|
];
|
|
}
|
|
|
|
public function pauseCampaign(Request $request, $id)
|
|
{
|
|
$campaign = Campaign::findOrFail($id);
|
|
|
|
if ($campaign->status != 'working') {
|
|
return $this->sendError([
|
|
'message' => __('You can only pause a campaign if it is on "Working" state, Please reload this page', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
$campaign->status = 'paused';
|
|
$campaign->save();
|
|
|
|
CampaignEmail::where('campaign_id', $campaign->id)
|
|
->whereIn('status', ['scheduled', 'pending', 'scheduling'])
|
|
->update([
|
|
'status' => 'paused'
|
|
]);
|
|
|
|
$campaign = Campaign::findOrFail($id);
|
|
|
|
return [
|
|
'message' => __('Campaign has been successfully marked as paused', 'fluent-crm'),
|
|
'campaign' => $campaign
|
|
];
|
|
}
|
|
|
|
public function resumeCampaign(Request $request, $id)
|
|
{
|
|
$campaign = Campaign::findOrFail($id);
|
|
|
|
if ($campaign->status != 'paused') {
|
|
return $this->sendError([
|
|
'message' => __('You can only resume a campaign if it is on "paused" state, Please reload this page', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
$campaign->status = 'working';
|
|
$campaign->save();
|
|
|
|
CampaignEmail::where('campaign_id', $campaign->id)
|
|
->where('status', 'paused')
|
|
->update([
|
|
'status' => 'scheduled',
|
|
'scheduled_at' => current_time('mysql')
|
|
]);
|
|
|
|
return [
|
|
'message' => __('Campaign has been successfully resumed', 'fluent-crm'),
|
|
'campaign' => Campaign::findOrFail($id)
|
|
];
|
|
}
|
|
|
|
public function updateCampaignTitle(Request $request, $id)
|
|
{
|
|
$campaign = Campaign::findOrFail($id);
|
|
$campaign->title = sanitize_text_field($request->get('title'));
|
|
$campaign->save();
|
|
|
|
if ($campaign->status == 'scheduled') {
|
|
$newTime = $request->get('scheduled_at');
|
|
if ($newTime != $campaign->scheduled_at) {
|
|
$campaign->scheduled_at = $newTime;
|
|
$campaign->save();
|
|
CampaignEmail::where('campaign_id', $campaign->id)
|
|
->whereNotIn('status', ['sent', 'failed', 'bounced'])
|
|
->update([
|
|
'status' => 'scheduled',
|
|
'scheduled_at' => $newTime
|
|
]);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'message' => __('Campaign has been updated', 'fluent-crm'),
|
|
'campaign' => Campaign::findOrFail($id)
|
|
];
|
|
}
|
|
|
|
public function duplicateCampaign(Request $request, $id)
|
|
{
|
|
$oldCampaign = Campaign::findOrFail($id);
|
|
$newCampaign = [
|
|
'title' => __('[Duplicate] ', 'fluent-crm') . $oldCampaign->title,
|
|
'slug' => $oldCampaign->slug . '-' . time(),
|
|
'email_body' => $oldCampaign->email_body,
|
|
'status' => 'draft',
|
|
'template_id' => $oldCampaign->template_id,
|
|
'email_subject' => $oldCampaign->email_subject,
|
|
'email_pre_header' => $oldCampaign->email_pre_header,
|
|
'utm_status' => $oldCampaign->utm_status,
|
|
'utm_source' => $oldCampaign->utm_source,
|
|
'utm_medium' => $oldCampaign->utm_medium,
|
|
'utm_campaign' => $oldCampaign->utm_campaign,
|
|
'utm_term' => $oldCampaign->utm_term,
|
|
'utm_content' => $oldCampaign->utm_content,
|
|
'design_template' => $oldCampaign->design_template,
|
|
'created_by' => get_current_user_id(),
|
|
'settings' => $oldCampaign->settings
|
|
];
|
|
$labelIds = $oldCampaign->getFormattedLabels()->pluck('id')->toArray();
|
|
|
|
$campaign = Campaign::create($newCampaign);
|
|
$campaign->attachLabels($labelIds);
|
|
|
|
$campaign->duplicateSubjects($oldCampaign);
|
|
|
|
do_action('fluent_crm/campaign_duplicated', $campaign, $oldCampaign);
|
|
|
|
return [
|
|
'campaign' => $campaign,
|
|
'message' => __('Campaign has been successfully duplicated', 'fluent-crm')
|
|
];
|
|
|
|
}
|
|
|
|
public function unSchedule(Request $request, $id)
|
|
{
|
|
$campaign = Campaign::withoutGlobalScope('type')
|
|
->whereIn('type', fluentCrmAutoProcessCampaignTypes())
|
|
->findOrFail($id);
|
|
|
|
$validStatuses = [
|
|
'scheduled',
|
|
'pending-scheduled',
|
|
'processing'
|
|
];
|
|
|
|
if (!in_array($campaign->status, $validStatuses)) {
|
|
return $this->sendError([
|
|
'message' => __('You can only un-schedule a campaign if it is on "scheduled" state, Please reload this page', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
if ($campaign->status == 'processing' && strtotime($campaign->scheduled_at) < current_time('timestamp')) {
|
|
return $this->sendError([
|
|
'message' => __('You can only un-schedule a campaign if it is on "scheduled" state, Please reload this page', 'fluent-crm')
|
|
]);
|
|
}
|
|
|
|
$campaign->status = 'draft';
|
|
$campaign->save();
|
|
|
|
// check if there has any emails, if yes then delete all of them
|
|
CampaignEmail::where('campaign_id', $campaign->id)
|
|
->delete();
|
|
|
|
CampaignEmail::withoutGlobalScope('type')->where('campaign_id', $campaign->id)
|
|
->whereIn('status', ['scheduled', 'scheduling'])
|
|
->delete();
|
|
|
|
return [
|
|
'message' => __('Campaign has been successfully un-scheduled', 'fluent-crm')
|
|
];
|
|
}
|
|
|
|
|
|
public function getShareUrl($id)
|
|
{
|
|
$campaign = Campaign::withoutGlobalScope('type')
|
|
->findOrFail($id);
|
|
|
|
return [
|
|
'sharable_url' => $campaign->getShareableUrl()
|
|
];
|
|
}
|
|
|
|
public function updateLabels(Request $request, $funnel_id)
|
|
{
|
|
$funnel = Campaign::findOrFail($funnel_id);
|
|
$action = $request->getSafe('action', 'sanitize_text_field');
|
|
$labelIds = $request->get('label_ids');
|
|
|
|
if (!is_array($labelIds)) {
|
|
$labelIds = [$labelIds];
|
|
}
|
|
|
|
$labelIds = array_map('intval', $labelIds);
|
|
$labelIds = array_unique(array_filter($labelIds));
|
|
|
|
if ($action == 'detach') {
|
|
$funnel->detachLabels($labelIds);
|
|
}
|
|
|
|
return $this->sendSuccess([
|
|
'message' => __('Labels has been updated', 'fluent-crm')
|
|
]);
|
|
|
|
}
|
|
|
|
private function getCampaignSentData($campaignId)
|
|
{
|
|
$campaignSentById = fluentcrm_get_campaign_meta($campaignId, '_campaign_sent_by', true);
|
|
$user = get_userdata($campaignSentById);
|
|
if ($user) {
|
|
return $user->display_name . ' (' . $user->user_email . ')';
|
|
}
|
|
return false;
|
|
}
|
|
}
|