Files
gustavoo-me/wp-content/plugins/fluent-crm/app/Modules/MCP/Tools/EmailTools.php
T
2026-07-23 22:56:30 -03:00

437 lines
18 KiB
PHP

<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CustomEmailCampaign;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Modules\MCP\Tools\ContextTools;
use FluentCrm\App\Services\BlockParser;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Libs\Mailer\Mailer;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\Framework\Support\Arr;
/**
* One-off email tools — wraps SubscriberController::sendCustomEmail
* (MCP_PLAN.md § 5.9), exposing every option the contact-profile "Send
* Custom Email" UI surfaces *except* the interactive ones (visual_builder
* design template and template_id picker):
*
* - subject + preheader + body
* - design_template (plain | classic | raw_html | raw_classic)
* - mailer overrides: from_name/from_email/reply_to_name/reply_to_email
* - is_transactional (auto-disables the footer to match UI behavior)
* - explicit disable_footer override
* - click/open trackers (yes|no|anonymous)
* - UTM tagging
* - free-form settings passthrough for template_config / footer_settings
*
* Reuses the normal queue + bounce + FluentSMTP plumbing so MCP-sent emails
* behave identically to one-offs sent from the contact profile.
*/
class EmailTools
{
public static function sendEmailToContact($params)
{
$params = (array) $params;
$resolved = MCPHelper::resolveContact($params);
if (is_wp_error($resolved)) {
return $resolved;
}
$contact = $resolved;
$subject = trim((string) ($params['subject'] ?? ''));
$body = (string) ($params['body'] ?? '');
if ($subject === '' || $body === '') {
return MCPHelper::error('invalid_param', __('subject and body are required', 'fluent-crm'));
}
// Status check matches the controller's gate. Phrase the error so
// the agent doesn't think `is_transactional=yes` will bypass it
// (review #8 — that flag is the *message* type, not a status
// override).
$allowedStatuses = ['subscribed', 'transactional'];
if (!in_array($contact->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 <addr>" 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;
}
}