PluginProbe
wpForo Forum / 3.2.1
wpForo Forum v3.2.1
3.2.1 3.2.0 3.1.7 3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 All 141 releases
wpforo / admin / pages / news / src / Services / EmailService.php

EmailService.php in wpForo Forum 3.2.1, at admin/pages/news/src/Services/EmailService.php

767 lines 38.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace gVectors\News\Services;
4
5 use gVectors\News\Config;
6 use gVectors\News\NewsModule;
7 use WP_User;
8
9 // Exit if accessed directly
10 if( ! defined( 'ABSPATH' ) ) exit;
11
12 /**
13 * Sends the two email types through the site's own wp_mail/SMTP:
14 * 1. news digest — proxy-built wrapper + per-item HTML blocks
15 * 2. license expiry reminder — fully proxy-built body
16 *
17 * The only local work is injecting the site tokens per recipient:
18 * {admin_name} {site_name} {site_url} {dashboard_url} {current_year}
19 * License rows are NEVER built locally. The two email types are never combined.
20 */
21 class EmailService {
22
23 private $config;
24 private $prefs;
25
26 public function __construct( Config $config, PrefsService $prefs ) {
27 $this->config = $config;
28 $this->prefs = $prefs;
29 }
30
31 /**
32 * All site administrators — role query, NOT a capability query (unreliable).
33 * @return WP_User[]
34 */
35 private function get_recipients(): array {
36 return get_users( [ 'role__in' => [ 'administrator' ] ] );
37 }
38
39 /**
40 * News emails: per-admin, only items neither dismissed nor already emailed,
41 * filtered through the site + per-admin channel/category/relevance matrix.
42 *
43 * Items flagged `standalone` on the proxy bypass the digest and go out as
44 * their own email (subject = item title). Everything else is combined into
45 * ONE digest, each item rendered inside its category card; a single-item
46 * digest uses that item's title as the subject (dynamic subject).
47 */
48 public function send_news_digest( array $payload ): void {
49 $items = [];
50 $now = time();
51 foreach( (array) ( $payload['news'] ?? [] ) as $item ) {
52 if( ! empty( $item['expires_at'] ) && strtotime( $item['expires_at'] ) <= $now ) continue;
53 $items[ $item['id'] ] = $item;
54 }
55
56 $recommendations = is_array( $payload['recommendations'] ?? null ) ? $payload['recommendations'] : [];
57 $cross_sell = is_array( $payload['cross_sell'] ?? null ) ? $payload['cross_sell'] : null;
58 if( empty( $items ) && empty( $recommendations ) ) return;
59
60 $wrapper = ! empty( $payload['email_wrapper'] ) ? $payload['email_wrapper'] : $this->fallback_wrapper();
61 $card_tpl = ! empty( $wrapper['item_card'] ) ? $wrapper['item_card'] : $this->fallback_card();
62
63 $emailed_meta_key = $this->config->get_emailed_news_meta();
64 $dismissed_meta_key = $this->config->get_dismissed_news_meta();
65 $recs_meta_key = $this->config->get_emailed_recs_meta();
66
67 foreach( $this->get_recipients() as $user ) {
68 $emailed = get_user_meta( $user->ID, $emailed_meta_key, true );
69 $emailed = is_array( $emailed ) ? $emailed : [];
70 $dismissed = get_user_meta( $user->ID, $dismissed_meta_key, true );
71 $dismissed = is_array( $dismissed ) ? $dismissed : [];
72
73 $unseen = array_diff_key( $items, array_flip( array_merge( $emailed, $dismissed ) ) );
74
75 // Site + per-admin matrix (category, relevance, unsubscribe). Filtered
76 // items are NOT recorded as emailed — re-enabling resumes delivery.
77 foreach( $unseen as $id => $item ) {
78 if( ! $this->prefs->news_item_allowed( $item, PrefsService::CHANNEL_EMAILS, $user->ID ) ) {
79 unset( $unseen[ $id ] );
80 }
81 }
82
83 // Cross-sell: not-yet-recommended, not-installed addons for THIS admin
84 $recs = $this->fresh_recommendations_for( $user, $recommendations );
85 $rec_section = ! empty( $recs ) ? $this->render_recs_section( $recs, $cross_sell ) : '';
86
87 if( empty( $unseen ) && $rec_section === '' ) continue;
88
89 $standalone = [];
90 $digest = [];
91 foreach( $unseen as $item ) {
92 if( ! empty( $item['standalone'] ) ) $standalone[] = $item;
93 else $digest[] = $item;
94 }
95
96 $sent_ids = [];
97
98 // Standalone items: one email each, the item title as the subject
99 foreach( $standalone as $item ) {
100 $content = $this->render_news_card( $item, '', $card_tpl );
101 $body = str_replace( [ '{news_content}', '{news_count}' ], [ $content, '1' ], $wrapper['body_html'] );
102 $subject = $item['title'] !== '' ? $item['title'] : $wrapper['subject'];
103 if( $this->send( $user, $subject, $body ) ) {
104 $sent_ids[] = $item['id'];
105 }
106 }
107
108 // Digest: every remaining item in its own category card, numbered when
109 // multiple; the recommendations section (if any) rides at the bottom.
110 if( ! empty( $digest ) ) {
111 $count = count( $digest );
112 $content = '';
113 $i = 0;
114 foreach( $digest as $item ) {
115 $i++;
116 $counter = $count > 1 ? $i . ' / ' . $count : '';
117 $content .= $this->render_news_card( $item, $counter, $card_tpl );
118 }
119 $content .= $rec_section;
120
121 $body = str_replace( [ '{news_content}', '{news_count}' ], [ $content, (string) $count ], $wrapper['body_html'] );
122
123 // Dynamic subject: a single-item digest reads like a dedicated email
124 if( $count === 1 && $digest[0]['title'] !== '' ) {
125 $subject = $digest[0]['title'];
126 } else {
127 $subject = str_replace( '{news_count}', (string) $count, $wrapper['subject'] );
128 }
129 $subject = $subject !== '' ? $subject : __( 'News from gVectors', 'gvectors' );
130
131 if( $this->send( $user, $subject, $body ) ) {
132 $sent_ids = array_merge( $sent_ids, array_column( $digest, 'id' ) );
133 $this->record_emailed_recs( $user->ID, $recs_meta_key, $recs );
134 }
135 } elseif( $rec_section !== '' ) {
136 // No news today — the recommendations go out as their own email,
137 // in the minimal local shell (the news wrapper's "what's new"
138 // copy would read wrong with zero items).
139 $shell = $this->fallback_wrapper();
140 $body = str_replace( [ '{news_content}', '{news_count}' ], [ $rec_section, '0' ], $shell['body_html'] );
141 $subject = ! empty( $cross_sell['subject'] ) ? $cross_sell['subject'] : __( 'Addons picked for your site', 'gvectors' );
142 if( $this->send( $user, $subject, $body ) ) {
143 $this->record_emailed_recs( $user->ID, $recs_meta_key, $recs );
144 }
145 }
146
147 if( ! empty( $sent_ids ) ) {
148 update_user_meta( $user->ID, $emailed_meta_key, array_values( array_unique( array_merge( $emailed, $sent_ids ) ) ) );
149 }
150 }
151 }
152
153 /**
154 * Recommendations this admin should still hear about: category allowed by
155 * BOTH scopes, never emailed to them before, addon not installed locally.
156 */
157 private function fresh_recommendations_for( WP_User $user, array $recommendations ): array {
158 if( empty( $recommendations ) ) return [];
159 if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'recommendations' ) ) return [];
160
161 $emailed = get_user_meta( $user->ID, $this->config->get_emailed_recs_meta(), true );
162 $emailed = is_array( $emailed ) ? $emailed : [];
163
164 $fresh = [];
165 foreach( $recommendations as $rec ) {
166 if( in_array( $rec['plugin_slug'], $emailed, true ) ) continue;
167 if( $this->prefs->is_addon_installed( $rec['plugin_slug'] ) ) continue;
168 $fresh[] = $rec;
169 }
170
171 return $fresh;
172 }
173
174 /**
175 * Render the "Recommended for you" section from the proxy templates
176 * (local fallbacks when the payload didn't carry them).
177 */
178 private function render_recs_section( array $recs, ?array $cross_sell ): string {
179 $item_tpl = ! empty( $cross_sell['item'] ) ? $cross_sell['item']
180 : '<div style="border:1px solid #e2e4e8;border-left:4px solid #2271b1;border-radius:4px;padding:14px 18px;margin:0 0 12px;">'
181 . '<div style="font-size:14px;color:#1d2327;"><strong>{product_name}</strong></div>'
182 . '<div style="font-size:13px;color:#50575e;margin-top:4px;">{reason}</div></div>';
183 $section_tpl = ! empty( $cross_sell['section'] ) ? $cross_sell['section']
184 : '<div style="border-top:2px solid #e2e4e8;margin:24px 0 0;padding:20px 0 0;">'
185 . '<div style="font-size:11px;font-weight:bold;letter-spacing:0.8px;text-transform:uppercase;color:#8c8f94;margin:0 0 14px;">'
186 . esc_html__( 'Recommended for you', 'gvectors' ) . '</div>{items}</div>';
187
188 $items_html = '';
189 foreach( $recs as $rec ) {
190 $items_html .= str_replace(
191 [ '{product_name}', '{reason}' ],
192 [ esc_html( $rec['product_name'] ), esc_html( $rec['reason'] ) ],
193 $item_tpl
194 );
195 }
196
197 $section = str_replace( [ '{items}', '{item_count}' ], [ $items_html, (string) count( $recs ) ], $section_tpl );
198
199 return wp_kses( $section, $this->email_allowed_html() );
200 }
201
202 private function record_emailed_recs( int $user_id, string $meta_key, array $recs ): void {
203 if( empty( $recs ) ) return;
204 $emailed = get_user_meta( $user_id, $meta_key, true );
205 $emailed = is_array( $emailed ) ? $emailed : [];
206 update_user_meta( $user_id, $meta_key, array_values( array_unique( array_merge( $emailed, array_column( $recs, 'plugin_slug' ) ) ) ) );
207 }
208
209 /**
210 * Expiry reminder (upcoming expirations, phase > 0): per-admin, each
211 * "license_id:phase" fires exactly once. Body fully assembled by the proxy.
212 */
213 public function send_expiry_reminders( array $at_risk ): void {
214 $this->send_phase_email(
215 $at_risk,
216 'email',
217 fn( $license ) => (int) ( $license['phase'] ?? 0 ) > 0,
218 __( 'Your addon licenses are expiring soon', 'gvectors' )
219 );
220 }
221
222 /**
223 * Winback reminder (already-expired licenses, phase < 0): same per-admin
224 * "license_id:phase" tracking — negative phases make the keys distinct.
225 */
226 public function send_winback_reminders( array $at_risk ): void {
227 $this->send_phase_email(
228 $at_risk,
229 'winback_email',
230 fn( $license ) => (int) ( $license['phase'] ?? 0 ) < 0,
231 __( 'Your addon license has expired', 'gvectors' )
232 );
233 }
234
235 /**
236 * Shared engine for the two phase-keyed reminder emails.
237 */
238 private function send_phase_email( array $at_risk, string $email_field, callable $phase_filter, string $fallback_subject ): void {
239 if( empty( $at_risk['licenses'] ) || empty( $at_risk[ $email_field ]['body_html'] ) ) return;
240
241 $keys = [];
242 foreach( $at_risk['licenses'] as $license ) {
243 if( ! empty( $license['id'] ) && isset( $license['phase'] ) && $phase_filter( $license ) ) {
244 $keys[] = sanitize_text_field( $license['id'] . ':' . (int) $license['phase'] );
245 }
246 }
247 if( empty( $keys ) ) return;
248
249 $meta_key = $this->config->get_emailed_phases_meta();
250 $subject = ! empty( $at_risk[ $email_field ]['subject'] ) ? $at_risk[ $email_field ]['subject'] : $fallback_subject;
251
252 foreach( $this->get_recipients() as $user ) {
253 if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'expiry_reminder' ) ) continue;
254
255 $sent = get_user_meta( $user->ID, $meta_key, true );
256 $sent = is_array( $sent ) ? $sent : [];
257
258 $new_keys = array_diff( $keys, $sent );
259 if( empty( $new_keys ) ) continue;
260
261 if( $this->send( $user, $subject, $at_risk[ $email_field ]['body_html'] ) ) {
262 update_user_meta( $user->ID, $meta_key, array_values( array_unique( array_merge( $sent, $new_keys ) ) ) );
263 }
264 }
265 }
266
267 /**
268 * Purchase confirmation — fired by the license module's
269 * `gvectors_transaction_licenses_activated` hook right after the dashboard
270 * polling verified a completed checkout and activated the license(s).
271 *
272 * Sent ONLY to the administrator who actually made the purchase, with the
273 * transaction id and EVERY license key — so the keys live in their inbox
274 * for future activations on other domains (e.g. staging → production).
275 *
276 * IMPORTANT: the pending-transactions polling is site-wide — ANOTHER
277 * admin's dashboard refresh may be the request that verifies/activates the
278 * purchase. The license module therefore records the purchaser's user id
279 * at checkout time and passes it here ($purchaser_id); the current user is
280 * only a fallback for legacy pending entries without attribution.
281 *
282 * Deliberately bypasses the preference matrix: this is a receipt for the
283 * recipient's own action, and losing the keys email would hurt them.
284 * No proxy request is made — templates come from the cached daily payload
285 * (or the local fallback), so consent state is not involved either.
286 */
287 public function handle_purchase_activated( string $transaction_id, array $licenses, string $core_slug = '', int $purchaser_id = 0 ): void {
288 if( $transaction_id === '' || empty( $licenses ) ) return;
289
290 $user = $purchaser_id > 0 ? get_user_by( 'id', $purchaser_id ) : null;
291 if( ! $user || empty( $user->user_email ) ) {
292 $user = wp_get_current_user(); // legacy entries without purchaser attribution
293 }
294 if( ! $user || ! $user->exists() || empty( $user->user_email ) ) return;
295 if( ! user_can( $user, 'activate_plugins' ) ) return;
296
297 // One confirmation per transaction, ever (polling/tabs may verify twice)
298 $option_key = $this->config->get_emailed_transactions_option();
299 $emailed = get_option( $option_key, [] );
300 $emailed = is_array( $emailed ) ? $emailed : [];
301 if( in_array( $transaction_id, $emailed, true ) ) return;
302
303 $templates = $this->get_purchase_templates();
304
305 $rows = '';
306 foreach( $licenses as $license ) {
307 if( ! is_array( $license ) || empty( $license['license_key'] ) ) continue;
308 $expires = ! empty( $license['expires_at'] )
309 ? date_i18n( 'F j, Y', strtotime( $license['expires_at'] ) )
310 : __( 'Lifetime', 'gvectors' );
311 $rows .= str_replace(
312 [ '{product_name}', '{license_key}', '{expires_at}' ],
313 [
314 esc_html( $license['product_name'] ?? ( $license['plugin_slug'] ?? __( 'Addon', 'gvectors' ) ) ),
315 esc_html( $license['license_key'] ),
316 esc_html( $expires ),
317 ],
318 $templates['license_row']
319 );
320 }
321 if( $rows === '' ) return;
322
323 $count = count( $licenses );
324 $body = str_replace(
325 [ '{transaction_id}', '{license_rows}', '{license_count}' ],
326 [ esc_html( $transaction_id ), $rows, (string) $count ],
327 $templates['body_html']
328 );
329 $subject = str_replace( '{license_count}', (string) $count, $templates['subject'] );
330
331 if( $this->send( $user, $subject, $body ) ) {
332 $emailed[] = $transaction_id;
333 update_option( $option_key, array_slice( array_values( array_unique( $emailed ) ), -50 ) );
334 }
335 }
336
337 /**
338 * Abandoned checkout recovery — fired by the license module's scheduled
339 * `gvectors_abandoned_checkout_check` when the proxy confirms a checkout
340 * is still unpaid at a recovery phase. The email body arrives fully built
341 * (proxy templates, incl. the attached-discount callout when the phase
342 * carries one); this only resolves the recipient and dedups.
343 *
344 * Sent ONLY to the admin who started the checkout (recorded in the
345 * scheduled event args at creation time). Gated by its own email-only
346 * preference category 'abandoned_checkout' (per-admin + site-wide, and
347 * allows() also covers the master unsubscribe); the server-side kill
348 * switch is ABANDONED_CHECKOUT_PHASES (empty = the proxy hands out no
349 * phases and nothing is ever scheduled).
350 */
351 public function handle_abandoned_checkout( string $transaction_id, array $email, int $purchaser_id = 0, int $phase_minutes = 0 ): void {
352 if( $transaction_id === '' || empty( $email['body_html'] ) || ! is_string( $email['body_html'] ) ) return;
353
354 // Runs in cron context — there is no meaningful current user to fall
355 // back to; without a valid recorded purchaser, nothing is sent.
356 $user = $purchaser_id > 0 ? get_user_by( 'id', $purchaser_id ) : null;
357 if( ! $user || empty( $user->user_email ) ) return;
358 if( ! user_can( $user, 'activate_plugins' ) ) return;
359
360 if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'abandoned_checkout' ) ) return;
361
362 $option_key = $this->config->get_abandoned_emailed_option();
363 $emailed = get_option( $option_key, [] );
364 $emailed = is_array( $emailed ) ? $emailed : [];
365 $key = sanitize_text_field( $transaction_id . ':' . (int) $phase_minutes );
366 if( in_array( $key, $emailed, true ) ) return;
367
368 $subject = ! empty( $email['subject'] ) && is_string( $email['subject'] )
369 ? $email['subject']
370 : __( 'You didn\'t finish your purchase', 'gvectors' );
371
372 if( $this->send( $user, $subject, $email['body_html'] ) ) {
373 $emailed[] = $key;
374 update_option( $option_key, array_slice( array_values( array_unique( $emailed ) ), -100 ) );
375 }
376 }
377
378 /**
379 * Updates that can't be installed — fired by the license module (in cron) when
380 * WordPress's own update check found new versions of licensed addons while this
381 * site blocks plugin installs (DISALLOW_FILE_MODS / read-only plugins folder), so
382 * neither the auto-updater nor the Updates screen can install them.
383 *
384 * Every administrator gets ONE email listing the versions they were not told
385 * about yet — per-admin keys "plugin_slug:version", so each release notifies once
386 * and a later release notifies again. Own email-only category 'blocked_update'
387 * (allows() also covers the master unsubscribe). No proxy request: templates come
388 * from the cached daily payload or the local fallback, so consent is not involved.
389 *
390 * @param array $updates plugin_slug => [name, current_version, new_version]
391 */
392 public function handle_blocked_updates( array $updates ): void {
393 $clean = [];
394 foreach( $updates as $slug => $update ) {
395 if( ! is_array( $update ) || empty( $update['new_version'] ) ) continue;
396 $slug = sanitize_key( (string) $slug );
397 if( $slug === '' ) continue;
398 $new = sanitize_text_field( (string) $update['new_version'] );
399 $clean[ $slug ] = [
400 'key' => $slug . ':' . $new,
401 'name' => sanitize_text_field( (string) ( $update['name'] ?? $slug ) ),
402 'current' => sanitize_text_field( (string) ( $update['current_version'] ?? '' ) ),
403 'new' => $new,
404 ];
405 }
406 if( ! $clean ) return;
407
408 $templates = $this->get_blocked_update_templates();
409 $meta_key = $this->config->get_emailed_blocked_updates_meta();
410
411 foreach( $this->get_recipients() as $user ) {
412 if( empty( $user->user_email ) ) continue;
413 if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'blocked_update' ) ) continue;
414
415 $sent = get_user_meta( $user->ID, $meta_key, true );
416 $sent = is_array( $sent ) ? $sent : [];
417 $fresh = array_filter( $clean, fn( $u ) => ! in_array( $u['key'], $sent, true ) );
418 if( ! $fresh ) continue;
419
420 $rows = '';
421 foreach( $fresh as $u ) {
422 $rows .= str_replace(
423 [ '{product_name}', '{current_version}', '{new_version}' ],
424 [ esc_html( $u['name'] ), esc_html( $u['current'] !== '' ? $u['current'] : '—' ), esc_html( $u['new'] ) ],
425 $templates['row']
426 );
427 }
428 $count = (string) count( $fresh );
429 $body = str_replace( [ '{update_rows}', '{update_count}' ], [ $rows, $count ], $templates['body_html'] );
430 $subject = str_replace( '{update_count}', $count, $templates['subject'] );
431
432 if( $this->send( $user, $subject, $body ) ) {
433 $sent = array_merge( $sent, array_column( $fresh, 'key' ) );
434 update_user_meta( $user->ID, $meta_key, array_slice( array_values( array_unique( $sent ) ), -100 ) );
435 }
436 }
437 }
438
439 /**
440 * Blocked-update templates: proxy-editable versions from the cached daily news
441 * payload (`blocked_update_email`), with a complete local fallback.
442 */
443 private function get_blocked_update_templates(): array {
444 $payload = get_transient( $this->config->get_news_transient() );
445 $remote = is_array( $payload ) && ! empty( $payload['blocked_update_email']['body_html'] ) ? $payload['blocked_update_email'] : null;
446 $row = '<div style="border:1px solid #e2e4e8;border-left:4px solid #996800;border-radius:4px;padding:12px 18px;margin:0 0 12px;">'
447 . '<div style="font-size:14px;color:#1d2327;"><strong>{product_name}</strong></div>'
448 . '<div style="font-size:13px;color:#50575e;margin-top:4px;">Installed: {current_version} &rarr; new version: <strong>{new_version}</strong></div>'
449 . '</div>';
450
451 if( $remote ) {
452 return [
453 'subject' => ! empty( $remote['subject'] ) ? $remote['subject'] : __( 'New addon versions can\'t be installed on {site_name}', 'gvectors' ),
454 'body_html' => $remote['body_html'],
455 'row' => ! empty( $remote['row'] ) ? $remote['row'] : $row,
456 ];
457 }
458
459 return [
460 'subject' => __( 'New addon versions can\'t be installed on {site_name}', 'gvectors' ),
461 'body_html' => '<div style="background:#f4f5f7;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">'
462 . '<div style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:8px;border:1px solid #e2e4e8;padding:28px;">'
463 . '<p style="margin:0 0 16px;color:#1d2327;font-size:15px;">Hi {admin_name},</p>'
464 . '<p style="margin:0 0 20px;color:#50575e;font-size:14px;line-height:1.6;">WordPress found new versions of your licensed gVectors addons on {site_name}, but it could not install them: this website does not allow WordPress to download and install plugins (for example <code>DISALLOW_FILE_MODS</code> in <code>wp-config.php</code> or a read-only plugins folder).</p>'
465 . '{update_rows}'
466 . '<div style="margin-top:20px;font-size:13px;line-height:1.6;color:#1d4d2b;background:#edfaef;border:1px solid #b8e6bf;border-radius:4px;padding:12px 16px;">'
467 . '<strong>Recommended &mdash; install the updates immediately:</strong> restore the default WordPress permissions: remove <code>DISALLOW_FILE_MODS</code> from <code>wp-config.php</code> (or set it to <code>false</code>) and make sure WordPress can write to <code>wp-content/plugins/</code> &mdash; your hosting provider can help with that. '
468 . 'The updates then appear in Dashboard &rarr; Updates and install with one click, and future releases arrive the same natural way.'
469 . '<div style="margin-top:6px;font-size:12px;">Tip: if you only want to block code editing in the dashboard, <code>DISALLOW_FILE_EDIT</code> does that without blocking updates.</div>'
470 . '</div>'
471 . '<p style="margin:16px 0 0;color:#8c8f94;font-size:12px;line-height:1.6;">If you can\'t change these settings, contact gVectors support to receive a manual download link for the new version(s).</p>'
472 . '</div></div>',
473 'row' => $row,
474 ];
475 }
476
477 /**
478 * Purchase templates: proxy-editable versions from the cached daily news
479 * payload, with a complete local fallback (a purchase must never lose its
480 * confirmation email because the cache is cold).
481 */
482 private function get_purchase_templates(): array {
483 $payload = get_transient( $this->config->get_news_transient() );
484 $remote = is_array( $payload ) && ! empty( $payload['purchase_email']['body_html'] ) ? $payload['purchase_email'] : null;
485
486 if( $remote ) {
487 return [
488 'subject' => ! empty( $remote['subject'] ) ? $remote['subject'] : __( 'Payment received — your license key(s)', 'gvectors' ),
489 'body_html' => $remote['body_html'],
490 'license_row' => ! empty( $remote['license_row'] ) ? $remote['license_row'] : $this->fallback_purchase_row(),
491 ];
492 }
493
494 return [
495 'subject' => __( 'Payment received — your license key(s) for {site_name}', 'gvectors' ),
496 'body_html' => '<div style="background:#f4f5f7;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">'
497 . '<div style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:8px;border:1px solid #e2e4e8;padding:28px;">'
498 . '<p style="margin:0 0 16px;color:#1d2327;font-size:15px;">Hi {admin_name},</p>'
499 . '<p style="margin:0 0 16px;color:#50575e;font-size:14px;line-height:1.6;">Your payment was received and {license_count} license(s) have been activated on {site_name}.</p>'
500 . '<p style="margin:0 0 16px;color:#50575e;font-size:13px;">Transaction ID: <span style="font-family:monospace;color:#1d2327;">{transaction_id}</span></p>'
501 . '{license_rows}'
502 . '<p style="margin:16px 0 0;color:#996800;font-size:13px;line-height:1.6;"><strong>Keep this email</strong> — you will need these license keys to activate your addons on another domain (e.g. when moving from staging to production).</p>'
503 . '</div></div>',
504 'license_row' => $this->fallback_purchase_row(),
505 ];
506 }
507
508 private function fallback_purchase_row(): string {
509 return '<div style="border:1px solid #e2e4e8;border-left:4px solid #00753e;border-radius:4px;padding:14px 18px;margin:0 0 12px;">'
510 . '<div style="font-size:14px;color:#1d2327;"><strong>{product_name}</strong></div>'
511 . '<div style="margin-top:8px;font-family:monospace;font-size:13px;color:#1d2327;background:#f6f7f7;border:1px dashed #c3c4c7;border-radius:3px;padding:8px 10px;">{license_key}</div>'
512 . '<div style="font-size:12px;color:#8c8f94;margin-top:6px;">Valid until: {expires_at}</div>'
513 . '</div>';
514 }
515
516 /**
517 * Unused-discount reminder: the site holds active, still-redeemable
518 * renewal offers (individual single-use discounts minted by the proxy —
519 * either by the automatic expiry phases or manually via the dashboard
520 * bulk tool) that have NOT been used yet. The proxy pre-builds the email;
521 * this only handles recipients and dedup.
522 *
523 * Per-admin dedup keys in the shared phases meta:
524 * "offer:{id}" — the first announcement of an offer
525 * "offer:{id}:final" — one last-chance nudge when the offer is within
526 * 3 days of expiring and still unused
527 * so each admin gets at most two emails per offer, ever.
528 *
529 * Own preference category 'renewal_offer' — unsubscribable on the settings
530 * page separately from the expiry/billing reminders.
531 */
532 public function send_offer_reminders( array $at_risk ): void {
533 if( empty( $at_risk['active_offers'] ) || ! is_array( $at_risk['active_offers'] ) ) return;
534 if( empty( $at_risk['offer_email']['body_html'] ) ) return;
535
536 $keys = [];
537 foreach( $at_risk['active_offers'] as $offer ) {
538 if( ! is_array( $offer ) || empty( $offer['id'] ) ) continue;
539 $id = (int) $offer['id'];
540 $keys[] = 'offer:' . $id;
541 if( isset( $offer['days_left'] ) && (int) $offer['days_left'] <= 3 ) {
542 $keys[] = 'offer:' . $id . ':final';
543 }
544 }
545 if( empty( $keys ) ) return;
546
547 $meta_key = $this->config->get_emailed_phases_meta();
548 $subject = ! empty( $at_risk['offer_email']['subject'] )
549 ? $at_risk['offer_email']['subject']
550 : __( 'You have an unused discount for your addons', 'gvectors' );
551
552 foreach( $this->get_recipients() as $user ) {
553 if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'renewal_offer' ) ) continue;
554
555 $sent = get_user_meta( $user->ID, $meta_key, true );
556 $sent = is_array( $sent ) ? $sent : [];
557
558 $new_keys = array_diff( $keys, $sent );
559 if( empty( $new_keys ) ) continue;
560
561 if( $this->send( $user, $subject, $at_risk['offer_email']['body_html'] ) ) {
562 update_user_meta( $user->ID, $meta_key, array_values( array_unique( array_merge( $sent, $new_keys ) ) ) );
563 }
564 }
565 }
566
567 /**
568 * Dunning notice: a subscription is past_due (payment failed). Per-admin
569 * dedup key "dun:{subscription_id}:{Y-m}" — at most one email per
570 * subscription per month while the dunning state persists.
571 */
572 public function send_dunning_notices( array $at_risk ): void {
573 if( empty( $at_risk['past_due'] ) || empty( $at_risk['dunning_email']['body_html'] ) ) return;
574
575 $keys = [];
576 foreach( $at_risk['past_due'] as $entry ) {
577 if( ! empty( $entry['subscription_id'] ) ) {
578 $keys[] = sanitize_text_field( 'dun:' . $entry['subscription_id'] . ':' . gmdate( 'Y-m' ) );
579 }
580 }
581 if( empty( $keys ) ) return;
582
583 $meta_key = $this->config->get_emailed_phases_meta();
584 $subject = ! empty( $at_risk['dunning_email']['subject'] )
585 ? $at_risk['dunning_email']['subject']
586 : __( 'A payment failed for your addon subscription', 'gvectors' );
587
588 foreach( $this->get_recipients() as $user ) {
589 if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'expiry_reminder' ) ) continue;
590
591 $sent = get_user_meta( $user->ID, $meta_key, true );
592 $sent = is_array( $sent ) ? $sent : [];
593
594 $new_keys = array_diff( $keys, $sent );
595 if( empty( $new_keys ) ) continue;
596
597 if( $this->send( $user, $subject, $at_risk['dunning_email']['body_html'] ) ) {
598 update_user_meta( $user->ID, $meta_key, array_values( array_unique( array_merge( $sent, $new_keys ) ) ) );
599 }
600 }
601 }
602
603 /**
604 * Replace the site tokens for a recipient, strip any leftover {tokens},
605 * append the compliance footer and send as HTML mail.
606 */
607 private function send( WP_User $user, string $subject, string $body_html ): bool {
608 $subject = $this->render_site_tokens( $subject, $user, false );
609 $body = $this->render_site_tokens( $body_html, $user, true );
610 $body .= $this->footer_line();
611
612 return (bool) wp_mail(
613 $user->user_email,
614 wp_specialchars_decode( $subject, ENT_QUOTES ),
615 $body,
616 [ 'Content-Type: text/html; charset=UTF-8' ]
617 );
618 }
619
620 /**
621 * Inject the 5 WP-side tokens (escaped), then strip any leftover {token}
622 * so a proxy-side typo never reaches a recipient.
623 */
624 public function render_site_tokens( string $html, WP_User $user, bool $escape = true ): string {
625 $admin_name = $user->display_name !== '' ? $user->display_name : $user->user_login;
626 $tokens = [
627 '{admin_name}' => $escape ? esc_html( $admin_name ) : $admin_name,
628 '{site_name}' => $escape ? esc_html( get_bloginfo( 'name' ) ) : get_bloginfo( 'name' ),
629 '{site_url}' => esc_url( home_url() ),
630 '{dashboard_url}' => esc_url( $this->config->get_dashboard_addons_url() ),
631 '{current_year}' => gmdate( 'Y' ),
632 ];
633
634 $html = strtr( $html, $tokens );
635
636 return preg_replace( '/\{[a-z0-9_]+\}/i', '', $html );
637 }
638
639 /**
640 * Local fallback map when the proxy didn't enrich items with display meta
641 * (older proxy version). Keep in sync with NewsService::TYPE_META proxy-side.
642 */
643 private const TYPE_META_FALLBACK = [
644 'new_addon' => [ 'label' => 'New Addon', 'badge_bg' => '#dcfce7', 'badge_color' => '#166534' ],
645 'new_feature' => [ 'label' => 'New Feature', 'badge_bg' => '#fff7ed', 'badge_color' => '#9a3412' ],
646 'new_version' => [ 'label' => 'New Version', 'badge_bg' => '#f3e8ff', 'badge_color' => '#7e22ce' ],
647 'discount' => [ 'label' => 'Discount', 'badge_bg' => '#fce7f3', 'badge_color' => '#be185d' ],
648 'announcement' => [ 'label' => 'Announcement', 'badge_bg' => '#dbeafe', 'badge_color' => '#1d4ed8' ],
649 ];
650
651 /**
652 * One news item → its category card: colored badge header (category label +
653 * "i / n" counter when the email holds several items), bold title, then the
654 * item's rich content. Content is sanitized with an email-safe allowlist
655 * (defense in depth — the proxy is trusted, but its content still never
656 * reaches the mail body unfiltered).
657 */
658 private function render_news_card( array $item, string $counter, string $card_tpl ): string {
659 // Inner content: proxy-rendered rich HTML, or a paragraph from the plain body
660 if( ! empty( $item['html_content'] ) ) {
661 $content = $item['html_content'];
662 } else {
663 $content = ! empty( $item['body'] )
664 ? '<p style="margin:0;">' . nl2br( esc_html( $item['body'] ) ) . '</p>'
665 : '';
666 }
667 if( ! empty( $item['link_url'] ) ) {
668 $label = ! empty( $item['link_label'] ) ? $item['link_label'] : __( 'Learn more', 'gvectors' );
669 $content .= '<div style="margin-top:12px;"><a href="' . esc_url( $item['link_url'] ) . '" style="background:#2271b1;color:#ffffff;text-decoration:none;padding:8px 16px;border-radius:4px;font-size:13px;display:inline-block;">' . esc_html( $label ) . '</a></div>';
670 }
671 $content = wp_kses( $content, $this->email_allowed_html() );
672
673 $meta = $this->item_type_meta( $item );
674
675 return str_replace(
676 [ '{type_label}', '{badge_bg}', '{badge_color}', '{title}', '{content}', '{item_counter}' ],
677 [ esc_html( $meta['label'] ), $meta['badge_bg'], $meta['badge_color'], esc_html( $item['title'] ), $content, esc_html( $counter ) ],
678 $card_tpl
679 );
680 }
681
682 /**
683 * Display meta for an item: prefer proxy-provided label/colors (source of
684 * truth), validated; fall back to the local map for older proxies.
685 */
686 private function item_type_meta( array $item ): array {
687 $meta = self::TYPE_META_FALLBACK[ $item['type'] ?? '' ] ?? self::TYPE_META_FALLBACK['announcement'];
688
689 if( ! empty( $item['type_label'] ) && is_string( $item['type_label'] ) ) {
690 $meta['label'] = $item['type_label'];
691 }
692 foreach( [ 'badge_bg', 'badge_color' ] as $key ) {
693 if( ! empty( $item[ $key ] ) && is_string( $item[ $key ] ) && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $item[ $key ] ) ) {
694 $meta[ $key ] = $item[ $key ];
695 }
696 }
697
698 return $meta;
699 }
700
701 /**
702 * Minimal local card used only when the proxy wrapper didn't ship one.
703 */
704 private function fallback_card(): string {
705 return '<div style="border:1px solid #e2e4e8;border-radius:6px;margin:0 0 18px;overflow:hidden;">'
706 . '<div style="background:{badge_bg};padding:8px 16px;">'
707 . '<span style="color:{badge_color};font-size:11px;font-weight:bold;letter-spacing:0.8px;text-transform:uppercase;">{type_label}</span>'
708 . '<span style="float:right;color:{badge_color};font-size:11px;font-weight:bold;opacity:0.75;">{item_counter}</span>'
709 . '</div>'
710 . '<div style="padding:16px 18px;">'
711 . '<div style="font-size:15px;color:#1d2327;font-weight:bold;margin:0 0 10px;">{title}</div>'
712 . '<div style="font-size:13px;color:#50575e;line-height:1.6;">{content}</div>'
713 . '</div>'
714 . '</div>';
715 }
716
717 /**
718 * Email-safe HTML allowlist for news content blocks.
719 */
720 private function email_allowed_html(): array {
721 $common = [ 'style' => true, 'class' => true, 'align' => true, 'width' => true, 'height' => true ];
722 return [
723 'div' => $common,
724 'p' => $common,
725 'span' => $common,
726 'a' => $common + [ 'href' => true, 'target' => true, 'rel' => true ],
727 'img' => $common + [ 'src' => true, 'alt' => true ],
728 'strong' => $common, 'b' => $common, 'em' => $common, 'i' => $common,
729 'h1' => $common, 'h2' => $common, 'h3' => $common, 'h4' => $common,
730 'ul' => $common, 'ol' => $common, 'li' => $common,
731 'table' => $common + [ 'cellpadding' => true, 'cellspacing' => true, 'border' => true ],
732 'thead' => $common, 'tbody' => $common, 'tr' => $common, 'td' => $common + [ 'colspan' => true ], 'th' => $common + [ 'colspan' => true ],
733 'br' => [], 'hr' => $common,
734 ];
735 }
736
737 /**
738 * Compliance footer: why this email was received + how to stop it.
739 * Links to the News & Emails settings page where each administrator can
740 * unsubscribe, pick email categories, or disable the whole service.
741 */
742 private function footer_line(): string {
743 return '<div style="max-width:600px;margin:12px auto 0;padding:0 4px;font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#8c8f94;line-height:1.5;">'
744 . sprintf(
745 /* translators: 1: site URL, 2: News & Emails settings page URL */
746 esc_html__( 'You received this email because you are an administrator of %1$s. To unsubscribe or choose which emails you receive, open the %2$s settings page in your WordPress dashboard.', 'gvectors' ),
747 '<a href="' . esc_url( home_url() ) . '" style="color:#8c8f94;">' . esc_html( NewsModule::get_site_domain() ) . '</a>',
748 '<a href="' . esc_url( $this->config->get_settings_page_url() ) . '" style="color:#8c8f94;">' . esc_html__( 'News & Emails', 'gvectors' ) . '</a>'
749 )
750 . '</div>';
751 }
752
753 /**
754 * Minimal local shell used only when the proxy wrapper is unavailable.
755 */
756 private function fallback_wrapper(): array {
757 return [
758 'subject' => __( 'News from gVectors', 'gvectors' ),
759 'body_html' => '<div style="background:#f4f5f7;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">'
760 . '<div style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:8px;border:1px solid #e2e4e8;padding:28px;">'
761 . '<p style="margin:0 0 16px;color:#1d2327;font-size:15px;">Hi {admin_name},</p>'
762 . '{news_content}'
763 . '</div></div>',
764 ];
765 }
766 }
767