From e473943c9825465b83011c549f4672f190db24d0 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 00:31:20 +0000 Subject: [PATCH 1/5] feat(send-email): add template registry via datamachine_email_templates filter Adds optional 'template' and 'context' inputs to datamachine/send-email. When 'template' is set, the body is resolved via the datamachine_email_templates filter ([ template_id => callable( array $context ): string ]); the rendered string is then run through the existing placeholder replacement so templates may emit {site_name}, {date}, etc. Unknown template ids return a structured error with a log entry. Backwards compatible: omitting 'template' preserves verbatim 'body' behavior. The schema now requires only 'to' and 'subject'; execute() enforces that one of 'body' or 'template' is provided. --- inc/Abilities/Publish/SendEmailAbility.php | 93 ++++++++++++++++++++-- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/inc/Abilities/Publish/SendEmailAbility.php b/inc/Abilities/Publish/SendEmailAbility.php index 6b697faa9..d2c5d2dc6 100644 --- a/inc/Abilities/Publish/SendEmailAbility.php +++ b/inc/Abilities/Publish/SendEmailAbility.php @@ -38,11 +38,14 @@ private function registerAbilities(): void { 'datamachine/send-email', array( 'label' => __( 'Send Email', 'data-machine' ), - 'description' => __( 'Send an email with optional attachments via wp_mail()', 'data-machine' ), + 'description' => __( 'Send an email with optional attachments via wp_mail(). Body can be supplied directly or rendered from a registered template via the datamachine_email_templates filter.', 'data-machine' ), 'category' => 'datamachine-publishing', 'input_schema' => array( 'type' => 'object', - 'required' => array( 'to', 'subject', 'body' ), + // Either `body` or `template` must be provided. Both branches + // are validated in execute(); the schema marks only `to` and + // `subject` as universally required. + 'required' => array( 'to', 'subject' ), 'properties' => array( 'to' => array( 'type' => 'string', @@ -60,11 +63,22 @@ private function registerAbilities(): void { ), 'subject' => array( 'type' => 'string', - 'description' => __( 'Email subject line. Supports {month}, {year}, {site_name}, {date} placeholders.', 'data-machine' ), + 'description' => __( 'Email subject line. Supports {month}, {year}, {site_name}, {date}, {admin_email} placeholders (replaced after template render).', 'data-machine' ), ), 'body' => array( 'type' => 'string', - 'description' => __( 'Email body content (HTML or plain text)', 'data-machine' ), + 'default' => '', + 'description' => __( 'Email body content (HTML or plain text). Used verbatim when `template` is omitted. Supports {month}, {year}, {site_name}, {date}, {admin_email} placeholders (replaced after template render).', 'data-machine' ), + ), + 'template' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Optional template id. Resolved via the datamachine_email_templates filter; the resolved callable receives the `context` array and returns the body. Placeholder replacement runs AFTER template render so templates may emit {site_name}, {date}, etc.', 'data-machine' ), + ), + 'context' => array( + 'type' => 'object', + 'default' => array(), + 'description' => __( 'Opaque context object passed to the template renderer. Ignored when `template` is omitted.', 'data-machine' ), ), 'content_type' => array( 'type' => 'string', @@ -158,6 +172,66 @@ public function execute( array $input ): array { ); } + // 1b. Resolve template (if provided) → body. Runs BEFORE placeholder + // replacement so templates may themselves emit {site_name}, {date}, etc. + if ( '' !== $config['template'] ) { + $templates = apply_filters( 'datamachine_email_templates', array() ); + if ( ! is_array( $templates ) || ! isset( $templates[ $config['template'] ] ) || ! is_callable( $templates[ $config['template'] ] ) ) { + $error = sprintf( 'Unknown email template: %s', $config['template'] ); + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: ' . $error, + 'data' => array( + 'template' => $config['template'], + 'registered_templates' => is_array( $templates ) ? array_keys( $templates ) : array(), + ), + ); + return array( + 'success' => false, + 'error' => $error, + 'logs' => $logs, + ); + } + + $rendered = call_user_func( $templates[ $config['template'] ], (array) $config['context'] ); + if ( ! is_string( $rendered ) ) { + $error = sprintf( 'Email template renderer did not return a string: %s', $config['template'] ); + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: ' . $error, + 'data' => array( + 'template' => $config['template'], + 'return_type' => gettype( $rendered ), + ), + ); + return array( + 'success' => false, + 'error' => $error, + 'logs' => $logs, + ); + } + + $config['body'] = $rendered; + $logs[] = array( + 'level' => 'debug', + 'message' => 'Email: Template rendered', + 'data' => array( + 'template' => $config['template'], + 'body_length' => strlen( $rendered ), + ), + ); + } elseif ( '' === (string) $config['body'] ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: No body provided and no template specified', + ); + return array( + 'success' => false, + 'error' => 'Either `body` or `template` must be provided.', + 'logs' => $logs, + ); + } + // 2. Build headers. $headers = array(); @@ -289,6 +363,8 @@ private function normalizeConfig( array $input ): array { 'bcc' => '', 'subject' => '', 'body' => '', + 'template' => '', + 'context' => array(), 'content_type' => 'text/html', 'from_name' => '', 'from_email' => '', @@ -296,7 +372,14 @@ private function normalizeConfig( array $input ): array { 'attachments' => array(), ); - return array_merge( $defaults, $input ); + $merged = array_merge( $defaults, $input ); + + // Normalize types defensively — REST/JSON callers may pass nulls. + $merged['template'] = is_string( $merged['template'] ) ? trim( $merged['template'] ) : ''; + $merged['context'] = is_array( $merged['context'] ) ? $merged['context'] : array(); + $merged['body'] = is_string( $merged['body'] ) ? $merged['body'] : ''; + + return $merged; } /** From 7294ccd8927c191dc3459dd30f4acf098d203994 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 00:32:32 +0000 Subject: [PATCH 2/5] feat(send-email-queued): add queued email variant with Action Scheduler worker Adds datamachine/send-email-queued ability that defers delivery to Action Scheduler under the datamachine-email group. Same input schema as datamachine/send-email plus optional send_at (ISO 8601 or unix ts) and priority (default 10). - Omitting send_at uses as_enqueue_async_action for immediate async dispatch. - Providing send_at uses as_schedule_single_action at that timestamp. - Worker hook 'datamachine_send_email_worker' is registered on init so Action Scheduler can dispatch it outside the originating request. - Worker calls the synchronous datamachine/send-email ability, logs the structured result, and re-enqueues itself with exponential backoff (60s, 300s, 1500s) up to 3 total attempts on failure. - Internal _attempt counter rides in the worker payload and is not part of the public input_schema. Output: { success, action_id, scheduled_for, error, logs }. Wired up in data-machine.php alongside SendEmailAbility. --- data-machine.php | 2 + .../Publish/SendEmailQueuedAbility.php | 409 ++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 inc/Abilities/Publish/SendEmailQueuedAbility.php diff --git a/data-machine.php b/data-machine.php index 7beff901e..ef03b7a53 100644 --- a/data-machine.php +++ b/data-machine.php @@ -216,6 +216,7 @@ function () { require_once __DIR__ . '/inc/Abilities/Fetch/QueryWordPressPostsAbility.php'; require_once __DIR__ . '/inc/Abilities/Publish/PublishWordPressAbility.php'; require_once __DIR__ . '/inc/Abilities/Publish/SendEmailAbility.php'; + require_once __DIR__ . '/inc/Abilities/Publish/SendEmailQueuedAbility.php'; require_once __DIR__ . '/inc/Abilities/Update/UpdateWordPressAbility.php'; require_once __DIR__ . '/inc/Abilities/Handler/TestHandlerAbility.php'; // Register ability hooks immediately during plugins_loaded. @@ -327,6 +328,7 @@ function () { new \DataMachine\Abilities\Fetch\QueryWordPressPostsAbility(); new \DataMachine\Abilities\Publish\PublishWordPressAbility(); new \DataMachine\Abilities\Publish\SendEmailAbility(); + new \DataMachine\Abilities\Publish\SendEmailQueuedAbility(); new \DataMachine\Abilities\Update\UpdateWordPressAbility(); new \DataMachine\Abilities\Handler\TestHandlerAbility(); diff --git a/inc/Abilities/Publish/SendEmailQueuedAbility.php b/inc/Abilities/Publish/SendEmailQueuedAbility.php new file mode 100644 index 000000000..b9ea96d74 --- /dev/null +++ b/inc/Abilities/Publish/SendEmailQueuedAbility.php @@ -0,0 +1,409 @@ +registerAbility(); + $this->registerWorkerHook(); + self::$registered = true; + } + + private function registerAbility(): void { + $register_callback = function () { + wp_register_ability( + 'datamachine/send-email-queued', + array( + 'label' => __( 'Send Email (Queued)', 'data-machine' ), + 'description' => __( 'Queue an email for delivery via Action Scheduler. Same input as datamachine/send-email, plus optional send_at and priority. Retries with exponential backoff up to 3 attempts.', 'data-machine' ), + 'category' => 'datamachine-publishing', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'to', 'subject' ), + 'properties' => array( + 'to' => array( + 'type' => 'string', + 'description' => __( 'Comma-separated recipient email addresses', 'data-machine' ), + ), + 'cc' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Comma-separated CC addresses', 'data-machine' ), + ), + 'bcc' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Comma-separated BCC addresses', 'data-machine' ), + ), + 'subject' => array( + 'type' => 'string', + 'description' => __( 'Email subject line. Supports placeholders (see datamachine/send-email).', 'data-machine' ), + ), + 'body' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Email body content. Used verbatim when `template` is omitted.', 'data-machine' ), + ), + 'template' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Optional template id resolved via datamachine_email_templates.', 'data-machine' ), + ), + 'context' => array( + 'type' => 'object', + 'default' => array(), + 'description' => __( 'Opaque context object passed to the template renderer.', 'data-machine' ), + ), + 'content_type' => array( + 'type' => 'string', + 'default' => 'text/html', + 'description' => __( 'Content type: text/html or text/plain', 'data-machine' ), + ), + 'from_name' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Sender name. Falls back to site name.', 'data-machine' ), + ), + 'from_email' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Sender email. Falls back to admin email.', 'data-machine' ), + ), + 'reply_to' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Reply-to email address', 'data-machine' ), + ), + 'attachments' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'default' => array(), + 'description' => __( 'Array of server file paths to attach', 'data-machine' ), + ), + 'send_at' => array( + 'type' => array( 'string', 'integer' ), + 'description' => __( 'Optional ISO 8601 timestamp or unix timestamp. Omit for "send now (async)".', 'data-machine' ), + ), + 'priority' => array( + 'type' => 'integer', + 'default' => 10, + 'description' => __( 'Action Scheduler priority hint (default 10).', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'action_id' => array( 'type' => 'integer' ), + 'scheduled_for' => array( 'type' => 'integer' ), + 'error' => array( 'type' => 'string' ), + 'logs' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( $this, 'execute' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + ), + ) + ); + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Register the worker hook on init so Action Scheduler can dispatch it + * outside the original request lifecycle. + */ + private function registerWorkerHook(): void { + $register = function (): void { + add_action( self::WORKER_HOOK, array( __CLASS__, 'runWorker' ), 10, 1 ); + }; + + if ( did_action( 'init' ) ) { + $register(); + } else { + add_action( 'init', $register ); + } + } + + /** + * Permission callback for ability. + * + * @return bool True if user has permission. + */ + public function checkPermission(): bool { + return PermissionHelper::can_manage(); + } + + /** + * Execute queued email send. + * + * @param array $input Input parameters. + * @return array Result with success/action_id/scheduled_for/error/logs. + */ + public function execute( array $input ): array { + $logs = array(); + + if ( ! function_exists( 'as_enqueue_async_action' ) || ! function_exists( 'as_schedule_single_action' ) ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email queue: Action Scheduler not available', + ); + return array( + 'success' => false, + 'error' => 'Action Scheduler not available.', + 'logs' => $logs, + ); + } + + // Strip queue-only fields from the payload that gets handed to the + // synchronous ability. send_at/priority are scheduler concerns. + $send_at_raw = $input['send_at'] ?? null; + $priority = isset( $input['priority'] ) ? (int) $input['priority'] : 10; + unset( $input['send_at'], $input['priority'] ); + + // Worker payload carries an internal _attempt counter. Not exposed in + // the public input_schema; defaults to 1 on first enqueue. + $payload = $input; + $payload['_attempt'] = 1; + + $timestamp = $this->normalizeSendAt( $send_at_raw ); + + if ( null === $timestamp ) { + // Async "send now". + $action_id = as_enqueue_async_action( self::WORKER_HOOK, array( $payload ), self::GROUP ); + $scheduled_for = time(); + } else { + $action_id = as_schedule_single_action( $timestamp, self::WORKER_HOOK, array( $payload ), self::GROUP ); + $scheduled_for = $timestamp; + } + + if ( ! $action_id ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email queue: Action Scheduler returned no action id', + 'data' => array( + 'send_at_raw' => $send_at_raw, + 'priority' => $priority, + ), + ); + return array( + 'success' => false, + 'error' => 'Failed to enqueue email.', + 'logs' => $logs, + ); + } + + $logs[] = array( + 'level' => 'info', + 'message' => null === $timestamp + ? 'Email queue: Enqueued async send' + : 'Email queue: Scheduled send at ' . gmdate( 'c', $scheduled_for ), + 'data' => array( + 'action_id' => (int) $action_id, + 'scheduled_for' => $scheduled_for, + 'priority' => $priority, + ), + ); + + return array( + 'success' => true, + 'action_id' => (int) $action_id, + 'scheduled_for' => $scheduled_for, + 'logs' => $logs, + ); + } + + /** + * Normalize send_at into a unix timestamp or null for "send now". + * + * @param mixed $send_at Raw input (string|int|null). + * @return int|null Unix timestamp or null. + */ + private function normalizeSendAt( $send_at ): ?int { + if ( null === $send_at || '' === $send_at ) { + return null; + } + + if ( is_int( $send_at ) ) { + return $send_at; + } + + if ( is_string( $send_at ) ) { + // Numeric strings (and @-prefixed) are unix timestamps. + if ( ctype_digit( ltrim( $send_at, '@' ) ) ) { + return (int) ltrim( $send_at, '@' ); + } + + $parsed = strtotime( $send_at ); + if ( false !== $parsed ) { + return $parsed; + } + } + + return null; + } + + /** + * Worker callback invoked by Action Scheduler. + * + * Calls the synchronous datamachine/send-email ability, logs the result, + * and re-enqueues itself with exponential backoff on failure. Hard caps + * at MAX_ATTEMPTS total attempts. + * + * @param array $payload Worker payload (includes _attempt counter). + */ + public static function runWorker( $payload ): void { + if ( ! is_array( $payload ) ) { + do_action( + 'datamachine_log', + 'error', + 'Email queue worker: invalid payload', + array( 'payload_type' => gettype( $payload ) ) + ); + return; + } + + $attempt = isset( $payload['_attempt'] ) ? max( 1, (int) $payload['_attempt'] ) : 1; + $send = $payload; + unset( $send['_attempt'] ); + + if ( ! function_exists( 'wp_get_ability' ) ) { + do_action( + 'datamachine_log', + 'error', + 'Email queue worker: wp_get_ability() not available', + array( 'attempt' => $attempt ) + ); + return; + } + + $ability = wp_get_ability( 'datamachine/send-email' ); + if ( ! $ability ) { + do_action( + 'datamachine_log', + 'error', + 'Email queue worker: datamachine/send-email ability not registered', + array( 'attempt' => $attempt ) + ); + return; + } + + $result = $ability->execute( $send ); + + // Log the structured result for downstream observability. + do_action( + 'datamachine_log', + ( is_array( $result ) && ! empty( $result['success'] ) ) ? 'info' : 'error', + 'Email queue worker: send attempt complete', + array( + 'attempt' => $attempt, + 'success' => is_array( $result ) ? (bool) ( $result['success'] ?? false ) : false, + 'result' => $result, + ) + ); + + $succeeded = is_array( $result ) && ! empty( $result['success'] ); + + if ( $succeeded ) { + return; + } + + if ( $attempt >= self::MAX_ATTEMPTS ) { + do_action( + 'datamachine_log', + 'error', + 'Email queue worker: giving up after max attempts', + array( + 'attempt' => $attempt, + 'max_attempts' => self::MAX_ATTEMPTS, + 'last_error' => is_array( $result ) ? ( $result['error'] ?? '' ) : '', + ) + ); + return; + } + + // Exponential backoff: 60s, 300s, 1500s, ... (60 * 5^(attempt-1)). + $delay = 60 * (int) pow( 5, $attempt - 1 ); + $retry_at = time() + $delay; + $send['_attempt'] = $attempt + 1; + + if ( ! function_exists( 'as_schedule_single_action' ) ) { + do_action( + 'datamachine_log', + 'error', + 'Email queue worker: cannot reschedule, Action Scheduler missing', + array( 'attempt' => $attempt ) + ); + return; + } + + $action_id = as_schedule_single_action( $retry_at, self::WORKER_HOOK, array( $send ), self::GROUP ); + + do_action( + 'datamachine_log', + 'warning', + 'Email queue worker: rescheduled with exponential backoff', + array( + 'attempt_just_completed' => $attempt, + 'next_attempt' => $attempt + 1, + 'retry_at' => $retry_at, + 'delay_seconds' => $delay, + 'action_id' => (int) $action_id, + ) + ); + } +} From 632cf4a0384cea6a3956cbae56b07768936af2a1 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 00:33:01 +0000 Subject: [PATCH 3/5] feat(cli): add wp datamachine email send-queued subcommand Mirrors the existing 'wp datamachine email send' flag surface and adds: - --template / --context for template-rendered bodies - --send-at for one-shot scheduled delivery (ISO 8601 or @unix) - --priority for Action Scheduler priority hint Delegates to datamachine/send-email-queued ability and reports the returned action id + scheduled time. --- inc/Cli/Commands/EmailCommand.php | 117 ++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/inc/Cli/Commands/EmailCommand.php b/inc/Cli/Commands/EmailCommand.php index 54015f02e..de7e2a5ed 100644 --- a/inc/Cli/Commands/EmailCommand.php +++ b/inc/Cli/Commands/EmailCommand.php @@ -100,6 +100,123 @@ public function send( array $args, array $assoc_args ): void { } } + /** + * Queue an email for delivery via Action Scheduler. + * + * ## OPTIONS + * + * --to= + * : Comma-separated recipient email addresses. + * + * --subject= + * : Email subject. Supports {month}, {year}, {site_name}, {date} placeholders. + * + * [--body=] + * : Email body content (HTML or plain text). Required unless --template is provided. + * + * [--template=] + * : Optional template id resolved via the datamachine_email_templates filter. + * + * [--context=] + * : JSON-encoded context object passed to the template renderer. + * + * [--cc=] + * : Comma-separated CC addresses. + * + * [--bcc=] + * : Comma-separated BCC addresses. + * + * [--from-name=] + * : Sender name. Defaults to site name. + * + * [--from-email=] + * : Sender email. Defaults to admin email. + * + * [--reply-to=] + * : Reply-to address. + * + * [--content-type=] + * : Content type: text/html or text/plain. + * --- + * default: text/html + * --- + * + * [--attachments=] + * : Comma-separated file paths to attach. + * + * [--send-at=] + * : ISO 8601 timestamp or unix timestamp (prefix with @). Omit for "send now (async)". + * + * [--priority=] + * : Action Scheduler priority hint. + * --- + * default: 10 + * --- + * + * ## EXAMPLES + * + * wp datamachine email send-queued --to=user@example.com --subject="Hi" --body="

Hello

" + * wp datamachine email send-queued --to=user@example.com --subject="Hi" --template=weekly-digest --context='{"week":42}' + * wp datamachine email send-queued --to=user@example.com --subject="Later" --body="Hi" --send-at="2030-01-01T09:00:00Z" + * + * @subcommand send-queued + */ + public function send_queued( array $args, array $assoc_args ): void { + $ability = wp_get_ability( 'datamachine/send-email-queued' ); + if ( ! $ability ) { + WP_CLI::error( 'Send email queued ability not available.' ); + } + + $context = array(); + if ( ! empty( $assoc_args['context'] ) ) { + $decoded = json_decode( $assoc_args['context'], true ); + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { + WP_CLI::error( 'Invalid --context JSON: ' . json_last_error_msg() ); + } + $context = $decoded; + } + + $input = array( + 'to' => $assoc_args['to'], + 'subject' => $assoc_args['subject'], + 'body' => $assoc_args['body'] ?? '', + 'template' => $assoc_args['template'] ?? '', + 'context' => $context, + 'cc' => $assoc_args['cc'] ?? '', + 'bcc' => $assoc_args['bcc'] ?? '', + 'from_name' => $assoc_args['from-name'] ?? '', + 'from_email' => $assoc_args['from-email'] ?? '', + 'reply_to' => $assoc_args['reply-to'] ?? '', + 'content_type' => $assoc_args['content-type'] ?? 'text/html', + 'attachments' => array(), + 'priority' => isset( $assoc_args['priority'] ) ? (int) $assoc_args['priority'] : 10, + ); + + if ( ! empty( $assoc_args['attachments'] ) ) { + $input['attachments'] = array_map( 'trim', explode( ',', $assoc_args['attachments'] ) ); + } + + if ( isset( $assoc_args['send-at'] ) && '' !== $assoc_args['send-at'] ) { + $input['send_at'] = $assoc_args['send-at']; + } + + $result = $ability->execute( $input ); + + if ( is_wp_error( $result ) ) { + WP_CLI::error( $result->get_error_message() ); + } + + if ( ! ( $result['success'] ?? false ) ) { + WP_CLI::error( $result['error'] ?? 'Email enqueue failed.' ); + } + + $action_id = (int) ( $result['action_id'] ?? 0 ); + $scheduled_for = (int) ( $result['scheduled_for'] ?? 0 ); + $when = $scheduled_for ? gmdate( 'c', $scheduled_for ) : 'now'; + + WP_CLI::success( sprintf( 'Email queued. Action id: %d. Scheduled for: %s', $action_id, $when ) ); + } + /** * Fetch emails from IMAP inbox. * From 3dcca1eabf4319ed7ca67572b2aad6da3a7a6691 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 00:35:21 +0000 Subject: [PATCH 4/5] test: add smoke test for email template render and queued send Covers: - Template render runs before placeholder replacement (template-emitted {site_name} and {date} are resolved on the post-render pass) - Unknown template id returns structured error with no wp_mail() call - Missing body AND missing template returns structured error - Backwards-compat: raw body path still works with placeholder resolution - Queued send-now uses as_enqueue_async_action under the datamachine-email group with _attempt=1 payload (send_at/priority stripped) - Queued with send_at uses as_schedule_single_action at the resolved ts - Worker invokes synchronous datamachine/send-email and does not retry on success - Worker reschedules with exponential backoff (60s baseline) on failure and caps at MAX_ATTEMPTS total attempts Adds a small PermissionHelper stub fixture so the abilities autoload cleanly outside the full plugin bootstrap. Run with: php tests/email-template-and-queued-smoke.php --- tests/email-template-and-queued-smoke.php | 438 ++++++++++++++++++++++ tests/fixtures/permission-helper-stub.php | 22 ++ 2 files changed, 460 insertions(+) create mode 100644 tests/email-template-and-queued-smoke.php create mode 100644 tests/fixtures/permission-helper-stub.php diff --git a/tests/email-template-and-queued-smoke.php b/tests/email-template-and-queued-smoke.php new file mode 100644 index 000000000..4170fa8f2 --- /dev/null +++ b/tests/email-template-and-queued-smoke.php @@ -0,0 +1,438 @@ + $to, + 'subject' => $subject, + 'body' => $body, + 'headers' => $headers, + 'attachments' => $attachments, + ); + return $GLOBALS['dm_email_smoke_wp_mail_return']; + } +} + +if ( ! function_exists( 'get_bloginfo' ) ) { + function get_bloginfo( string $key ) { + return 'Smoke Site'; + } +} + +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $key, $default = false ) { + if ( 'date_format' === $key ) { + return 'Y-m-d'; + } + if ( 'admin_email' === $key ) { + return 'admin@example.com'; + } + return $default; + } +} + +if ( ! function_exists( 'wp_date' ) ) { + function wp_date( string $format, ?int $timestamp = null ): string { + return gmdate( $format, $timestamp ?? time() ); + } +} + +// Action Scheduler stubs (queue capture). +if ( ! function_exists( 'as_enqueue_async_action' ) ) { + function as_enqueue_async_action( string $hook, array $args = array(), string $group = '' ): int { + $id = ++$GLOBALS['dm_email_smoke_action_id_seq']; + $GLOBALS['dm_email_smoke_async'][] = array( + 'id' => $id, + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + ); + return $id; + } +} + +if ( ! function_exists( 'as_schedule_single_action' ) ) { + function as_schedule_single_action( int $timestamp, string $hook, array $args = array(), string $group = '' ): int { + $id = ++$GLOBALS['dm_email_smoke_action_id_seq']; + $GLOBALS['dm_email_smoke_scheduled'][] = array( + 'id' => $id, + 'timestamp' => $timestamp, + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + ); + return $id; + } +} + +// wp_get_ability stub: returns an object whose execute() proxies to a +// configurable closure stack. Used to verify the worker calls the sync ability. +if ( ! function_exists( 'wp_get_ability' ) ) { + function wp_get_ability( string $name ): ?object { + if ( 'datamachine/send-email' !== $name ) { + return null; + } + + return new class() { + public function execute( array $input ): array { + $GLOBALS['dm_email_smoke_ability_calls'][] = $input; + if ( ! empty( $GLOBALS['dm_email_smoke_ability_returns'] ) ) { + return array_shift( $GLOBALS['dm_email_smoke_ability_returns'] ); + } + return array( 'success' => true, 'logs' => array() ); + } + }; + } +} + +// --------------------------------------------------------------------------- +// Permission stub (the abilities call PermissionHelper::can_manage()) and +// the abilities under test. +// --------------------------------------------------------------------------- + +require_once __DIR__ . '/fixtures/permission-helper-stub.php'; +require_once __DIR__ . '/../inc/Abilities/Publish/SendEmailAbility.php'; +require_once __DIR__ . '/../inc/Abilities/Publish/SendEmailQueuedAbility.php'; + +use DataMachine\Abilities\Publish\SendEmailAbility; +use DataMachine\Abilities\Publish\SendEmailQueuedAbility; + +function dm_assert( bool $condition, string $message ): void { + if ( $condition ) { + echo " [PASS] {$message}\n"; + return; + } + echo " [FAIL] {$message}\n"; + exit( 1 ); +} + +function dm_email_smoke_reset(): void { + $GLOBALS['dm_email_smoke_filters'] = array(); + $GLOBALS['dm_email_smoke_actions'] = array(); + $GLOBALS['dm_email_smoke_async'] = array(); + $GLOBALS['dm_email_smoke_scheduled'] = array(); + $GLOBALS['dm_email_smoke_logs'] = array(); + $GLOBALS['dm_email_smoke_wp_mail_calls'] = array(); + $GLOBALS['dm_email_smoke_wp_mail_return'] = true; + $GLOBALS['dm_email_smoke_ability_calls'] = array(); + $GLOBALS['dm_email_smoke_ability_returns'] = array(); +} + +echo "=== email-template-and-queued-smoke ===\n"; + +// --------------------------------------------------------------------------- +// [1] Template render runs BEFORE placeholder replacement. +// --------------------------------------------------------------------------- +echo "\n[1] template render + placeholder replacement\n"; +dm_email_smoke_reset(); + +add_filter( 'datamachine_email_templates', static function ( array $templates ): array { + $templates['smoke-template'] = static function ( array $context ): string { + $name = isset( $context['name'] ) ? (string) $context['name'] : 'friend'; + // Template emits {site_name} and {date} — placeholder pass must resolve them. + return "

Hello {$name} from {site_name} on {date}

"; + }; + return $templates; +} ); + +$ability = new SendEmailAbility(); +$result = $ability->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Smoke {site_name}', + 'template' => 'smoke-template', + 'context' => array( 'name' => 'Chris' ), +) ); + +dm_assert( true === ( $result['success'] ?? false ), 'template render path returns success' ); +dm_assert( 1 === count( $GLOBALS['dm_email_smoke_wp_mail_calls'] ), 'wp_mail() invoked exactly once' ); +$call = $GLOBALS['dm_email_smoke_wp_mail_calls'][0]; +dm_assert( false !== strpos( $call['body'], 'Hello Chris' ), 'template context interpolated into body' ); +dm_assert( false !== strpos( $call['body'], 'Smoke Site' ), 'template-emitted {site_name} resolved after render' ); +dm_assert( false === strpos( $call['body'], '{site_name}' ), 'no unresolved {site_name} placeholder remains' ); +dm_assert( false === strpos( $call['body'], '{date}' ), 'no unresolved {date} placeholder remains' ); +dm_assert( false !== strpos( $call['subject'], 'Smoke Site' ), 'subject placeholder still works' ); + +// --------------------------------------------------------------------------- +// [2] Unknown template id → structured error, no wp_mail invocation. +// --------------------------------------------------------------------------- +echo "\n[2] unknown template id returns structured error\n"; +dm_email_smoke_reset(); + +$ability = new SendEmailAbility(); +$result = $ability->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Hi', + 'template' => 'does-not-exist', +) ); + +dm_assert( false === ( $result['success'] ?? null ), 'unknown template id returns success=false' ); +dm_assert( ! empty( $result['error'] ), 'unknown template id returns an error message' ); +dm_assert( false !== strpos( (string) $result['error'], 'does-not-exist' ), 'error names the missing template id' ); +dm_assert( 0 === count( $GLOBALS['dm_email_smoke_wp_mail_calls'] ), 'wp_mail() NOT invoked when template missing' ); + +// --------------------------------------------------------------------------- +// [3] Missing body AND missing template → structured error. +// --------------------------------------------------------------------------- +echo "\n[3] missing body+template returns structured error\n"; +dm_email_smoke_reset(); + +$ability = new SendEmailAbility(); +$result = $ability->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Hi', +) ); + +dm_assert( false === ( $result['success'] ?? null ), 'missing body+template returns success=false' ); +dm_assert( false !== strpos( (string) ( $result['error'] ?? '' ), 'body' ), 'error mentions body' ); +dm_assert( 0 === count( $GLOBALS['dm_email_smoke_wp_mail_calls'] ), 'wp_mail() NOT invoked when neither provided' ); + +// --------------------------------------------------------------------------- +// [4] Backwards-compat: omitting template uses body verbatim. +// --------------------------------------------------------------------------- +echo "\n[4] backwards-compat: raw body still works\n"; +dm_email_smoke_reset(); + +$ability = new SendEmailAbility(); +$result = $ability->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Hi', + 'body' => '

Plain body for {site_name}

', +) ); + +dm_assert( true === ( $result['success'] ?? false ), 'raw body path returns success' ); +$call = $GLOBALS['dm_email_smoke_wp_mail_calls'][0]; +dm_assert( false !== strpos( $call['body'], 'Plain body for Smoke Site' ), 'raw body placeholders resolved' ); + +// --------------------------------------------------------------------------- +// [5] Queued "send-now" uses as_enqueue_async_action with _attempt=1. +// --------------------------------------------------------------------------- +echo "\n[5] queued send-now enqueues async\n"; +dm_email_smoke_reset(); + +$queued = new SendEmailQueuedAbility(); +$result = $queued->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Async hi', + 'body' => '

Hi

', +) ); + +dm_assert( true === ( $result['success'] ?? false ), 'queued send-now returns success' ); +dm_assert( 1 === count( $GLOBALS['dm_email_smoke_async'] ), 'exactly one async action enqueued' ); +$async = $GLOBALS['dm_email_smoke_async'][0]; +dm_assert( SendEmailQueuedAbility::WORKER_HOOK === $async['hook'], 'enqueued under worker hook' ); +dm_assert( SendEmailQueuedAbility::GROUP === $async['group'], 'enqueued under datamachine-email group' ); +dm_assert( isset( $async['args'][0]['_attempt'] ) && 1 === $async['args'][0]['_attempt'], 'payload carries _attempt=1' ); +dm_assert( ! isset( $async['args'][0]['send_at'] ), 'send_at stripped from worker payload' ); +dm_assert( ! isset( $async['args'][0]['priority'] ), 'priority stripped from worker payload' ); +dm_assert( 0 === count( $GLOBALS['dm_email_smoke_scheduled'] ), 'no scheduled action created for send-now' ); + +// --------------------------------------------------------------------------- +// [6] Queued with send_at uses as_schedule_single_action. +// --------------------------------------------------------------------------- +echo "\n[6] queued with send_at schedules at timestamp\n"; +dm_email_smoke_reset(); + +$future = time() + 3600; +$queued = new SendEmailQueuedAbility(); +$result = $queued->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Later', + 'body' => '

Later

', + 'send_at' => gmdate( 'c', $future ), +) ); + +dm_assert( true === ( $result['success'] ?? false ), 'scheduled send returns success' ); +dm_assert( 1 === count( $GLOBALS['dm_email_smoke_scheduled'] ), 'exactly one scheduled action' ); +dm_assert( 0 === count( $GLOBALS['dm_email_smoke_async'] ), 'no async action for scheduled send' ); +$scheduled = $GLOBALS['dm_email_smoke_scheduled'][0]; +dm_assert( $future === $scheduled['timestamp'], 'scheduled at exact requested timestamp' ); +dm_assert( SendEmailQueuedAbility::WORKER_HOOK === $scheduled['hook'], 'scheduled under worker hook' ); +dm_assert( SendEmailQueuedAbility::GROUP === $scheduled['group'], 'scheduled under datamachine-email group' ); +dm_assert( $future === ( $result['scheduled_for'] ?? 0 ), 'result.scheduled_for matches' ); + +// --------------------------------------------------------------------------- +// [7] Worker invokes synchronous ability and does NOT retry on success. +// --------------------------------------------------------------------------- +echo "\n[7] worker invokes sync ability and stops on success\n"; +dm_email_smoke_reset(); + +$GLOBALS['dm_email_smoke_ability_returns'] = array( + array( 'success' => true, 'logs' => array() ), +); + +SendEmailQueuedAbility::runWorker( array( + 'to' => 'user@example.com', + 'subject' => 'Hi', + 'body' => '

Hi

', + '_attempt' => 1, +) ); + +dm_assert( 1 === count( $GLOBALS['dm_email_smoke_ability_calls'] ), 'sync ability called exactly once' ); +$ability_input = $GLOBALS['dm_email_smoke_ability_calls'][0]; +dm_assert( ! isset( $ability_input['_attempt'] ), '_attempt stripped before invoking sync ability' ); +dm_assert( 'user@example.com' === ( $ability_input['to'] ?? '' ), 'payload forwarded to sync ability' ); +dm_assert( 0 === count( $GLOBALS['dm_email_smoke_scheduled'] ), 'no retry scheduled on success' ); + +// --------------------------------------------------------------------------- +// [8] Worker re-enqueues with exponential backoff on failure, up to cap. +// --------------------------------------------------------------------------- +echo "\n[8] worker retries with backoff, caps at MAX_ATTEMPTS\n"; +dm_email_smoke_reset(); + +// First attempt fails — worker should reschedule with _attempt=2. +$GLOBALS['dm_email_smoke_ability_returns'] = array( + array( 'success' => false, 'error' => 'smtp boom', 'logs' => array() ), +); + +$before_retry = time(); +SendEmailQueuedAbility::runWorker( array( + 'to' => 'user@example.com', + 'subject' => 'Hi', + 'body' => '

Hi

', + '_attempt' => 1, +) ); + +dm_assert( 1 === count( $GLOBALS['dm_email_smoke_scheduled'] ), 'failure reschedules worker' ); +$retry = $GLOBALS['dm_email_smoke_scheduled'][0]; +dm_assert( SendEmailQueuedAbility::WORKER_HOOK === $retry['hook'], 'retry hook matches worker' ); +dm_assert( 2 === $retry['args'][0]['_attempt'], 'retry payload has _attempt=2' ); +dm_assert( $retry['timestamp'] >= $before_retry + 60, 'first retry delayed at least 60s' ); +dm_assert( $retry['timestamp'] <= $before_retry + 70, 'first retry delay matches 60s baseline' ); + +// Final attempt fails — worker must NOT reschedule beyond MAX_ATTEMPTS. +dm_email_smoke_reset(); +$GLOBALS['dm_email_smoke_ability_returns'] = array( + array( 'success' => false, 'error' => 'final boom', 'logs' => array() ), +); + +SendEmailQueuedAbility::runWorker( array( + 'to' => 'user@example.com', + 'subject' => 'Hi', + 'body' => '

Hi

', + '_attempt' => SendEmailQueuedAbility::MAX_ATTEMPTS, +) ); + +dm_assert( 1 === count( $GLOBALS['dm_email_smoke_ability_calls'] ), 'sync ability invoked on final attempt' ); +dm_assert( 0 === count( $GLOBALS['dm_email_smoke_scheduled'] ), 'no further retry past MAX_ATTEMPTS' ); + +echo "\nAll email template and queued smoke assertions passed.\n"; diff --git a/tests/fixtures/permission-helper-stub.php b/tests/fixtures/permission-helper-stub.php new file mode 100644 index 000000000..dbcfdd6fe --- /dev/null +++ b/tests/fixtures/permission-helper-stub.php @@ -0,0 +1,22 @@ + Date: Mon, 18 May 2026 02:09:10 +0000 Subject: [PATCH 5/5] style: phpcs alignment fixes for send-email template + queued --- inc/Abilities/Publish/SendEmailAbility.php | 2 +- inc/Abilities/Publish/SendEmailQueuedAbility.php | 10 +++++----- tests/email-template-and-queued-smoke.php | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/inc/Abilities/Publish/SendEmailAbility.php b/inc/Abilities/Publish/SendEmailAbility.php index d2c5d2dc6..b56e4ac8b 100644 --- a/inc/Abilities/Publish/SendEmailAbility.php +++ b/inc/Abilities/Publish/SendEmailAbility.php @@ -182,7 +182,7 @@ public function execute( array $input ): array { 'level' => 'error', 'message' => 'Email: ' . $error, 'data' => array( - 'template' => $config['template'], + 'template' => $config['template'], 'registered_templates' => is_array( $templates ) ? array_keys( $templates ) : array(), ), ); diff --git a/inc/Abilities/Publish/SendEmailQueuedAbility.php b/inc/Abilities/Publish/SendEmailQueuedAbility.php index b9ea96d74..36dc8f93c 100644 --- a/inc/Abilities/Publish/SendEmailQueuedAbility.php +++ b/inc/Abilities/Publish/SendEmailQueuedAbility.php @@ -217,8 +217,8 @@ public function execute( array $input ): array { // Worker payload carries an internal _attempt counter. Not exposed in // the public input_schema; defaults to 1 on first enqueue. - $payload = $input; - $payload['_attempt'] = 1; + $payload = $input; + $payload['_attempt'] = 1; $timestamp = $this->normalizeSendAt( $send_at_raw ); @@ -377,9 +377,9 @@ public static function runWorker( $payload ): void { } // Exponential backoff: 60s, 300s, 1500s, ... (60 * 5^(attempt-1)). - $delay = 60 * (int) pow( 5, $attempt - 1 ); - $retry_at = time() + $delay; - $send['_attempt'] = $attempt + 1; + $delay = 60 * (int) pow( 5, $attempt - 1 ); + $retry_at = time() + $delay; + $send['_attempt'] = $attempt + 1; if ( ! function_exists( 'as_schedule_single_action' ) ) { do_action( diff --git a/tests/email-template-and-queued-smoke.php b/tests/email-template-and-queued-smoke.php index 4170fa8f2..e45a0220e 100644 --- a/tests/email-template-and-queued-smoke.php +++ b/tests/email-template-and-queued-smoke.php @@ -140,14 +140,14 @@ function get_bloginfo( string $key ) { } if ( ! function_exists( 'get_option' ) ) { - function get_option( string $key, $default = false ) { + function get_option( string $key, $default_value = false ) { if ( 'date_format' === $key ) { return 'Y-m-d'; } if ( 'admin_email' === $key ) { return 'admin@example.com'; } - return $default; + return $default_value; } }