Skip to content

feat: send-email template registry + queued variant (closes #2064)#2067

Merged
chubes4 merged 3 commits into
mainfrom
feat-2064-send-email-template
May 18, 2026
Merged

feat: send-email template registry + queued variant (closes #2064)#2067
chubes4 merged 3 commits into
mainfrom
feat-2064-send-email-template

Conversation

@chubes4
Copy link
Copy Markdown
Member

@chubes4 chubes4 commented May 18, 2026

Summary

Closes #2064.

Three additions, all backwards-compatible with the existing
extrachill-eventsQualifyDigestAbilities consumer:

  1. Template registry on datamachine/send-email — new optional
    template + context inputs. Templates are resolved via the new
    datamachine_email_templates filter ([id => callable( array $context ): string]),
    applied lazily inside execute() so consumers can hook at any
    priority before the first call. Unknown ids return a structured
    failure. Template render runs BEFORE placeholder replacement so
    templates may emit {site_name}, {year}, etc.
  2. Per-site SMTP routing on datamachine/send-email — new optional
    mail_site_id input. When > 0 and multisite is active, wraps ONLY
    the wp_mail() call in switch_to_blog() / restore_current_blog().
    Validation, header building, and template rendering still run in the
    original site context. Invalid blog ids and non-multisite environments
    return a structured error.
  3. datamachine/send-email-queued ability — same payload shape as
    datamachine/send-email plus send_at (ISO 8601 or unix timestamp)
    and priority. Empty send_at enqueues async via
    as_enqueue_async_action; otherwise as_schedule_single_action.
    Worker hook datamachine_send_email_worker invokes the synchronous
    ability and re-enqueues on failure with a 5-minute backoff, hard
    cap of 3 total attempts. Attempt counter rides in _attempt on the
    payload (internal — not exposed in the public schema). AS group:
    data-machine-email.

Files touched

File Purpose
inc/Abilities/Publish/SendEmailAbility.php Add template/context/mail_site_id inputs, datamachine_email_templates filter, switch_to_blog plumbing
inc/Abilities/Publish/SendEmailQueuedAbility.php New ability + worker + retry policy
data-machine.php Register SendEmailQueuedAbility in bootstrap
inc/Cli/Commands/EmailCommand.php Add wp datamachine email send-queued mirroring send plus --send-at, --priority, --template, --context, --mail-site-id
tests/send-email-template-smoke.php Pure-PHP smoke test covering all new behavior

Smoke test

php tests/send-email-template-smoke.php

Output:

  [PASS] send-email ability registered
  [PASS] send-email-queued ability registered
  [PASS] send-email resolvable via wp_get_ability

Case 1: backward-compat raw body
  [PASS] raw body success
  [PASS] raw body wp_mail called once
  [PASS] raw body subject placeholders resolved
  [PASS] raw body content placeholders resolved
  [PASS] raw body no switch_to_blog

Case 2: template render then placeholder replacement
  [PASS] template render success
  [PASS] template body contains rendered context
  [PASS] placeholders applied after template render

Case 3: unknown template
  [PASS] unknown template fails
  [PASS] unknown template error mentions id

Case 4: mail_site_id switches blog around wp_mail only
  [PASS] mail_site_id success
  [PASS] switch_to_blog called once with site 7
  [PASS] wp_mail observed switched blog
  [PASS] restore_current_blog returned current to 1

Case 5: invalid mail_site_id rejected
  [PASS] invalid mail_site_id fails
  [PASS] no switch_to_blog on invalid id

Case 6: queued enqueue async
  [PASS] queued async success
  [PASS] queued async returned action_id > 0
  [PASS] queued async used async action
  [PASS] queued async hook is worker

Case 7: queued send_at parses ISO 8601
  [PASS] queued ISO success
  [PASS] queued ISO used single action
  [PASS] queued ISO timestamp roughly matches

Case 8: queued invalid send_at rejected
  [PASS] invalid send_at fails

Case 9: worker retry + give up
  [PASS] worker scheduled a retry on first failure
  [PASS] retry uses worker hook
  [PASS] retry payload increments _attempt
  [PASS] retry scheduled ~5 min out
  [PASS] worker gives up at MAX_ATTEMPTS
  [PASS] worker success: no retry
  [PASS] worker success: wp_mail invoked
  [PASS] worker strips _attempt before forwarding

OK: 35 assertions passed

Manual end-to-end check (CLI)

After deploy, the queued path can be exercised end-to-end with:

# Register a fake template via wp eval, then queue + drain.
wp eval 'add_filter("datamachine_email_templates", function($t){ $t["smoke"] = fn($c) => "<p>Hello ".($c["who"] ?? "world")."</p>"; return $t; });'
wp datamachine email send-queued --to=admin@example.com --subject="Smoke {date}" --template=smoke --context='{"who":"chubes"}'
wp datamachine drain

Vendor-name grep guard

grep -E 'extrachill|sendy|easy.wp.smtp|kimaki|cc-connect|telegram|slack|whatsapp' \
  inc/Abilities/Publish/SendEmailAbility.php \
  inc/Abilities/Publish/SendEmailQueuedAbility.php \
  inc/Cli/Commands/EmailCommand.php \
  tests/send-email-template-smoke.php
# (no matches — exit 1)

Touched files are clean. Pre-existing vendor-name occurrences elsewhere in inc/ are out of scope for this PR.

Backwards compatibility

The existing single consumer (extrachill-events
QualifyDigestAbilities::send_digest_email()) calls
datamachine/send-email with only to/subject/body/cc/headers-equivalent
fields. All new inputs are optional with sensible defaults:

  • template = "" → unchanged raw-body flow
  • context = [] → only consulted when template is set
  • mail_site_id = 0 → no switch_to_blog

Existing wp datamachine email send CLI and the inc/Api/Email.php
REST shim are not modified — they continue to work unchanged.

What's intentionally NOT in this PR

  • HTML template content / branding — belongs in extrachill-multisite (separate issue).
  • Per-plugin migrations from raw wp_mail() — one issue per plugin will follow.
  • Auth/permission overhaul — PermissionHelper::can_manage() stays for both abilities.
  • New top-level CLI group — queued sends sit under existing wp datamachine email.

cc <@532385681268408341> — ready for review.

homeboy-ci Bot added 2 commits May 18, 2026 00:26
Extends `datamachine/send-email` with three optional inputs:

- `template` — id resolved via the new `datamachine_email_templates`
  filter (`[id => callable( array $context ): string]`). The filter is
  applied lazily inside execute() so consumers can hook at any priority
  before the first call. Unknown ids return a structured failure.
- `context` — opaque array forwarded to the template callable.
- `mail_site_id` — when > 0 and multisite is active, wraps ONLY the
  `wp_mail()` call in switch_to_blog() / restore_current_blog() so
  site-scoped SMTP config applies. Invalid ids return a structured error.

Template rendering runs BEFORE placeholder replacement, so templates may
emit `{site_name}`, `{year}`, etc. Fully backwards-compatible: callers
that omit the new fields see no behavior change. `body` is no longer
hard-required at the schema level (`template` may substitute); execute()
enforces that at least one is supplied.

Refs #2064
New `datamachine/send-email-queued` ability defers delivery to Action
Scheduler. Same payload shape as `datamachine/send-email` plus:

- `send_at` — ISO 8601 string or unix timestamp. Empty enqueues async
  via as_enqueue_async_action; otherwise as_schedule_single_action.
- `priority` — reserved hint, currently informational.

Worker hook `datamachine_send_email_worker` invokes the underlying
`datamachine/send-email` ability and, on failure, re-enqueues itself
with a 5-minute backoff. Hard cap of 3 total attempts. Attempt counter
rides in `_attempt` on the payload (internal, not part of the public
schema). Group: `data-machine-email`.

Wires the new ability into the plugin bootstrap and adds
`wp datamachine email send-queued` mirroring `wp datamachine email
send` with extra `--send-at`, `--priority`, `--template`,
`--context` (JSON), and `--mail-site-id` flags.

Adds tests/send-email-template-smoke.php exercising:
  - backward-compat raw body
  - template render + post-render placeholder replacement
  - unknown template structured error
  - mail_site_id switch_to_blog scoping + invalid id rejection
  - queued enqueue async + ISO 8601 send_at + invalid send_at
  - worker retry on failure with backoff + give up at MAX_ATTEMPTS
  - worker strips `_attempt` before forwarding

All 35 assertions pass.

Closes #2064
@homeboy-ci
Copy link
Copy Markdown
Contributor

homeboy-ci Bot commented May 18, 2026

Homeboy Results — data-machine

Lint

lint — passed

ℹ️ Full options: homeboy docs commands/lint
Deep dive: homeboy lint data-machine --changed-since f7c7e25

Test

test — passed

  • 597 passed
  • 3 skipped

ℹ️ Auto-fix lint issues: homeboy refactor data-machine --from lint --write
ℹ️ Collect coverage: homeboy test data-machine --coverage
ℹ️ Save test baseline: homeboy test data-machine --baseline
ℹ️ Pass args to test runner: homeboy test -- [args]
ℹ️ Full options: homeboy docs commands/test
Deep dive: homeboy test data-machine --changed-since f7c7e25

Audit

audit — passed

  • requested_detectors — 35 finding(s)
  • dead_code — 15 finding(s)
  • parallel-implementation — 7 finding(s)
  • dead_guard — 3 finding(s)
  • test_coverage — 3 finding(s)
  • Publish — 2 finding(s)
  • intra-method-duplication — 1 finding(s)
  • Total: 66 finding(s)

Deep dive: homeboy audit data-machine --changed-since f7c7e25

Tooling versions
  • Homeboy CLI: homeboy 0.182.0+eaec2212
  • Extension: wordpress from https://github.com/Extra-Chill/homeboy-extensions
  • Extension revision: dd47f26a
  • Action: unknown@unknown

@chubes4 chubes4 merged commit 203f31d into main May 18, 2026
5 checks passed
@chubes4 chubes4 deleted the feat-2064-send-email-template branch May 18, 2026 01:45
chubes4 added a commit to Extra-Chill/extrachill-contact that referenced this pull request May 18, 2026
…#3)

Routes both admin notification and user confirmation through
ec_send_email() (extrachill-multisite) instead of raw wp_mail().

- Admin email uses extrachill/minimal (no link grid, Reply-To preserved
  via the ability's reply_to input).
- User confirmation uses extrachill/branded — the canonical EC link
  grid and footer markup that previously lived inline now ships from
  the extrachill/branded template, so the local HEREDOC + ec_get_site_url
  block is deleted.
- function_exists guards keep the plugin safe to load when the
  multisite mail wrapper is not yet available.

Sendy/Turnstile/REST handling is untouched.

Depends on Extra-Chill/data-machine#2067 and
Extra-Chill/extrachill-multisite#27 — opened as DRAFT until both land.
chubes4 added a commit to Extra-Chill/extrachill-studio that referenced this pull request May 18, 2026
) (#72)

* refactor(email): migrate transcription email to ec_send_email, retire SMTP switch_to_blog

Replaces raw wp_mail() in inc/transcription/callback.php with the
ec_send_email() wrapper from extrachill-multisite. The DM
datamachine/send-email ability now handles SMTP-site routing via the
mail_site_id input (resolved by ec_mail_site_id()), so the manual
switch_to_blog( ec_get_blog_id('main') ) wrapper around wp_mail —
previously needed because Easy WP SMTP stores config per-site — is
deleted.

Two switch_to_blog calls remain in the file, both clearly commented as
non-SMTP:
- ec_studio_transcription_callback_create_draft(): post meta writes on
  main, because the draft lives there.
- ec_studio_transcription_callback_send_email(): get_edit_post_link()
  resolution on main, for the same reason.

Reduces inc/transcription/email-template.php to a pure inner-body
renderer: the document chrome, greeting (Hey {recipient_name},), CTA
button (Review draft), link grid, and footer are now owned by the
extrachill/branded shell template. Transcription-specific content
(filename, stats, preview) is passed as context.body_html; the edit URL
+ label are passed as context.cta_url + context.cta_label.

Closes #71

Depends on Extra-Chill/data-machine#2067 and
Extra-Chill/extrachill-multisite#27 — opened DRAFT until those land.

* style: phpcs auto-fixes + translators comment for duration sprintf
chubes4 added a commit to Extra-Chill/extrachill-shop that referenced this pull request May 18, 2026
…template (#9)

* refactor(email): route shipping-label email through ec_send_email + branded template

The shop-create-shipping-label ability previously called back into
extrachill_api_send_label_email() in extrachill-api — an upside-down
dependency (the ability layer should not reach into the REST wrapper
layer). Replace the callback with a self-contained branded email
dispatched via ec_send_email() so the side-effect lives where it
belongs.

The new helper builds the order-specific HTML body (tracking, carrier,
ship-to address, items, label download link) and hands it to the
extrachill/branded template via context.body_html. SMTP routing /
switch_to_blog is delegated to the underlying datamachine/send-email
ability via the mail_site_id default applied by ec_send_email.

Guards function_exists( 'ec_send_email' ) so the label purchase still
succeeds when the foundation helper (extrachill-multisite#27) is not
yet active.

Refs Extra-Chill/extrachill-api#51
Depends on Extra-Chill/data-machine#2067 + Extra-Chill/extrachill-multisite#27

* style: phpcs auto-fixes + reorder declare(strict_types) after docblock
chubes4 added a commit to Extra-Chill/extrachill-api that referenced this pull request May 18, 2026
closes #51) (#54)

The POST /shop/shipping-labels handler previously inlined Shippo calls,
order meta writes, status transitions, and a raw wp_mail() dispatch
directly in the REST layer. Per MEMORY.md the extrachill-api plugin
is a thin REST wrapper — abilities own logic and side-effects.

Collapse the handler to a wp_get_ability( 'extrachill/shop-create-shipping-label' )->execute()
call, mirroring the existing GET handler at line 117. The ability in
extrachill-shop now owns the full side-effect cluster, including the
email dispatch which routes through ec_send_email() and the
extrachill/branded template (companion PR in extrachill-shop).

Delete extrachill_api_send_label_email() — no longer needed. Zero
wp_mail() calls remain in inc/.

Closes #51
Depends on Extra-Chill/data-machine#2067 + Extra-Chill/extrachill-multisite#27
Companion: extrachill-shop branch feat-51-shop-ability-email-side-effect
chubes4 added a commit to Extra-Chill/data-machine-events that referenced this pull request May 18, 2026
…loses #267) (#269)

* refactor(email): migrate submission notification to datamachine/send-email

Replace raw wp_mail() in SubmissionNotification with a direct call to
the datamachine/send-email ability. This plugin is a DM extension, so
it depends on DM core (already declared in Requires Plugins) and stays
vendor-neutral — no extrachill-multisite coupling, no ec_send_email
wrapper.

Operators wire optional branded templates via two filters:

  * data_machine_events_submission_notification_template — return a
    template id registered through DM's datamachine_email_templates
    filter. Default '' sends the raw HTML body.
  * data_machine_events_submission_notification_context — merge or
    override the context array passed to that template. Defaults
    always include body_html, subject, event_id, event_title,
    event_url, submitter_name, submitter_email, site_name.

If the ability is missing (DM disabled or too old), the notification
is skipped and the failure is logged — no silent fallback to wp_mail.

Depends on Extra-Chill/data-machine#2067.
Closes #267.

* style: mark intentional fallback error_log calls with phpcs:ignore

---------

Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant