PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.3
MailPoet – Newsletters, Email Marketing, and Automation v5.34.3
5.35.0 5.34.3 5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / EmailEditor / Integrations / MailPoet / Endpoints / GenerateSubjectSuggestionsEndpoint.php
mailpoet / lib / EmailEditor / Integrations / MailPoet / Endpoints Last commit date
GenerateSubjectSuggestionsEndpoint.php 3 months ago index.php 3 months ago
GenerateSubjectSuggestionsEndpoint.php
256 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\EmailEditor\Integrations\MailPoet\Endpoints;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
9 use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
10 use MailPoet\API\REST\Endpoint;
11 use MailPoet\API\REST\ErrorResponse;
12 use MailPoet\API\REST\Request;
13 use MailPoet\API\REST\Response;
14 use MailPoet\EmailEditor\Integrations\MailPoet\EmailEditor;
15 use MailPoet\Logging\LoggerFactory;
16 use MailPoet\Validator\Builder;
17 use MailPoet\Validator\Schema;
18 use MailPoet\WP\Functions as WPFunctions;
19
20 class GenerateSubjectSuggestionsEndpoint extends Endpoint {
21 private WPFunctions $wp;
22
23 private const TAG_CATEGORIES_FOR_SUBJECT = ['Subscriber', 'Site', 'Customer', 'Order'];
24
25 private const SUBJECT_MAX_LENGTH = 60;
26 private const PREHEADER_MAX_LENGTH = 150;
27
28 public function __construct(
29 WPFunctions $wp
30 ) {
31 $this->wp = $wp;
32 }
33
34 public function handle(Request $request): Response {
35 /** @var int $postId validated by schema */
36 $postId = $request->getParam('post_id');
37
38 if (!function_exists('wp_ai_client_prompt')) {
39 return new ErrorResponse(
40 503,
41 __('AI text generation is not available.', 'mailpoet'),
42 'mailpoet_ai_unavailable'
43 );
44 }
45
46 $post = $this->wp->getPost($postId);
47 if (!$post instanceof \WP_Post || $post->post_type !== 'mailpoet_email') { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
48 return new ErrorResponse(
49 404,
50 __('Email not found.', 'mailpoet'),
51 'mailpoet_ai_email_not_found'
52 );
53 }
54
55 if (!current_user_can('edit_post', $postId)) {
56 return new ErrorResponse(
57 403,
58 __('You are not allowed to generate suggestions for this email.', 'mailpoet'),
59 'mailpoet_ai_forbidden'
60 );
61 }
62
63 $html = do_blocks($post->post_content); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
64 $bodyText = $this->wp->wpStripAllTags($html);
65 $bodyText = (string)preg_replace('/\s+/', ' ', trim($bodyText));
66
67 if ($bodyText === '') {
68 return new ErrorResponse(
69 400,
70 __('Email body is empty. Add content before generating suggestions.', 'mailpoet'),
71 'mailpoet_ai_empty_content'
72 );
73 }
74
75 $personalizationTags = $this->getAvailablePersonalizationTags();
76 $tagsInstruction = '';
77 if (!empty($personalizationTags)) {
78 $tagsList = implode(', ', array_map(function ($tag) {
79 return $tag['token'] . ' (' . $tag['name'] . ')';
80 }, $personalizationTags));
81 $tagsInstruction = 'You may use these personalization tags in the subject lines and preview text to make them more personal: '
82 . $tagsList . '. Use them sparingly — not every suggestion needs a tag. Insert the tag token exactly as shown (e.g. [mailpoet/subscriber-firstname]). ';
83 }
84
85 $systemInstruction = 'You are an expert email marketer. Generate subject lines and preview text for marketing emails. '
86 . 'Match the tone and style of the email body — if it\'s a business email, keep it professional; '
87 . 'if it\'s fun or casual, feel free to use emojis. '
88 . 'IMPORTANT: Generate suggestions in the same language as the email body content. '
89 . $tagsInstruction
90 . 'Subject lines must be under 60 characters. Preview text must be under 150 characters and complement the subject line. '
91 . 'Respond ONLY with valid JSON (no markdown, no code fences) in this exact format: '
92 . '{"suggestions":[{"subject":"...","preheader":"..."},{"subject":"...","preheader":"..."},{"subject":"...","preheader":"..."},{"subject":"...","preheader":"..."}]}';
93
94 $prompt = "Based on the following email body content, generate 4 different subject line and preview text pairs:\n\n" . $bodyText;
95
96 $promptBuilder = wp_ai_client_prompt($prompt)
97 ->using_system_instruction($systemInstruction)
98 ->using_model_preference(
99 ['anthropic', 'claude-sonnet-4-6'],
100 ['google', 'gemini-3-flash-preview'],
101 ['google', 'gemini-2.5-flash'],
102 ['openai', 'gpt-5.4-mini'],
103 ['openai', 'gpt-4.1-mini']
104 )
105 ->as_json_response();
106
107 $logger = LoggerFactory::getInstance()->getLogger(LoggerFactory::TOPIC_EMAIL_EDITOR);
108
109 if (!$promptBuilder->is_supported_for_text_generation()) {
110 return new ErrorResponse(
111 503,
112 __('AI text generation is not available. Please check your AI provider configuration.', 'mailpoet'),
113 'mailpoet_ai_unavailable'
114 );
115 }
116
117 $result = $promptBuilder->generate_text();
118
119 if (is_wp_error($result)) {
120 $logger->error('AI subject generation failed', [
121 'error_code' => $result->get_error_code(),
122 'error_message' => $result->get_error_message(),
123 'post_id' => $postId,
124 ]);
125 return new ErrorResponse(
126 502,
127 __('Failed to generate suggestions. Please check your AI provider configuration and try again.', 'mailpoet'),
128 'mailpoet_ai_generation_failed'
129 );
130 }
131
132 $decoded = $this->parseAiResponse($result);
133 if ($decoded === null) {
134 $logger->error('AI subject generation returned invalid JSON', [
135 'response_type' => gettype($result),
136 'response_length' => is_string($result) ? mb_strlen($result) : null,
137 'response_preview' => is_string($result) ? mb_substr($result, 0, 200) : null,
138 'post_id' => $postId,
139 ]);
140 return new ErrorResponse(
141 502,
142 __('AI returned an unexpected response.', 'mailpoet'),
143 'mailpoet_ai_invalid_response'
144 );
145 }
146
147 $validSuggestions = [];
148 foreach ($decoded['suggestions'] as $suggestion) {
149 if (
150 !is_array($suggestion)
151 || !isset($suggestion['subject'], $suggestion['preheader'])
152 || !is_string($suggestion['subject'])
153 || !is_string($suggestion['preheader'])
154 || mb_strlen($suggestion['subject']) > self::SUBJECT_MAX_LENGTH
155 || mb_strlen($suggestion['preheader']) > self::PREHEADER_MAX_LENGTH
156 ) {
157 $logger->info('AI subject suggestion filtered out', [
158 'suggestion_type' => gettype($suggestion),
159 'suggestion_keys' => is_array($suggestion) ? array_keys($suggestion) : [],
160 'subject_length' => is_array($suggestion) && isset($suggestion['subject']) && is_string($suggestion['subject']) ? mb_strlen($suggestion['subject']) : null,
161 'preheader_length' => is_array($suggestion) && isset($suggestion['preheader']) && is_string($suggestion['preheader']) ? mb_strlen($suggestion['preheader']) : null,
162 'post_id' => $postId,
163 ]);
164 continue;
165 }
166 $validSuggestions[] = [
167 'subject' => $suggestion['subject'],
168 'preheader' => $suggestion['preheader'],
169 ];
170 }
171
172 if (empty($validSuggestions)) {
173 $logger->error('AI subject generation returned no valid suggestions', [
174 'suggestion_count' => count($decoded['suggestions']),
175 'post_id' => $postId,
176 ]);
177 return new ErrorResponse(
178 502,
179 __('AI did not return any valid suggestions. Please try again.', 'mailpoet'),
180 'mailpoet_ai_no_valid_suggestions'
181 );
182 }
183
184 return new Response(['suggestions' => $validSuggestions]);
185 }
186
187 /**
188 * @return array{suggestions: array<int, mixed>}|null
189 */
190 private function parseAiResponse(string $result): ?array {
191 $json = preg_replace('/^```(?:json)?\s*|```\s*$/i', '', trim($result)) ?? trim($result);
192 $json = trim($json);
193
194 $decoded = json_decode($json, true);
195
196 if (is_array($decoded) && isset($decoded['suggestions']) && is_array($decoded['suggestions'])) {
197 return ['suggestions' => array_values($decoded['suggestions'])];
198 }
199
200 if (is_array($decoded) && !isset($decoded['suggestions']) && isset($decoded[0])) {
201 return ['suggestions' => array_values($decoded)];
202 }
203
204 return null;
205 }
206
207 /**
208 * @return array<int, array{name: string, token: string}>
209 */
210 private function getAvailablePersonalizationTags(): array {
211 if (!class_exists(Email_Editor_Container::class)) {
212 return [];
213 }
214 $registry = Email_Editor_Container::container()->get(Personalization_Tags_Registry::class);
215 $tags = [];
216 $urlKeywords = ['url', 'link'];
217 foreach ($registry->get_all() as $tag) {
218 $postTypes = $tag->get_post_types();
219 if (!empty($postTypes) && !in_array(EmailEditor::MAILPOET_EMAIL_POST_TYPE, $postTypes, true)) {
220 continue;
221 }
222 if (!in_array($tag->get_category(), self::TAG_CATEGORIES_FOR_SUBJECT, true)) {
223 continue;
224 }
225 $token = $tag->get_token();
226 $tokenLower = strtolower($token);
227 $isUrl = false;
228 foreach ($urlKeywords as $keyword) {
229 if (strpos($tokenLower, $keyword) !== false) {
230 $isUrl = true;
231 break;
232 }
233 }
234 if ($isUrl) {
235 continue;
236 }
237 $tags[] = [
238 'name' => $tag->get_name(),
239 'token' => $token,
240 ];
241 }
242 return $tags;
243 }
244
245 public function checkPermissions(): bool {
246 return current_user_can('edit_posts');
247 }
248
249 /** @return array<string, Schema> */
250 public static function getRequestSchema(): array {
251 return [
252 'post_id' => Builder::integer()->required(),
253 ];
254 }
255 }
256