From 47d9dbb58b12dd4ef4556a07659c7cf8a4c09893 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Nov 2026 21:10:42 +0000 Subject: [PATCH] feat(theme): send the weekly AutoJack newsletter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose the previous Monday–Sunday posts into an issue that matches the welcome email, and mail confirmed Resend contacts in batches. The weekly cron stays off until Settings → Newsletter enables it. Co-authored-by: Jack Arturo --- .github/workflows/test.yml | 27 + functions.php | 3 + inc/newsletter-digest.php | 782 ++++++++++++++++ inc/newsletter-weekly.php | 1568 +++++++++++++++++++++++++++++++++ inc/newsletter.php | 5 + tests/newsletter-weekly.php | 287 ++++++ tests/theme-design-static.php | 5 +- 7 files changed, 2676 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 inc/newsletter-digest.php create mode 100644 inc/newsletter-weekly.php create mode 100644 tests/newsletter-weekly.php diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..01d2c6e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: PHP syntax + run: find . -name '*.php' -print0 | xargs -0 -n1 php -l + + - name: Newsletter and theme checks + run: | + php tests/theme-design-static.php + php tests/comment-webhook-delivery.php + php tests/newsletter-weekly.php diff --git a/functions.php b/functions.php index cc07a0a..6bb392f 100644 --- a/functions.php +++ b/functions.php @@ -21,6 +21,9 @@ // AutoJack newsletter signup form helper. require_once get_template_directory() . '/inc/newsletter.php'; +// Weekly issue. Automatic send stays off until Settings → Newsletter enables it. +require_once get_template_directory() . '/inc/newsletter-weekly.php'; + // Comment webhook to the AutoJack hub (inert without wp-config constants). require_once get_template_directory() . '/inc/comment-webhook.php'; diff --git a/inc/newsletter-digest.php b/inc/newsletter-digest.php new file mode 100644 index 0000000..8c5dee3 --- /dev/null +++ b/inc/newsletter-digest.php @@ -0,0 +1,782 @@ + + */ +function minimalcode_newsletter_brand() { + return array( + 'paper' => '#f3efe6', + 'paperDark' => '#ebe5d6', + 'ink' => '#0e0d0c', + 'inkSoft' => '#2a2724', + 'inkMute' => '#6b665c', + 'rule' => '#1a1814', + 'hot' => '#e3ff04', + 'link' => '#b53114', + 'autojackStart' => '#6366f1', + 'autojackEnd' => '#8b5cf6', + 'fontSerif' => "Georgia, 'Times New Roman', Times, serif", + 'fontSans' => "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", + 'fontMono' => "'Courier New', Courier, monospace", + 'portraitUrl' => 'https://drunk.support/wp-content/themes/minimalcode/assets/images/autojack-profile.jpg', + 'siteUrl' => 'https://drunk.support', + 'siteName' => 'drunk.support', + ); +} + +/** + * Defaults. Automatic weekly sending is off until someone opts in. + * + * @return array{enabled: bool, weekday: int, hour: int, minute: int} + */ +function minimalcode_newsletter_default_settings() { + return array( + 'enabled' => false, + 'weekday' => 1, + 'hour' => 8, + 'minute' => 0, + ); +} + +/** + * @param mixed $input Raw settings. + * @return array{enabled: bool, weekday: int, hour: int, minute: int} + */ +function minimalcode_newsletter_sanitize_settings( $input ) { + $defaults = minimalcode_newsletter_default_settings(); + if ( ! is_array( $input ) ) { + return $defaults; + } + $weekday = isset( $input['weekday'] ) ? (int) $input['weekday'] : $defaults['weekday']; + $hour = isset( $input['hour'] ) ? (int) $input['hour'] : $defaults['hour']; + $minute = isset( $input['minute'] ) ? (int) $input['minute'] : $defaults['minute']; + return array( + 'enabled' => ! empty( $input['enabled'] ), + 'weekday' => max( 1, min( 7, $weekday ) ), + 'hour' => max( 0, min( 23, $hour ) ), + 'minute' => max( 0, min( 59, $minute ) ), + ); +} + +/** + * HTML-escape a string for the email body. + * + * @param string $text Raw text. + * @return string + */ +function minimalcode_newsletter_esc( $text ) { + return htmlspecialchars( (string) $text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); +} + +/** + * Allow only http(s) URLs with no whitespace or quotes. + * + * @param string $url URL. + * @return string + */ +function minimalcode_newsletter_safe_url( $url ) { + $url = trim( (string) $url ); + if ( ! preg_match( '#^https?://#i', $url ) ) { + return ''; + } + if ( preg_match( '/[\s<>"\']/', $url ) ) { + return ''; + } + return $url; +} + +/** + * @param string $email Address. + * @return bool + */ +function minimalcode_newsletter_valid_email( $email ) { + $email = strtolower( trim( (string) $email ) ); + if ( strlen( $email ) > 254 || strlen( $email ) < 3 ) { + return false; + } + return (bool) preg_match( '/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $email ); +} + +/** + * Base64url without padding, matching the worker's token encoding. + * + * @param string $bytes Raw bytes. + * @return string + */ +function minimalcode_newsletter_b64url_encode( $bytes ) { + return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' ); +} + +/** + * @param string $encoded Base64url. + * @return string|null Raw bytes, or null when malformed. + */ +function minimalcode_newsletter_b64url_decode( $encoded ) { + $encoded = (string) $encoded; + if ( '' === $encoded || ! preg_match( '/^[A-Za-z0-9_-]+$/', $encoded ) ) { + return null; + } + $padded = strtr( $encoded, '-_', '+/' ); + $pad = strlen( $padded ) % 4; + if ( 0 !== $pad ) { + $padded .= str_repeat( '=', 4 - $pad ); + } + $raw = base64_decode( $padded, true ); + return false === $raw ? null : $raw; +} + +/** + * Sign an unsubscribe token the same way the news.autojack.ai worker does. + * + * Payload is {"e":email,"x":unix_expiry}. HMAC-SHA256 is over the base64url + * payload, not the raw JSON. Returns '' when the secret or email is unusable. + * + * @param string $email Recipient. + * @param string $secret NEWSLETTER_CONFIRM_SECRET. + * @param int $ttl_seconds Lifetime. + * @param int|null $now Unix time. + * @return string + */ +function minimalcode_newsletter_sign_token( $email, $secret, $ttl_seconds = 31536000, $now = null ) { + $email = strtolower( trim( (string) $email ) ); + $secret = (string) $secret; + if ( ! minimalcode_newsletter_valid_email( $email ) || strlen( $secret ) < 16 ) { + return ''; + } + $now = null === $now ? time() : (int) $now; + $payload = json_encode( + array( + 'e' => $email, + 'x' => $now + (int) $ttl_seconds, + ), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ); + if ( ! is_string( $payload ) ) { + return ''; + } + $payload_b64 = minimalcode_newsletter_b64url_encode( $payload ); + $sig = hash_hmac( 'sha256', $payload_b64, $secret, true ); + return $payload_b64 . '.' . minimalcode_newsletter_b64url_encode( $sig ); +} + +/** + * @param string $token Token. + * @param string $secret Secret. + * @param int|null $now Unix time. + * @return array{valid: bool, email: string, reason: string} + */ +function minimalcode_newsletter_verify_token( $token, $secret, $now = null ) { + $invalid = array( + 'valid' => false, + 'email' => '', + 'reason' => 'malformed', + ); + $secret = (string) $secret; + if ( strlen( $secret ) < 16 || ! is_string( $token ) ) { + $invalid['reason'] = strlen( $secret ) < 16 ? 'unconfigured' : 'malformed'; + return $invalid; + } + $parts = explode( '.', $token ); + if ( 2 !== count( $parts ) || '' === $parts[0] || '' === $parts[1] ) { + return $invalid; + } + $provided = minimalcode_newsletter_b64url_decode( $parts[1] ); + if ( null === $provided ) { + return $invalid; + } + $expected = hash_hmac( 'sha256', $parts[0], $secret, true ); + if ( ! hash_equals( $expected, $provided ) ) { + $invalid['reason'] = 'bad_signature'; + return $invalid; + } + $json = minimalcode_newsletter_b64url_decode( $parts[0] ); + if ( null === $json ) { + return $invalid; + } + $payload = json_decode( $json, true ); + if ( ! is_array( $payload ) || ! isset( $payload['x'] ) || ! is_numeric( $payload['x'] ) ) { + return $invalid; + } + $expiry = $payload['x']; + if ( is_float( $expiry ) && $expiry != (int) $expiry ) { + return $invalid; + } + $expiry = (int) $expiry; + $now = null === $now ? time() : (int) $now; + if ( $now > $expiry ) { + $invalid['reason'] = 'expired'; + return $invalid; + } + $email = isset( $payload['e'] ) ? strtolower( trim( (string) $payload['e'] ) ) : ''; + if ( ! minimalcode_newsletter_valid_email( $email ) ) { + return $invalid; + } + return array( + 'valid' => true, + 'email' => $email, + 'reason' => '', + ); +} + +/** + * Previous complete Monday 00:00:00 through Sunday 23:59:59 in $tz. + * + * A Monday morning send therefore covers the week that just ended, not the + * week that is one hour old. + * + * @param DateTimeImmutable $now Instant. + * @param DateTimeZone $tz Site timezone. + * @return array{start: DateTimeImmutable, end: DateTimeImmutable, week_id: string, timezone: string} + */ +function minimalcode_newsletter_previous_week( DateTimeImmutable $now, DateTimeZone $tz ) { + $local = $now->setTimezone( $tz ); + $dow = (int) $local->format( 'N' ); + $this_monday = $local->setTime( 0, 0, 0 )->modify( '-' . ( $dow - 1 ) . ' days' ); + $start = $this_monday->modify( '-7 days' ); + $end = $this_monday->modify( '-1 second' ); + return array( + 'start' => $start, + 'end' => $end, + 'timezone' => $tz->getName(), + 'week_id' => $start->format( 'Y-m-d' ), + ); +} + +/** + * Next weekday/hour/minute strictly in the future (or exactly now if still ahead). + * + * Weekday is ISO-8601: 1 Monday … 7 Sunday. + * + * @param DateTimeImmutable $now Instant. + * @param DateTimeZone $tz Site timezone. + * @param int $weekday 1–7. + * @param int $hour 0–23. + * @param int $minute 0–59. + * @return DateTimeImmutable + */ +function minimalcode_newsletter_next_slot( DateTimeImmutable $now, DateTimeZone $tz, $weekday, $hour, $minute ) { + $local = $now->setTimezone( $tz ); + $weekday = max( 1, min( 7, (int) $weekday ) ); + $hour = max( 0, min( 23, (int) $hour ) ); + $minute = max( 0, min( 59, (int) $minute ) ); + $candidate = $local->setTime( $hour, $minute, 0 ); + $delta = ( $weekday - (int) $candidate->format( 'N' ) + 7 ) % 7; + if ( 0 === $delta && $candidate <= $local ) { + $delta = 7; + } + if ( $delta > 0 ) { + $candidate = $candidate->modify( '+' . $delta . ' days' ); + } + return $candidate; +} + +/** + * @param mixed $post Post row. + * @return array{id: int, title: string, excerpt: string, url: string, date: string, category: string}|null + */ +function minimalcode_newsletter_normalize_post( $post ) { + if ( ! is_array( $post ) ) { + return null; + } + $title = trim( (string) ( $post['title'] ?? '' ) ); + $url = minimalcode_newsletter_safe_url( $post['url'] ?? '' ); + if ( '' === $title || '' === $url ) { + return null; + } + $excerpt = trim( preg_replace( '/\s+/', ' ', (string) ( $post['excerpt'] ?? '' ) ) ); + if ( mb_strlen( $excerpt ) > 320 ) { + $excerpt = rtrim( mb_substr( $excerpt, 0, 300 ) ); + $excerpt = (string) preg_replace( '/\s+\S*$/', '', $excerpt ) . '…'; + } + return array( + 'id' => (int) ( $post['id'] ?? 0 ), + 'title' => $title, + 'excerpt' => $excerpt, + 'url' => $url, + 'date' => trim( (string) ( $post['date'] ?? '' ) ), + 'category' => trim( (string) ( $post['category'] ?? '' ) ), + ); +} + +/** + * Obvious stand-in used only for preview and test sends when the week is empty. + * + * @return array + */ +function minimalcode_newsletter_sample_posts() { + return array( + array( + 'id' => 0, + 'title' => 'Sample: a post from the previous week', + 'excerpt' => 'No posts were published last week, so this block is here only to show the layout. It is not sent to the list.', + 'url' => 'https://drunk.support/', + 'date' => '8 Nov 2026', + 'category' => 'autojack', + ), + ); +} + +/** + * Confirmed subscribers only. + * + * The worker inserts new signups as unsubscribed=true until they confirm, then + * flips the flag off. Missing or non-false values are skipped (fail closed). + * Pending and unsubscribed contacts never pass. + * + * @param array $contacts Resend contact rows. + * @return string[] Emails. + */ +function minimalcode_newsletter_confirmed_subscribers( array $contacts ) { + $out = array(); + foreach ( $contacts as $contact ) { + if ( ! is_array( $contact ) || ! array_key_exists( 'unsubscribed', $contact ) ) { + continue; + } + if ( false !== $contact['unsubscribed'] ) { + continue; + } + $email = strtolower( trim( (string) ( $contact['email'] ?? '' ) ) ); + if ( ! minimalcode_newsletter_valid_email( $email ) ) { + continue; + } + $out[ $email ] = $email; + } + return array_values( $out ); +} + +/** + * @param string[] $emails Emails. + * @param int $size Batch size. + * @return array + */ +function minimalcode_newsletter_chunk( array $emails, $size ) { + $size = max( 1, (int) $size ); + if ( ! $emails ) { + return array(); + } + return array_chunk( array_values( $emails ), $size ); +} + +/** + * Decide what a cron run, a manual send, or a test send is allowed to do. + * + * Automatic sends require enabled=true. Manual "send now" is an explicit + * action and may run while the weekly schedule is still off. Neither path + * runs when sends_allowed is false (CI) or when this week was already sent. + * + * @param array $in Input. + * @return array + */ +function minimalcode_newsletter_plan( array $in ) { + $trigger = isset( $in['trigger'] ) ? (string) $in['trigger'] : 'cron'; + $enabled = ! empty( $in['enabled'] ); + $allowed = ! isset( $in['sends_allowed'] ) || $in['sends_allowed']; + $tz_in = $in['timezone'] ?? 'UTC'; + $tz = $tz_in instanceof DateTimeZone ? $tz_in : new DateTimeZone( (string) $tz_in ); + if ( isset( $in['now'] ) && $in['now'] instanceof DateTimeImmutable ) { + $now = $in['now']; + } else { + $now = new DateTimeImmutable( isset( $in['now'] ) ? (string) $in['now'] : 'now', $tz ); + } + $week = minimalcode_newsletter_previous_week( $now, $tz ); + $posts = array(); + foreach ( (array) ( $in['posts'] ?? array() ) as $post ) { + $norm = minimalcode_newsletter_normalize_post( $post ); + if ( $norm ) { + $posts[] = $norm; + } + } + $base = array( + 'week_id' => $week['week_id'], + 'timezone' => $week['timezone'], + 'window_start' => $week['start']->format( 'Y-m-d H:i:s' ), + 'window_end' => $week['end']->format( 'Y-m-d H:i:s' ), + 'week_label' => $week['start']->format( 'j M Y' ), + 'post_ids' => array_values( + array_filter( + array_map( + function ( $post ) { + return (int) $post['id']; + }, + $posts + ) + ) + ), + 'posts' => $posts, + 'recipients' => array(), + 'batches' => array(), + 'sample' => false, + 'action' => '', + 'reason' => '', + ); + + if ( ! $allowed ) { + $base['action'] = 'skip_blocked'; + $base['reason'] = 'Real sends are disabled in CI and tests.'; + return $base; + } + + if ( 'test' === $trigger ) { + $email = strtolower( trim( (string) ( $in['test_email'] ?? '' ) ) ); + if ( ! minimalcode_newsletter_valid_email( $email ) ) { + $base['action'] = 'skip_invalid_test'; + $base['reason'] = 'Test address is not a valid email.'; + return $base; + } + if ( ! $posts ) { + $base['posts'] = minimalcode_newsletter_sample_posts(); + $base['post_ids'] = array(); + $base['sample'] = true; + } + $base['recipients'] = array( $email ); + $base['batches'] = array( array( $email ) ); + $base['action'] = 'test'; + $base['reason'] = 'Test send to one address.'; + return $base; + } + + if ( 'cron' === $trigger && ! $enabled ) { + $base['action'] = 'skip_disabled'; + $base['reason'] = 'Automatic weekly send is off.'; + return $base; + } + + $status = (string) ( $in['week_status'] ?? '' ); + if ( in_array( $status, array( 'sent', 'skipped_empty', 'sending' ), true ) ) { + $base['action'] = 'skip_duplicate'; + $base['reason'] = 'This week was already handled (' . $status . ').'; + return $base; + } + + if ( ! $posts ) { + $base['action'] = 'skip_empty'; + $base['reason'] = 'No posts in the previous Monday–Sunday window.'; + return $base; + } + + $recipients = minimalcode_newsletter_confirmed_subscribers( (array) ( $in['subscribers'] ?? array() ) ); + if ( ! $recipients ) { + $base['action'] = 'skip_no_recipients'; + $base['reason'] = 'No confirmed subscribers.'; + return $base; + } + + $size = max( 1, (int) ( $in['batch_size'] ?? 10 ) ); + $base['recipients'] = $recipients; + $base['batches'] = minimalcode_newsletter_chunk( $recipients, $size ); + $base['action'] = 'send'; + $base['reason'] = 'Ready to send.'; + return $base; +} + +/** + * CI and automated tests must not reach Resend. + * + * @param array $env Environ-like map. Keys CI and GITHUB_ACTIONS. + * @param bool $explicit_allow MINIMALCODE_NEWSLETTER_ALLOW_SEND. + * @return bool + */ +function minimalcode_newsletter_real_send_allowed( array $env, $explicit_allow = false ) { + $ci = ! empty( $env['CI'] ) || ! empty( $env['GITHUB_ACTIONS'] ); + if ( $ci && ! $explicit_allow ) { + return false; + } + return true; +} + +/** + * Stable for a week and a recipient so a retry inside 24h does not double-send. + * + * @param string $week_id Week id. + * @param string $email Email. + * @return string + */ +function minimalcode_newsletter_idempotency_key( $week_id, $email ) { + $hash = substr( hash( 'sha256', strtolower( trim( (string) $email ) ) ), 0, 32 ); + $key = 'weekly/' . preg_replace( '/[^0-9-]/', '', (string) $week_id ) . '/' . $hash; + return substr( $key, 0, 256 ); +} + +/** + * @param array> $posts Posts. + * @param bool $test Prefix the subject. + * @return string + */ +function minimalcode_newsletter_subject( array $posts, $test = false ) { + $count = count( $posts ); + if ( 1 === $count ) { + $title = trim( (string) $posts[0]['title'] ); + $subject = 'This week on drunk.support: ' . $title; + } elseif ( $count > 1 ) { + $subject = 'This week on drunk.support — ' . $count . ' new posts'; + } else { + $subject = 'This week on drunk.support'; + } + if ( mb_strlen( $subject ) > 90 ) { + $subject = rtrim( mb_substr( $subject, 0, 87 ) ) . '…'; + } + if ( $test ) { + $subject = '[test] ' . $subject; + } + return $subject; +} + +/** + * HTML issue. Header, hot button, and footer match the welcome email shell. + * + * @param array $args Render args. + * @return string + */ +function minimalcode_newsletter_render_html( array $args ) { + $brand = minimalcode_newsletter_brand(); + $site_url = minimalcode_newsletter_safe_url( $args['site_url'] ?? $brand['siteUrl'] ); + if ( '' === $site_url ) { + $site_url = $brand['siteUrl']; + } + $site_name = trim( (string) ( $args['site_name'] ?? $brand['siteName'] ) ); + if ( '' === $site_name ) { + $site_name = $brand['siteName']; + } + $portrait = minimalcode_newsletter_safe_url( $args['portrait_url'] ?? $brand['portraitUrl'] ); + if ( '' === $portrait ) { + $portrait = $brand['portraitUrl']; + } + $posts = array(); + foreach ( (array) ( $args['posts'] ?? array() ) as $post ) { + $norm = minimalcode_newsletter_normalize_post( $post ); + if ( $norm ) { + $posts[] = $norm; + } + } + $unsub = minimalcode_newsletter_safe_url( $args['unsubscribe_url'] ?? '' ); + $label = trim( (string) ( $args['week_label'] ?? '' ) ); + $kicker = $label ? 'VOL.04 / WEEK OF ' . strtoupper( $label ) . ' · EST. 2009' : 'VOL.04 · EST. 2009'; + $sample = ! empty( $args['sample'] ); + $first = $posts ? $posts[0]['title'] : 'No new posts this week'; + $preview = $sample + ? 'Sample layout only. Nothing was sent to the list.' + : $first . ' — and the rest of the week on drunk.support.'; + $subject = minimalcode_newsletter_subject( $posts, ! empty( $args['test'] ) ); + + if ( 1 === count( $posts ) ) { + $cta_url = $posts[0]['url']; + $cta_label = 'Read the post'; + } else { + $cta_url = $site_url; + $cta_label = 'Read the log'; + } + + $b = $brand; + $unsub_html = $unsub + ? '
Unsubscribe' + : ''; + + $blocks = ''; + foreach ( $posts as $post ) { + $meta_bits = array(); + if ( '' !== $post['category'] ) { + $meta_bits[] = $post['category']; + } + if ( '' !== $post['date'] ) { + $meta_bits[] = $post['date']; + } + $meta = $meta_bits + ? '
' . minimalcode_newsletter_esc( implode( ' · ', $meta_bits ) ) . '
' + : ''; + $excerpt = '' !== $post['excerpt'] + ? '

' . minimalcode_newsletter_esc( $post['excerpt'] ) . '

' + : ''; + $blocks .= '' + . $meta + . '

' . minimalcode_newsletter_esc( $post['title'] ) . '

' + . $excerpt + . 'Read this post' + . ' '; + } + + $sample_banner = $sample + ? '

Sample issue — no posts were published last week. This is a preview, not a send to the list.

' + : ''; + + $button = '
' . minimalcode_newsletter_esc( $cta_label ) . '
'; + + return ' + + + + + + +' . minimalcode_newsletter_esc( $subject ) . ' + + +' . minimalcode_newsletter_esc( $preview ) . ' + + + + +
+ + + + + + + + + + +
+ + + + + +
+
' . minimalcode_newsletter_esc( $site_name ) . '
+
' . minimalcode_newsletter_esc( $kicker ) . '
+
+ AutoJack +
+
 
+
+ ' . $sample_banner . ' +

This week on the log.

+

I’m AutoJack — the AI that runs Jack’s automation hub and writes this blog. Here is what landed on drunk.support this week.

+

Reply to this issue and I’ll actually answer — about the code, the tools, whatever you’re building.

+ ' . $blocks . '
+ ' . $button . ' +
+

No spam. Unsubscribe anytime from the footer of any issue.

+
+ AutoJack · written and sent by an AI · Berlin
+ ' . minimalcode_newsletter_esc( $site_name ) . '' . $unsub_html . ' +
+
+ +'; +} + +/** + * Plain-text alternative. + * + * @param array $args Render args. + * @return string + */ +function minimalcode_newsletter_render_text( array $args ) { + $brand = minimalcode_newsletter_brand(); + $site_url = minimalcode_newsletter_safe_url( $args['site_url'] ?? $brand['siteUrl'] ); + if ( '' === $site_url ) { + $site_url = $brand['siteUrl']; + } + $site_name = trim( (string) ( $args['site_name'] ?? $brand['siteName'] ) ); + $posts = array(); + foreach ( (array) ( $args['posts'] ?? array() ) as $post ) { + $norm = minimalcode_newsletter_normalize_post( $post ); + if ( $norm ) { + $posts[] = $norm; + } + } + $unsub = minimalcode_newsletter_safe_url( $args['unsubscribe_url'] ?? '' ); + $label = trim( (string) ( $args['week_label'] ?? '' ) ); + $lines = array(); + $lines[] = $site_name ? $site_name : 'drunk.support'; + $lines[] = $label ? 'Week of ' . $label : 'This week'; + $lines[] = ''; + if ( ! empty( $args['sample'] ) ) { + $lines[] = 'SAMPLE — no posts were published last week. This preview is not a send to the list.'; + $lines[] = ''; + } + $lines[] = "I'm AutoJack — the AI that runs Jack's automation hub and writes this blog. Here's what landed on drunk.support this week."; + $lines[] = ''; + $lines[] = "Reply to this issue and I'll actually answer."; + $lines[] = ''; + foreach ( $posts as $post ) { + $lines[] = $post['title']; + $meta = trim( $post['date'] . ( $post['category'] ? ' · ' . $post['category'] : '' ) ); + if ( '' !== $meta ) { + $lines[] = $meta; + } + if ( '' !== $post['excerpt'] ) { + $lines[] = $post['excerpt']; + } + $lines[] = $post['url']; + $lines[] = ''; + } + $lines[] = '---'; + $lines[] = $site_url; + if ( $unsub ) { + $lines[] = 'Unsubscribe: ' . $unsub; + } + $lines[] = 'AutoJack · written and sent by an AI · Berlin'; + return implode( "\n", $lines ) . "\n"; +} + +/** + * Subject, bodies, and list-unsubscribe headers for one recipient. + * + * @param array $args Render args. unsubscribe_url required for a real send. + * @return array{subject: string, html: string, text: string, headers: array} + */ +function minimalcode_newsletter_message( array $args ) { + $posts = (array) ( $args['posts'] ?? array() ); + $subject = minimalcode_newsletter_subject( $posts, ! empty( $args['test'] ) ); + $html = minimalcode_newsletter_render_html( $args ); + $text = minimalcode_newsletter_render_text( $args ); + $headers = array(); + $unsub = minimalcode_newsletter_safe_url( $args['unsubscribe_url'] ?? '' ); + if ( $unsub ) { + $headers['List-Unsubscribe'] = '<' . $unsub . '>'; + $headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; + } + return array( + 'subject' => $subject, + 'html' => $html, + 'text' => $text, + 'headers' => $headers, + ); +} + +/** + * Public unsubscribe page, same paper/ink system as the welcome email. + * + * @param string $heading Heading. + * @param string $message Message. Already escaped by the caller when it includes HTML... pass plain text. + * @param int $status HTTP status. + * @return string Full HTML document. + */ +function minimalcode_newsletter_unsubscribe_document( $heading, $message, $status = 200 ) { + $b = minimalcode_newsletter_brand(); + unset( $status ); + return ' + + + + + +' . minimalcode_newsletter_esc( $heading ) . ' + + +
+

' . minimalcode_newsletter_esc( $b['siteName'] ) . '

+

AutoJack newsletter

+
+

' . minimalcode_newsletter_esc( $heading ) . '

+

' . minimalcode_newsletter_esc( $message ) . '

+

← back to ' . minimalcode_newsletter_esc( $b['siteName'] ) . '

+
+ +'; +} diff --git a/inc/newsletter-weekly.php b/inc/newsletter-weekly.php new file mode 100644 index 0000000..c0b3267 --- /dev/null +++ b/inc/newsletter-weekly.php @@ -0,0 +1,1568 @@ +' ); // optional + * define( 'MINIMALCODE_NEWSLETTER_SEGMENT_ID', '...' ); // optional, only if the audience id was migrated + * + * Automatic sending is OFF until Settings → Newsletter enables it. Enabling + * does not mail the list immediately; the first automatic run is the next + * scheduled slot. Send now is a separate, explicit action. + * + * WP-Cron does not run without site traffic. A real crontab should hit + * wp-cron.php every five minutes, or `wp cron event run --due-now`. + * + * @package MinimalCode + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +require_once __DIR__ . '/newsletter-digest.php'; + +/** + * @return array{enabled: bool, weekday: int, hour: int, minute: int} + */ +function minimalcode_newsletter_settings() { + $stored = get_option( 'minimalcode_newsletter_settings', array() ); + return minimalcode_newsletter_sanitize_settings( is_array( $stored ) ? $stored : array() ); +} + +/** + * @param array $input Raw settings. + * @return array{enabled: bool, weekday: int, hour: int, minute: int} + */ +function minimalcode_newsletter_save_settings( array $input ) { + $old = minimalcode_newsletter_settings(); + $settings = minimalcode_newsletter_sanitize_settings( $input ); + update_option( 'minimalcode_newsletter_settings', $settings, true ); + if ( $settings['enabled'] && ! $old['enabled'] ) { + update_option( 'minimalcode_newsletter_armed_at', time(), false ); + } + if ( ! $settings['enabled'] ) { + delete_option( 'minimalcode_newsletter_armed_at' ); + } + minimalcode_newsletter_ensure_schedule(); + return $settings; +} + +/** + * Constant names that are still empty. Values are never returned. + * + * @return string[] + */ +function minimalcode_newsletter_missing_config() { + $missing = array(); + if ( ! defined( 'MINIMALCODE_RESEND_API_KEY' ) || ! is_string( MINIMALCODE_RESEND_API_KEY ) || '' === MINIMALCODE_RESEND_API_KEY ) { + $missing[] = 'MINIMALCODE_RESEND_API_KEY'; + } + $has_audience = defined( 'MINIMALCODE_NEWSLETTER_AUDIENCE_ID' ) && is_string( MINIMALCODE_NEWSLETTER_AUDIENCE_ID ) && '' !== MINIMALCODE_NEWSLETTER_AUDIENCE_ID; + $has_segment = defined( 'MINIMALCODE_NEWSLETTER_SEGMENT_ID' ) && is_string( MINIMALCODE_NEWSLETTER_SEGMENT_ID ) && '' !== MINIMALCODE_NEWSLETTER_SEGMENT_ID; + if ( ! $has_audience && ! $has_segment ) { + $missing[] = 'MINIMALCODE_NEWSLETTER_AUDIENCE_ID'; + } + if ( ! defined( 'MINIMALCODE_NEWSLETTER_CONFIRM_SECRET' ) || ! is_string( MINIMALCODE_NEWSLETTER_CONFIRM_SECRET ) || strlen( MINIMALCODE_NEWSLETTER_CONFIRM_SECRET ) < 16 ) { + $missing[] = 'MINIMALCODE_NEWSLETTER_CONFIRM_SECRET'; + } + return $missing; +} + +/** + * @return bool + */ +function minimalcode_newsletter_ci_blocked() { + return ! minimalcode_newsletter_real_send_allowed( + array( + 'CI' => getenv( 'CI' ) ? '1' : '', + 'GITHUB_ACTIONS' => getenv( 'GITHUB_ACTIONS' ) ? '1' : '', + ), + defined( 'MINIMALCODE_NEWSLETTER_ALLOW_SEND' ) && MINIMALCODE_NEWSLETTER_ALLOW_SEND + ); +} + +/** + * @return int + */ +function minimalcode_newsletter_batch_size() { + $size = (int) apply_filters( 'minimalcode_newsletter_batch_size', 10 ); + return max( 1, min( 50, $size ) ); +} + +/** + * Pause between recipients inside one batch. Default is half a second, + * under Resend's typical 2 requests/second. + * + * @return int Microseconds. + */ +function minimalcode_newsletter_send_delay_us() { + $delay = (int) apply_filters( 'minimalcode_newsletter_send_delay_us', 500000 ); + return max( 0, min( 2000000, $delay ) ); +} + +/** + * @return string + */ +function minimalcode_newsletter_from() { + $from = 'AutoJack '; + if ( defined( 'MINIMALCODE_NEWSLETTER_FROM' ) && is_string( MINIMALCODE_NEWSLETTER_FROM ) && '' !== MINIMALCODE_NEWSLETTER_FROM ) { + $from = MINIMALCODE_NEWSLETTER_FROM; + } + return (string) apply_filters( 'minimalcode_newsletter_from', $from ); +} + +/** + * @return bool + */ +function minimalcode_newsletter_using_action_scheduler() { + return function_exists( 'as_schedule_single_action' ) + && function_exists( 'as_next_scheduled_action' ) + && function_exists( 'as_unschedule_all_actions' ); +} + +/** + * @param string $hook Hook. + * @param array $args Args. Null clears every instance of the hook. + * @return void + */ +function minimalcode_newsletter_unschedule( $hook, $args = null ) { + if ( null === $args ) { + wp_clear_scheduled_hook( $hook ); + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( $hook ); + } + return; + } + wp_clear_scheduled_hook( $hook, $args ); + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( $hook, $args, 'minimalcode-newsletter' ); + } +} + +/** + * @param int $timestamp Unix time. + * @param string $hook Hook. + * @param array $args Args. + * @return void + */ +function minimalcode_newsletter_schedule_single( $timestamp, $hook, $args = array() ) { + $timestamp = max( time() + 5, (int) $timestamp ); + if ( minimalcode_newsletter_using_action_scheduler() ) { + wp_clear_scheduled_hook( $hook, $args ); + if ( ! as_next_scheduled_action( $hook, $args, 'minimalcode-newsletter' ) ) { + as_schedule_single_action( $timestamp, $hook, $args, 'minimalcode-newsletter' ); + } + return; + } + if ( ! wp_next_scheduled( $hook, $args ) ) { + wp_schedule_single_event( $timestamp, $hook, $args ); + } +} + +/** + * @param string $hook Hook. + * @param array $args Args. + * @return int|false + */ +function minimalcode_newsletter_next_scheduled( $hook, $args = array() ) { + if ( minimalcode_newsletter_using_action_scheduler() ) { + $next = as_next_scheduled_action( $hook, $args, 'minimalcode-newsletter' ); + return $next ? (int) $next : false; + } + $next = wp_next_scheduled( $hook, $args ); + return $next ? (int) $next : false; +} + +/** + * Queue the next slot, or leave a due event alone so cron can fire it. + * + * Enabling arms the clock at "now", so a past slot from before the toggle + * was turned on is not sent as a surprise. A slot that was missed while the + * feature was already on is caught up for 18 hours. + * + * @return void + */ +function minimalcode_newsletter_ensure_schedule() { + $settings = minimalcode_newsletter_settings(); + if ( ! $settings['enabled'] ) { + if ( minimalcode_newsletter_next_scheduled( 'minimalcode_newsletter_weekly' ) ) { + minimalcode_newsletter_unschedule( 'minimalcode_newsletter_weekly' ); + } + minimalcode_newsletter_resume_if_needed(); + return; + } + + if ( ! get_option( 'minimalcode_newsletter_armed_at' ) ) { + update_option( 'minimalcode_newsletter_armed_at', time(), false ); + } + + $scheduled = minimalcode_newsletter_next_scheduled( 'minimalcode_newsletter_weekly' ); + if ( $scheduled && $scheduled <= time() + 30 ) { + minimalcode_newsletter_resume_if_needed(); + return; + } + + $tz = wp_timezone(); + $now = new DateTimeImmutable( 'now', $tz ); + $next = minimalcode_newsletter_next_slot( $now, $tz, $settings['weekday'], $settings['hour'], $settings['minute'] ); + $armed = (int) get_option( 'minimalcode_newsletter_armed_at', 0 ); + $prev = $next->modify( '-7 days' ); + $age = $now->getTimestamp() - $prev->getTimestamp(); + $week = minimalcode_newsletter_previous_week( $now, $tz ); + $status = minimalcode_newsletter_week_status( $week['week_id'] ); + $claim = get_option( 'minimalcode_nl_claim_' . $week['week_id'] ); + $catchup = $armed > 0 + && $prev->getTimestamp() >= $armed + && $age >= 0 + && $age < 18 * HOUR_IN_SECONDS + && ! in_array( $status, array( 'sent', 'skipped_empty', 'sending' ), true ) + && ! $claim; + + $want = $catchup ? time() + 90 : $next->getTimestamp(); + if ( $scheduled && abs( $scheduled - $want ) <= 120 ) { + minimalcode_newsletter_resume_if_needed(); + return; + } + + minimalcode_newsletter_unschedule( 'minimalcode_newsletter_weekly' ); + minimalcode_newsletter_schedule_single( $want, 'minimalcode_newsletter_weekly' ); + minimalcode_newsletter_resume_if_needed(); +} +add_action( 'init', 'minimalcode_newsletter_ensure_schedule', 20 ); + +/** + * Continue a batch that died between cron hits. Admin, cron, and WP-CLI only. + * + * @return void + */ +function minimalcode_newsletter_resume_if_needed() { + $cli = defined( 'WP_CLI' ) && WP_CLI; + if ( ! is_admin() && ! wp_doing_cron() && ! $cli ) { + return; + } + $state = get_option( 'minimalcode_newsletter_active' ); + if ( ! is_array( $state ) || empty( $state['week_id'] ) || ! empty( $state['finished'] ) ) { + return; + } + $week_id = (string) $state['week_id']; + if ( minimalcode_newsletter_next_scheduled( 'minimalcode_newsletter_batch', array( $week_id ) ) ) { + return; + } + $lock = (int) ( $state['lock'] ?? 0 ); + if ( $lock > time() - 90 ) { + return; + } + minimalcode_newsletter_schedule_single( time() + 60, 'minimalcode_newsletter_batch', array( $week_id ) ); +} + +/** + * @param string $week_id Week id (Monday date). + * @return string + */ +function minimalcode_newsletter_week_status( $week_id ) { + $weeks = get_option( 'minimalcode_newsletter_weeks', array() ); + if ( ! is_array( $weeks ) || ! isset( $weeks[ $week_id ] ) ) { + return ''; + } + return (string) $weeks[ $week_id ]; +} + +/** + * @param string $week_id Week id. + * @param string $status Status. + * @return void + */ +function minimalcode_newsletter_set_week_status( $week_id, $status ) { + $weeks = get_option( 'minimalcode_newsletter_weeks', array() ); + if ( ! is_array( $weeks ) ) { + $weeks = array(); + } + $weeks[ $week_id ] = $status; + if ( count( $weeks ) > 60 ) { + $weeks = array_slice( $weeks, -60, null, true ); + } + update_option( 'minimalcode_newsletter_weeks', $weeks, false ); +} + +/** + * @param array $entry Log row. + * @return void + */ +function minimalcode_newsletter_log_run( array $entry ) { + $log = get_option( 'minimalcode_newsletter_log', array() ); + if ( ! is_array( $log ) ) { + $log = array(); + } + array_unshift( $log, $entry ); + update_option( 'minimalcode_newsletter_log', array_slice( $log, 0, 30 ), false ); + $status = isset( $entry['status'] ) ? (string) $entry['status'] : 'unknown'; + $week = isset( $entry['week_id'] ) ? (string) $entry['week_id'] : ''; + $sent = isset( $entry['sent_count'] ) ? (int) $entry['sent_count'] : 0; + $failed = isset( $entry['failure_count'] ) ? (int) $entry['failure_count'] : 0; + error_log( 'minimalcode newsletter ' . $status . ' week ' . $week . ' sent ' . $sent . ' failed ' . $failed ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log +} + +/** + * @param string $week_id Week id. + * @param string $status Status. + * @return bool + */ +function minimalcode_newsletter_already_logged( $week_id, $status ) { + $log = get_option( 'minimalcode_newsletter_log', array() ); + if ( ! is_array( $log ) ) { + return false; + } + foreach ( $log as $row ) { + if ( is_array( $row ) && ( $row['week_id'] ?? '' ) === $week_id && ( $row['status'] ?? '' ) === $status ) { + return true; + } + } + return false; +} + +/** + * Posts published in the window, newest first. + * + * @param DateTimeImmutable $start Inclusive, site timezone. + * @param DateTimeImmutable $end Inclusive, site timezone. + * @return array> + */ +function minimalcode_newsletter_collect_posts( DateTimeImmutable $start, DateTimeImmutable $end ) { + $query = new WP_Query( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => 40, + 'orderby' => 'date', + 'order' => 'DESC', + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'date_query' => array( + array( + 'column' => 'post_date', + 'after' => $start->format( 'Y-m-d H:i:s' ), + 'before' => $end->format( 'Y-m-d H:i:s' ), + 'inclusive' => true, + ), + ), + ) + ); + + $posts = array(); + foreach ( $query->posts as $post ) { + if ( ! $post instanceof WP_Post ) { + continue; + } + if ( has_excerpt( $post ) ) { + $excerpt = $post->post_excerpt; + } else { + $excerpt = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ), 40, '…' ); + } + $cats = get_the_category( $post->ID ); + $posts[] = array( + 'id' => (int) $post->ID, + 'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ), + 'excerpt' => trim( html_entity_decode( wp_strip_all_tags( $excerpt ), ENT_QUOTES, 'UTF-8' ) ), + 'url' => (string) get_permalink( $post ), + 'date' => (string) get_the_date( 'j M Y', $post ), + 'category' => ( $cats && isset( $cats[0]->name ) ) ? (string) $cats[0]->name : '', + ); + } + wp_reset_postdata(); + return $posts; +} + +/** + * @param string $method GET|POST|PATCH. + * @param string $path Path beginning with /. + * @param array|null $body JSON body. + * @param array $extra_headers Extra headers. + * @return array{status: int, body: array}|WP_Error + */ +function minimalcode_newsletter_resend( $method, $path, $body = null, $extra_headers = array() ) { + if ( minimalcode_newsletter_ci_blocked() ) { + return new WP_Error( 'blocked', 'Refusing to call Resend from CI.' ); + } + if ( ! defined( 'MINIMALCODE_RESEND_API_KEY' ) || ! is_string( MINIMALCODE_RESEND_API_KEY ) || '' === MINIMALCODE_RESEND_API_KEY ) { + return new WP_Error( 'config', 'Resend API key is not configured.' ); + } + + $headers = array( + 'Authorization' => 'Bearer ' . MINIMALCODE_RESEND_API_KEY, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ); + foreach ( $extra_headers as $key => $value ) { + $headers[ $key ] = (string) $value; + } + + $args = array( + 'method' => $method, + 'timeout' => 20, + 'headers' => $headers, + ); + if ( null !== $body ) { + $args['body'] = wp_json_encode( $body ); + } + + $response = wp_remote_request( 'https://api.resend.com' . $path, $args ); + if ( is_wp_error( $response ) ) { + return new WP_Error( 'resend', $response->get_error_message() ); + } + + $raw = (string) wp_remote_retrieve_body( $response ); + $decoded = json_decode( $raw, true ); + return array( + 'status' => (int) wp_remote_retrieve_response_code( $response ), + 'body' => is_array( $decoded ) ? $decoded : array(), + ); +} + +/** + * Short Resend failure text with any key-shaped fragment removed. + * + * @param int $status HTTP status. + * @param array $body Decoded body. + * @return string + */ +function minimalcode_newsletter_resend_error_text( $status, $body ) { + $msg = 'HTTP ' . (int) $status; + if ( isset( $body['message'] ) && is_string( $body['message'] ) ) { + $msg .= ' ' . $body['message']; + } + $msg = (string) preg_replace( '/re_[A-Za-z0-9]+/', '[redacted]', $msg ); + return mb_substr( trim( wp_strip_all_tags( $msg ) ), 0, 180 ); +} + +/** + * List contacts in the newsletter audience. Never lists the whole account. + * + * @param string $path Path without query string. + * @return array|WP_Error + */ +function minimalcode_newsletter_list_contact_pages( $path ) { + $all = array(); + $after = ''; + for ( $page = 0; $page < 100; $page++ ) { + $url = $path . '?limit=100'; + if ( '' !== $after ) { + $url .= '&after=' . rawurlencode( $after ); + } + $res = minimalcode_newsletter_resend( 'GET', $url ); + if ( is_wp_error( $res ) ) { + return $res; + } + if ( 404 === $res['status'] ) { + return new WP_Error( 'not_found', 'That Resend list was not found.' ); + } + if ( $res['status'] < 200 || $res['status'] >= 300 ) { + return new WP_Error( 'resend', minimalcode_newsletter_resend_error_text( $res['status'], $res['body'] ) ); + } + $rows = isset( $res['body']['data'] ) && is_array( $res['body']['data'] ) ? $res['body']['data'] : array(); + foreach ( $rows as $row ) { + $all[] = $row; + } + // Legacy audience responses omit has_more. A short page is the end; + // a full page continues unless the API explicitly says there is no more. + if ( count( $rows ) < 100 ) { + break; + } + if ( array_key_exists( 'has_more', $res['body'] ) && empty( $res['body']['has_more'] ) ) { + break; + } + $last = end( $rows ); + if ( ! is_array( $last ) || empty( $last['id'] ) || $last['id'] === $after ) { + break; + } + $after = (string) $last['id']; + } + return $all; +} + +/** + * @return array|WP_Error Contact rows. + */ +function minimalcode_newsletter_fetch_subscribers() { + $audience = defined( 'MINIMALCODE_NEWSLETTER_AUDIENCE_ID' ) ? (string) MINIMALCODE_NEWSLETTER_AUDIENCE_ID : ''; + $segment = defined( 'MINIMALCODE_NEWSLETTER_SEGMENT_ID' ) ? (string) MINIMALCODE_NEWSLETTER_SEGMENT_ID : ''; + + if ( '' !== $audience ) { + $listed = minimalcode_newsletter_list_contact_pages( '/audiences/' . rawurlencode( $audience ) . '/contacts' ); + if ( ! is_wp_error( $listed ) ) { + return $listed; + } + if ( 'not_found' !== $listed->get_error_code() || '' === $segment ) { + return $listed; + } + } + if ( '' !== $segment ) { + return minimalcode_newsletter_list_contact_pages( '/segments/' . rawurlencode( $segment ) . '/contacts' ); + } + return new WP_Error( 'config', 'Newsletter audience is not configured.' ); +} + +/** + * @param string $email Address. + * @return true|WP_Error + */ +function minimalcode_newsletter_mark_unsubscribed( $email ) { + $email = strtolower( trim( (string) $email ) ); + $audience = defined( 'MINIMALCODE_NEWSLETTER_AUDIENCE_ID' ) ? (string) MINIMALCODE_NEWSLETTER_AUDIENCE_ID : ''; + $body = array( 'unsubscribed' => true ); + + if ( '' !== $audience ) { + $res = minimalcode_newsletter_resend( 'PATCH', '/audiences/' . rawurlencode( $audience ) . '/contacts/' . rawurlencode( $email ), $body ); + if ( ! is_wp_error( $res ) && $res['status'] >= 200 && $res['status'] < 300 ) { + return true; + } + if ( is_wp_error( $res ) ) { + return $res; + } + if ( 404 !== $res['status'] ) { + return new WP_Error( 'resend', minimalcode_newsletter_resend_error_text( $res['status'], $res['body'] ) ); + } + } + + $res = minimalcode_newsletter_resend( 'PATCH', '/contacts/' . rawurlencode( $email ), $body ); + if ( is_wp_error( $res ) ) { + return $res; + } + if ( $res['status'] >= 200 && $res['status'] < 300 ) { + return true; + } + return new WP_Error( 'resend', minimalcode_newsletter_resend_error_text( $res['status'], $res['body'] ) ); +} + +/** + * @param string $email Recipient. + * @return string + */ +function minimalcode_newsletter_unsubscribe_url( $email ) { + if ( ! defined( 'MINIMALCODE_NEWSLETTER_CONFIRM_SECRET' ) ) { + return ''; + } + $token = minimalcode_newsletter_sign_token( $email, MINIMALCODE_NEWSLETTER_CONFIRM_SECRET, 365 * DAY_IN_SECONDS, time() ); + if ( '' === $token ) { + return ''; + } + return add_query_arg( 'token', $token, home_url( '/newsletter/unsubscribe/' ) ); +} + +/** + * @param array $state Send state. + * @param string $email Recipient. + * @return array{ok: bool, error: string} + */ +function minimalcode_newsletter_deliver_one( array $state, $email ) { + $url = minimalcode_newsletter_unsubscribe_url( $email ); + if ( '' === $url ) { + return array( + 'ok' => false, + 'error' => 'Could not build an unsubscribe link.', + ); + } + $message = minimalcode_newsletter_message( + array( + 'posts' => $state['posts'], + 'unsubscribe_url' => $url, + 'site_url' => home_url( '/' ), + 'site_name' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), + 'week_label' => $state['week_label'] ?? '', + 'test' => ! empty( $state['test'] ), + 'sample' => ! empty( $state['sample'] ), + 'portrait_url' => get_template_directory_uri() . '/assets/images/autojack-profile.jpg', + ) + ); + if ( empty( $message['headers']['List-Unsubscribe'] ) || empty( $message['headers']['List-Unsubscribe-Post'] ) ) { + return array( + 'ok' => false, + 'error' => 'Refusing to send without List-Unsubscribe.', + ); + } + + $idem = ! empty( $state['test'] ) + ? 'test/' . gmdate( 'YmdHis' ) . '/' . bin2hex( random_bytes( 4 ) ) + : minimalcode_newsletter_idempotency_key( (string) $state['week_id'], $email ); + + $res = minimalcode_newsletter_resend( + 'POST', + '/emails', + array( + 'from' => minimalcode_newsletter_from(), + 'to' => array( $email ), + 'subject' => $message['subject'], + 'html' => $message['html'], + 'text' => $message['text'], + 'headers' => $message['headers'], + ), + array( + 'Idempotency-Key' => $idem, + ) + ); + if ( is_wp_error( $res ) ) { + return array( + 'ok' => false, + 'error' => $res->get_error_message(), + ); + } + if ( $res['status'] < 200 || $res['status'] >= 300 ) { + return array( + 'ok' => false, + 'error' => minimalcode_newsletter_resend_error_text( $res['status'], $res['body'] ), + ); + } + return array( + 'ok' => true, + 'error' => '', + ); +} + +/** + * @param array $plan Plan. + * @param string $trigger cron|manual|test. + * @return void + */ +function minimalcode_newsletter_record_empty( array $plan, $trigger ) { + $week_id = (string) $plan['week_id']; + if ( get_option( 'minimalcode_nl_claim_' . $week_id ) ) { + return; + } + if ( ! add_option( 'minimalcode_nl_claim_' . $week_id, 'skipped_empty', '', false ) ) { + return; + } + minimalcode_newsletter_set_week_status( $week_id, 'skipped_empty' ); + minimalcode_newsletter_log_run( + array( + 'week_id' => $week_id, + 'date' => gmdate( 'c' ), + 'post_ids' => $plan['post_ids'], + 'recipient_count' => 0, + 'sent_count' => 0, + 'failure_count' => 0, + 'failures' => array(), + 'status' => 'skipped_empty', + 'trigger' => $trigger, + ) + ); +} + +/** + * @param array $state Active state. + * @param string $override_status Optional status. + * @return void + */ +function minimalcode_newsletter_finish( array $state, $override_status = '' ) { + $sent = (int) $state['sent']; + $failures = array_slice( (array) $state['failures'], 0, 40 ); + $status = $override_status; + if ( '' === $status ) { + if ( $sent > 0 && $failures ) { + $status = 'partial'; + } elseif ( $sent > 0 ) { + $status = 'sent'; + } else { + $status = 'failed'; + } + } + + $week_id = (string) $state['week_id']; + $release = ( 'failed' === $status ) || ( 'cancelled' === $status && $sent < 1 ); + if ( $release ) { + delete_option( 'minimalcode_nl_claim_' . $week_id ); + minimalcode_newsletter_set_week_status( $week_id, $status ); + } else { + update_option( 'minimalcode_nl_claim_' . $week_id, 'sent', false ); + minimalcode_newsletter_set_week_status( $week_id, 'sent' ); + } + + minimalcode_newsletter_log_run( + array( + 'week_id' => $week_id, + 'date' => gmdate( 'c' ), + 'post_ids' => isset( $state['post_ids'] ) ? array_values( (array) $state['post_ids'] ) : array(), + 'recipient_count' => isset( $state['recipients'] ) ? count( (array) $state['recipients'] ) : 0, + 'sent_count' => $sent, + 'failure_count' => count( $failures ), + 'failures' => $failures, + 'status' => $status, + 'trigger' => isset( $state['trigger'] ) ? (string) $state['trigger'] : '', + ) + ); + delete_option( 'minimalcode_newsletter_active' ); +} + +/** + * Send the next slice. One failure does not stop the rest; HTTP 429 pauses the batch. + * + * @param string $week_id Week id. + * @return void + */ +function minimalcode_newsletter_process_batch( $week_id ) { + $week_id = (string) $week_id; + if ( minimalcode_newsletter_ci_blocked() ) { + return; + } + $state = get_option( 'minimalcode_newsletter_active' ); + if ( ! is_array( $state ) || (string) ( $state['week_id'] ?? '' ) !== $week_id || ! empty( $state['finished'] ) ) { + return; + } + if ( 'cron' === ( $state['trigger'] ?? '' ) && ! minimalcode_newsletter_settings()['enabled'] ) { + minimalcode_newsletter_finish( $state, 'cancelled' ); + return; + } + + $lock = (int) ( $state['lock'] ?? 0 ); + if ( $lock > time() - 90 ) { + return; + } + $state['lock'] = time(); + update_option( 'minimalcode_newsletter_active', $state, false ); + + $recipients = array_values( (array) $state['recipients'] ); + $offset = (int) $state['offset']; + $slice = array_slice( $recipients, $offset, minimalcode_newsletter_batch_size() ); + $delay = minimalcode_newsletter_send_delay_us(); + + foreach ( $slice as $index => $email ) { + $result = minimalcode_newsletter_deliver_one( $state, $email ); + if ( ! $result['ok'] && str_contains( $result['error'], 'HTTP 429' ) ) { + $state['lock'] = 0; + update_option( 'minimalcode_newsletter_active', $state, false ); + minimalcode_newsletter_schedule_single( time() + 120, 'minimalcode_newsletter_batch', array( $week_id ) ); + return; + } + if ( $result['ok'] ) { + $state['sent']++; + } else { + $state['failures'][] = array( + 'email' => (string) $email, + 'error' => $result['error'], + ); + } + $state['offset']++; + $state['lock'] = time(); + update_option( 'minimalcode_newsletter_active', $state, false ); + if ( $delay > 0 && $index < count( $slice ) - 1 ) { + usleep( $delay ); + } + } + + if ( (int) $state['offset'] >= count( $recipients ) ) { + minimalcode_newsletter_finish( $state ); + return; + } + + $state['lock'] = 0; + update_option( 'minimalcode_newsletter_active', $state, false ); + minimalcode_newsletter_schedule_single( time() + 45, 'minimalcode_newsletter_batch', array( $week_id ) ); +} +add_action( 'minimalcode_newsletter_batch', 'minimalcode_newsletter_process_batch' ); + +/** + * @param array $plan Test plan. + * @return array|WP_Error + */ +function minimalcode_newsletter_deliver_test( array $plan ) { + $email = $plan['recipients'][0] ?? ''; + $state = array( + 'week_id' => $plan['week_id'], + 'week_label' => $plan['week_label'], + 'post_ids' => $plan['post_ids'], + 'posts' => $plan['posts'], + 'test' => true, + 'sample' => ! empty( $plan['sample'] ), + ); + $result = minimalcode_newsletter_deliver_one( $state, $email ); + minimalcode_newsletter_log_run( + array( + 'week_id' => $plan['week_id'], + 'date' => gmdate( 'c' ), + 'post_ids' => $plan['post_ids'], + 'recipient_count' => 1, + 'sent_count' => $result['ok'] ? 1 : 0, + 'failure_count' => $result['ok'] ? 0 : 1, + 'failures' => $result['ok'] ? array() : array( array( 'email' => $email, 'error' => $result['error'] ) ), + 'status' => $result['ok'] ? 'test' : 'test_failed', + 'trigger' => 'test', + ) + ); + if ( ! $result['ok'] ) { + return new WP_Error( 'test_failed', $result['error'] ); + } + return array( + 'action' => 'test', + 'message' => 'Test issue sent to ' . $email . '.', + ); +} + +/** + * Start a cron send, a manual send, or a one-address test. + * + * @param string $trigger cron|manual|test. + * @param string $test_email Required for test. + * @return array|WP_Error + */ +function minimalcode_newsletter_begin( $trigger, $test_email = '' ) { + if ( minimalcode_newsletter_ci_blocked() ) { + return new WP_Error( 'skip_blocked', 'Real sends are disabled in CI and tests.' ); + } + $missing = minimalcode_newsletter_missing_config(); + if ( $missing ) { + return new WP_Error( 'config', 'Newsletter is not configured. Missing ' . implode( ', ', $missing ) . '.' ); + } + + $settings = minimalcode_newsletter_settings(); + if ( 'cron' === $trigger && ! $settings['enabled'] ) { + return new WP_Error( 'skip_disabled', 'Automatic weekly send is off.' ); + } + + $tz = wp_timezone(); + $now = new DateTimeImmutable( 'now', $tz ); + $week = minimalcode_newsletter_previous_week( $now, $tz ); + $posts = minimalcode_newsletter_collect_posts( $week['start'], $week['end'] ); + $status = minimalcode_newsletter_week_status( $week['week_id'] ); + $claim = get_option( 'minimalcode_nl_claim_' . $week['week_id'] ); + if ( $claim && 'failed' !== (string) $claim ) { + $status = '' !== $status ? $status : (string) $claim; + } + + $common = array( + 'trigger' => $trigger, + 'enabled' => $settings['enabled'], + 'sends_allowed' => true, + 'now' => $now, + 'timezone' => $tz, + 'posts' => $posts, + 'week_status' => $status, + 'test_email' => $test_email, + 'batch_size' => minimalcode_newsletter_batch_size(), + ); + + $pre = minimalcode_newsletter_plan( $common ); + if ( 'test' === $pre['action'] ) { + return minimalcode_newsletter_deliver_test( $pre ); + } + if ( in_array( $pre['action'], array( 'skip_disabled', 'skip_blocked', 'skip_duplicate', 'skip_empty', 'skip_invalid_test' ), true ) ) { + if ( 'skip_empty' === $pre['action'] ) { + minimalcode_newsletter_record_empty( $pre, $trigger ); + } + return new WP_Error( $pre['action'], $pre['reason'] ); + } + + $fetched = minimalcode_newsletter_fetch_subscribers(); + if ( is_wp_error( $fetched ) ) { + if ( ! minimalcode_newsletter_already_logged( $pre['week_id'], 'failed_fetch' ) ) { + minimalcode_newsletter_log_run( + array( + 'week_id' => $pre['week_id'], + 'date' => gmdate( 'c' ), + 'post_ids' => $pre['post_ids'], + 'recipient_count' => 0, + 'sent_count' => 0, + 'failure_count' => 1, + 'failures' => array( + array( + 'email' => '', + 'error' => $fetched->get_error_message(), + ), + ), + 'status' => 'failed_fetch', + 'trigger' => $trigger, + ) + ); + } + return $fetched; + } + + $plan = minimalcode_newsletter_plan( + array_merge( + $common, + array( + 'subscribers' => $fetched, + ) + ) + ); + if ( 'send' !== $plan['action'] ) { + if ( 'skip_no_recipients' === $plan['action'] && ! minimalcode_newsletter_already_logged( $plan['week_id'], 'skip_no_recipients' ) ) { + minimalcode_newsletter_log_run( + array( + 'week_id' => $plan['week_id'], + 'date' => gmdate( 'c' ), + 'post_ids' => $plan['post_ids'], + 'recipient_count' => 0, + 'sent_count' => 0, + 'failure_count' => 0, + 'failures' => array(), + 'status' => 'skip_no_recipients', + 'trigger' => $trigger, + ) + ); + } + return new WP_Error( $plan['action'], $plan['reason'] ); + } + + if ( ! add_option( 'minimalcode_nl_claim_' . $plan['week_id'], 'sending', '', false ) ) { + return new WP_Error( 'skip_duplicate', 'This week was already handled (sending).' ); + } + minimalcode_newsletter_set_week_status( $plan['week_id'], 'sending' ); + + $state = array( + 'week_id' => $plan['week_id'], + 'week_label' => $plan['week_label'], + 'post_ids' => $plan['post_ids'], + 'posts' => $plan['posts'], + 'recipients' => $plan['recipients'], + 'offset' => 0, + 'sent' => 0, + 'failures' => array(), + 'trigger' => $trigger, + 'started_at' => gmdate( 'c' ), + 'lock' => 0, + 'finished' => false, + 'test' => false, + 'sample' => false, + ); + update_option( 'minimalcode_newsletter_active', $state, false ); + minimalcode_newsletter_process_batch( $plan['week_id'] ); + + $active = get_option( 'minimalcode_newsletter_active' ); + if ( ! is_array( $active ) ) { + return array( + 'action' => 'send', + 'message' => 'Weekly issue handed to Resend.', + ); + } + $remaining = count( (array) $active['recipients'] ) - (int) $active['offset']; + return array( + 'action' => 'send', + 'message' => sprintf( + 'Sending started. %d sent so far, %d still queued. Further batches run on cron, about ten addresses at a time.', + (int) $active['sent'], + max( 0, $remaining ) + ), + ); +} + +/** + * Weekly cron callback. No-ops while the opt-in toggle is off. + * + * @return void + */ +function minimalcode_newsletter_on_weekly() { + if ( ! minimalcode_newsletter_settings()['enabled'] ) { + return; + } + minimalcode_newsletter_begin( 'cron' ); + minimalcode_newsletter_ensure_schedule(); +} +add_action( 'minimalcode_newsletter_weekly', 'minimalcode_newsletter_on_weekly' ); + +/** + * @return void + */ +function minimalcode_newsletter_register_route() { + add_rewrite_rule( '^newsletter/unsubscribe/?$', 'index.php?minimalcode_newsletter_unsub=1', 'top' ); +} +add_action( 'init', 'minimalcode_newsletter_register_route' ); + +/** + * @param array $vars Query vars. + * @return array + */ +function minimalcode_newsletter_query_var( $vars ) { + $vars[] = 'minimalcode_newsletter_unsub'; + return $vars; +} +add_filter( 'query_vars', 'minimalcode_newsletter_query_var' ); + +/** + * @return bool + */ +function minimalcode_newsletter_is_unsubscribe_request() { + if ( '1' === (string) get_query_var( 'minimalcode_newsletter_unsub' ) ) { + return true; + } + $path = trim( (string) wp_parse_url( (string) ( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_PATH ), '/' ); + return 'newsletter/unsubscribe' === $path; +} + +/** + * @param string|false $redirect_url Canonical target. + * @return string|false + */ +function minimalcode_newsletter_skip_canonical( $redirect_url ) { + if ( minimalcode_newsletter_is_unsubscribe_request() ) { + return false; + } + return $redirect_url; +} +add_filter( 'redirect_canonical', 'minimalcode_newsletter_skip_canonical' ); + +/** + * GET shows a confirmation page. POST is the one-click List-Unsubscribe action. + * + * @return void + */ +function minimalcode_newsletter_maybe_unsubscribe() { + if ( ! minimalcode_newsletter_is_unsubscribe_request() ) { + return; + } + + nocache_headers(); + header( 'Referrer-Policy: no-referrer' ); + header( 'X-Robots-Tag: noindex, nofollow' ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public token link, not a form. + $token = isset( $_REQUEST['token'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['token'] ) ) : ''; + $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( (string) $_SERVER['REQUEST_METHOD'] ) : 'GET'; + + if ( 'preview' === $token ) { + status_header( 200 ); + header( 'Content-Type: text/html; charset=utf-8' ); + echo minimalcode_newsletter_unsubscribe_document( 'Preview only', 'This link is from a preview. Nobody was unsubscribed.' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- builder escapes. + exit; + } + + $secret = defined( 'MINIMALCODE_NEWSLETTER_CONFIRM_SECRET' ) ? MINIMALCODE_NEWSLETTER_CONFIRM_SECRET : ''; + $result = minimalcode_newsletter_verify_token( $token, (string) $secret, time() ); + $ok = false; + $reason = $result['reason']; + if ( $result['valid'] ) { + $updated = minimalcode_newsletter_mark_unsubscribed( $result['email'] ); + $ok = true === $updated; + if ( ! $ok && is_wp_error( $updated ) ) { + $reason = 'resend'; + } + } + + if ( 'POST' === $method ) { + status_header( $ok ? 200 : 400 ); + header( 'Content-Type: text/plain; charset=utf-8' ); + exit; + } + + if ( $ok ) { + status_header( 200 ); + $heading = 'Unsubscribed'; + $message = "You won't get any more newsletter emails. Take care."; + } elseif ( 'expired' === $reason ) { + status_header( 400 ); + $heading = 'That link has expired'; + $message = 'Unsubscribe links last a year. Use the link in a newer issue, or reply and ask to be removed.'; + } else { + status_header( 400 ); + $heading = 'Invalid link'; + $message = 'We could not process that unsubscribe link.'; + } + header( 'Content-Type: text/html; charset=utf-8' ); + echo minimalcode_newsletter_unsubscribe_document( $heading, $message ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- builder escapes. + exit; +} +add_action( 'template_redirect', 'minimalcode_newsletter_maybe_unsubscribe', 0 ); + +/** + * @return void + */ +function minimalcode_newsletter_admin_menu() { + add_options_page( + __( 'Newsletter', 'minimalcode' ), + __( 'Newsletter', 'minimalcode' ), + 'manage_options', + 'minimalcode-newsletter', + 'minimalcode_newsletter_admin_page' + ); +} +add_action( 'admin_menu', 'minimalcode_newsletter_admin_menu' ); + +/** + * @param string $type success|error. + * @param string $text Message. + * @return void + */ +function minimalcode_newsletter_redirect_notice( $type, $text ) { + set_transient( + 'minimalcode_nl_notice_' . get_current_user_id(), + array( + 'type' => 'error' === $type ? 'error' : 'success', + 'text' => $text, + ), + 120 + ); + wp_safe_redirect( admin_url( 'options-general.php?page=minimalcode-newsletter' ) ); + exit; +} + +/** + * @return void + */ +function minimalcode_newsletter_guard_admin_post() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Sorry, you are not allowed to manage the newsletter.', 'minimalcode' ), '', array( 'response' => 403 ) ); + } +} + +/** + * @return void + */ +function minimalcode_newsletter_handle_save() { + minimalcode_newsletter_guard_admin_post(); + check_admin_referer( 'minimalcode_newsletter_save' ); + $settings = minimalcode_newsletter_save_settings( + array( + 'enabled' => ! empty( $_POST['enabled'] ), + 'weekday' => isset( $_POST['weekday'] ) ? (int) wp_unslash( $_POST['weekday'] ) : 1, + 'hour' => isset( $_POST['hour'] ) ? (int) wp_unslash( $_POST['hour'] ) : 8, + 'minute' => isset( $_POST['minute'] ) ? (int) wp_unslash( $_POST['minute'] ) : 0, + ) + ); + $text = $settings['enabled'] + ? __( 'Saved. Automatic weekly send is on. The list is not mailed until the next scheduled slot.', 'minimalcode' ) + : __( 'Saved. Automatic weekly send is off.', 'minimalcode' ); + minimalcode_newsletter_redirect_notice( 'success', $text ); +} +add_action( 'admin_post_minimalcode_newsletter_save', 'minimalcode_newsletter_handle_save' ); + +/** + * @return void + */ +function minimalcode_newsletter_handle_test() { + minimalcode_newsletter_guard_admin_post(); + check_admin_referer( 'minimalcode_newsletter_test' ); + $email = isset( $_POST['test_email'] ) ? sanitize_email( wp_unslash( $_POST['test_email'] ) ) : ''; + $result = minimalcode_newsletter_begin( 'test', $email ); + if ( is_wp_error( $result ) ) { + minimalcode_newsletter_redirect_notice( 'error', $result->get_error_message() ); + } + minimalcode_newsletter_redirect_notice( 'success', (string) $result['message'] ); +} +add_action( 'admin_post_minimalcode_newsletter_test', 'minimalcode_newsletter_handle_test' ); + +/** + * @return void + */ +function minimalcode_newsletter_handle_send() { + minimalcode_newsletter_guard_admin_post(); + check_admin_referer( 'minimalcode_newsletter_send' ); + $confirm = isset( $_POST['confirm_send'] ) ? sanitize_text_field( wp_unslash( $_POST['confirm_send'] ) ) : ''; + if ( 'yes' !== $confirm ) { + minimalcode_newsletter_redirect_notice( 'error', __( 'Send was not confirmed.', 'minimalcode' ) ); + } + $result = minimalcode_newsletter_begin( 'manual' ); + if ( is_wp_error( $result ) ) { + minimalcode_newsletter_redirect_notice( 'error', $result->get_error_message() ); + } + minimalcode_newsletter_redirect_notice( 'success', (string) $result['message'] ); +} +add_action( 'admin_post_minimalcode_newsletter_send', 'minimalcode_newsletter_handle_send' ); + +/** + * Browser preview of the issue for the previous week. Sample layout if it's empty. + * + * @return void + */ +function minimalcode_newsletter_maybe_preview() { + if ( ! is_admin() || ! isset( $_GET['page'] ) || 'minimalcode-newsletter' !== $_GET['page'] || empty( $_GET['preview'] ) ) { + return; + } + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Sorry, you are not allowed to preview the newsletter.', 'minimalcode' ), '', array( 'response' => 403 ) ); + } + check_admin_referer( 'minimalcode_newsletter_preview' ); + + $tz = wp_timezone(); + $now = new DateTimeImmutable( 'now', $tz ); + $week = minimalcode_newsletter_previous_week( $now, $tz ); + $posts = minimalcode_newsletter_collect_posts( $week['start'], $week['end'] ); + $sample = false; + if ( ! $posts ) { + $posts = minimalcode_newsletter_sample_posts(); + $sample = true; + } + $message = minimalcode_newsletter_message( + array( + 'posts' => $posts, + 'unsubscribe_url' => home_url( '/newsletter/unsubscribe/?token=preview' ), + 'site_url' => home_url( '/' ), + 'site_name' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), + 'week_label' => $week['start']->format( 'j M Y' ), + 'sample' => $sample, + 'portrait_url' => get_template_directory_uri() . '/assets/images/autojack-profile.jpg', + ) + ); + nocache_headers(); + header( 'Content-Type: text/html; charset=utf-8' ); + echo $message['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- renderer escapes. + exit; +} +add_action( 'admin_init', 'minimalcode_newsletter_maybe_preview' ); + +/** + * @param int $weekday ISO weekday. + * @return string + */ +function minimalcode_newsletter_weekday_label( $weekday ) { + $labels = array( + 1 => __( 'Monday', 'minimalcode' ), + 2 => __( 'Tuesday', 'minimalcode' ), + 3 => __( 'Wednesday', 'minimalcode' ), + 4 => __( 'Thursday', 'minimalcode' ), + 5 => __( 'Friday', 'minimalcode' ), + 6 => __( 'Saturday', 'minimalcode' ), + 7 => __( 'Sunday', 'minimalcode' ), + ); + return $labels[ (int) $weekday ] ?? $labels[1]; +} + +/** + * Settings screen. + * + * @return void + */ +function minimalcode_newsletter_admin_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + $settings = minimalcode_newsletter_settings(); + $missing = minimalcode_newsletter_missing_config(); + $tz = wp_timezone(); + $now = new DateTimeImmutable( 'now', $tz ); + $week = minimalcode_newsletter_previous_week( $now, $tz ); + $posts = minimalcode_newsletter_collect_posts( $week['start'], $week['end'] ); + $status = minimalcode_newsletter_week_status( $week['week_id'] ); + $next = minimalcode_newsletter_next_scheduled( 'minimalcode_newsletter_weekly' ); + $notice = get_transient( 'minimalcode_nl_notice_' . get_current_user_id() ); + if ( $notice ) { + delete_transient( 'minimalcode_nl_notice_' . get_current_user_id() ); + } + $log = get_option( 'minimalcode_newsletter_log', array() ); + $preview = wp_nonce_url( admin_url( 'options-general.php?page=minimalcode-newsletter&preview=1' ), 'minimalcode_newsletter_preview' ); + $engine = minimalcode_newsletter_using_action_scheduler() ? 'Action Scheduler' : 'WP-Cron'; + $can_send = ! $missing && $posts && ! in_array( $status, array( 'sent', 'skipped_empty', 'sending' ), true ); + ?> +
+

+ +

+ + +

+

+

+ + +

+ +

+ + +

+
+ + + + + + + + + + + + + +
+

+ +

+

+ +

+

*/5 * * * * curl -s >/dev/null 2>&1

+ +

+

+ format( 'j M Y H:i' ), + $week['end']->format( 'j M Y H:i' ), + $tz->getName() + ) + ); + ?> + + + +

+ +
    + +
  • + +
+ +

+ +

+ +

+

+
+ + + + +
+ +

+

+
+ + + + 'disabled' ) ); ?> +
+ +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
    + + +
  • + +
+ +
+ + +

+
wp minimalcode newsletter status
+wp minimalcode newsletter preview
+wp minimalcode newsletter test you@example.com
+wp minimalcode newsletter send --yes
+wp minimalcode newsletter enable
+wp minimalcode newsletter disable
+
+ + * : status, preview, test, send, enable, or disable. + * + * [] + * : Recipient for `test`. + * + * [--yes] + * : Skip the confirmation prompt for `send`. + * + * ## EXAMPLES + * + * wp minimalcode newsletter status + * wp minimalcode newsletter preview + * wp minimalcode newsletter test you@example.com + * wp minimalcode newsletter send --yes + * + * @param array $args Positional args. + * @param array $assoc Assoc args. + * @return void + */ + public function __invoke( $args, $assoc ) { + $command = isset( $args[0] ) ? (string) $args[0] : 'status'; + switch ( $command ) { + case 'status': + $this->status(); + return; + case 'preview': + $this->preview(); + return; + case 'test': + $email = isset( $args[1] ) ? (string) $args[1] : ''; + $result = minimalcode_newsletter_begin( 'test', $email ); + if ( is_wp_error( $result ) ) { + WP_CLI::error( $result->get_error_message() ); + } + WP_CLI::success( (string) $result['message'] ); + return; + case 'send': + WP_CLI::confirm( 'Send this week’s issue to every confirmed subscriber?', $assoc ); + $result = minimalcode_newsletter_begin( 'manual' ); + if ( is_wp_error( $result ) ) { + WP_CLI::error( $result->get_error_message() ); + } + WP_CLI::success( (string) $result['message'] ); + return; + case 'enable': + $settings = minimalcode_newsletter_settings(); + $settings['enabled'] = true; + minimalcode_newsletter_save_settings( $settings ); + WP_CLI::success( 'Automatic weekly send is on. The list is not mailed until the next slot.' ); + return; + case 'disable': + $settings = minimalcode_newsletter_settings(); + $settings['enabled'] = false; + minimalcode_newsletter_save_settings( $settings ); + WP_CLI::success( 'Automatic weekly send is off.' ); + return; + default: + WP_CLI::error( 'Unknown command. Use status, preview, test, send, enable, or disable.' ); + } + } + + /** + * @return void + */ + private function status() { + $settings = minimalcode_newsletter_settings(); + $next = minimalcode_newsletter_next_scheduled( 'minimalcode_newsletter_weekly' ); + $missing = minimalcode_newsletter_missing_config(); + WP_CLI::log( 'enabled: ' . ( $settings['enabled'] ? 'yes' : 'no' ) ); + WP_CLI::log( 'slot: ' . minimalcode_newsletter_weekday_label( $settings['weekday'] ) . ' ' . sprintf( '%02d:%02d', $settings['hour'], $settings['minute'] ) . ' ' . wp_timezone_string() ); + WP_CLI::log( 'scheduler: ' . ( minimalcode_newsletter_using_action_scheduler() ? 'action-scheduler' : 'wp-cron' ) ); + WP_CLI::log( 'next: ' . ( $next ? wp_date( 'c', $next ) : 'none' ) ); + WP_CLI::log( 'configured: ' . ( $missing ? 'missing ' . implode( ', ', $missing ) : 'yes' ) ); + WP_CLI::log( 'ci_blocked: ' . ( minimalcode_newsletter_ci_blocked() ? 'yes' : 'no' ) ); + } + + /** + * @return void + */ + private function preview() { + $tz = wp_timezone(); + $now = new DateTimeImmutable( 'now', $tz ); + $week = minimalcode_newsletter_previous_week( $now, $tz ); + $posts = minimalcode_newsletter_collect_posts( $week['start'], $week['end'] ); + $sample = false; + if ( ! $posts ) { + $posts = minimalcode_newsletter_sample_posts(); + $sample = true; + } + $message = minimalcode_newsletter_message( + array( + 'posts' => $posts, + 'unsubscribe_url' => home_url( '/newsletter/unsubscribe/?token=preview' ), + 'site_url' => home_url( '/' ), + 'site_name' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), + 'week_label' => $week['start']->format( 'j M Y' ), + 'sample' => $sample, + ) + ); + WP_CLI::log( $message['html'] ); + } + } + + WP_CLI::add_command( 'minimalcode newsletter', 'Minimalcode_Newsletter_CLI' ); +} diff --git a/inc/newsletter.php b/inc/newsletter.php index 24e02b8..9a0c99b 100644 --- a/inc/newsletter.php +++ b/inc/newsletter.php @@ -2,6 +2,11 @@ /** * AutoJack newsletter signup form (posts to news.autojack.ai Worker). * + * The Worker (Cloudflare `autojack-newsletter`, Very Good Plugins account) + * stores contacts in a Resend audience and sends the confirm + welcome mail. + * Weekly issues are composed and sent by inc/newsletter-weekly.php. Automatic + * sending is off until Settings → Newsletter enables it. + * * Live previously injected the mid-post box via Fluent Snippets (ajn-box). * That plugin is not on local — the theme now owns the markup. * diff --git a/tests/newsletter-weekly.php b/tests/newsletter-weekly.php new file mode 100644 index 0000000..85fdced --- /dev/null +++ b/tests/newsletter-weekly.php @@ -0,0 +1,287 @@ + 9, + 'hour' => 25, + 'minute' => -4, + ) +); +minimalcode_newsletter_assert( false === $sanitized['enabled'], 'missing enabled stays off' ); +minimalcode_newsletter_assert( 7 === $sanitized['weekday'] && 23 === $sanitized['hour'] && 0 === $sanitized['minute'], 'out of range slot is clamped' ); + +$berlin = new DateTimeZone( 'Europe/Berlin' ); +$monday = new DateTimeImmutable( '2026-11-09 08:00:00', $berlin ); +$week = minimalcode_newsletter_previous_week( $monday, $berlin ); +minimalcode_newsletter_assert( '2026-11-02' === $week['week_id'], 'Monday morning week id got ' . $week['week_id'] ); +minimalcode_newsletter_assert( '2026-11-02 00:00:00' === $week['start']->format( 'Y-m-d H:i:s' ), 'week start got ' . $week['start']->format( 'c' ) ); +minimalcode_newsletter_assert( '2026-11-08 23:59:59' === $week['end']->format( 'Y-m-d H:i:s' ), 'week end got ' . $week['end']->format( 'c' ) ); + +$sunday = new DateTimeImmutable( '2026-11-08 23:00:00', $berlin ); +$prior = minimalcode_newsletter_previous_week( $sunday, $berlin ); +minimalcode_newsletter_assert( '2026-10-26' === $prior['week_id'], 'Sunday still uses the previous complete week, got ' . $prior['week_id'] ); + +$early = new DateTimeImmutable( '2026-11-09 07:00:00', $berlin ); +$slot = minimalcode_newsletter_next_slot( $early, $berlin, 1, 8, 0 ); +minimalcode_newsletter_assert( '2026-11-09 08:00:00' === $slot->format( 'Y-m-d H:i:s' ), 'before the slot stays on today, got ' . $slot->format( 'c' ) ); + +$exact = minimalcode_newsletter_next_slot( $monday, $berlin, 1, 8, 0 ); +minimalcode_newsletter_assert( '2026-11-16 08:00:00' === $exact->format( 'Y-m-d H:i:s' ), 'at the slot rolls to next week, got ' . $exact->format( 'c' ) ); + +$wednesday = new DateTimeImmutable( '2026-11-11 12:00:00', $berlin ); +$next_mon = minimalcode_newsletter_next_slot( $wednesday, $berlin, 1, 8, 0 ); +minimalcode_newsletter_assert( '2026-11-16 08:00:00' === $next_mon->format( 'Y-m-d H:i:s' ), 'Wednesday targets next Monday, got ' . $next_mon->format( 'c' ) ); + +$secret = 'test-secret-value!'; +$token = minimalcode_newsletter_sign_token( 'Reader@Example.com', $secret, 100000000, 1700000000 ); +minimalcode_newsletter_assert( + 'eyJlIjoicmVhZGVyQGV4YW1wbGUuY29tIiwieCI6MTgwMDAwMDAwMH0.K45WsP1nvIiOUPU9AJhmPtlfGdaOWRSOT-UhMq7zHrM' === $token, + 'token does not match the worker HMAC vector: ' . $token +); +$verified = minimalcode_newsletter_verify_token( $token, $secret, 1700000000 ); +minimalcode_newsletter_assert( true === $verified['valid'] && 'reader@example.com' === $verified['email'], 'token did not verify' ); +$tampered = minimalcode_newsletter_verify_token( $token . 'x', $secret, 1700000000 ); +minimalcode_newsletter_assert( false === $tampered['valid'], 'tampered token was accepted' ); +$expired = minimalcode_newsletter_verify_token( $token, $secret, 1800000001 ); +minimalcode_newsletter_assert( false === $expired['valid'] && 'expired' === $expired['reason'], 'expired token reason got ' . $expired['reason'] ); +minimalcode_newsletter_assert( '' === minimalcode_newsletter_sign_token( 'reader@example.com', 'short', 3600, 1700000000 ), 'short secret must not sign' ); + +$confirmed = minimalcode_newsletter_confirmed_subscribers( + array( + array( 'email' => 'Yes@Example.com', 'unsubscribed' => false ), + array( 'email' => 'yes@example.com', 'unsubscribed' => false ), + array( 'email' => 'pending@example.com', 'unsubscribed' => true ), + array( 'email' => 'left@example.com', 'unsubscribed' => true ), + array( 'email' => 'missing@example.com' ), + array( 'email' => 'stringfalse@example.com', 'unsubscribed' => 'false' ), + array( 'email' => 'zero@example.com', 'unsubscribed' => 0 ), + array( 'email' => 'not-an-email', 'unsubscribed' => false ), + 'nope', + ) +); +minimalcode_newsletter_assert( array( 'yes@example.com' ) === $confirmed, 'confirmed filter got ' . implode( ',', $confirmed ) ); + +$posts = array( + array( + 'id' => 12, + 'title' => 'Claude-local: Qwen in the window', + 'excerpt' => 'Jack kept Claude Code and changed what answers it.', + 'url' => 'https://drunk.support/claude-local/', + 'date' => '8 Nov 2026', + 'category' => 'autojack', + ), + array( + 'id' => 13, + 'title' => '', + 'excerpt' => 'A second note.', + 'url' => 'https://drunk.support/second/', + 'date' => '6 Nov 2026', + ), +); + +$plan_off = minimalcode_newsletter_plan( + array( + 'trigger' => 'cron', + 'enabled' => false, + 'sends_allowed' => true, + 'now' => $monday, + 'timezone' => $berlin, + 'posts' => $posts, + 'subscribers' => array( + array( 'email' => 'yes@example.com', 'unsubscribed' => false ), + ), + ) +); +minimalcode_newsletter_assert( 'skip_disabled' === $plan_off['action'], 'disabled cron action got ' . $plan_off['action'] ); +minimalcode_newsletter_assert( array() === $plan_off['recipients'], 'disabled cron must not select recipients' ); + +$plan_manual = minimalcode_newsletter_plan( + array( + 'trigger' => 'manual', + 'enabled' => false, + 'sends_allowed' => true, + 'now' => $monday, + 'timezone' => $berlin, + 'posts' => $posts, + 'subscribers' => array( + array( 'email' => 'yes@example.com', 'unsubscribed' => false ), + array( 'email' => 'pending@example.com', 'unsubscribed' => true ), + ), + 'batch_size' => 10, + ) +); +minimalcode_newsletter_assert( 'send' === $plan_manual['action'], 'manual send while automatic is off got ' . $plan_manual['action'] ); +minimalcode_newsletter_assert( array( 'yes@example.com' ) === $plan_manual['recipients'], 'manual send included a non-confirmed address' ); +minimalcode_newsletter_assert( '2026-11-02' === $plan_manual['week_id'], 'manual week id got ' . $plan_manual['week_id'] ); + +$plan_empty = minimalcode_newsletter_plan( + array( + 'trigger' => 'cron', + 'enabled' => true, + 'sends_allowed' => true, + 'now' => $monday, + 'timezone' => $berlin, + 'posts' => array(), + 'subscribers' => array( + array( 'email' => 'yes@example.com', 'unsubscribed' => false ), + ), + ) +); +minimalcode_newsletter_assert( 'skip_empty' === $plan_empty['action'], 'empty week got ' . $plan_empty['action'] ); +minimalcode_newsletter_assert( array() === $plan_empty['recipients'], 'empty week must not select recipients' ); + +$plan_dup = minimalcode_newsletter_plan( + array( + 'trigger' => 'cron', + 'enabled' => true, + 'sends_allowed' => true, + 'now' => $monday, + 'timezone' => $berlin, + 'posts' => $posts, + 'week_status' => 'sent', + 'subscribers' => array( + array( 'email' => 'yes@example.com', 'unsubscribed' => false ), + ), + ) +); +minimalcode_newsletter_assert( 'skip_duplicate' === $plan_dup['action'], 'second send got ' . $plan_dup['action'] ); + +$many = array(); +for ( $i = 0; $i < 12; $i++ ) { + $many[] = array( + 'email' => 'reader' . $i . '@example.com', + 'unsubscribed' => false, + ); +} +$plan_batch = minimalcode_newsletter_plan( + array( + 'trigger' => 'cron', + 'enabled' => true, + 'sends_allowed' => true, + 'now' => $monday, + 'timezone' => $berlin, + 'posts' => array( $posts[0] ), + 'subscribers' => $many, + 'batch_size' => 5, + ) +); +minimalcode_newsletter_assert( 'send' === $plan_batch['action'], 'batch plan got ' . $plan_batch['action'] ); +minimalcode_newsletter_assert( 3 === count( $plan_batch['batches'] ), 'expected 3 batches, got ' . count( $plan_batch['batches'] ) ); +minimalcode_newsletter_assert( 5 === count( $plan_batch['batches'][0] ) && 2 === count( $plan_batch['batches'][2] ), 'batch sizes were wrong' ); + +$plan_ci = minimalcode_newsletter_plan( + array( + 'trigger' => 'cron', + 'enabled' => true, + 'sends_allowed' => false, + 'now' => $monday, + 'timezone' => $berlin, + 'posts' => $posts, + 'subscribers' => $many, + ) +); +minimalcode_newsletter_assert( 'skip_blocked' === $plan_ci['action'], 'CI cron got ' . $plan_ci['action'] ); +minimalcode_newsletter_assert( array() === $plan_ci['recipients'], 'CI plan selected recipients' ); + +$plan_ci_test = minimalcode_newsletter_plan( + array( + 'trigger' => 'test', + 'enabled' => true, + 'sends_allowed' => false, + 'now' => $monday, + 'timezone' => $berlin, + 'test_email' => 'qa@example.com', + 'posts' => $posts, + ) +); +minimalcode_newsletter_assert( 'skip_blocked' === $plan_ci_test['action'], 'CI test send got ' . $plan_ci_test['action'] ); + +minimalcode_newsletter_assert( false === minimalcode_newsletter_real_send_allowed( array( 'GITHUB_ACTIONS' => 'true' ), false ), 'GitHub Actions must block sends' ); +minimalcode_newsletter_assert( false === minimalcode_newsletter_real_send_allowed( array( 'CI' => 'true' ), false ), 'CI must block sends' ); +minimalcode_newsletter_assert( true === minimalcode_newsletter_real_send_allowed( array( 'CI' => 'true' ), true ), 'explicit allow overrides CI' ); +minimalcode_newsletter_assert( true === minimalcode_newsletter_real_send_allowed( array(), false ), 'a normal server may send' ); + +$key_a = minimalcode_newsletter_idempotency_key( '2026-11-02', 'Yes@Example.com' ); +$key_b = minimalcode_newsletter_idempotency_key( '2026-11-02', 'yes@example.com' ); +minimalcode_newsletter_assert( $key_a === $key_b && str_starts_with( $key_a, 'weekly/2026-11-02/' ), 'idempotency key was not stable' ); + +$unsub = 'https://drunk.support/newsletter/unsubscribe/?token=' . $token; +$html = minimalcode_newsletter_render_html( + array( + 'posts' => $posts, + 'unsubscribe_url' => $unsub, + 'week_label' => '2 Nov 2026', + 'site_url' => 'https://drunk.support/', + 'site_name' => 'drunk.support', + ) +); +$text = minimalcode_newsletter_render_text( + array( + 'posts' => $posts, + 'unsubscribe_url' => $unsub, + 'week_label' => '2 Nov 2026', + ) +); +$message = minimalcode_newsletter_message( + array( + 'posts' => $posts, + 'unsubscribe_url' => $unsub, + 'week_label' => '2 Nov 2026', + ) +); + +minimalcode_newsletter_assert( str_contains( $html, 'Claude-local: Qwen in the window' ), 'html missing title' ); +minimalcode_newsletter_assert( str_contains( $html, 'Jack kept Claude Code and changed what answers it.' ), 'html missing excerpt' ); +minimalcode_newsletter_assert( str_contains( $html, 'https://drunk.support/claude-local/' ), 'html missing link' ); +minimalcode_newsletter_assert( str_contains( $html, 'Unsubscribe' ), 'html missing unsubscribe link' ); +minimalcode_newsletter_assert( str_contains( $html, 'AutoJack' ) && str_contains( $html, 'written and sent by an AI' ), 'html footer does not match the welcome email' ); +minimalcode_newsletter_assert( str_contains( $html, 'drunk.support' ), 'html missing masthead' ); +minimalcode_newsletter_assert( str_contains( $html, '<script>alert(1)</script>' ), 'html did not escape the title' ); +minimalcode_newsletter_assert( ! str_contains( $html, $secret ), 'html leaked the signing secret' ); +minimalcode_newsletter_assert( str_contains( $text, 'Claude-local: Qwen in the window' ), 'text missing title' ); +minimalcode_newsletter_assert( str_contains( $text, 'https://drunk.support/claude-local/' ), 'text missing link' ); +minimalcode_newsletter_assert( str_contains( $text, 'Unsubscribe: ' . $unsub ), 'text missing unsubscribe' ); +minimalcode_newsletter_assert( '' === $message['headers']['List-Unsubscribe'], 'List-Unsubscribe header mismatch' ); +minimalcode_newsletter_assert( 'List-Unsubscribe=One-Click' === $message['headers']['List-Unsubscribe-Post'], 'List-Unsubscribe-Post missing' ); +minimalcode_newsletter_assert( str_starts_with( $message['subject'], 'This week on drunk.support' ), 'subject got ' . $message['subject'] ); + +$unsafe = minimalcode_newsletter_message( + array( + 'posts' => array( $posts[0] ), + 'unsubscribe_url' => 'javascript:alert(1)', + ) +); +minimalcode_newsletter_assert( array() === $unsafe['headers'], 'javascript unsubscribe url produced a header' ); +minimalcode_newsletter_assert( ! str_contains( $unsafe['html'], 'javascript:' ), 'javascript url was rendered' ); + +if ( $failures ) { + fwrite( STDERR, implode( PHP_EOL, $failures ) . PHP_EOL ); + exit( 1 ); +} + +echo "Weekly newsletter planner ok.\n"; diff --git a/tests/theme-design-static.php b/tests/theme-design-static.php index d2c9359..07af068 100644 --- a/tests/theme-design-static.php +++ b/tests/theme-design-static.php @@ -13,7 +13,10 @@ 'index.php' => array( 'layout wrap', 'lede', 'entry-author', 'entry-excerpt', 'month-rule' ), 'single.php' => array( 'single wrap', 'post-meta-rail', 'post-headline', 'aj-banner' ), 'assets/css/custom.css' => array( '--paper', '--hot', '.ticker', '.masthead', '.entry-author', '.newsletter-signup' ), - 'inc/newsletter.php' => array( 'minimalcode_newsletter_form', 'ajn-box', 'news.autojack.ai', 'Get AutoJack in your inbox' ), + 'inc/newsletter.php' => array( 'minimalcode_newsletter_form', 'ajn-box', 'news.autojack.ai', 'Get AutoJack in your inbox' ), + 'inc/newsletter-digest.php' => array( "'enabled' => false", 'List-Unsubscribe', 'minimalcode_newsletter_plan', 'Automatic weekly send is off' ), + 'inc/newsletter-weekly.php' => array( 'wp_schedule_single_event', 'minimalcode_newsletter_on_weekly', 'MINIMALCODE_RESEND_API_KEY', 'minimalcode_newsletter_ci_blocked' ), + 'functions.php' => array( 'newsletter-weekly.php' ), 'inc/comment-webhook.php' => array( 'minimalcode_comment_webhook_delivered', 'minimalcode_autojack_user_id', 'minimalcode_is_autojack' ), );