Skip to content

feat: extend datamachine/send-email with template registry + queued variant #2064

@chubes4

Description

@chubes4

Background

datamachine/send-email already exists at inc/Abilities/Publish/SendEmailAbility.php and is the cleanest email primitive DM exposes today. It handles to/cc/bcc, headers, attachments, {month}/{year}/{site_name}/{date}/{admin_email} placeholder replacement, and returns structured logs. It is backed by wp datamachine email send and an internal REST shim. The companion IMAP-side abilities live in inc/Abilities/Email/EmailAbilities.php.

Right now exactly one consumer on the Extra Chill network calls it (extrachill-eventsQualifyDigestAbilities::send_digest_email()). Meanwhile roughly 15 other plugins still call raw wp_mail() with hand-rolled <html><body> markup — duplicated header juggling, duplicated branding shims, no shared logging, no shared retry path.

The goal is to make datamachine/send-email the single email primitive for every plugin in the network. This issue covers the DM-side enhancements required. A separate extrachill-multisite issue will register an EC-branded template. Per-plugin consumer migrations will follow as their own issues.

Per the layer-purity rule, DM core must stay vendor- and site-agnostic: no extrachill, no sendy, no easy-wp-smtp string literals anywhere in this work.

Note on per-site SMTP routing

An earlier draft of this issue proposed a mail_site_id input that would wrap wp_mail() in switch_to_blog/restore_current_blog. Dropped on review — the canonical blog map already lives in extrachill-multisite (ec_get_blog_id()), and callers can establish the desired blog context themselves before invoking the ability. The existing wp_mail() call inside SendEmailAbility::execute() already honors whatever blog context the caller sets up. DM does not need a per-site-SMTP routing concept; that policy belongs in the calling platform plugin.

Proposed changes to datamachine/send-email

1. Template registry via datamachine_email_templates filter

Add two new optional input fields:

  • template (string) — template id to render the body with
  • context (object) — opaque context array passed to the template renderer

Registration shape:

add_filter( 'datamachine_email_templates', function ( array $templates ): array {
    $templates['weekly-digest'] = function ( array $context ): string {
        // returns the rendered HTML/text body
    };
    return $templates;
} );

Behavior:

  • When template is omitted, body is used verbatim — fully backwards compatible with the existing single consumer and the existing CLI/REST surface.
  • When template is set, the resolved callable is invoked with $context and its return value becomes the body. body may then be omitted from the input (relax the current required list to allow template OR body).
  • If template is set but no callable is registered for that id, return a structured failure with error: "Unknown email template: <id>" and a log entry — do not silently fall back.
  • Templates receive raw $context only. They do not receive site globals; if a template needs them, the registering plugin pulls them itself.

2. Placeholder ordering

Confirm and document that placeholder replacement runs after template render, so templates may themselves emit {site_name}, {date}, etc. and have them resolved by the existing replacement step. Update the docblock and the body/subject schema descriptions to make this explicit.

Proposed new ability datamachine/send-email-queued

A thin queued companion that defers the actual send to Action Scheduler.

Input schema

Same as datamachine/send-email, plus:

  • send_at (string|int, optional) — ISO 8601 timestamp or unix timestamp. Omit for "send now (async)".
  • priority (int, optional, default 10) — Action Scheduler priority/group hint.

Output schema

{
  success: bool,
  action_id: int,    // scheduled action id
  scheduled_for: int, // unix ts the worker will fire
  error: string,
  logs: array
}

Behavior

  • When send_at is omitted → as_enqueue_async_action( 'datamachine_send_email_worker', [ $payload ], 'datamachine-email' ).
  • When send_at is provided → as_schedule_single_action( $ts, 'datamachine_send_email_worker', [ $payload ], 'datamachine-email' ).
  • Worker (datamachine_send_email_worker action hook):
    1. Calls wp_get_ability( 'datamachine/send-email' )->execute( $payload ).
    2. Logs the structured result via the existing DM logger.
    3. On success === false, re-enqueues itself with exponential backoff. Hard cap 3 total attempts. Attempt counter rides in $payload['_attempt'] (defaults to 1 on first enqueue; do not expose _attempt in the public schema). After the cap, log a final error entry and stop.
  • The worker hook must be registered on init (or equivalent) so Action Scheduler can dispatch it without the calling request still being alive.

Why a separate ability instead of a flag

  • Keeps datamachine/send-email synchronous and side-effect-narrow (one wp_mail() call, return immediately).
  • Lets the queued variant declare its own meta.annotations (idempotent: false, destructive: false) and a distinct permission ceiling if we later want to gate scheduling separately.
  • Matches the existing DM split between e.g. datamachine/schedule-next-step and the synchronous step abilities.

Blog context for queued sends

Action Scheduler runs workers in whatever blog context the cron/queue runner happens to be in — typically the main site. If a caller needs a queued send to fire under a specific subsite context (for per-site SMTP config or per-site placeholder values), the caller should pass that intent in $payload as standard fields (e.g. pre-resolve the body, pre-resolve from_email) or wrap their own switch_to_blog policy in a higher-level platform-specific ability. DM does not solve cross-blog scheduling here; that belongs to the platform layer.

System task / CLI surface

Recommend matching the existing pattern in inc/Cli/Commands/EmailCommand.php:

  • Add wp datamachine email send-queued as a sibling to the existing wp datamachine email send. Same flag surface as send, plus --send-at=<iso8601|@timestamp> and --priority=<int>. Output: action id + scheduled time.
  • No new top-level group. Queued sends are still "email", just deferred.
  • No entry under wp datamachine system run — system tasks are for periodic site-health work, not for ad-hoc queued sends. (If a future use case wants a recurring digest, that should be a flow/pipeline that calls the queued ability, not a hardcoded system task.)

Constraints

  • Vendor-agnostic. No extrachill, no sendy, no easy-wp-smtp, no mailgun, no platform names anywhere in DM source. Grep guard: grep -riE 'extrachill|sendy|easy.wp.smtp|kimaki|cc-connect|telegram|slack|whatsapp' inc/ must come back clean after the work.
  • Backwards-compatible. The existing single consumer (extrachill-eventsQualifyDigestAbilities) must keep working without code changes. Existing CLI invocations (wp datamachine email send --to=... --subject=... --body=...) must keep working unchanged. The REST shim at inc/Api/Email.php must keep working unchanged.
  • Permission callback stays at PermissionHelper::can_manage() for both abilities. A broader auth review (per-ability ceilings, agent-token scopes for queued sends, etc.) is a separate concern and should be its own issue.
  • No vendor providers. Continue to ship via wp_mail() only. Whatever SMTP plugin is hooked into phpmailer_init does its job; DM stays out of provider selection.
  • No blog-context plumbing. DM does not own subsite routing. Callers set their own switch_to_blog context before calling the ability if they need it.

Out of scope

  • HTML template content and branding — lives in extrachill-multisite (separate issue will register the EC-branded template via datamachine_email_templates).
  • Per-site SMTP routing / switch_to_blog policy — owned by extrachill-multisite (ec_get_blog_id() already provides the canonical map; callers wrap switch_to_blog themselves).
  • Per-plugin consumer migrations from raw wp_mail() to datamachine/send-email — one issue per plugin will follow once this lands.
  • Sendy / Mailgun / generic provider abstraction — wp_mail() is the pipe; any SMTP plugin that hooks it (Easy WP SMTP, WP Mail SMTP, etc.) keeps working transparently.
  • Auth/permission overhaul.
  • Bounce / delivery-receipt handling.
  • Bulk send / list expansion / unsubscribe link injection. (Bulk delivery belongs in a dedicated newsletter system, not in the transactional primitive.)

Acceptance criteria

  • template and context documented in datamachine/send-email input_schema with descriptions.
  • Existing required fields remain backwards-compatible: callers omitting template see no behavior change.
  • datamachine_email_templates filter applied; unknown template id returns structured error with a log entry; known template id renders body before placeholder replacement.
  • datamachine/send-email-queued registered and discoverable (wp ability list shows it under datamachine-publishing or a dedicated datamachine-publishing-queue category).
  • Worker (datamachine_send_email_worker) calls the synchronous ability, logs the result, retries once on failure with exponential backoff, hard-caps at 3 attempts.
  • wp datamachine email send-queued CLI subcommand exists, supports --send-at and --priority, prints action id + scheduled time on success.
  • At least one smoke test exercising: (a) template render + placeholder replacement, (b) queued send → worker → underlying ability invocation. Pattern can match existing tests/*-smoke.php files in this repo.
  • Vendor-name grep stays clean: grep -riE 'extrachill|sendy|easy.wp.smtp|kimaki|cc-connect|telegram|slack|whatsapp' inc/ returns nothing new.
  • Existing QualifyDigestAbilities consumer continues to work without modification (verified manually or via existing tests in extrachill-events).

cc @Chubes — ready for review when triaged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions