Resend notification provider — the shipped email channel
Resend notification provider — the shipped email channel
Card: t_1419d598 (build task 3 of 8 from t_c9b50255).
Method: live probes on prod (161.33.71.67, aarch64) against the
pinned @medusajs/medusa 2.21.2, including a boot of the actual
production image. Isolated scratch DB (resend_e2e_2026 on the pg-temp
container). No tenant DB was written to.
1. What landed
| File (on prod) | Change |
|---|---|
builds/medusa-backend/apps/backend/src/modules/resend/service.ts | The provider: AbstractNotificationProviderService, identifier = "notification-resend", one send(). |
builds/medusa-backend/apps/backend/src/modules/resend/index.ts | ModuleProvider(Modules.NOTIFICATION, { services: [...] }). |
builds/medusa-backend/apps/backend/src/modules/resend/emails/layout.tsx | Shared shell + formatPrice + SummaryRow. Inline styles (see §4). |
builds/medusa-backend/apps/backend/src/modules/resend/emails/order-placed.tsx | Order-confirmation template. |
builds/medusa-backend/apps/backend/src/modules/resend/emails/order-shipped.tsx | Shipping-confirmation template. |
builds/medusa-backend/apps/backend/package.json | resend, @react-email/components, @react-email/render added; react + react-dom MOVED from devDependencies to dependencies. |
builds/medusa-backend/package.json | Root overrides["@react-email/render"] = "2.1.1" — one copy of render in the tree. |
builds/medusa-backend/apps/backend/medusa-config.ts | Comment corrected in place (see §5) — the code was already correct (card t_7b0702c4 owns that block). |
Backups: package.json.bak-sol-t1419d598,
package-lock.json.bak-sol-t1419d598,
medusa-config.ts.bak-sol-t1419d598-comment.
2. The version matrix — MEASURED, not inherited
Medusa's Cloud guide pins @react-email/render 0.3.3 / components 1.1.4 and
warns that newer React Email "are not supported due to Node version
incompatibilities on Cloud". Cloud's ceiling is Cloud's. Our constraints are
Node 24 + ARM64 + React 18/19, so the matrix was measured on our own stack.
| Package | Pin | Why this one |
|---|---|---|
resend | 6.34.1 (exact) | Latest measured working; MIT. |
@react-email/components | 1.0.12 (exact) | Measured OK. Cloud's 0.3.3 fails the Tailwind-utility translation (text-gray-800 does not resolve) — so Cloud's pin is not a floor, it is a ceiling. |
@react-email/render | 2.1.1 (exact) | Measured OK; components@1.0.12 otherwise nests 2.0.6 → 2 copies. Root overrides collapses it to 1. |
react / react-dom | 18.3.1 (exact) | In dependencies, not devDeps (see §3). Matches the monorepo root so npm hoists ONE copy. |
Measured on prod, Node v24.19.0, aarch64:
resend 6.34.1 MIT
@react-email/components 1.0.12 MIT
@react-email/render 2.1.1 MIT
react 18.3.1 MIT
react-dom 18.3.1 MIT
render copies in the tree: 1 (2.1.1) npm ls @react-email/render -> exit 0
React 19 also works (the card asked for React 19/18 compatibility, proved
not assumed). Isolated tree, react 19.2.0 + react-dom 19.2.0 + the same
pinned render/components:
{"react":"19.2.0","react_dom":"19.2.0","render":"2.1.1","components":"1.0.12",
"bytes":9585,"has_name":true,"has_total":true,"has_order_ref":true,
"unresolved_jsx":false,"ok":true}
Both templates render real HTML (probe L, on the pinned stack):
order-placed 9892 bytes doctype ✓ order-ref ✓ display_id ✓ total ✓ unresolved-jsx ✗
order-shipped 7587 bytes doctype ✓ order-ref ✓ display_id ✓ unresolved-jsx ✗
3. react in dependencies, not devDependencies
The card called this out and it is real. The shipped Dockerfile
(builds/Dockerfile.medusa) is a multi-stage build; the runner stage
copies /app/node_modules from the builder. If react is a devDependency and
the production install prunes dev deps, the provider dies at runtime with
Cannot find module 'react/jsx-runtime'.
Proved in the built image:
/app/node_modules/react/jsx-runtime.js <- resolves
react 18.3.1
react-dom 18.3.1
@react-email/render 2.1.1
@react-email/components 1.0.12
4. Two decisions that changed the implementation
4.1 The provider THROWS on failure — the card's "return {}" instruction is wrong
The card said: "on error log and return {} (do NOT throw — the module already
records status=failure)".
That is false on the pinned v2.21.2. notification-module-service.js does:
const res = await this.notificationProviderService_.send(provider, entry.data)
.catch((e) => { entry.data.status = FAILURE; ...; throw ... })
entry.data.external_id = res.id
entry.data.status = SUCCESS
It records status=failure only when send() rejects. A provider that
swallows the error and returns {} yields status=success with
external_id=NULL — a false success that a naive health gate would read as
"email is alive". The shipped order.created subscriber swallows the error, so
the DB row is the only evidence.
This provider therefore throws (MedusaError.Types.UNEXPECTED_STATE).
A must-fail control was built and run against the same stub, the same DB and
the same templates, with exactly one statement changed (return {} instead of
throw) — it reproduced the false success. The card's instruction is
overridden on measured evidence, and the override is documented in the
provider's own class doc.
4.2 Inline styles, not Tailwind utilities
<Tailwind> translates class names to inline CSS at render time, and that
translation has changed behaviour between React Email majors (measured:
text-gray-800 resolves on components@1.0.12, not on the older 0.3.3).
Inline styles are inert and render identically in every client. The styles
object is explicitly annotated Record<string, CSSProperties> — see §6, that
annotation is load-bearing, not decoration.
5. The shipped default STAYS none — and the config comment said otherwise
t_7b0702c4 left a comment in medusa-config.ts:
"When the provider modules land, switch the shipped default to
resendin this file AND in the compose template (one line each)."
Following that instruction today would crash-loop the fleet. Probed in the real image:
TENANT_EMAIL_PROVIDER=resend, no RESEND_API_KEY
-> "Loaders for module Notification failed: Option `api_key` is required
in the Resend provider's options." [BOOT FAILS]
TENANT_EMAIL_PROVIDER=resend, key, no RESEND_FROM_EMAIL
-> "Option `from` is required ..." [BOOT FAILS]
validateOptions runs at boot, not at send time. The module's existence
resolves the original "missing module ⇒ boot failure" reason, but a missing
credential is an equally fatal boot failure — and no Resend credential
exists anywhere in the fleet (verified: no RESEND_API_KEY in any compose
file, .env, or vault; the only key on prod is the literal placeholder
re_probe_only_not_a_real_key).
So the default stays none — the tenant boots, feed works, and every email
send fails loudly at SEND time. A loud failure is recoverable; a boot loop is
an outage. The comment in medusa-config.ts was corrected in place to state
the measured reason and the exact condition under which the default may flip
(once the fleet has a provisioned credential AND onboarding sets
RESEND_API_KEY / RESEND_FROM_EMAIL per tenant).
6. A build-breaker I introduced, and the Dockerfile that hid it
The first version of layout.tsx declared styles as an un-annotated object
literal. textAlign: "center" widened to string, which fails React's
CSSProperties → medusa build exited 1 with TS2322 on every template.
The shipped Dockerfile swallows build failures:
RUN npm run build 2>/dev/null || npx medusa build 2>/dev/null || echo "build attempted"
so the image still built — with a stale/absent compiled backend. Proved by A/B on the same tree:
(a) WITH src/modules/resend -> exit 1 (TS2322, 4 errors)
(b) WITHOUT src/modules/resend -> exit 0 (backend + frontend built)
Fixed by annotating styles: Record<string, CSSProperties>. After the fix:
medusa build -> exit 0
"Backend build completed successfully (25.76s)"
"Frontend build completed successfully (78.17s)"
lint: src/modules/resend/** -> 0 problems
The || echo "build attempted" in the Dockerfile is a live trap — it makes
a broken build look green. It is not this card's file to change; it is flagged
in the card handoff for the image/infra lane.
7. Evidence — the acceptance criteria, all DB-verified
Probed twice: once in the source tree, once inside the built production
image (solos-medusa:t1419d598-probe, NODE_ENV=production, Node 24.22.0
aarch64). The notification table afterwards, read from Postgres — not from
logs, not from return values:
id | channel | status | provider_id | external_id | template
------------------------------+---------+---------+-------------+-----------------+----------------
noti_01M5CQQBGR49V4GJP7VW75VQ0D | email | success | resend | stub-msg-000005 | order-placed
noti_01M5CQQBW533B4NVVZ63HR7SER | email | failure | resend | | order-placed
noti_01M5CQQBZXSTP0YT8MXRD1GJAJ | email | failure | resend | | order-placed
noti_01M5CQQC525ACD20Q8NMY6ZSY7 | email | failure | resend | | no-such-template
noti_01M5CQQC7SF33J27PGDYCTBN50 | feed | success | local | | t
noti_01M5CQQC8VBPPY7F3HGHXBB771 | sms | failure | | | t
And the provider table — the registration criterion:
id | is_enabled | channels
--------+------------+----------
local | t | {feed}
resend | t | {email}
Mapping to the criteria:
| Criterion | Result | Evidence |
|---|---|---|
Provider registers at boot, channels={email}, is_enabled=t | PASS | notification_provider row resend / t / {email} |
Send succeeds, row status=success, external_id set | PASS | success + external_id=stub-msg-000005 (the Resend message id) |
Failed send leaves status=failure — row, not log | PASS | 3 failure rows: HTTP 422, socket destroyed, unknown template |
feed still routes to local | PASS | feed / success / local |
| Channel with no provider fails loudly | PASS | sms → "Could not find a notification provider for channel: sms" |
react/react-dom in dependencies | PASS | react/jsx-runtime resolves inside the built image |
8. Honest limits — what is NOT proven
- No real delivery test was performed. No email reached a real inbox. The
Resend API was a local stub (
127.0.0.1:9911,stub-resend.mjs) with the placeholder keyre_probe_only_not_a_real_key. This proves the provider's behaviour, the module's recording of it, the version matrix, and the image — it does not prove deliverability, SPF/DKIM, or that Resend accepts ourfromaddress. - No verified domain exists. There is no Resend account, no domain, and no
API key in the fleet. A live delivery test (send → a real inbox → confirm
the row's
external_idmatches the Resend message id) is still owed and needs a credential from whoever owns the Resend account. - A template override (
html_templates) was exercised only by unit reasoning, not by a probe. Theorder-placed/order-shippedpaths are the ones measured. - The swallow-control (
return {}) variant was proven on the stub; the claim that it yields a false success is measured, but the exact health-gate behaviour is the health-gate card's (t_7e2d75cb). - The
order.placed→ email wiring (a subscriber) is not in this card — it ist_eaa229fe. The shipped v2.21.2order.createdsubscriber still references the non-existentorder-created-template. - The O365 provider (
t_154c2421) landed in the same tree during this run;medusa buildpasses with both modules present, but only Resend was booted and probed here.
9. Follow-ups this card leaves behind
- Provision a Resend account + verified domain + API key, then run the live delivery test (the only untestable item left on this card).
- Flip the default to
resendinmedusa-config.ts+ both compose files, but only after (1) and after onboarding sets the two env vars per tenant. - Fix the Dockerfile's
|| echo "build attempted"— it makes a broken build look green (it hid a real TS2322 failure in this very card). - Consider not requiring
fromat boot: an operator who sets the key but forgets the sender gets a crash-loop instead of a loud send failure. The current behaviour is deliberate (fail closed at boot) but it is a fleet-wide footgun while the default isresend.
10. Files
Source of truth for the provider: builds/medusa-backend/apps/backend/src/modules/resend/
on prod (not a git repo — the build tree is a deployment copy).
Probe artifacts on prod:
/home/ubuntu/resend-e2e/app/probeK.ts (live boot + e2e),
/home/ubuntu/resend-e2e/app/probe-boot-only.ts (boot-only),
/home/ubuntu/resend-e2e/stub-resend.mjs + stub-state.json (the stub and
its request log).

