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/SendEmailAbility.php b/inc/Abilities/Publish/SendEmailAbility.php index 6b697faa9..c08dcba0c 100644 --- a/inc/Abilities/Publish/SendEmailAbility.php +++ b/inc/Abilities/Publish/SendEmailAbility.php @@ -4,12 +4,19 @@ * * Abilities API primitive for sending emails via wp_mail(). * Centralizes email composition, header building, attachment validation, - * and placeholder replacement. + * placeholder replacement, optional template rendering via the + * `datamachine_email_templates` filter, and optional per-site SMTP routing + * via `switch_to_blog()`. * * This is the bottom layer — pure business logic, no handler config, * no engine data, no pipeline context. Any caller (REST, CLI, chat tool, * pipeline handler) can invoke this directly. * + * Placeholder ordering: template render runs FIRST, then placeholder + * replacement runs on the rendered body. Templates may therefore emit + * `{site_name}`, `{date}`, etc. and have them resolved by the standard + * replacement pass. + * * @package DataMachine\Abilities\Publish */ @@ -38,11 +45,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 may be supplied directly or rendered from a template registered via the datamachine_email_templates filter. Optionally routes the wp_mail() call through a specific site via switch_to_blog() on multisite.', 'data-machine' ), 'category' => 'datamachine-publishing', 'input_schema' => array( 'type' => 'object', - 'required' => array( 'to', 'subject', 'body' ), + // `body` is no longer hard-required: callers may supply `template` instead. + // Validation is enforced in execute() so existing callers passing `body` + // continue to work unchanged. + 'required' => array( 'to', 'subject' ), 'properties' => array( 'to' => array( 'type' => 'string', @@ -60,11 +70,27 @@ 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. Placeholders are resolved 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). Ignored when `template` is supplied. Supports {month}, {year}, {site_name}, {date}, {admin_email} placeholders.', 'data-machine' ), + ), + 'template' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Optional template id resolved via the datamachine_email_templates filter. When set, the registered callable receives `context` and its return value is used as the body before placeholder replacement. When empty, `body` is used verbatim.', 'data-machine' ), + ), + 'context' => array( + 'type' => 'object', + 'default' => array(), + 'description' => __( 'Opaque context array passed to the template callable. Each template owns its own context contract.', 'data-machine' ), + ), + 'mail_site_id' => array( + 'type' => 'integer', + 'default' => 0, + 'description' => __( 'Optional multisite blog id. When > 0 and multisite is active, the wp_mail() call is wrapped in switch_to_blog()/restore_current_blog() so site-scoped SMTP config applies. Validation, header building, and template rendering run in the original site context.', 'data-machine' ), ), 'content_type' => array( 'type' => 'string', @@ -200,11 +226,92 @@ public function execute( array $input ): array { } } - // 3. Process subject placeholders. - $subject = $this->replacePlaceholders( $config['subject'] ); + // 3. Resolve body — template (if any) renders before placeholder replacement. + $template_id = is_string( $config['template'] ) ? trim( $config['template'] ) : ''; + $body_source = $config['body']; + + if ( '' !== $template_id ) { + // Apply the filter lazily inside execute() so consumers can hook at any + // priority before the first call. Shape: [ id => callable( array $context ): string ]. + $templates = apply_filters( 'datamachine_email_templates', array() ); + + if ( ! is_array( $templates ) || ! isset( $templates[ $template_id ] ) || ! is_callable( $templates[ $template_id ] ) ) { + $error = sprintf( 'Unknown email template: %s', $template_id ); + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: ' . $error, + 'data' => array( + 'template' => $template_id, + 'registered_templates' => is_array( $templates ) ? array_keys( $templates ) : array(), + ), + ); + return array( + 'success' => false, + 'error' => $error, + 'logs' => $logs, + ); + } - // 4. Process body placeholders. - $body = $this->replacePlaceholders( $config['body'] ); + $context = is_array( $config['context'] ) ? $config['context'] : array(); + + try { + $rendered = call_user_func( $templates[ $template_id ], $context ); + } catch ( \Throwable $e ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: Template render threw - ' . $e->getMessage(), + 'data' => array( 'template' => $template_id ), + ); + return array( + 'success' => false, + 'error' => 'Template render failed: ' . $e->getMessage(), + 'logs' => $logs, + ); + } + + if ( ! is_string( $rendered ) ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: Template did not return a string', + 'data' => array( + 'template' => $template_id, + 'returned_type' => gettype( $rendered ), + ), + ); + return array( + 'success' => false, + 'error' => sprintf( 'Template "%s" did not return a string', $template_id ), + 'logs' => $logs, + ); + } + + $body_source = $rendered; + + $logs[] = array( + 'level' => 'debug', + 'message' => 'Email: Template rendered', + 'data' => array( + 'template' => $template_id, + 'body_length' => strlen( $body_source ), + ), + ); + } elseif ( '' === trim( (string) $body_source ) ) { + // No template and no body — nothing to send. + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: Neither `body` nor `template` provided', + ); + return array( + 'success' => false, + 'error' => 'Either `body` or `template` is required', + 'logs' => $logs, + ); + } + + // 4. Process subject + body placeholders. Runs AFTER template render so + // templates can emit placeholders too. + $subject = $this->replacePlaceholders( $config['subject'] ); + $body = $this->replacePlaceholders( $body_source ); // 5. Validate attachments exist. $attachments = array(); @@ -227,6 +334,41 @@ public function execute( array $input ): array { } } + // 6. Resolve optional per-site SMTP routing. + $mail_site_id = (int) $config['mail_site_id']; + $should_switch = false; + + if ( $mail_site_id > 0 ) { + if ( ! is_multisite() ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: mail_site_id provided but multisite is not active', + 'data' => array( 'mail_site_id' => $mail_site_id ), + ); + return array( + 'success' => false, + 'error' => 'mail_site_id requires a multisite install', + 'logs' => $logs, + ); + } + + $blog_details = get_blog_details( $mail_site_id ); + if ( ! $blog_details ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: mail_site_id refers to an unknown blog', + 'data' => array( 'mail_site_id' => $mail_site_id ), + ); + return array( + 'success' => false, + 'error' => sprintf( 'Unknown mail_site_id: %d', $mail_site_id ), + 'logs' => $logs, + ); + } + + $should_switch = true; + } + $logs[] = array( 'level' => 'debug', 'message' => 'Email: Sending', @@ -236,11 +378,30 @@ public function execute( array $input ): array { 'content_type' => $content_type, 'attachment_count' => count( $attachments ), 'body_length' => strlen( $body ), + 'template' => $template_id, + 'mail_site_id' => $should_switch ? $mail_site_id : 0, ), ); - // 6. Send via wp_mail(). - $sent = wp_mail( $to, $subject, $body, $headers, $attachments ); + // 7. Send via wp_mail(). Wrap ONLY this call in switch_to_blog when routing. + if ( $should_switch ) { + switch_to_blog( $mail_site_id ); + } + + $sent = wp_mail( $to, $subject, $body, $headers, $attachments ); + $error_msg = ''; + + if ( ! $sent ) { + global $phpmailer; + $error_msg = 'wp_mail() returned false'; + if ( isset( $phpmailer ) && $phpmailer instanceof \PHPMailer\PHPMailer\PHPMailer ) { + $error_msg = ! empty( $phpmailer->ErrorInfo ) ? $phpmailer->ErrorInfo : $error_msg; + } + } + + if ( $should_switch ) { + restore_current_blog(); + } if ( $sent ) { $logs[] = array( @@ -257,13 +418,6 @@ public function execute( array $input ): array { ); } - // wp_mail failed — attempt to extract error info. - global $phpmailer; - $error_msg = 'wp_mail() returned false'; - if ( isset( $phpmailer ) && $phpmailer instanceof \PHPMailer\PHPMailer\PHPMailer ) { - $error_msg = ! empty( $phpmailer->ErrorInfo ) ? $phpmailer->ErrorInfo : $error_msg; - } - $logs[] = array( 'level' => 'error', 'message' => 'Email: Send failed - ' . $error_msg, @@ -289,6 +443,9 @@ private function normalizeConfig( array $input ): array { 'bcc' => '', 'subject' => '', 'body' => '', + 'template' => '', + 'context' => array(), + 'mail_site_id' => 0, 'content_type' => 'text/html', 'from_name' => '', 'from_email' => '', diff --git a/inc/Abilities/Publish/SendEmailQueuedAbility.php b/inc/Abilities/Publish/SendEmailQueuedAbility.php new file mode 100644 index 000000000..0b239a015 --- /dev/null +++ b/inc/Abilities/Publish/SendEmailQueuedAbility.php @@ -0,0 +1,445 @@ +registerAbility(); + $this->registerWorker(); + self::$registered = true; + } + + /** + * Register the `datamachine/send-email-queued` ability. + */ + 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. Accepts the same payload as datamachine/send-email plus optional send_at and priority. Returns the scheduled action id.', 'data-machine' ), + 'category' => 'datamachine-publishing', + 'input_schema' => array( + 'type' => 'object', + // Mirror datamachine/send-email: `to` + `subject` are required at the + // schema level; `body`/`template` are validated by the worker when the + // underlying ability runs. + '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.', 'data-machine' ), + ), + 'body' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Email body. Ignored when `template` is supplied.', 'data-machine' ), + ), + 'template' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Template id resolved via datamachine_email_templates at worker run time.', 'data-machine' ), + ), + 'context' => array( + 'type' => 'object', + 'default' => array(), + 'description' => __( 'Opaque context passed to the template callable.', 'data-machine' ), + ), + 'mail_site_id' => array( + 'type' => 'integer', + 'default' => 0, + 'description' => __( 'Optional multisite blog id used to wrap wp_mail() in switch_to_blog().', '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' => 'string', + 'default' => '', + 'description' => __( 'When to send. Accepts ISO 8601 string or unix timestamp (as string or int). Empty enqueues asynchronously.', 'data-machine' ), + ), + 'priority' => array( + 'type' => 'integer', + 'default' => 10, + 'description' => __( 'Reserved for future Action Scheduler priority/group hints; currently informational.', '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 action hook. + * + * Hooked on plugins_loaded so Action Scheduler can dispatch it even when + * the originating HTTP request that scheduled the job is no longer alive. + */ + private function registerWorker(): void { + add_action( self::WORKER_HOOK, array( $this, 'runWorker' ), 10, 1 ); + } + + /** + * Permission callback for ability. + * + * @return bool True if user has permission. + */ + public function checkPermission(): bool { + return PermissionHelper::can_manage(); + } + + /** + * Execute: schedule the send via Action Scheduler. + * + * @param array $input Send-email payload + send_at/priority. + * @return array Result with success flag, action_id, scheduled_for. + */ + public function execute( array $input ): array { + $logs = array(); + + if ( ! function_exists( 'as_schedule_single_action' ) || ! function_exists( 'as_enqueue_async_action' ) ) { + return array( + 'success' => false, + 'error' => 'Action Scheduler not available.', + 'logs' => $logs, + ); + } + + // Validate the bare minimum here. The underlying ability re-validates + // the full payload when the worker runs. + $to = isset( $input['to'] ) ? (string) $input['to'] : ''; + if ( '' === trim( $to ) ) { + return array( + 'success' => false, + 'error' => 'Recipient (to) is required.', + 'logs' => $logs, + ); + } + + $send_at_raw = $input['send_at'] ?? ''; + unset( $input['send_at'] ); // Not forwarded to the underlying ability. + + // Strip control fields from payload before scheduling. + $priority = isset( $input['priority'] ) ? (int) $input['priority'] : 10; + unset( $input['priority'] ); + + // Initialize the attempt counter. `_attempt` is internal — not part of + // the public input schema. + if ( ! isset( $input['_attempt'] ) ) { + $input['_attempt'] = 1; + } + + $timestamp = $this->parseSendAt( $send_at_raw ); + + if ( $timestamp instanceof \WP_Error ) { + return array( + 'success' => false, + 'error' => $timestamp->get_error_message(), + 'logs' => $logs, + ); + } + + // Action Scheduler payload — wrap in an indexed array so the hook + // receives a single $payload argument. + $as_args = array( $input ); + + if ( null === $timestamp ) { + $action_id = as_enqueue_async_action( self::WORKER_HOOK, $as_args, self::GROUP ); + $scheduled_for = time(); + } else { + $action_id = as_schedule_single_action( $timestamp, self::WORKER_HOOK, $as_args, self::GROUP ); + $scheduled_for = $timestamp; + } + + if ( empty( $action_id ) || ! is_numeric( $action_id ) ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email queue: Action Scheduler did not return an action id', + ); + return array( + 'success' => false, + 'error' => 'Failed to schedule email action.', + 'logs' => $logs, + ); + } + + $logs[] = array( + 'level' => 'info', + 'message' => 'Email queued', + 'data' => array( + 'action_id' => (int) $action_id, + 'scheduled_for' => $scheduled_for, + 'priority' => $priority, + ), + ); + + return array( + 'success' => true, + 'action_id' => (int) $action_id, + 'scheduled_for' => (int) $scheduled_for, + 'logs' => $logs, + ); + } + + /** + * Worker callback — runs when Action Scheduler dispatches the hook. + * + * Calls `datamachine/send-email` with the payload. On failure, re-enqueues + * a retry with a 5-minute delay, up to MAX_ATTEMPTS total attempts. + * + * @param array $payload Send-email payload (includes `_attempt`). + * @return void + */ + public function runWorker( $payload ): void { + if ( ! is_array( $payload ) ) { + do_action( + 'datamachine_log', + 'error', + 'Email worker: payload was not an array', + array( 'payload_type' => gettype( $payload ) ) + ); + return; + } + + $attempt = isset( $payload['_attempt'] ) ? max( 1, (int) $payload['_attempt'] ) : 1; + + $ability = wp_get_ability( 'datamachine/send-email' ); + if ( ! $ability ) { + do_action( + 'datamachine_log', + 'error', + 'Email worker: datamachine/send-email ability not registered', + array( 'attempt' => $attempt ) + ); + return; + } + + // Strip internal control fields before forwarding to the underlying ability. + $ability_input = $payload; + unset( $ability_input['_attempt'] ); + + $result = $ability->execute( $ability_input ); + + $success = is_array( $result ) && ! empty( $result['success'] ); + + if ( $success ) { + do_action( + 'datamachine_log', + 'info', + 'Email worker: send succeeded', + array( + 'attempt' => $attempt, + 'recipients' => $result['recipients'] ?? array(), + 'subject' => $result['subject'] ?? '', + ) + ); + return; + } + + $error_msg = is_array( $result ) ? ( $result['error'] ?? 'unknown error' ) : 'invalid result'; + + if ( $attempt >= self::MAX_ATTEMPTS ) { + do_action( + 'datamachine_log', + 'error', + 'Email worker: send failed after max attempts, giving up', + array( + 'attempt' => $attempt, + 'max_attempts' => self::MAX_ATTEMPTS, + 'error' => $error_msg, + ) + ); + return; + } + + // Re-enqueue with backoff. Bump the attempt counter on the payload. + $payload['_attempt'] = $attempt + 1; + $retry_at = time() + self::RETRY_BACKOFF_SECONDS; + + if ( ! function_exists( 'as_schedule_single_action' ) ) { + do_action( + 'datamachine_log', + 'error', + 'Email worker: Action Scheduler unavailable for retry', + array( + 'attempt' => $attempt, + 'error' => $error_msg, + ) + ); + return; + } + + $retry_action_id = as_schedule_single_action( $retry_at, self::WORKER_HOOK, array( $payload ), self::GROUP ); + + do_action( + 'datamachine_log', + 'warning', + 'Email worker: send failed, retry scheduled', + array( + 'attempt' => $attempt, + 'next_attempt' => $attempt + 1, + 'retry_at' => $retry_at, + 'retry_action_id' => $retry_action_id, + 'error' => $error_msg, + ) + ); + } + + /** + * Parse a `send_at` input into a unix timestamp. + * + * Accepts: + * - empty string / null → null (caller enqueues async) + * - integer or numeric string → treated as unix timestamp + * - ISO 8601 / strtotime-parseable string → converted via strtotime() + * + * @param mixed $raw Raw send_at value. + * @return int|null|\WP_Error Unix timestamp, null for "send now async", or WP_Error on invalid input. + */ + private function parseSendAt( $raw ) { + if ( null === $raw || '' === $raw ) { + return null; + } + + if ( is_int( $raw ) ) { + return $raw > 0 ? $raw : new \WP_Error( 'invalid_send_at', 'send_at must be a positive timestamp.' ); + } + + if ( is_string( $raw ) ) { + $trimmed = trim( $raw ); + if ( '' === $trimmed ) { + return null; + } + + // Numeric strings: treat as unix timestamp. + if ( ctype_digit( ltrim( $trimmed, '+' ) ) ) { + $ts = (int) $trimmed; + return $ts > 0 ? $ts : new \WP_Error( 'invalid_send_at', 'send_at must be a positive timestamp.' ); + } + + $ts = strtotime( $trimmed ); + if ( false === $ts ) { + return new \WP_Error( 'invalid_send_at', sprintf( 'Could not parse send_at: %s', $trimmed ) ); + } + + return $ts; + } + + return new \WP_Error( 'invalid_send_at', 'send_at must be a string or integer.' ); + } +} diff --git a/inc/Cli/Commands/EmailCommand.php b/inc/Cli/Commands/EmailCommand.php index 54015f02e..a36c229fe 100644 --- a/inc/Cli/Commands/EmailCommand.php +++ b/inc/Cli/Commands/EmailCommand.php @@ -100,6 +100,126 @@ public function send( array $args, array $assoc_args ): void { } } + /** + * Queue an email for delivery via Action Scheduler. + * + * Mirrors `wp datamachine email send` and adds `--send-at` plus + * `--priority`. The actual delivery runs in the queue worker, which + * invokes `datamachine/send-email` under the hood. + * + * ## 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). Optional when --template is supplied. + * + * [--template=] + * : Template id resolved via the datamachine_email_templates filter at worker run time. + * + * [--context=] + * : JSON-encoded context object passed to the template callable. + * + * [--mail-site-id=] + * : Multisite blog id used to wrap wp_mail() in switch_to_blog(). + * + * [--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=] + * : When to send. Accepts ISO 8601 (e.g. 2026-04-01T09:00:00Z) or a unix timestamp. Omit for "send now (async)". + * + * [--priority=] + * : Reserved priority hint. + * --- + * default: 10 + * --- + * + * ## EXAMPLES + * + * wp datamachine email send-queued --to=user@example.com --subject="Report" --body="

Hello

" + * wp datamachine email send-queued --to=a@x.com --subject="Digest" --template=weekly-digest --context='{"week":"2026-W14"}' + * wp datamachine email send-queued --to=a@x.com --subject="Later" --body="..." --send-at=2026-04-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.' ); + } + + $input = array( + 'to' => $assoc_args['to'], + 'subject' => $assoc_args['subject'], + 'body' => $assoc_args['body'] ?? '', + 'template' => $assoc_args['template'] ?? '', + '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', + 'mail_site_id' => isset( $assoc_args['mail-site-id'] ) ? (int) $assoc_args['mail-site-id'] : 0, + 'attachments' => array(), + 'send_at' => $assoc_args['send-at'] ?? '', + '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 ( ! empty( $assoc_args['context'] ) ) { + $decoded = json_decode( (string) $assoc_args['context'], true ); + if ( ! is_array( $decoded ) ) { + WP_CLI::error( '--context must be a JSON-encoded object.' ); + } + $input['context'] = $decoded; + } + + $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 queue failed.' ); + } + + $action_id = (int) ( $result['action_id'] ?? 0 ); + $scheduled_for = (int) ( $result['scheduled_for'] ?? 0 ); + $when = $scheduled_for > 0 ? 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. * diff --git a/tests/send-email-template-smoke.php b/tests/send-email-template-smoke.php new file mode 100644 index 000000000..1117e248d --- /dev/null +++ b/tests/send-email-template-smoke.php @@ -0,0 +1,430 @@ +args = $args; + } + public function execute( array $input ) { + return call_user_func( $this->args['execute_callback'], $input ); + } + }; +} + +function is_email( $email ) { + if ( ! is_string( $email ) ) { + return false; + } + return preg_match( '/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email ) ? $email : false; +} + +function get_bloginfo( string $key ) { + return 'Test Site'; +} + +function get_option( string $key, $default_value = null ) { + if ( 'admin_email' === $key ) { + return 'admin@example.com'; + } + if ( 'date_format' === $key ) { + return 'Y-m-d'; + } + return $default_value; +} + +function wp_date( string $format, ?int $timestamp = null ): string { + return gmdate( $format, $timestamp ?? time() ); +} + +function wp_mail( $to, $subject, $body, $headers = array(), $attachments = array() ): bool { + $GLOBALS['ec_wp_mail_calls'][] = array( + 'to' => $to, + 'subject' => $subject, + 'body' => $body, + 'headers' => $headers, + 'attachments' => $attachments, + 'blog' => $GLOBALS['ec_current_blog'], + ); + return (bool) $GLOBALS['ec_wp_mail_result']; +} + +function is_multisite(): bool { + return (bool) $GLOBALS['ec_is_multisite']; +} + +function get_blog_details( $id ) { + return in_array( (int) $id, $GLOBALS['ec_known_blogs'], true ) ? (object) array( 'blog_id' => (int) $id ) : false; +} + +function switch_to_blog( int $id ): bool { + $GLOBALS['ec_switch_history'][] = $id; + $GLOBALS['ec_current_blog'] = $id; + return true; +} + +function restore_current_blog(): bool { + $GLOBALS['ec_current_blog'] = 1; + return true; +} + +function as_schedule_single_action( int $timestamp, string $hook, array $args = array(), string $group = '' ): int { + $id = ++$GLOBALS['ec_action_id_seq']; + $GLOBALS['ec_scheduled'][ $id ] = array( 'kind' => 'single', 'timestamp' => $timestamp, 'hook' => $hook, 'args' => $args, 'group' => $group ); + return $id; +} + +function as_enqueue_async_action( string $hook, array $args = array(), string $group = '' ): int { + $id = ++$GLOBALS['ec_action_id_seq']; + $GLOBALS['ec_scheduled'][ $id ] = array( 'kind' => 'async', 'timestamp' => time(), 'hook' => $hook, 'args' => $args, 'group' => $group ); + return $id; +} + +function __( string $s, string $domain = '' ): string { + return $s; +} + +/* --------------------------------------------------------------------------- + * Stub PermissionHelper + WP_Error. + * -------------------------------------------------------------------------*/ + +if ( ! class_exists( '\\DataMachine\\Abilities\\PermissionHelper' ) ) { + eval( 'namespace DataMachine\\Abilities; class PermissionHelper { public static function can_manage(): bool { return true; } }' ); +} + +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + private string $code; + private string $message; + public function __construct( string $code = '', string $message = '' ) { + $this->code = $code; + $this->message = $message; + } + public function get_error_message(): string { + return $this->message; + } + public function get_error_code(): string { + return $this->code; + } + } +} + +function is_wp_error( $thing ): bool { + return $thing instanceof WP_Error; +} + +/* --------------------------------------------------------------------------- + * Load the abilities under test. + * -------------------------------------------------------------------------*/ + +require_once __DIR__ . '/../inc/Abilities/Publish/SendEmailAbility.php'; +require_once __DIR__ . '/../inc/Abilities/Publish/SendEmailQueuedAbility.php'; + +new \DataMachine\Abilities\Publish\SendEmailAbility(); +new \DataMachine\Abilities\Publish\SendEmailQueuedAbility(); + +ec_assert( 'send-email ability registered', isset( $GLOBALS['ec_abilities']['datamachine/send-email'] ) ); +ec_assert( 'send-email-queued ability registered', isset( $GLOBALS['ec_abilities']['datamachine/send-email-queued'] ) ); + +$send = wp_get_ability( 'datamachine/send-email' ); +ec_assert( 'send-email resolvable via wp_get_ability', null !== $send ); + +/* --------------------------------------------------------------------------- + * Case 1 — backward compatible: raw `body`, no template, no mail_site_id. + * -------------------------------------------------------------------------*/ + +echo "\nCase 1: backward-compat raw body\n"; +$GLOBALS['ec_wp_mail_calls'] = array(); +$GLOBALS['ec_switch_history'] = array(); + +$res = $send->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Hello {site_name}', + 'body' => '

Body content for {year}

', +) ); + +ec_assert( 'raw body success', true === ( $res['success'] ?? false ), $res['error'] ?? '' ); +ec_assert( 'raw body wp_mail called once', count( $GLOBALS['ec_wp_mail_calls'] ) === 1 ); +ec_assert( 'raw body subject placeholders resolved', false !== strpos( $GLOBALS['ec_wp_mail_calls'][0]['subject'], 'Test Site' ) ); +ec_assert( 'raw body content placeholders resolved', false !== strpos( $GLOBALS['ec_wp_mail_calls'][0]['body'], gmdate( 'Y' ) ) ); +ec_assert( 'raw body no switch_to_blog', count( $GLOBALS['ec_switch_history'] ) === 0 ); + +/* --------------------------------------------------------------------------- + * Case 2 — template render + placeholder replacement after template. + * -------------------------------------------------------------------------*/ + +echo "\nCase 2: template render then placeholder replacement\n"; +add_filter( 'datamachine_email_templates', function ( array $templates ): array { + $templates['fake-digest'] = function ( array $context ): string { + return '

' . ( $context['title'] ?? 'untitled' ) . '

Year: {year}

'; + }; + return $templates; +} ); + +$GLOBALS['ec_wp_mail_calls'] = array(); +$res = $send->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'template' => 'fake-digest', + 'context' => array( 'title' => 'My Title' ), +) ); + +ec_assert( 'template render success', true === ( $res['success'] ?? false ), $res['error'] ?? '' ); +ec_assert( 'template body contains rendered context', false !== strpos( $GLOBALS['ec_wp_mail_calls'][0]['body'] ?? '', 'My Title' ) ); +ec_assert( 'placeholders applied after template render', false !== strpos( $GLOBALS['ec_wp_mail_calls'][0]['body'] ?? '', gmdate( 'Y' ) ) ); + +/* --------------------------------------------------------------------------- + * Case 3 — unknown template returns structured error. + * -------------------------------------------------------------------------*/ + +echo "\nCase 3: unknown template\n"; +$res = $send->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'template' => 'does-not-exist', +) ); + +ec_assert( 'unknown template fails', false === ( $res['success'] ?? true ) ); +ec_assert( 'unknown template error mentions id', isset( $res['error'] ) && false !== strpos( $res['error'], 'does-not-exist' ) ); + +/* --------------------------------------------------------------------------- + * Case 4 — mail_site_id wraps wp_mail in switch_to_blog/restore. + * -------------------------------------------------------------------------*/ + +echo "\nCase 4: mail_site_id switches blog around wp_mail only\n"; +$GLOBALS['ec_wp_mail_calls'] = array(); +$GLOBALS['ec_switch_history'] = array(); + +$res = $send->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'body' => 'body', + 'mail_site_id' => 7, +) ); + +ec_assert( 'mail_site_id success', true === ( $res['success'] ?? false ), $res['error'] ?? '' ); +ec_assert( 'switch_to_blog called once with site 7', $GLOBALS['ec_switch_history'] === array( 7 ) ); +ec_assert( 'wp_mail observed switched blog', ( $GLOBALS['ec_wp_mail_calls'][0]['blog'] ?? 0 ) === 7 ); +ec_assert( 'restore_current_blog returned current to 1', $GLOBALS['ec_current_blog'] === 1 ); + +/* --------------------------------------------------------------------------- + * Case 5 — invalid mail_site_id rejects with structured error and no switch. + * -------------------------------------------------------------------------*/ + +echo "\nCase 5: invalid mail_site_id rejected\n"; +$GLOBALS['ec_switch_history'] = array(); +$res = $send->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'body' => 'body', + 'mail_site_id' => 999, +) ); + +ec_assert( 'invalid mail_site_id fails', false === ( $res['success'] ?? true ) ); +ec_assert( 'no switch_to_blog on invalid id', count( $GLOBALS['ec_switch_history'] ) === 0 ); + +/* --------------------------------------------------------------------------- + * Case 6 — queued ability enqueues async when send_at omitted. + * -------------------------------------------------------------------------*/ + +echo "\nCase 6: queued enqueue async\n"; +$GLOBALS['ec_scheduled'] = array(); +$queued = wp_get_ability( 'datamachine/send-email-queued' ); + +$res = $queued->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'body' => 'body', +) ); + +ec_assert( 'queued async success', true === ( $res['success'] ?? false ), $res['error'] ?? '' ); +ec_assert( 'queued async returned action_id > 0', ( $res['action_id'] ?? 0 ) > 0 ); +$first = reset( $GLOBALS['ec_scheduled'] ); +ec_assert( 'queued async used async action', ( $first['kind'] ?? '' ) === 'async' ); +ec_assert( 'queued async hook is worker', ( $first['hook'] ?? '' ) === 'datamachine_send_email_worker' ); + +/* --------------------------------------------------------------------------- + * Case 7 — queued ability schedules single action when send_at is ISO 8601. + * -------------------------------------------------------------------------*/ + +echo "\nCase 7: queued send_at parses ISO 8601\n"; +$GLOBALS['ec_scheduled'] = array(); +$future_iso = gmdate( 'c', time() + 3600 ); +$res = $queued->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'body' => 'body', + 'send_at' => $future_iso, +) ); +ec_assert( 'queued ISO success', true === ( $res['success'] ?? false ), $res['error'] ?? '' ); +$first = reset( $GLOBALS['ec_scheduled'] ); +ec_assert( 'queued ISO used single action', ( $first['kind'] ?? '' ) === 'single' ); +ec_assert( 'queued ISO timestamp roughly matches', abs( ( $first['timestamp'] ?? 0 ) - ( time() + 3600 ) ) < 5 ); + +/* --------------------------------------------------------------------------- + * Case 8 — invalid send_at rejected. + * -------------------------------------------------------------------------*/ + +echo "\nCase 8: queued invalid send_at rejected\n"; +$res = $queued->execute( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'body' => 'body', + 'send_at' => 'not-a-date', +) ); +ec_assert( 'invalid send_at fails', false === ( $res['success'] ?? true ) ); + +/* --------------------------------------------------------------------------- + * Case 9 — worker invokes underlying ability and re-enqueues on failure + * up to MAX_ATTEMPTS, then gives up. + * -------------------------------------------------------------------------*/ + +echo "\nCase 9: worker retry + give up\n"; + +// First call: wp_mail() will fail, worker should re-enqueue. +$GLOBALS['ec_wp_mail_result'] = false; +$GLOBALS['ec_scheduled'] = array(); + +$worker = new \DataMachine\Abilities\Publish\SendEmailQueuedAbility(); +$worker->runWorker( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'body' => 'body', + '_attempt' => 1, +) ); + +ec_assert( 'worker scheduled a retry on first failure', count( $GLOBALS['ec_scheduled'] ) === 1 ); +$retry = reset( $GLOBALS['ec_scheduled'] ); +ec_assert( 'retry uses worker hook', ( $retry['hook'] ?? '' ) === 'datamachine_send_email_worker' ); +$retry_payload = $retry['args'][0] ?? array(); +ec_assert( 'retry payload increments _attempt', ( $retry_payload['_attempt'] ?? 0 ) === 2 ); +ec_assert( 'retry scheduled ~5 min out', abs( ( $retry['timestamp'] ?? 0 ) - ( time() + 300 ) ) < 5 ); + +// Third attempt (max): should NOT re-enqueue. +$GLOBALS['ec_scheduled'] = array(); +$worker->runWorker( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'body' => 'body', + '_attempt' => 3, +) ); +ec_assert( 'worker gives up at MAX_ATTEMPTS', count( $GLOBALS['ec_scheduled'] ) === 0 ); + +// Restore wp_mail success and verify worker succeeds and does NOT re-enqueue. +$GLOBALS['ec_wp_mail_result'] = true; +$GLOBALS['ec_scheduled'] = array(); +$GLOBALS['ec_wp_mail_calls'] = array(); +$worker->runWorker( array( + 'to' => 'user@example.com', + 'subject' => 'Subject', + 'body' => 'body', + '_attempt' => 1, +) ); +ec_assert( 'worker success: no retry', count( $GLOBALS['ec_scheduled'] ) === 0 ); +ec_assert( 'worker success: wp_mail invoked', count( $GLOBALS['ec_wp_mail_calls'] ) === 1 ); +ec_assert( 'worker strips _attempt before forwarding', ! isset( $GLOBALS['ec_wp_mail_calls'][0]['_attempt'] ) ); + +/* --------------------------------------------------------------------------- + * Summary + * -------------------------------------------------------------------------*/ + +echo "\n"; +if ( $failed > 0 ) { + echo "FAILED: $failed / $total\n"; + exit( 1 ); +} +echo "OK: $total assertions passed\n";