status, $allowedStatuses, true)) { return MCPHelper::error('invalid_param', sprintf( /* translators: 1: current contact status, 2: comma-separated list of allowed statuses */ __("The contact's status is '%1\$s'. To send to this contact, the contact's own status must be one of: %2\$s. The is_transactional parameter controls the message type, not the contact gate.", 'fluent-crm'), $contact->status, implode(', ', $allowedStatuses) ), [ 'current_status' => $contact->status, 'allowed_statuses' => $allowedStatuses, ]); } $defaults = Helper::getGlobalEmailSettings(); $designTemplate = sanitize_key((string) ($params['design_template'] ?? 'classic')); if ($designTemplate === '') { $designTemplate = 'classic'; } // Defense in depth — even though the schema enum constrains this, // a non-honoring agent (or a direct REST call) could still try to // pass `visual_builder` or another disallowed value. Reject server // side with a structured error. $allowed = array_keys(ContextTools::allowedDesignTemplates()); if (!in_array($designTemplate, $allowed, true)) { return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [ 'design_template' => $designTemplate, 'allowed' => $allowed, ]); } $isTransactional = self::yesNo($params['is_transactional'] ?? null, 'no'); // Footer toggle — UI behavior: turning on transactional auto-disables // the global footer because transactional mail must not include a // marketing unsubscribe link. Honor that by default; let the caller // override explicitly. if (array_key_exists('disable_footer', $params)) { $disableFooter = self::yesNo($params['disable_footer'], 'no'); } else { $disableFooter = $isTransactional === 'yes' ? 'yes' : 'no'; } $clickTracker = self::trackerValue($params['click_tracker'] ?? null); $openTracker = self::trackerValue($params['open_tracker'] ?? null); // Build the mailer override block. $fromName = sanitize_text_field((string) ($params['from_name'] ?? $defaults['from_name'])); $fromEmail = sanitize_email((string) ($params['from_email'] ?? $defaults['from_email'])); $replyToName = sanitize_text_field((string) ($params['reply_to_name'] ?? ($defaults['reply_to_name'] ?? ''))); $replyToEmail = sanitize_email((string) ($params['reply_to_email'] ?? ($defaults['reply_to_email'] ?? ''))); $mailerSettings = [ 'from_name' => $fromName, 'from_email' => $fromEmail, 'reply_to_name' => $replyToName, 'reply_to_email' => $replyToEmail, 'is_custom' => 'yes', ]; // Compose the settings object the way the UI does. $settings = [ 'mailer_settings' => $mailerSettings, 'is_transactional' => $isTransactional, 'footer_settings' => [ 'disable_footer' => $disableFooter, ], 'template_config' => Helper::getTemplateConfig($designTemplate), ]; if ($clickTracker !== null) { $settings['click_tracker'] = $clickTracker; } if ($openTracker !== null) { $settings['open_tracker'] = $openTracker; } // Allow callers to pass an arbitrary `settings` object for things // we haven't surfaced as top-level params (e.g. visual-builder style // overrides). Caller-provided keys win on conflict. if (!empty($params['settings']) && is_array($params['settings'])) { $settings = array_replace_recursive($settings, $params['settings']); } // Custom title for audit / log; default keeps recipient email so the // entry is searchable in the campaign list. $title = isset($params['title']) && $params['title'] !== '' ? sanitize_text_field((string) $params['title']) : sprintf(__('MCP one-off to %s', 'fluent-crm'), $contact->email); $campaignData = [ 'title' => $title, 'email_subject' => $subject, 'email_pre_header' => sanitize_text_field((string) ($params['pre_header'] ?? $params['preheader'] ?? '')), 'email_body' => $body, 'design_template' => $designTemplate, 'settings' => $settings, 'status' => 'draft', ]; // UTM tagging — flatten the optional `utm` object onto the // campaign's utm_* columns. if (!empty($params['utm']) && is_array($params['utm'])) { $utm = $params['utm']; $campaignData['utm_status'] = !empty($utm['status']) ? 1 : 0; foreach (['source', 'medium', 'campaign', 'term', 'content'] as $key) { if (isset($utm[$key])) { $campaignData['utm_' . $key] = sanitize_text_field((string) $utm[$key]); } } } $campaignData = Sanitize::campaign($campaignData); // Mirror the WP_Error surfacing behavior of the controller. add_action('wp_mail_failed', function ($wpError) { if (method_exists(Helper::class, 'debugLog')) { Helper::debugLog('MCP send-email-to-contact failure', $wpError->get_error_message(), 'error'); } }, 10, 1); $campaign = CustomEmailCampaign::create($campaignData); $campaign->subscribe([(int) $contact->id], [ 'status' => 'scheduled', 'scheduled_at' => current_time('mysql'), ]); do_action('fluentcrm_process_contact_jobs', $contact); return [ 'ok' => true, 'campaign_id' => (int) $campaign->id, 'message' => __('Email queued for delivery', 'fluent-crm'), 'contact' => [ 'id' => (int) $contact->id, 'email' => $contact->email, ], 'applied' => [ 'is_transactional' => $isTransactional, 'disable_footer' => $disableFooter, 'design_template' => $designTemplate, 'from' => self::formatAddress($fromName, $fromEmail), 'reply_to' => self::formatAddress($replyToName, $replyToEmail), ], ]; } /** * Render an RFC-5322 "Display Name " string. Previous version * (`trim(... ' <>')`) ate the closing `>` from any "Name (with parens)" * — review #15. */ private static function formatAddress($name, $email) { $email = trim((string) $email); $name = trim((string) $name); if ($email === '') return ''; if ($name === '') return $email; return $name . ' <' . $email . '>'; } /** * `send-test-email` — render and send a one-off test copy of either: * - a saved campaign (pass campaign_id), or * - a draft body/subject the agent supplies inline. * * Differs from send-email-to-contact: NO campaign record is created, * NO subscriber is enrolled, NO row is logged to fc_campaign_emails, * and the recipient does NOT need to be subscribed. The subject is * prefixed with "TEST:" to match what the contact-profile UI does. * Mirrors CampaignController::sendTestEmail. */ public static function sendTestEmail($params) { $params = (array) $params; // Resolve recipient address — defaults to the current WP user. $toEmail = sanitize_email((string) ($params['to_email'] ?? '')); if (!$toEmail) { $user = wp_get_current_user(); $toEmail = $user ? $user->user_email : ''; } if (!$toEmail || !is_email($toEmail)) { return MCPHelper::error('invalid_param', __('A valid to_email is required.', 'fluent-crm')); } // Source the email content from a saved campaign or inline params. $campaignId = isset($params['campaign_id']) ? (int) $params['campaign_id'] : 0; $subject = $body = $preHeader = ''; $designTemplate = ''; $settings = []; if ($campaignId) { // Need to bypass the global type scope so test sends work for // custom_email_campaign / sequence_mail / etc., not just // type='campaign'. $campaign = Campaign::withoutGlobalScope('type')->find($campaignId); if (!$campaign) { return MCPHelper::error('not_found', __('Campaign not found', 'fluent-crm'), ['campaign_id' => $campaignId]); } $subject = (string) $campaign->email_subject; $body = (string) $campaign->email_body; $preHeader = (string) $campaign->email_pre_header; $designTemplate = (string) $campaign->design_template; $settings = is_array($campaign->settings) ? $campaign->settings : (array) maybe_unserialize($campaign->settings); } // Inline params override campaign-derived values. if (isset($params['subject']) && $params['subject'] !== '') { $subject = (string) $params['subject']; } if (isset($params['body']) && $params['body'] !== '') { $body = (string) $params['body']; } if (isset($params['pre_header'])) { $preHeader = (string) $params['pre_header']; } if (isset($params['design_template']) && $params['design_template'] !== '') { $designTemplate = sanitize_key((string) $params['design_template']); } if ($designTemplate === '') { $designTemplate = 'classic'; } // Apply the same MCP-safe enum guard as send-email-to-contact. $allowedTemplates = array_keys(ContextTools::allowedDesignTemplates()); if (!in_array($designTemplate, $allowedTemplates, true)) { return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [ 'design_template' => $designTemplate, 'allowed' => $allowedTemplates, ]); } if ($subject === '' || $body === '') { return MCPHelper::error('invalid_param', __('Provide either campaign_id, or subject + body.', 'fluent-crm')); } // Resolve the subscriber whose data smartcodes get filled with. // Priority: explicit against_contact_*, then to_email, then any // subscribed contact (mirrors CampaignController fallback). $subscriber = null; if (!empty($params['against_contact_id'])) { $subscriber = Subscriber::find((int) $params['against_contact_id']); } if (!$subscriber && !empty($params['against_contact_email'])) { $subscriber = Subscriber::where('email', sanitize_email($params['against_contact_email']))->first(); } if (!$subscriber) { $subscriber = Subscriber::where('email', $toEmail)->first(); } if (!$subscriber) { $subscriber = Subscriber::where('status', 'subscribed')->first(); } if (!$subscriber) { return MCPHelper::error('not_supported', __('No subscriber found to drive smartcode rendering. Add at least one subscribed contact.', 'fluent-crm')); } // Catch wp_mail errors for the response. $mailErrors = []; $mailErrorListener = function ($wpError) use (&$mailErrors) { $mailErrors[] = $wpError->get_error_message(); }; add_action('wp_mail_failed', $mailErrorListener, 10, 1); // Block-template rendering — same gate the controller uses. $rawTemplates = ['raw_html', 'raw_classic']; if (!in_array($designTemplate, $rawTemplates, true)) { $body = (new BlockParser($subscriber))->parse($body); } // Footer config — pulled from a stand-in object so we can pass non- // persisted draft data through Helper::getFooterConfig the same way // the controller does. $stub = (object) [ 'design_template' => $designTemplate, 'settings' => $settings ?: ['template_config' => []], 'email_pre_header' => $preHeader, 'email_body' => $body, 'email_subject' => $subject, ]; $footerConfig = method_exists(Helper::class, 'getFooterConfig') ? Helper::getFooterConfig($stub) : ['footer_content' => '']; $footerText = Arr::get($footerConfig, 'footer_content', ''); // Run the standard parse_campaign_email_text filter chain so // smartcodes resolve. $body = apply_filters('fluent_crm/parse_campaign_email_text', $body, $subscriber); $footerText = apply_filters('fluent_crm/parse_campaign_email_text', $footerText, $subscriber); $subject = apply_filters('fluent_crm/parse_campaign_email_text', $subject, $subscriber); $preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber); $footerConfig['footer_content'] = $footerText; $templateData = [ 'preHeader' => $preHeader, 'email_body' => $body, 'footer_text' => $footerText, 'footer_config' => $footerConfig, 'config' => wp_parse_args( Arr::get($settings, 'template_config', []), Helper::getTemplateConfig($designTemplate) ), ]; $body = apply_filters( 'fluent_crm/email-design-template-' . $designTemplate, $body, $templateData, $stub, $subscriber ); $body = str_replace('{{crm_global_email_footer}}', $footerText, $body); $body = str_replace('{{crm_preheader_text}}', $preHeader, $body); $data = [ 'to' => [ 'email' => $toEmail, 'name' => $subscriber->full_name ?: $toEmail, ], 'subject' => 'TEST: ' . $subject, 'body' => $body, 'headers' => Helper::getMailHeadersFromSettings(Arr::get($settings, 'mailer_settings', [])), ]; if (method_exists(Helper::class, 'maybeDisableEmojiOnEmail')) { Helper::maybeDisableEmojiOnEmail(); } $result = Mailer::send($data, $subscriber, null, true); remove_action('wp_mail_failed', $mailErrorListener, 10); $sent = $result !== false && empty($mailErrors); return [ 'ok' => $sent, 'sent' => $sent, 'to' => $toEmail, 'rendered_against' => [ 'contact_id' => (int) $subscriber->id, 'email' => $subscriber->email, ], 'subject_preview' => 'TEST: ' . $subject, 'design_template' => $designTemplate, 'errors' => $mailErrors, 'note' => __('Test sends bypass the queue, do not enroll the recipient, and do not appear in email_history.', 'fluent-crm'), ]; } private static function yesNo($value, $default = 'no') { if ($value === null) { return $default; } if (is_bool($value)) { return $value ? 'yes' : 'no'; } $str = strtolower((string) $value); if (in_array($str, ['yes', 'true', '1', 'on'], true)) { return 'yes'; } if (in_array($str, ['no', 'false', '0', 'off', ''], true)) { return 'no'; } return $default; } private static function trackerValue($value) { if ($value === null || $value === '') { return null; } $str = strtolower((string) $value); if (in_array($str, ['yes', 'no', 'anonymous'], true)) { return $str; } if (is_bool($value)) { return $value ? 'yes' : 'no'; } return null; } }