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-events → QualifyDigestAbilities::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):
- Calls
wp_get_ability( 'datamachine/send-email' )->execute( $payload ).
- Logs the structured result via the existing DM logger.
- 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-events → QualifyDigestAbilities) 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
cc @Chubes — ready for review when triaged.
Background
datamachine/send-emailalready exists atinc/Abilities/Publish/SendEmailAbility.phpand 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 bywp datamachine email sendand an internal REST shim. The companion IMAP-side abilities live ininc/Abilities/Email/EmailAbilities.php.Right now exactly one consumer on the Extra Chill network calls it (
extrachill-events→QualifyDigestAbilities::send_digest_email()). Meanwhile roughly 15 other plugins still call rawwp_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-emailthe single email primitive for every plugin in the network. This issue covers the DM-side enhancements required. A separateextrachill-multisiteissue 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, nosendy, noeasy-wp-smtpstring literals anywhere in this work.Note on per-site SMTP routing
An earlier draft of this issue proposed a
mail_site_idinput that would wrapwp_mail()inswitch_to_blog/restore_current_blog. Dropped on review — the canonical blog map already lives inextrachill-multisite(ec_get_blog_id()), and callers can establish the desired blog context themselves before invoking the ability. The existingwp_mail()call insideSendEmailAbility::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-email1. Template registry via
datamachine_email_templatesfilterAdd two new optional input fields:
template(string) — template id to render the body withcontext(object) — opaque context array passed to the template rendererRegistration shape:
Behavior:
templateis omitted,bodyis used verbatim — fully backwards compatible with the existing single consumer and the existing CLI/REST surface.templateis set, the resolved callable is invoked with$contextand its return value becomes the body.bodymay then be omitted from the input (relax the currentrequiredlist to allowtemplateORbody).templateis set but no callable is registered for that id, return a structured failure witherror: "Unknown email template: <id>"and a log entry — do not silently fall back.$contextonly. 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 thebody/subjectschema descriptions to make this explicit.Proposed new ability
datamachine/send-email-queuedA 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
Behavior
send_atis omitted →as_enqueue_async_action( 'datamachine_send_email_worker', [ $payload ], 'datamachine-email' ).send_atis provided →as_schedule_single_action( $ts, 'datamachine_send_email_worker', [ $payload ], 'datamachine-email' ).datamachine_send_email_workeraction hook):wp_get_ability( 'datamachine/send-email' )->execute( $payload ).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_attemptin the public schema). After the cap, log a finalerrorentry and stop.init(or equivalent) so Action Scheduler can dispatch it without the calling request still being alive.Why a separate ability instead of a flag
datamachine/send-emailsynchronous and side-effect-narrow (onewp_mail()call, return immediately).meta.annotations(idempotent: false,destructive: false) and a distinct permission ceiling if we later want to gate scheduling separately.datamachine/schedule-next-stepand 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
$payloadas standard fields (e.g. pre-resolve the body, pre-resolvefrom_email) or wrap their ownswitch_to_blogpolicy 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:wp datamachine email send-queuedas a sibling to the existingwp datamachine email send. Same flag surface assend, plus--send-at=<iso8601|@timestamp>and--priority=<int>. Output: action id + scheduled time.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
extrachill, nosendy, noeasy-wp-smtp, nomailgun, 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.extrachill-events→QualifyDigestAbilities) must keep working without code changes. Existing CLI invocations (wp datamachine email send --to=... --subject=... --body=...) must keep working unchanged. The REST shim atinc/Api/Email.phpmust keep working unchanged.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.wp_mail()only. Whatever SMTP plugin is hooked intophpmailer_initdoes its job; DM stays out of provider selection.switch_to_blogcontext before calling the ability if they need it.Out of scope
extrachill-multisite(separate issue will register the EC-branded template viadatamachine_email_templates).switch_to_blogpolicy — owned byextrachill-multisite(ec_get_blog_id()already provides the canonical map; callers wrapswitch_to_blogthemselves).wp_mail()todatamachine/send-email— one issue per plugin will follow once this lands.wp_mail()is the pipe; any SMTP plugin that hooks it (Easy WP SMTP, WP Mail SMTP, etc.) keeps working transparently.Acceptance criteria
templateandcontextdocumented indatamachine/send-emailinput_schemawith descriptions.templatesee no behavior change.datamachine_email_templatesfilter applied; unknown template id returns structured error with a log entry; known template id renders body before placeholder replacement.datamachine/send-email-queuedregistered and discoverable (wp ability listshows it underdatamachine-publishingor a dedicateddatamachine-publishing-queuecategory).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-queuedCLI subcommand exists, supports--send-atand--priority, prints action id + scheduled time on success.tests/*-smoke.phpfiles in this repo.grep -riE 'extrachill|sendy|easy.wp.smtp|kimaki|cc-connect|telegram|slack|whatsapp' inc/returns nothing new.QualifyDigestAbilitiesconsumer continues to work without modification (verified manually or via existing tests inextrachill-events).cc @Chubes — ready for review when triaged.