['wordpress'], 'open_ai' => ['auto', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini'], 'claude' => ['auto', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001', 'claude-opus-4-6'], 'gemini' => ['auto', 'gemini-3.5-flash', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-3.1-flash-lite', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite'], ]; private $autoProviderModels = [ 'open_ai' => 'gpt-5.4', 'claude' => 'claude-sonnet-4-6', 'gemini' => 'gemini-3.5-flash', 'wordpress' => 'wordpress', ]; public function getSettings(Request $request) { $settings = $this->getSavedSettings(); // Mask the API key for frontend display if (!empty($settings['api_key'])) { $settings['api_key'] = '****' . substr($settings['api_key'], -4); } global $wp_version; $hasWordPressAi = (intval(explode('.', $wp_version)[0]) >= 7); $connectorsUrl = admin_url('options-connectors.php'); return $this->sendSuccess([ 'settings' => $settings, 'has_wordpress_ai' => $hasWordPressAi, 'connectors_url' => $connectorsUrl, ]); } public function saveSettings(Request $request) { $data = $request->get('settings', []); $isEnabled = sanitize_text_field(Arr::get($data, 'is_enabled', 'no')); $provider = $this->normalizeProvider(sanitize_text_field(Arr::get($data, 'provider', ''))); $model = sanitize_text_field(Arr::get($data, 'model', 'auto')); $apiKey = sanitize_text_field(Arr::get($data, 'api_key', '')); $customPrompt = sanitize_textarea_field(Arr::get($data, 'custom_prompt', '')); if ($isEnabled !== 'yes') { $isEnabled = 'no'; } global $wp_version; $hasWordPressAi = (intval(explode('.', $wp_version)[0]) >= 7); if ($provider === 'wordpress' && !$hasWordPressAi) { return $this->sendError([ 'message' => __('WordPress AI is only supported in WordPress 7.0 or higher.', 'fluent-crm'), ], 422); } if (!$model) { $model = 'auto'; } $validProviders = array_keys($this->providerModels); if ($provider && !in_array($provider, $validProviders, true)) { return $this->sendError([ 'message' => __('Invalid AI provider selected.', 'fluent-crm'), ], 422); } $existingCredentials = $this->getSavedCredentials(); // Handle API key: if masked value is sent back, keep existing; if empty, clear it. $plainApiKey = Arr::get($existingCredentials, 'api_key', ''); if (empty($apiKey)) { $plainApiKey = ''; } elseif (strpos($apiKey, '****') !== 0) { $plainApiKey = $apiKey; } $credentials = [ 'provider' => $provider, 'model' => $model, 'api_key' => $plainApiKey, 'created_by' => 'fluent_crm', ]; $preferences = [ 'is_enabled' => $isEnabled, 'custom_prompt' => $customPrompt, ]; update_option($this->credentialsOptionKey, $credentials, false); fluentcrm_update_option($this->writingSettingsOptionKey, $preferences); return $this->sendSuccess([ 'message' => __('AI configuration saved successfully.', 'fluent-crm'), ]); } public function getModels(Request $request) { $data = $request->get('settings', []); $provider = $this->normalizeProvider(sanitize_text_field(Arr::get($data, 'provider', ''))); $validProviders = array_keys($this->providerModels); if (!$provider || !in_array($provider, $validProviders, true)) { return $this->sendError([ 'message' => __('Invalid AI provider selected.', 'fluent-crm'), ], 422); } return $this->sendSuccess([ 'models' => $this->formatModelOptions($provider), ]); } public function testConnection(Request $request) { $data = $request->get('settings', []); $provider = $this->normalizeProvider(sanitize_text_field(Arr::get($data, 'provider', ''))); $model = sanitize_text_field(Arr::get($data, 'model', 'auto')); $apiKey = sanitize_text_field(Arr::get($data, 'api_key', '')); if (!$provider || !$model) { return $this->sendError([ 'message' => __('Please select a provider and model first.', 'fluent-crm'), ], 422); } $validProviders = array_keys($this->providerModels); if (!in_array($provider, $validProviders, true)) { return $this->sendError([ 'message' => __('Invalid AI provider selected.', 'fluent-crm'), ], 422); } // If the API key is masked, use the saved one if (!$apiKey || strpos($apiKey, '****') === 0) { $savedSettings = $this->getSavedSettings(); $apiKey = Arr::get($savedSettings, 'api_key', ''); } if ($provider !== 'wordpress' && !$apiKey) { return $this->sendError([ 'message' => __('Please enter an API key.', 'fluent-crm'), ], 422); } $resolvedModel = $this->resolveModel($provider, $model); if (!$resolvedModel) { return $this->sendError([ 'message' => __('AI model is not configured. Please set it in Settings.', 'fluent-crm'), ], 422); } $result = $this->callProviderApi($provider, $resolvedModel, $apiKey, 'Say "Connection successful" in exactly two words.', '', 15); if (is_wp_error($result)) { return $this->sendError([ 'message' => $result->get_error_message(), ], 422); } return $this->sendSuccess([ 'message' => __('Connection successful! Your API key is valid.', 'fluent-crm'), ]); } public function generate(Request $request) { $action = sanitize_text_field($request->get('action', '')); $content = sanitize_textarea_field($request->get('content', '')); $tone = sanitize_text_field($request->get('tone', '')); $customPrompt = sanitize_textarea_field($request->get('custom_prompt', '')); $validActions = ['rewrite', 'shorten', 'expand', 'fix_grammar', 'custom']; if (!in_array($action, $validActions, true)) { return $this->sendError([ 'message' => __('Invalid action specified.', 'fluent-crm'), ], 422); } if (empty($content) && $action !== 'custom') { return $this->sendError([ 'message' => __('No content provided to process.', 'fluent-crm'), ], 422); } if ($action === 'custom' && empty($content) && empty($customPrompt)) { return $this->sendError([ 'message' => __('Please provide a prompt or select some text.', 'fluent-crm'), ], 422); } $settings = $this->getSavedSettings(); if (Arr::get($settings, 'is_enabled') !== 'yes') { return $this->sendError([ 'message' => __('AI features are not enabled. Please configure AI in Settings.', 'fluent-crm'), ], 422); } $provider = Arr::get($settings, 'provider', ''); $apiKey = Arr::get($settings, 'api_key', ''); if ($provider !== 'wordpress' && !$apiKey) { return $this->sendError([ 'message' => __('AI API key is not configured. Please add it in Settings.', 'fluent-crm'), ], 422); } $model = Arr::get($settings, 'model', ''); $validProviders = array_keys($this->providerModels); if (!$provider || !in_array($provider, $validProviders, true)) { return $this->sendError([ 'message' => __('AI provider is not configured. Please set it in Settings.', 'fluent-crm'), ], 422); } if (!$model) { return $this->sendError([ 'message' => __('AI model is not configured. Please set it in Settings.', 'fluent-crm'), ], 422); } $resolvedModel = $this->resolveModel($provider, $model); if (!$resolvedModel) { return $this->sendError([ 'message' => __('AI model is not configured. Please set it in Settings.', 'fluent-crm'), ], 422); } $userPrompt = $this->buildUserPrompt($action, $content, $customPrompt); $result = $this->callProviderApi($provider, $resolvedModel, $apiKey, $userPrompt, $this->getSystemPrompt($tone, $settings), 30); if (is_wp_error($result)) { return $this->sendError([ 'message' => $result->get_error_message(), ], 422); } return $this->sendSuccess([ 'content' => sanitize_textarea_field($result), ]); } /** * Generate a complete email body from a user prompt for campaign editors. */ public function generateEmailBody(Request $request) { $prompt = sanitize_textarea_field($request->get('prompt', '')); $tone = sanitize_text_field($request->get('tone', 'friendly')); $audience = sanitize_text_field($request->get('audience', '')); $length = sanitize_text_field($request->get('length', 'medium')); $cta = sanitize_text_field($request->get('cta', '')); $context = Helper::parseArrayOrJson($request->get('context', [])); if (!$prompt) { return $this->sendError([ 'message' => __('Please provide a prompt to generate the email body.', 'fluent-crm'), ], 422); } if (!in_array($tone, ['friendly', 'professional', 'casual', 'persuasive', 'educational'], true)) { $tone = 'friendly'; } if (!in_array($length, ['short', 'medium', 'long'], true)) { $length = 'medium'; } $settings = $this->getSavedSettings(); $aiConfig = $this->validateAiGenerationConfig($settings); if (is_wp_error($aiConfig)) { return $this->sendError([ 'message' => $aiConfig->get_error_message(), ], 422); } $promptData = [ 'prompt' => $prompt, 'tone' => $tone, 'audience' => $audience, 'length' => $length, 'cta' => $cta, 'context' => $this->sanitizePromptContext($context), ]; $result = $this->callProviderApi( $aiConfig['provider'], $aiConfig['model'], $aiConfig['api_key'], $this->buildEmailBodyUserPrompt($promptData), $this->getEmailBodySystemPrompt($settings), 45 ); if (is_wp_error($result)) { return $this->sendError([ 'message' => $result->get_error_message(), ], 422); } $generated = $this->parseGeneratedEmailBody($result); /** * Filter generated AI email body content before returning it to the editor. * * @param array $generated Generated subject suggestions, preheader, and body HTML. * @param array $promptData Sanitized prompt inputs and editor context. * @param string $result Raw AI provider response. */ $generated = apply_filters('fluent_crm/ai_email_body_generated_content', $generated, $promptData, $result); if (!is_array($generated)) { $generated = [ 'subject_suggestions' => [], 'preview_text' => '', 'email_body' => '', ]; } return $this->sendSuccess([ 'email_body' => $this->sanitizeGeneratedEmailBody(Arr::get($generated, 'email_body', ''), Arr::get($promptData, 'context.output_format', '')), 'subject_suggestions' => array_values(array_map('sanitize_text_field', Arr::get($generated, 'subject_suggestions', []))), 'preview_text' => sanitize_text_field(Arr::get($generated, 'preview_text', '')), 'provider' => $aiConfig['provider'], 'model' => $aiConfig['model'], ]); } private function sanitizeGeneratedEmailBody($emailBody, $outputFormat) { $emailBody = (string) $emailBody; if ($outputFormat === 'gutenberg_blocks' && function_exists('filter_block_content')) { return filter_block_content($emailBody); } return wp_kses_post($emailBody); } /** * Generate or return a cached markdown summary for a contact profile. */ public function contactSummary(Request $request) { $subscriberId = intval($request->get('subscriber_id', 0)); $generate = $request->get('generate') == 'yes'; $regenerate = $request->get('regenerate') == 'yes'; $locale = $this->getContactSummaryLocale(); if (!$subscriberId) { return $this->sendError([ 'message' => __('Invalid contact selected.', 'fluent-crm'), ], 422); } $subscriber = Subscriber::with(['tags', 'lists'])->find($subscriberId); if (!$subscriber) { return $this->sendError([ 'message' => __('Subscriber not found', 'fluent-crm'), ], 404); } $metaKey = '_ai_contact_summary'; $cachedSummary = fluentcrm_get_subscriber_meta($subscriberId, $metaKey, []); if (!$regenerate && $this->isCachedContactSummaryForLocale($cachedSummary, $locale)) { return $this->sendSuccess([ 'summary' => $cachedSummary, 'cached' => true, ]); } if (!$generate && !$regenerate) { return $this->sendSuccess([ 'summary' => [], 'cached' => false, ]); } $settings = $this->getSavedSettings(); if (Arr::get($settings, 'is_enabled') !== 'yes') { return $this->sendError([ 'message' => __('AI features are not enabled. Please configure AI in Settings.', 'fluent-crm'), ], 422); } $provider = Arr::get($settings, 'provider', ''); $apiKey = Arr::get($settings, 'api_key', ''); if ($provider !== 'wordpress' && !$apiKey) { return $this->sendError([ 'message' => __('AI API key is not configured. Please add it in Settings.', 'fluent-crm'), ], 422); } $model = Arr::get($settings, 'model', ''); $validProviders = array_keys($this->providerModels); if (!$provider || !in_array($provider, $validProviders, true)) { return $this->sendError([ 'message' => __('AI provider is not configured. Please set it in Settings.', 'fluent-crm'), ], 422); } if (!$model) { return $this->sendError([ 'message' => __('AI model is not configured. Please set it in Settings.', 'fluent-crm'), ], 422); } $resolvedModel = $this->resolveModel($provider, $model); if (!$resolvedModel) { return $this->sendError([ 'message' => __('AI model is not configured. Please set it in Settings.', 'fluent-crm'), ], 422); } $context = $this->buildContactSummaryContext($subscriber, $locale); $result = $this->callProviderApi( $provider, $resolvedModel, $apiKey, $this->buildContactSummaryPrompt($context, $locale), $this->getContactSummarySystemPrompt($settings, $locale), 45 ); if (is_wp_error($result)) { return $this->sendError([ 'message' => $result->get_error_message(), ], 422); } $summary = [ 'content' => sanitize_textarea_field($result), 'generated_at' => fluentCrmTimestamp(), 'provider' => $provider, 'model' => $model, 'locale' => $locale, 'counts' => Arr::get($context, 'counts', []), ]; fluentcrm_update_subscriber_meta($subscriberId, $metaKey, $summary); return $this->sendSuccess([ 'summary' => $summary, 'cached' => false, ]); } /** * Resolve the WordPress site locale used for AI contact summaries. * * The contact summary must follow Settings > General > Site Language, not the * current admin user's profile language. The fallback keeps cache comparison * deterministic if WordPress returns an empty locale for any reason. * * @return string Sanitized WordPress locale, for example en_US or bn_BD. */ private function getContactSummaryLocale() { $locale = sanitize_text_field((string) get_locale()); return $locale ?: 'en_US'; } /** * Check whether a cached AI contact summary can be reused for the site locale. * * New summaries store their generation locale and are reusable only when it * matches the current site locale. Older cached summaries did not store a * locale and were generated from English-only prompts, so they are treated as * valid only while the current site locale is English. * * @param array $summary Cached subscriber meta summary payload. * @param string $locale Current sanitized WordPress site locale. * * @return bool True when the cached summary can be shown without regenerating. */ private function isCachedContactSummaryForLocale($summary, $locale) { if (!is_array($summary) || empty($summary['content'])) { return false; } $cachedLocale = sanitize_text_field(Arr::get($summary, 'locale', '')); if ($cachedLocale) { return $cachedLocale === $locale; } // Legacy cached summaries were generated from English-only prompts. return $this->isEnglishLocale($locale); } /** * Determine whether a WordPress locale belongs to the English language family. * * Used only for legacy AI summary cache compatibility, where existing cached * records have no explicit locale but were produced by English prompts. * * @param string $locale WordPress locale to inspect. * * @return bool True for locales beginning with en, such as en_US or en_GB. */ private function isEnglishLocale($locale) { return strtolower(substr((string) $locale, 0, 2)) === 'en'; } private function getSystemPrompt($tone = '', $settings = []) { $prompt = 'You are an email copywriting assistant. Write like a real human — natural, conversational, and warm. ' . 'Avoid AI-sounding patterns: no em dashes, no "I hope this email finds you well", no "In today\'s fast-paced world", no "leverage", no "streamline", no "I\'d be happy to". ' . 'Use short sentences. Use simple words. Write the way people actually talk in emails. ' . 'Return ONLY the improved text in markdown format. No explanations, preamble, or wrapping code blocks.' . "\n\n" . 'You can use these smartcode placeholders to personalize the email: ' . '{{contact.first_name}} (recipient first name), ' . '{{contact.last_name}} (recipient last name), ' . '{{contact.full_name}} (recipient full name), ' . '{{contact.email}} (recipient email), ' . '{{crm.business_name}} (sender business name), ' . '{{crm.business_address}} (sender business address). ' . 'Use these smartcodes where appropriate to make emails feel personal. Keep existing smartcodes in the text intact.'; if ($tone) { $prompt .= ' Use a ' . strtolower($tone) . ' tone throughout.'; } $customSystemPrompt = trim(Arr::get($settings, 'custom_prompt', '')); if ($customSystemPrompt) { $prompt .= "\n\nAdditional instructions: " . $customSystemPrompt; } return $prompt; } private function buildUserPrompt($action, $content, $customPrompt = '') { switch ($action) { case 'rewrite': return "Rewrite the following email text while keeping the same meaning:\n\n" . $content; case 'shorten': return "Make the following email text shorter and more concise:\n\n" . $content; case 'expand': return "Expand the following email text with more detail and engagement:\n\n" . $content; case 'fix_grammar': return "Fix grammar, spelling, and punctuation in the following text:\n\n" . $content; case 'custom': return $customPrompt . "\n\nText:\n" . $content; default: return $content; } } private function validateAiGenerationConfig($settings) { if (Arr::get($settings, 'is_enabled') !== 'yes') { return new \WP_Error('ai_disabled', __('AI features are not enabled. Please configure AI in Settings.', 'fluent-crm')); } $provider = Arr::get($settings, 'provider', ''); $apiKey = Arr::get($settings, 'api_key', ''); if ($provider !== 'wordpress' && !$apiKey) { return new \WP_Error('missing_api_key', __('AI API key is not configured. Please add it in Settings.', 'fluent-crm')); } $model = Arr::get($settings, 'model', ''); $validProviders = array_keys($this->providerModels); if (!$provider || !in_array($provider, $validProviders, true)) { return new \WP_Error('missing_provider', __('AI provider is not configured. Please set it in Settings.', 'fluent-crm')); } if (!$model) { return new \WP_Error('missing_model', __('AI model is not configured. Please set it in Settings.', 'fluent-crm')); } $resolvedModel = $this->resolveModel($provider, $model); if (!$resolvedModel) { return new \WP_Error('missing_model', __('AI model is not configured. Please set it in Settings.', 'fluent-crm')); } return [ 'provider' => $provider, 'model' => $resolvedModel, 'api_key' => $apiKey, ]; } private function getEmailBodySystemPrompt($settings = []) { $prompt = 'You are an expert email marketing copywriter for FluentCRM users. ' . 'Generate a complete email body that is ready to insert into an email editor. ' . 'Use real, specific copy based only on the user prompt. Do not invent discounts, dates, guarantees, scarcity, purchase history, or personal facts. ' . 'Use FluentCRM smartcodes sparingly when helpful, such as {{contact.first_name}}, {{contact.full_name}}, and {{crm.business_name}}. ' . 'Match the requested output_format exactly: gutenberg_blocks must return valid WordPress block markup using paragraph, heading, list, and button blocks; classic_html must return clean rich HTML fragments; raw_html must return clean raw HTML fragments. ' . 'For HTML outputs, use only these tags when needed: h2, h3, p, ul, ol, li, strong, em, a, br. Do not include html, head, body, style, script, table, img, or wrapper div tags. ' . 'Return ONLY valid JSON with this exact shape: {"subject_suggestions":["..."],"preview_text":"...","email_body":"..."}. No markdown fences, no explanations.'; $customSystemPrompt = trim(Arr::get($settings, 'custom_prompt', '')); if ($customSystemPrompt) { $prompt .= "\n\nAdditional brand instructions: " . $customSystemPrompt; } /** * Filter the AI email body system prompt. * * @param string $prompt System prompt sent to the configured AI provider. * @param array $settings Saved AI settings. */ return apply_filters('fluent_crm/ai_email_body_system_prompt', $prompt, $settings); } private function buildEmailBodyUserPrompt($promptData) { $lengthMap = [ 'short' => 'Short: about 2-3 short paragraphs or one heading plus a few bullets.', 'medium' => 'Medium: about 4-6 short paragraphs or sections.', 'long' => 'Long: a more detailed email with clear sections and supporting bullets.', ]; $prompt = "Create a complete marketing email body from this brief.\n\n" . 'Goal: ' . Arr::get($promptData, 'prompt') . "\n" . 'Tone: ' . Arr::get($promptData, 'tone') . "\n" . 'Length: ' . Arr::get($lengthMap, Arr::get($promptData, 'length'), $lengthMap['medium']) . "\n"; if ($audience = Arr::get($promptData, 'audience')) { $prompt .= 'Audience: ' . $audience . "\n"; } if ($cta = Arr::get($promptData, 'cta')) { $prompt .= 'Primary CTA: ' . $cta . "\n"; } $context = Arr::get($promptData, 'context', []); if ($context) { $prompt .= "\nEditor context:\n" . wp_json_encode($context, JSON_PRETTY_PRINT); } $prompt .= "\n\nOutput rules:\n" . $this->getEmailBodyOutputRules(Arr::get($context, 'output_format', 'classic_html')); /** * Filter the AI email body user prompt. * * @param string $prompt User prompt sent to the configured AI provider. * @param array $promptData Sanitized prompt inputs and editor context. */ return apply_filters('fluent_crm/ai_email_body_user_prompt', $prompt, $promptData); } private function getEmailBodyOutputRules($outputFormat) { if ($outputFormat === 'gutenberg_blocks') { return 'Set email_body to WordPress Gutenberg block markup only. Example shape:
Copy
and