config = $config;
$this->prefs = $prefs;
}
/**
* All site administrators — role query, NOT a capability query (unreliable).
* @return WP_User[]
*/
private function get_recipients(): array {
return get_users( [ 'role__in' => [ 'administrator' ] ] );
}
/**
* News emails: per-admin, only items neither dismissed nor already emailed,
* filtered through the site + per-admin channel/category/relevance matrix.
*
* Items flagged `standalone` on the proxy bypass the digest and go out as
* their own email (subject = item title). Everything else is combined into
* ONE digest, each item rendered inside its category card; a single-item
* digest uses that item's title as the subject (dynamic subject).
*/
public function send_news_digest( array $payload ): void {
$items = [];
$now = time();
foreach( (array) ( $payload['news'] ?? [] ) as $item ) {
if( ! empty( $item['expires_at'] ) && strtotime( $item['expires_at'] ) <= $now ) continue;
$items[ $item['id'] ] = $item;
}
$recommendations = is_array( $payload['recommendations'] ?? null ) ? $payload['recommendations'] : [];
$cross_sell = is_array( $payload['cross_sell'] ?? null ) ? $payload['cross_sell'] : null;
if( empty( $items ) && empty( $recommendations ) ) return;
$wrapper = ! empty( $payload['email_wrapper'] ) ? $payload['email_wrapper'] : $this->fallback_wrapper();
$card_tpl = ! empty( $wrapper['item_card'] ) ? $wrapper['item_card'] : $this->fallback_card();
$emailed_meta_key = $this->config->get_emailed_news_meta();
$dismissed_meta_key = $this->config->get_dismissed_news_meta();
$recs_meta_key = $this->config->get_emailed_recs_meta();
foreach( $this->get_recipients() as $user ) {
$emailed = get_user_meta( $user->ID, $emailed_meta_key, true );
$emailed = is_array( $emailed ) ? $emailed : [];
$dismissed = get_user_meta( $user->ID, $dismissed_meta_key, true );
$dismissed = is_array( $dismissed ) ? $dismissed : [];
$unseen = array_diff_key( $items, array_flip( array_merge( $emailed, $dismissed ) ) );
// Site + per-admin matrix (category, relevance, unsubscribe). Filtered
// items are NOT recorded as emailed — re-enabling resumes delivery.
foreach( $unseen as $id => $item ) {
if( ! $this->prefs->news_item_allowed( $item, PrefsService::CHANNEL_EMAILS, $user->ID ) ) {
unset( $unseen[ $id ] );
}
}
// Cross-sell: not-yet-recommended, not-installed addons for THIS admin
$recs = $this->fresh_recommendations_for( $user, $recommendations );
$rec_section = ! empty( $recs ) ? $this->render_recs_section( $recs, $cross_sell ) : '';
if( empty( $unseen ) && $rec_section === '' ) continue;
$standalone = [];
$digest = [];
foreach( $unseen as $item ) {
if( ! empty( $item['standalone'] ) ) $standalone[] = $item;
else $digest[] = $item;
}
$sent_ids = [];
// Standalone items: one email each, the item title as the subject
foreach( $standalone as $item ) {
$content = $this->render_news_card( $item, '', $card_tpl );
$body = str_replace( [ '{news_content}', '{news_count}' ], [ $content, '1' ], $wrapper['body_html'] );
$subject = $item['title'] !== '' ? $item['title'] : $wrapper['subject'];
if( $this->send( $user, $subject, $body ) ) {
$sent_ids[] = $item['id'];
}
}
// Digest: every remaining item in its own category card, numbered when
// multiple; the recommendations section (if any) rides at the bottom.
if( ! empty( $digest ) ) {
$count = count( $digest );
$content = '';
$i = 0;
foreach( $digest as $item ) {
$i++;
$counter = $count > 1 ? $i . ' / ' . $count : '';
$content .= $this->render_news_card( $item, $counter, $card_tpl );
}
$content .= $rec_section;
$body = str_replace( [ '{news_content}', '{news_count}' ], [ $content, (string) $count ], $wrapper['body_html'] );
// Dynamic subject: a single-item digest reads like a dedicated email
if( $count === 1 && $digest[0]['title'] !== '' ) {
$subject = $digest[0]['title'];
} else {
$subject = str_replace( '{news_count}', (string) $count, $wrapper['subject'] );
}
$subject = $subject !== '' ? $subject : __( 'News from gVectors', 'gvectors' );
if( $this->send( $user, $subject, $body ) ) {
$sent_ids = array_merge( $sent_ids, array_column( $digest, 'id' ) );
$this->record_emailed_recs( $user->ID, $recs_meta_key, $recs );
}
} elseif( $rec_section !== '' ) {
// No news today — the recommendations go out as their own email,
// in the minimal local shell (the news wrapper's "what's new"
// copy would read wrong with zero items).
$shell = $this->fallback_wrapper();
$body = str_replace( [ '{news_content}', '{news_count}' ], [ $rec_section, '0' ], $shell['body_html'] );
$subject = ! empty( $cross_sell['subject'] ) ? $cross_sell['subject'] : __( 'Addons picked for your site', 'gvectors' );
if( $this->send( $user, $subject, $body ) ) {
$this->record_emailed_recs( $user->ID, $recs_meta_key, $recs );
}
}
if( ! empty( $sent_ids ) ) {
update_user_meta( $user->ID, $emailed_meta_key, array_values( array_unique( array_merge( $emailed, $sent_ids ) ) ) );
}
}
}
/**
* Recommendations this admin should still hear about: category allowed by
* BOTH scopes, never emailed to them before, addon not installed locally.
*/
private function fresh_recommendations_for( WP_User $user, array $recommendations ): array {
if( empty( $recommendations ) ) return [];
if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'recommendations' ) ) return [];
$emailed = get_user_meta( $user->ID, $this->config->get_emailed_recs_meta(), true );
$emailed = is_array( $emailed ) ? $emailed : [];
$fresh = [];
foreach( $recommendations as $rec ) {
if( in_array( $rec['plugin_slug'], $emailed, true ) ) continue;
if( $this->prefs->is_addon_installed( $rec['plugin_slug'] ) ) continue;
$fresh[] = $rec;
}
return $fresh;
}
/**
* Render the "Recommended for you" section from the proxy templates
* (local fallbacks when the payload didn't carry them).
*/
private function render_recs_section( array $recs, ?array $cross_sell ): string {
$item_tpl = ! empty( $cross_sell['item'] ) ? $cross_sell['item']
: '
'
. '
{product_name}
'
. '
{reason}
';
$section_tpl = ! empty( $cross_sell['section'] ) ? $cross_sell['section']
: ''
. '
'
. esc_html__( 'Recommended for you', 'gvectors' ) . '
{items}
';
$items_html = '';
foreach( $recs as $rec ) {
$items_html .= str_replace(
[ '{product_name}', '{reason}' ],
[ esc_html( $rec['product_name'] ), esc_html( $rec['reason'] ) ],
$item_tpl
);
}
$section = str_replace( [ '{items}', '{item_count}' ], [ $items_html, (string) count( $recs ) ], $section_tpl );
return wp_kses( $section, $this->email_allowed_html() );
}
private function record_emailed_recs( int $user_id, string $meta_key, array $recs ): void {
if( empty( $recs ) ) return;
$emailed = get_user_meta( $user_id, $meta_key, true );
$emailed = is_array( $emailed ) ? $emailed : [];
update_user_meta( $user_id, $meta_key, array_values( array_unique( array_merge( $emailed, array_column( $recs, 'plugin_slug' ) ) ) ) );
}
/**
* Expiry reminder (upcoming expirations, phase > 0): per-admin, each
* "license_id:phase" fires exactly once. Body fully assembled by the proxy.
*/
public function send_expiry_reminders( array $at_risk ): void {
$this->send_phase_email(
$at_risk,
'email',
fn( $license ) => (int) ( $license['phase'] ?? 0 ) > 0,
__( 'Your addon licenses are expiring soon', 'gvectors' )
);
}
/**
* Winback reminder (already-expired licenses, phase < 0): same per-admin
* "license_id:phase" tracking — negative phases make the keys distinct.
*/
public function send_winback_reminders( array $at_risk ): void {
$this->send_phase_email(
$at_risk,
'winback_email',
fn( $license ) => (int) ( $license['phase'] ?? 0 ) < 0,
__( 'Your addon license has expired', 'gvectors' )
);
}
/**
* Shared engine for the two phase-keyed reminder emails.
*/
private function send_phase_email( array $at_risk, string $email_field, callable $phase_filter, string $fallback_subject ): void {
if( empty( $at_risk['licenses'] ) || empty( $at_risk[ $email_field ]['body_html'] ) ) return;
$keys = [];
foreach( $at_risk['licenses'] as $license ) {
if( ! empty( $license['id'] ) && isset( $license['phase'] ) && $phase_filter( $license ) ) {
$keys[] = sanitize_text_field( $license['id'] . ':' . (int) $license['phase'] );
}
}
if( empty( $keys ) ) return;
$meta_key = $this->config->get_emailed_phases_meta();
$subject = ! empty( $at_risk[ $email_field ]['subject'] ) ? $at_risk[ $email_field ]['subject'] : $fallback_subject;
foreach( $this->get_recipients() as $user ) {
if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'expiry_reminder' ) ) continue;
$sent = get_user_meta( $user->ID, $meta_key, true );
$sent = is_array( $sent ) ? $sent : [];
$new_keys = array_diff( $keys, $sent );
if( empty( $new_keys ) ) continue;
if( $this->send( $user, $subject, $at_risk[ $email_field ]['body_html'] ) ) {
update_user_meta( $user->ID, $meta_key, array_values( array_unique( array_merge( $sent, $new_keys ) ) ) );
}
}
}
/**
* Purchase confirmation — fired by the license module's
* `gvectors_transaction_licenses_activated` hook right after the dashboard
* polling verified a completed checkout and activated the license(s).
*
* Sent ONLY to the administrator who actually made the purchase, with the
* transaction id and EVERY license key — so the keys live in their inbox
* for future activations on other domains (e.g. staging → production).
*
* IMPORTANT: the pending-transactions polling is site-wide — ANOTHER
* admin's dashboard refresh may be the request that verifies/activates the
* purchase. The license module therefore records the purchaser's user id
* at checkout time and passes it here ($purchaser_id); the current user is
* only a fallback for legacy pending entries without attribution.
*
* Deliberately bypasses the preference matrix: this is a receipt for the
* recipient's own action, and losing the keys email would hurt them.
* No proxy request is made — templates come from the cached daily payload
* (or the local fallback), so consent state is not involved either.
*/
public function handle_purchase_activated( string $transaction_id, array $licenses, string $core_slug = '', int $purchaser_id = 0 ): void {
if( $transaction_id === '' || empty( $licenses ) ) return;
$user = $purchaser_id > 0 ? get_user_by( 'id', $purchaser_id ) : null;
if( ! $user || empty( $user->user_email ) ) {
$user = wp_get_current_user(); // legacy entries without purchaser attribution
}
if( ! $user || ! $user->exists() || empty( $user->user_email ) ) return;
if( ! user_can( $user, 'activate_plugins' ) ) return;
// One confirmation per transaction, ever (polling/tabs may verify twice)
$option_key = $this->config->get_emailed_transactions_option();
$emailed = get_option( $option_key, [] );
$emailed = is_array( $emailed ) ? $emailed : [];
if( in_array( $transaction_id, $emailed, true ) ) return;
$templates = $this->get_purchase_templates();
$rows = '';
foreach( $licenses as $license ) {
if( ! is_array( $license ) || empty( $license['license_key'] ) ) continue;
$expires = ! empty( $license['expires_at'] )
? date_i18n( 'F j, Y', strtotime( $license['expires_at'] ) )
: __( 'Lifetime', 'gvectors' );
$rows .= str_replace(
[ '{product_name}', '{license_key}', '{expires_at}' ],
[
esc_html( $license['product_name'] ?? ( $license['plugin_slug'] ?? __( 'Addon', 'gvectors' ) ) ),
esc_html( $license['license_key'] ),
esc_html( $expires ),
],
$templates['license_row']
);
}
if( $rows === '' ) return;
$count = count( $licenses );
$body = str_replace(
[ '{transaction_id}', '{license_rows}', '{license_count}' ],
[ esc_html( $transaction_id ), $rows, (string) $count ],
$templates['body_html']
);
$subject = str_replace( '{license_count}', (string) $count, $templates['subject'] );
if( $this->send( $user, $subject, $body ) ) {
$emailed[] = $transaction_id;
update_option( $option_key, array_slice( array_values( array_unique( $emailed ) ), -50 ) );
}
}
/**
* Abandoned checkout recovery — fired by the license module's scheduled
* `gvectors_abandoned_checkout_check` when the proxy confirms a checkout
* is still unpaid at a recovery phase. The email body arrives fully built
* (proxy templates, incl. the attached-discount callout when the phase
* carries one); this only resolves the recipient and dedups.
*
* Sent ONLY to the admin who started the checkout (recorded in the
* scheduled event args at creation time). Gated by its own email-only
* preference category 'abandoned_checkout' (per-admin + site-wide, and
* allows() also covers the master unsubscribe); the server-side kill
* switch is ABANDONED_CHECKOUT_PHASES (empty = the proxy hands out no
* phases and nothing is ever scheduled).
*/
public function handle_abandoned_checkout( string $transaction_id, array $email, int $purchaser_id = 0, int $phase_minutes = 0 ): void {
if( $transaction_id === '' || empty( $email['body_html'] ) || ! is_string( $email['body_html'] ) ) return;
// Runs in cron context — there is no meaningful current user to fall
// back to; without a valid recorded purchaser, nothing is sent.
$user = $purchaser_id > 0 ? get_user_by( 'id', $purchaser_id ) : null;
if( ! $user || empty( $user->user_email ) ) return;
if( ! user_can( $user, 'activate_plugins' ) ) return;
if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'abandoned_checkout' ) ) return;
$option_key = $this->config->get_abandoned_emailed_option();
$emailed = get_option( $option_key, [] );
$emailed = is_array( $emailed ) ? $emailed : [];
$key = sanitize_text_field( $transaction_id . ':' . (int) $phase_minutes );
if( in_array( $key, $emailed, true ) ) return;
$subject = ! empty( $email['subject'] ) && is_string( $email['subject'] )
? $email['subject']
: __( 'You didn\'t finish your purchase', 'gvectors' );
if( $this->send( $user, $subject, $email['body_html'] ) ) {
$emailed[] = $key;
update_option( $option_key, array_slice( array_values( array_unique( $emailed ) ), -100 ) );
}
}
/**
* Purchase templates: proxy-editable versions from the cached daily news
* payload, with a complete local fallback (a purchase must never lose its
* confirmation email because the cache is cold).
*/
private function get_purchase_templates(): array {
$payload = get_transient( $this->config->get_news_transient() );
$remote = is_array( $payload ) && ! empty( $payload['purchase_email']['body_html'] ) ? $payload['purchase_email'] : null;
if( $remote ) {
return [
'subject' => ! empty( $remote['subject'] ) ? $remote['subject'] : __( 'Payment received — your license key(s)', 'gvectors' ),
'body_html' => $remote['body_html'],
'license_row' => ! empty( $remote['license_row'] ) ? $remote['license_row'] : $this->fallback_purchase_row(),
];
}
return [
'subject' => __( 'Payment received — your license key(s) for {site_name}', 'gvectors' ),
'body_html' => ''
. '
'
. '
Hi {admin_name},
'
. '
Your payment was received and {license_count} license(s) have been activated on {site_name}.
'
. '
Transaction ID: {transaction_id}
'
. '{license_rows}'
. '
Keep this email — you will need these license keys to activate your addons on another domain (e.g. when moving from staging to production).
'
. '
',
'license_row' => $this->fallback_purchase_row(),
];
}
private function fallback_purchase_row(): string {
return ''
. '
{product_name}
'
. '
{license_key}
'
. '
Valid until: {expires_at}
'
. '
';
}
/**
* Unused-discount reminder: the site holds active, still-redeemable
* renewal offers (individual single-use discounts minted by the proxy —
* either by the automatic expiry phases or manually via the dashboard
* bulk tool) that have NOT been used yet. The proxy pre-builds the email;
* this only handles recipients and dedup.
*
* Per-admin dedup keys in the shared phases meta:
* "offer:{id}" — the first announcement of an offer
* "offer:{id}:final" — one last-chance nudge when the offer is within
* 3 days of expiring and still unused
* so each admin gets at most two emails per offer, ever.
*
* Own preference category 'renewal_offer' — unsubscribable on the settings
* page separately from the expiry/billing reminders.
*/
public function send_offer_reminders( array $at_risk ): void {
if( empty( $at_risk['active_offers'] ) || ! is_array( $at_risk['active_offers'] ) ) return;
if( empty( $at_risk['offer_email']['body_html'] ) ) return;
$keys = [];
foreach( $at_risk['active_offers'] as $offer ) {
if( ! is_array( $offer ) || empty( $offer['id'] ) ) continue;
$id = (int) $offer['id'];
$keys[] = 'offer:' . $id;
if( isset( $offer['days_left'] ) && (int) $offer['days_left'] <= 3 ) {
$keys[] = 'offer:' . $id . ':final';
}
}
if( empty( $keys ) ) return;
$meta_key = $this->config->get_emailed_phases_meta();
$subject = ! empty( $at_risk['offer_email']['subject'] )
? $at_risk['offer_email']['subject']
: __( 'You have an unused discount for your addons', 'gvectors' );
foreach( $this->get_recipients() as $user ) {
if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'renewal_offer' ) ) continue;
$sent = get_user_meta( $user->ID, $meta_key, true );
$sent = is_array( $sent ) ? $sent : [];
$new_keys = array_diff( $keys, $sent );
if( empty( $new_keys ) ) continue;
if( $this->send( $user, $subject, $at_risk['offer_email']['body_html'] ) ) {
update_user_meta( $user->ID, $meta_key, array_values( array_unique( array_merge( $sent, $new_keys ) ) ) );
}
}
}
/**
* Dunning notice: a subscription is past_due (payment failed). Per-admin
* dedup key "dun:{subscription_id}:{Y-m}" — at most one email per
* subscription per month while the dunning state persists.
*/
public function send_dunning_notices( array $at_risk ): void {
if( empty( $at_risk['past_due'] ) || empty( $at_risk['dunning_email']['body_html'] ) ) return;
$keys = [];
foreach( $at_risk['past_due'] as $entry ) {
if( ! empty( $entry['subscription_id'] ) ) {
$keys[] = sanitize_text_field( 'dun:' . $entry['subscription_id'] . ':' . gmdate( 'Y-m' ) );
}
}
if( empty( $keys ) ) return;
$meta_key = $this->config->get_emailed_phases_meta();
$subject = ! empty( $at_risk['dunning_email']['subject'] )
? $at_risk['dunning_email']['subject']
: __( 'A payment failed for your addon subscription', 'gvectors' );
foreach( $this->get_recipients() as $user ) {
if( ! $this->prefs->allows( $user->ID, PrefsService::CHANNEL_EMAILS, 'expiry_reminder' ) ) continue;
$sent = get_user_meta( $user->ID, $meta_key, true );
$sent = is_array( $sent ) ? $sent : [];
$new_keys = array_diff( $keys, $sent );
if( empty( $new_keys ) ) continue;
if( $this->send( $user, $subject, $at_risk['dunning_email']['body_html'] ) ) {
update_user_meta( $user->ID, $meta_key, array_values( array_unique( array_merge( $sent, $new_keys ) ) ) );
}
}
}
/**
* Replace the site tokens for a recipient, strip any leftover {tokens},
* append the compliance footer and send as HTML mail.
*/
private function send( WP_User $user, string $subject, string $body_html ): bool {
$subject = $this->render_site_tokens( $subject, $user, false );
$body = $this->render_site_tokens( $body_html, $user, true );
$body .= $this->footer_line();
return (bool) wp_mail(
$user->user_email,
wp_specialchars_decode( $subject, ENT_QUOTES ),
$body,
[ 'Content-Type: text/html; charset=UTF-8' ]
);
}
/**
* Inject the 5 WP-side tokens (escaped), then strip any leftover {token}
* so a proxy-side typo never reaches a recipient.
*/
public function render_site_tokens( string $html, WP_User $user, bool $escape = true ): string {
$admin_name = $user->display_name !== '' ? $user->display_name : $user->user_login;
$tokens = [
'{admin_name}' => $escape ? esc_html( $admin_name ) : $admin_name,
'{site_name}' => $escape ? esc_html( get_bloginfo( 'name' ) ) : get_bloginfo( 'name' ),
'{site_url}' => esc_url( home_url() ),
'{dashboard_url}' => esc_url( $this->config->get_dashboard_addons_url() ),
'{current_year}' => gmdate( 'Y' ),
];
$html = strtr( $html, $tokens );
return preg_replace( '/\{[a-z0-9_]+\}/i', '', $html );
}
/**
* Local fallback map when the proxy didn't enrich items with display meta
* (older proxy version). Keep in sync with NewsService::TYPE_META proxy-side.
*/
private const TYPE_META_FALLBACK = [
'new_addon' => [ 'label' => 'New Addon', 'badge_bg' => '#dcfce7', 'badge_color' => '#166534' ],
'new_feature' => [ 'label' => 'New Feature', 'badge_bg' => '#fff7ed', 'badge_color' => '#9a3412' ],
'new_version' => [ 'label' => 'New Version', 'badge_bg' => '#f3e8ff', 'badge_color' => '#7e22ce' ],
'discount' => [ 'label' => 'Discount', 'badge_bg' => '#fce7f3', 'badge_color' => '#be185d' ],
'announcement' => [ 'label' => 'Announcement', 'badge_bg' => '#dbeafe', 'badge_color' => '#1d4ed8' ],
];
/**
* One news item → its category card: colored badge header (category label +
* "i / n" counter when the email holds several items), bold title, then the
* item's rich content. Content is sanitized with an email-safe allowlist
* (defense in depth — the proxy is trusted, but its content still never
* reaches the mail body unfiltered).
*/
private function render_news_card( array $item, string $counter, string $card_tpl ): string {
// Inner content: proxy-rendered rich HTML, or a paragraph from the plain body
if( ! empty( $item['html_content'] ) ) {
$content = $item['html_content'];
} else {
$content = ! empty( $item['body'] )
? '' . nl2br( esc_html( $item['body'] ) ) . '
'
: '';
}
if( ! empty( $item['link_url'] ) ) {
$label = ! empty( $item['link_label'] ) ? $item['link_label'] : __( 'Learn more', 'gvectors' );
$content .= '';
}
$content = wp_kses( $content, $this->email_allowed_html() );
$meta = $this->item_type_meta( $item );
return str_replace(
[ '{type_label}', '{badge_bg}', '{badge_color}', '{title}', '{content}', '{item_counter}' ],
[ esc_html( $meta['label'] ), $meta['badge_bg'], $meta['badge_color'], esc_html( $item['title'] ), $content, esc_html( $counter ) ],
$card_tpl
);
}
/**
* Display meta for an item: prefer proxy-provided label/colors (source of
* truth), validated; fall back to the local map for older proxies.
*/
private function item_type_meta( array $item ): array {
$meta = self::TYPE_META_FALLBACK[ $item['type'] ?? '' ] ?? self::TYPE_META_FALLBACK['announcement'];
if( ! empty( $item['type_label'] ) && is_string( $item['type_label'] ) ) {
$meta['label'] = $item['type_label'];
}
foreach( [ 'badge_bg', 'badge_color' ] as $key ) {
if( ! empty( $item[ $key ] ) && is_string( $item[ $key ] ) && preg_match( '/^#[0-9a-fA-F]{3,8}$/', $item[ $key ] ) ) {
$meta[ $key ] = $item[ $key ];
}
}
return $meta;
}
/**
* Minimal local card used only when the proxy wrapper didn't ship one.
*/
private function fallback_card(): string {
return ''
. '
'
. '{type_label}'
. '{item_counter}'
. '
'
. '
'
. '
{title}
'
. '
{content}
'
. '
'
. '
';
}
/**
* Email-safe HTML allowlist for news content blocks.
*/
private function email_allowed_html(): array {
$common = [ 'style' => true, 'class' => true, 'align' => true, 'width' => true, 'height' => true ];
return [
'div' => $common,
'p' => $common,
'span' => $common,
'a' => $common + [ 'href' => true, 'target' => true, 'rel' => true ],
'img' => $common + [ 'src' => true, 'alt' => true ],
'strong' => $common, 'b' => $common, 'em' => $common, 'i' => $common,
'h1' => $common, 'h2' => $common, 'h3' => $common, 'h4' => $common,
'ul' => $common, 'ol' => $common, 'li' => $common,
'table' => $common + [ 'cellpadding' => true, 'cellspacing' => true, 'border' => true ],
'thead' => $common, 'tbody' => $common, 'tr' => $common, 'td' => $common + [ 'colspan' => true ], 'th' => $common + [ 'colspan' => true ],
'br' => [], 'hr' => $common,
];
}
/**
* Compliance footer: why this email was received + how to stop it.
* Links to the News & Emails settings page where each administrator can
* unsubscribe, pick email categories, or disable the whole service.
*/
private function footer_line(): string {
return '';
}
/**
* Minimal local shell used only when the proxy wrapper is unavailable.
*/
private function fallback_wrapper(): array {
return [
'subject' => __( 'News from gVectors', 'gvectors' ),
'body_html' => ''
. '
'
. '
Hi {admin_name},
'
. '{news_content}'
. '
',
];
}
}