PluginProbe
wpForo Forum / 3.1.6
wpForo Forum v3.1.6
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 1.4.11 1.4.12 1.4.13 All 138 releases
wpforo / admin / pages / news / src / Services / EmailService.php

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

668 lines 32.6 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 * Purchase templates: proxy-editable versions from the cached daily news
380 * payload, with a complete local fallback (a purchase must never lose its
381 * confirmation email because the cache is cold).
382 */
383 private function get_purchase_templates(): array {
384 $payload = get_transient( $this->config->get_news_transient() );
385 $remote = is_array( $payload ) && ! empty( $payload['purchase_email']['body_html'] ) ? $payload['purchase_email'] : null;
386
387 if( $remote ) {
388 return [
389 'subject' => ! empty( $remote['subject'] ) ? $remote['subject'] : __( 'Payment received — your license key(s)', 'gvectors' ),
390 'body_html' => $remote['body_html'],
391 'license_row' => ! empty( $remote['license_row'] ) ? $remote['license_row'] : $this->fallback_purchase_row(),
392 ];
393 }
394
395 return [
396 'subject' => __( 'Payment received — your license key(s) for {site_name}', 'gvectors' ),
397 'body_html' => '<div style="background:#f4f5f7;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">'
398 . '<div style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:8px;border:1px solid #e2e4e8;padding:28px;">'
399 . '<p style="margin:0 0 16px;color:#1d2327;font-size:15px;">Hi {admin_name},</p>'
400 . '<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>'
401 . '<p style="margin:0 0 16px;color:#50575e;font-size:13px;">Transaction ID: <span style="font-family:monospace;color:#1d2327;">{transaction_id}</span></p>'
402 . '{license_rows}'
403 . '<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>'
404 . '</div></div>',
405 'license_row' => $this->fallback_purchase_row(),
406 ];
407 }
408
409 private function fallback_purchase_row(): string {
410 return '<div style="border:1px solid #e2e4e8;border-left:4px solid #00753e;border-radius:4px;padding:14px 18px;margin:0 0 12px;">'
411 . '<div style="font-size:14px;color:#1d2327;"><strong>{product_name}</strong></div>'
412 . '<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>'
413 . '<div style="font-size:12px;color:#8c8f94;margin-top:6px;">Valid until: {expires_at}</div>'
414 . '</div>';
415 }
416
417 /**
418 * Unused-discount reminder: the site holds active, still-redeemable
419 * renewal offers (individual single-use discounts minted by the proxy —
420 * either by the automatic expiry phases or manually via the dashboard
421 * bulk tool) that have NOT been used yet. The proxy pre-builds the email;
422 * this only handles recipients and dedup.
423 *
424 * Per-admin dedup keys in the shared phases meta:
425 * "offer:{id}" — the first announcement of an offer
426 * "offer:{id}:final" — one last-chance nudge when the offer is within
427 * 3 days of expiring and still unused
428 * so each admin gets at most two emails per offer, ever.
429 *
430 * Own preference category 'renewal_offer' — unsubscribable on the settings
431 * page separately from the expiry/billing reminders.
432 */
433 public function send_offer_reminders( array $at_risk ): void {
434 if( empty( $at_risk['active_offers'] ) || ! is_array( $at_risk['active_offers'] ) ) return;
435 if( empty( $at_risk['offer_email']['body_html'] ) ) return;
436
437 $keys = [];
438 foreach( $at_risk['active_offers'] as $offer ) {
439 if( ! is_array( $offer ) || empty( $offer['id'] ) ) continue;
440 $id = (int) $offer['id'];
441 $keys[] = 'offer:' . $id;
442 if( isset( $offer['days_left'] ) && (int) $offer['days_left'] <= 3 ) {
443 $keys[] = 'offer:' . $id . ':final';
444 }
445 }
446 if( empty( $keys ) ) return;
447
448 $meta_key = $this->config->get_emailed_phases_meta();
449 $subject = ! empty( $at_risk['offer_email']['subject'] )
450 ? $at_risk['offer_email']['subject']
451 : __( 'You have an unused discount for your addons', 'gvectors' );
452
453 foreach( $this->get_recipients() as $user ) {
454 if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'renewal_offer' ) ) continue;
455
456 $sent = get_user_meta( $user->ID, $meta_key, true );
457 $sent = is_array( $sent ) ? $sent : [];
458
459 $new_keys = array_diff( $keys, $sent );
460 if( empty( $new_keys ) ) continue;
461
462 if( $this->send( $user, $subject, $at_risk['offer_email']['body_html'] ) ) {
463 update_user_meta( $user->ID, $meta_key, array_values( array_unique( array_merge( $sent, $new_keys ) ) ) );
464 }
465 }
466 }
467
468 /**
469 * Dunning notice: a subscription is past_due (payment failed). Per-admin
470 * dedup key "dun:{subscription_id}:{Y-m}" — at most one email per
471 * subscription per month while the dunning state persists.
472 */
473 public function send_dunning_notices( array $at_risk ): void {
474 if( empty( $at_risk['past_due'] ) || empty( $at_risk['dunning_email']['body_html'] ) ) return;
475
476 $keys = [];
477 foreach( $at_risk['past_due'] as $entry ) {
478 if( ! empty( $entry['subscription_id'] ) ) {
479 $keys[] = sanitize_text_field( 'dun:' . $entry['subscription_id'] . ':' . gmdate( 'Y-m' ) );
480 }
481 }
482 if( empty( $keys ) ) return;
483
484 $meta_key = $this->config->get_emailed_phases_meta();
485 $subject = ! empty( $at_risk['dunning_email']['subject'] )
486 ? $at_risk['dunning_email']['subject']
487 : __( 'A payment failed for your addon subscription', 'gvectors' );
488
489 foreach( $this->get_recipients() as $user ) {
490 if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'expiry_reminder' ) ) continue;
491
492 $sent = get_user_meta( $user->ID, $meta_key, true );
493 $sent = is_array( $sent ) ? $sent : [];
494
495 $new_keys = array_diff( $keys, $sent );
496 if( empty( $new_keys ) ) continue;
497
498 if( $this->send( $user, $subject, $at_risk['dunning_email']['body_html'] ) ) {
499 update_user_meta( $user->ID, $meta_key, array_values( array_unique( array_merge( $sent, $new_keys ) ) ) );
500 }
501 }
502 }
503
504 /**
505 * Replace the site tokens for a recipient, strip any leftover {tokens},
506 * append the compliance footer and send as HTML mail.
507 */
508 private function send( WP_User $user, string $subject, string $body_html ): bool {
509 $subject = $this->render_site_tokens( $subject, $user, false );
510 $body = $this->render_site_tokens( $body_html, $user, true );
511 $body .= $this->footer_line();
512
513 return (bool) wp_mail(
514 $user->user_email,
515 wp_specialchars_decode( $subject, ENT_QUOTES ),
516 $body,
517 [ 'Content-Type: text/html; charset=UTF-8' ]
518 );
519 }
520
521 /**
522 * Inject the 5 WP-side tokens (escaped), then strip any leftover {token}
523 * so a proxy-side typo never reaches a recipient.
524 */
525 public function render_site_tokens( string $html, WP_User $user, bool $escape = true ): string {
526 $admin_name = $user->display_name !== '' ? $user->display_name : $user->user_login;
527 $tokens = [
528 '{admin_name}' => $escape ? esc_html( $admin_name ) : $admin_name,
529 '{site_name}' => $escape ? esc_html( get_bloginfo( 'name' ) ) : get_bloginfo( 'name' ),
530 '{site_url}' => esc_url( home_url() ),
531 '{dashboard_url}' => esc_url( $this->config->get_dashboard_addons_url() ),
532 '{current_year}' => gmdate( 'Y' ),
533 ];
534
535 $html = strtr( $html, $tokens );
536
537 return preg_replace( '/\{[a-z0-9_]+\}/i', '', $html );
538 }
539
540 /**
541 * Local fallback map when the proxy didn't enrich items with display meta
542 * (older proxy version). Keep in sync with NewsService::TYPE_META proxy-side.
543 */
544 private const TYPE_META_FALLBACK = [
545 'new_addon' => [ 'label' => 'New Addon', 'badge_bg' => '#dcfce7', 'badge_color' => '#166534' ],
546 'new_feature' => [ 'label' => 'New Feature', 'badge_bg' => '#fff7ed', 'badge_color' => '#9a3412' ],
547 'new_version' => [ 'label' => 'New Version', 'badge_bg' => '#f3e8ff', 'badge_color' => '#7e22ce' ],
548 'discount' => [ 'label' => 'Discount', 'badge_bg' => '#fce7f3', 'badge_color' => '#be185d' ],
549 'announcement' => [ 'label' => 'Announcement', 'badge_bg' => '#dbeafe', 'badge_color' => '#1d4ed8' ],
550 ];
551
552 /**
553 * One news item → its category card: colored badge header (category label +
554 * "i / n" counter when the email holds several items), bold title, then the
555 * item's rich content. Content is sanitized with an email-safe allowlist
556 * (defense in depth — the proxy is trusted, but its content still never
557 * reaches the mail body unfiltered).
558 */
559 private function render_news_card( array $item, string $counter, string $card_tpl ): string {
560 // Inner content: proxy-rendered rich HTML, or a paragraph from the plain body
561 if( ! empty( $item['html_content'] ) ) {
562 $content = $item['html_content'];
563 } else {
564 $content = ! empty( $item['body'] )
565 ? '<p style="margin:0;">' . nl2br( esc_html( $item['body'] ) ) . '</p>'
566 : '';
567 }
568 if( ! empty( $item['link_url'] ) ) {
569 $label = ! empty( $item['link_label'] ) ? $item['link_label'] : __( 'Learn more', 'gvectors' );
570 $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>';
571 }
572 $content = wp_kses( $content, $this->email_allowed_html() );
573
574 $meta = $this->item_type_meta( $item );
575
576 return str_replace(
577 [ '{type_label}', '{badge_bg}', '{badge_color}', '{title}', '{content}', '{item_counter}' ],
578 [ esc_html( $meta['label'] ), $meta['badge_bg'], $meta['badge_color'], esc_html( $item['title'] ), $content, esc_html( $counter ) ],
579 $card_tpl
580 );
581 }
582
583 /**
584 * Display meta for an item: prefer proxy-provided label/colors (source of
585 * truth), validated; fall back to the local map for older proxies.
586 */
587 private function item_type_meta( array $item ): array {
588 $meta = self::TYPE_META_FALLBACK[ $item['type'] ?? '' ] ?? self::TYPE_META_FALLBACK['announcement'];
589
590 if( ! empty( $item['type_label'] ) && is_string( $item['type_label'] ) ) {
591 $meta['label'] = $item['type_label'];
592 }
593 foreach( [ 'badge_bg', 'badge_color' ] as $key ) {
594 if( ! empty( $item[ $key ] ) && is_string( $item[ $key ] ) && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $item[ $key ] ) ) {
595 $meta[ $key ] = $item[ $key ];
596 }
597 }
598
599 return $meta;
600 }
601
602 /**
603 * Minimal local card used only when the proxy wrapper didn't ship one.
604 */
605 private function fallback_card(): string {
606 return '<div style="border:1px solid #e2e4e8;border-radius:6px;margin:0 0 18px;overflow:hidden;">'
607 . '<div style="background:{badge_bg};padding:8px 16px;">'
608 . '<span style="color:{badge_color};font-size:11px;font-weight:bold;letter-spacing:0.8px;text-transform:uppercase;">{type_label}</span>'
609 . '<span style="float:right;color:{badge_color};font-size:11px;font-weight:bold;opacity:0.75;">{item_counter}</span>'
610 . '</div>'
611 . '<div style="padding:16px 18px;">'
612 . '<div style="font-size:15px;color:#1d2327;font-weight:bold;margin:0 0 10px;">{title}</div>'
613 . '<div style="font-size:13px;color:#50575e;line-height:1.6;">{content}</div>'
614 . '</div>'
615 . '</div>';
616 }
617
618 /**
619 * Email-safe HTML allowlist for news content blocks.
620 */
621 private function email_allowed_html(): array {
622 $common = [ 'style' => true, 'class' => true, 'align' => true, 'width' => true, 'height' => true ];
623 return [
624 'div' => $common,
625 'p' => $common,
626 'span' => $common,
627 'a' => $common + [ 'href' => true, 'target' => true, 'rel' => true ],
628 'img' => $common + [ 'src' => true, 'alt' => true ],
629 'strong' => $common, 'b' => $common, 'em' => $common, 'i' => $common,
630 'h1' => $common, 'h2' => $common, 'h3' => $common, 'h4' => $common,
631 'ul' => $common, 'ol' => $common, 'li' => $common,
632 'table' => $common + [ 'cellpadding' => true, 'cellspacing' => true, 'border' => true ],
633 'thead' => $common, 'tbody' => $common, 'tr' => $common, 'td' => $common + [ 'colspan' => true ], 'th' => $common + [ 'colspan' => true ],
634 'br' => [], 'hr' => $common,
635 ];
636 }
637
638 /**
639 * Compliance footer: why this email was received + how to stop it.
640 * Links to the News & Emails settings page where each administrator can
641 * unsubscribe, pick email categories, or disable the whole service.
642 */
643 private function footer_line(): string {
644 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;">'
645 . sprintf(
646 /* translators: 1: site URL, 2: News & Emails settings page URL */
647 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' ),
648 '<a href="' . esc_url( home_url() ) . '" style="color:#8c8f94;">' . esc_html( NewsModule::get_site_domain() ) . '</a>',
649 '<a href="' . esc_url( $this->config->get_settings_page_url() ) . '" style="color:#8c8f94;">' . esc_html__( 'News & Emails', 'gvectors' ) . '</a>'
650 )
651 . '</div>';
652 }
653
654 /**
655 * Minimal local shell used only when the proxy wrapper is unavailable.
656 */
657 private function fallback_wrapper(): array {
658 return [
659 'subject' => __( 'News from gVectors', 'gvectors' ),
660 'body_html' => '<div style="background:#f4f5f7;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">'
661 . '<div style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:8px;border:1px solid #e2e4e8;padding:28px;">'
662 . '<p style="margin:0 0 16px;color:#1d2327;font-size:15px;">Hi {admin_name},</p>'
663 . '{news_content}'
664 . '</div></div>',
665 ];
666 }
667 }
668