| 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 |
|