Medusa notification channels on pinned v2.21.2 — the two open items, closed by probe
Medusa notification channels on pinned v2.21.2 — the two open items, closed by probe
Card: t_c9b50255 (board docker-vhost, assignee sol, created by tam).
Scope from the design of record: eve-customer-service-design.md §7 open items
1 (feed channel on the pinned version) and 2 (email provider: O365/Graph vs
Resend), plus §5.1–§5.2's channel split.
Method: live probes against the pinned code on prod (161.33.71.67,
/home/ubuntu/builds/medusa-backend, @medusajs/medusa: 2.21.2 exactly). Not docs,
not inference. The tenant DBs were not written to — the end-to-end probes ran against
an isolated scratch database (notif_probe_2026) created for this card.
1. The headline answers
| # | Question | Answer |
|---|---|---|
| 1 | Does pinned v2.21.2 support the feed channel pattern? | YES — and feed is a Medusa string, not a fixed type. Any channel name works. But feed has a specific meaning: it is the Medusa Admin bell/panel, not a customer-facing in-app thread. See §2. |
| 2 | Email provider: O365/Graph vs Resend? | Resend as the shipped default; O365 as a per-tenant opt-in. Both behind one provider interface, selected by env var at boot. See §4. |
| 3 | (found, not asked) | 🔴 A user-supplied notification module config REPLACES the default local→feed provider — it does not merge. Configuring only an email provider silently kills feed. See §3. |
2. feed — confirmed on v2.21.2, but the design's use of it is wrong
2.1 What feed actually is (probed, not assumed)
feed is a first-class channel with a built-in provider — this part of the design
of record is correct and now proven rather than inferred.
Live DB on prod (medusa DB, the running v2.21.2 instance):
id | handle | is_enabled | channels
-------------+-------------+------------+----------
local | local | t | {feed}
The provider row is written by the framework's default config, not by us. In
@medusajs/utils@2.21.2 dist/common/define-config.js the default module set
contains:
{ resolve: MODULE_PACKAGE_NAMES[Modules.NOTIFICATION], options: { providers: [
{ resolve: "@medusajs/medusa/notification-local", id: "local",
options: { name: "Local Notification Provider", channels: ["feed"] } } ] } }
Probe A (calling the real defineConfig) confirms the default and shows what a
user config does to it:
a_default: providers = [ local → ["feed"] ]
b_email_only: providers = [ resend → ["email"] ] ← local/feed GONE
c_explicit_both: providers = [ local → ["feed"], resend → ["email"] ]
Probe C/D (end-to-end createNotifications on the pinned runtime, scratch DB):
default_feed → ok, status=success, provider_id=local
default_email_NO_provider → FAILED: "Could not find a notification provider for channel: email"
The DB row for that failure is the proof:
id | channel | status | provider_id | to
noti_01M5CHK4V67FXTS7HXY4SW25VP | feed | success | local | probe-feed@example.invalid
noti_01M5CHK4VT0EZZCYSM5VNMS101 | email | failure | | probe-email@example.invalid
provider_id is NULL and status=failure — the email was not silently swallowed;
the Notification Module raises and the row records the failure. That is good news for
the health gate: a missing email provider is a visible DB row, not a silent no-op.
(The silent part is the caller: configurable-notifications in the shipped
@medusajs/medusa catches the error and only logger.errors it, so nothing surfaces
to the operator. The gate must read the DB, not the logs.)
2.2 🔴 The correction: feed is the ADMIN bell, not the customer thread
The design of record says (§4.3, §5.2) that the customer's thread is "an in-app
feed message thread attached to their account and their order", and that EVE-drafted
replies arrive via feed. That is not what feed does in v2.21.2.
Probed on the shipped Admin dashboard bundle (@medusajs/dashboard@2.21.2,
dist/app.js line ~144660), the notification bell drawer queries:
sdk.admin.notification.list({ ..., to: [me?.id, me?.email, ""], channel: "feed" })
to is the logged-in admin's own id/email. So feed renders in the Medusa
Admin bell for the operator. It is addressed to admins, not to customers.
Corroborating sweep: there is no store-facing notifications route in v2.21.2.@medusajs/medusa/dist/api/store/ contains no notification route, and no store
route file references the notification module. The only notification API surface is
/admin/notifications (read-only GET; it has no POST — creating a notification is
module-internal only).
Consequence for the design of record: the customer-facing in-app thread must be
a channel we own, not feed. Medusa's channel name is a free string — Probe C
proved three custom channels routing side by side:
PROBE3 [{"channel":"feed", "ok":true, "provider_id":"local"},
{"channel":"inapp", "ok":true, "provider_id":"probe-inapp"},
{"channel":"email", "ok":true, "provider_id":"probe-email"},
{"channel":"sms", "ok":false, "error":"Could not find a notification provider for channel: sms"}]
Recommended channel map (this is a design change, flagged for Min/Echo/Tam):
| Channel | Carries | Provider | Status |
|---|---|---|---|
feed | Medusa Admin bell — operator-facing alerts (order placed, claim raised) | local (default) | ✅ exists today, keep it |
inapp (ours, new) | customer-facing thread — EVE replies, order updates, per §4.3/§5.2 | our module | 🔴 to build |
email | receipts, shipping confirmations, password reset | Resend (default) / O365 (opt-in) | 🔴 to build |
sms | off by default (Mira's rule) | — | not configured, by design |
sms failing above is the correct behaviour and is worth keeping: a channel with
no provider fails loudly instead of pretending.
3. 🔴 The trap: user config REPLACES the default, it does not merge
This is the highest-value finding in the card, and it is invisible from the docs.
Probe A, case (b): a medusa-config.ts that declares the notification module
with only an email provider produces:
b_email_only: providers = [ resend → ["email"] ] ← the default local/feed is GONE
Probe D/Case 2, end-to-end, on the pinned runtime:
default_feed → FAILED: "Could not find a notification provider for channel: feed"
default_email → ok, status=success, provider_id=probe-email
And the provider table shows the framework disabling the orphaned default:
id | is_enabled | channels
-------------+------------+----------
local | f | {feed} ← was enabled, now DISABLED
probe-email | t | {email}
So: adding an email provider by editing medusa-config.ts silently breaks the
Admin feed channel — the operator's bell goes dead, and it fails at the send
level, not at boot, so a health check that only pings /health stays green.
The rule for the tenant template: always declare BOTH providers explicitly.
Never rely on the framework default surviving a user config. The tenant
medusa-config.ts must always list local→["feed"] plus the chosen email
provider→["email"], with the email provider chosen by env var so one image serves
every tenant.
Probe E — one image, per-tenant provider selection by env var (verified on the
pinned defineConfig):
{"tenant_email_provider":"resend","providers":[{"id":"local","channels":["feed"]},{"id":"resend","channels":["email"]}]}
{"tenant_email_provider":"o365", "providers":[{"id":"local","channels":["feed"]},{"id":"o365","channels":["email"]}]}
{"tenant_email_provider":"none", "providers":[{"id":"local","channels":["feed"]}]}
Note none still keeps feed — because the local provider is declared
unconditionally in this shape. The template must not omit it.
3.1 One provider per channel is enforced at BOOT (fail-closed)
Probe B (real loader, fake container) and Case 1 (real boot):
{"case":"two_providers_same_channel_email","threw":true,
"error":"Error: Multiple providers are configured for the same channel: email"}
At boot, the same error stops the process:
error: Loaders for module Notification failed: Multiple providers are configured for the same channel: email
This one is good news and needs no workaround: a misconfigured duplicate channel
is a boot failure, not a silent misroute. The tenant cannot start in a broken
state. The DB sync also correctly disables providers that are no longer configured
(the local→f row above).
4. Email provider: Resend default, O365 opt-in
4.1 The decision
Ship Resend as the default email provider. Offer O365/Graph as a per-tenant opt-in for tenants who already have M365 and want mail from their own domain and mailbox.
Reasoning:
- Resend is the low-friction default. No tenant admin action, no Entra app
registration, no admin consent, no client secret in the tenant. Resend's Free
tier is 3,000 emails/month (100/day); Pro is US$20/mo for 50,000 + US$0.90/1,000
overage. A tenant sending 50 receipts/month never pays; a tenant at 5,000/month
costs US$20 — which sits inside our existing A$0 "email" cost assumption in
services-pricing-model-aud.mdonly if Mira prices it. 🔴 This is a pricing input Mira owns, not me — see §6. - O365 is a per-tenant entitlement, and we already sell it. The vault is
consistent on this (Tams
agenticmail-eve-study: "customer mail should ride the tenant's OWN mailbox, not a fleet-run instance";licence-verify-stalwart: "the correct move would be tenant-owned (O365/Graph or a properly licensed mail provider)"). For a tenant already paying for M365, sending from their own domain with their own DKIM/DMARC is the correct posture and needs no extra line item. - Both are the same shape in Medusa. Each is a
ModuleProvider(Modules.NOTIFICATION)with onesend()method. The provider is a string key, so the tenant template picks one by env var with no image rebuild (Probe E). - O365's limits make it unsuitable as a fleet default. Exchange Online caps at
10,000 recipients/day, 30 messages/minute, 500 recipients/message (default),
with a tenant-level external-recipient limit (TERRL, 5,000/day on trial) and
onmicrosoft.comdomains throttled to 100 external recipients/day. Those are fine for a tenant's own transactional mail, and not fine as a shared fleet sender. Microsoft also states plainly that Exchange Online "isn't suited to accommodate bulk-mailing scenarios" and recommends a third-party provider for that.
Implementation shape (per-tenant, no rebuild):
TENANT_EMAIL_PROVIDER=resend|o365|none
RESEND_API_KEY, RESEND_FROM_EMAIL (when resend)
O365_TENANT_ID, O365_CLIENT_ID, O365_CLIENT_SECRET, O365_FROM, O365_MAILBOX (when o365)
modules: [{ resolve: "@medusajs/medusa/notification", options: { providers: [
{ resolve: "@medusajs/medusa/notification-local", id: "local",
options: { name: "Local Notification Provider", channels: ["feed"] } }, // ALWAYS declared
...(emailProvider ? [{ resolve: emailProviderModule, id: emailProviderId,
options: { channels: ["email"], ...creds } }] : []),
] } }]
4.2 Licence + supply chain (both providers are MIT/Apache, no licence trap)
- Resend SDK — MIT. React Email (
@react-email/components,@react-email/render) — MIT. Both are the portable win Echo identified inmedusa-cloud-engineering-verification.md§2b, and both were absent from our pinned tree (verified:resend,@react-email/components,@react-email/render,nodemailerall ABSENT from the 720-packagenode_modules). - Microsoft Graph client — MIT (
@microsoft/microsoft-graph-client,@azure/identity). Also absent from the tree. - No AGPL exposure (contrast with the Stalwart/AgenticMail line we rejected).
4.3 The version-pin warning that applies to both
Echo's §2b warning transfers and is now measured: @react-email/render is at
2.1.1 today and @react-email/components at a matching major — but Medusa's
Cloud guide pins 0.3.3 / 1.1.4 and warns "newer versions of React Email are not
supported due to Node version incompatibilities". Pin deliberately and test on our
Node 24 / ARM64 stack; do not copy Cloud's pins and do not take latest. Our
backend engines says node >=22.22.0 and prod runs Node v24.19.0 on aarch64.
MEASURED (card
t_1419d598, 2026-10-20) — the pins are now settled.resend 6.34.1/@react-email/components 1.0.12/@react-email/render 2.1.1/react 18.3.1+react-dom 18.3.1(independencies, not devDeps), plus a rootoverrides["@react-email/render"] = "2.1.1"so the tree holds one copy (components@1.0.12otherwise nests2.0.6). Proven on our stack: both templates render real HTML, and the matrix also works under React 19.2.0. Cloud's0.3.3pin fails the Tailwind-utility translation on our version — Cloud's ceiling is Cloud's, and it is not a floor for us. Full evidence: resend-notification-provider-2026-10-20.
4.4 🔴 Measured correction: the provider must THROW, not "return {}" (§6 item 3)
The build task for the Resend provider was specified as "on error log and return
{} (do NOT throw — the module already records status=failure)". That is
false on the pinned v2.21.2 and the instruction was overridden on measured
evidence.
notification-module-service.js records status=failure only when the
provider's send() rejects. It never inspects the return value:
const res = await this.notificationProviderService_.send(provider, entry.data)
.catch((e) => { entry.data.status = FAILURE; ...; throw ... })
entry.data.external_id = res.id // undefined when the provider returned {}
entry.data.status = SUCCESS
A provider that swallows the error and returns {} therefore produces
status=success with external_id=NULL — a false success that reads as
delivered and would pass a health gate. A must-fail control (identical stub,
identical DB, identical templates, one statement changed) reproduced it.
The shipped Resend provider throws on every send failure. This is the
single most important property of the module, because the shipped
order.created subscriber swallows errors — the DB row is the only evidence.
4.5 🔴 Measured correction: the default provider stays none for now
tenant-notification-providers-2026-10-20.md §4.1 instructed a future agent to
flip the shipped default from none to resend "when the provider modules
land". The module now lands — but the default still must not flip, for a
second measured reason: validateOptions runs at boot, so a missing
credential is a boot failure, exactly like a missing module:
TENANT_EMAIL_PROVIDER=resend, no RESEND_API_KEY -> "Option `api_key` is required ..." [BOOT FAILS]
TENANT_EMAIL_PROVIDER=resend, key, no RESEND_FROM_EMAIL -> "Option `from` is required ..." [BOOT FAILS]
No Resend credential exists anywhere in the fleet (no RESEND_API_KEY in any
compose file, .env or vault). Flipping the default today would crash-loop
every tenant — an outage, not a loud failure. The default flips only after a
credential is provisioned and onboarding sets RESEND_API_KEY /
RESEND_FROM_EMAIL per tenant. The stale instruction in medusa-config.ts was
corrected in place.
5. What I did NOT do (and what remains)
- I did not build the providers. This card was scoped as confirm + choose. The
Resend and O365 module providers, the
inappchannel provider, the React Email templates, and the per-channel health gate are unbuilt and are follow-up build tasks (§6). - I did not touch the tenant template or the images.
docker-compose.tenant-template.ymlanddocker-compose.test-tenant.ymlhave no email/SMTP/Resend/O365 env today (verified) — so every tenant currently runs with no email provider at all and its email channel is dead. The template change is a build task, not this card. - I did not test real delivery. The probes prove routing, not deliverability. No email reached a real inbox; the probe provider was a local file writer. A live Resend/O365 send test with a real domain is still owed.
- I did not price it. §6 item 1 is Mira's.
- I did not verify the O365 app-registration path end to end — no Entra tenant
was available to me. The Graph permission model (
Mail.Sendapplication permission, admin consent required, "send mail as any user in the organization", scopeable to a specific mailbox via Exchange Online RBAC for Applications) is documented, not probed.
Update 2026-10-20 (card
t_154c2421, the O365 provider build): the O365 provider is now BUILT and PROVED against a local stand-in —[o365-notification-provider-2026-10-20](o365-notification-provider-2026-10-20). The client-credentials flow, thePOST /users/{mailbox}/sendMailrequest shape, the 202-not-delivery handling and the 429/403 handling are all exercised live against the real Microsoft client libraries. Still not proved: a real Entra tenant, admin consent, and deliverability to a real inbox — those need a tenant, not code. The app-registration path itself remains the tenant admin's step.That build also caught a real bug in the request body shape (Graph wraps the message and puts
saveToSentItemsat the top level, not insidemessage) that a permissive mock would have hidden. The lesson is in the implementation record, not here.The §6 item 4 build task below is now done.
5.1 Cleanup performed
Probe artefacts live in /home/ubuntu/notif-probe/ on prod (app + probe scripts) and
in a scratch database notif_probe_2026 on the pg-temp container. No tenant
database was written to. The scratch DB and directory can be dropped; I left them in
place so the probes can be re-run.
6. Follow-up build tasks (my lane, to be created)
| # | Task | Why it matters |
|---|---|---|
| 1 | 🔴 Price the email provider (Mira) | Resend Free 3,000/mo then US$20/mo @50k; O365 rides the tenant's M365. services-pricing-model-aud.md currently assumes A$0. A per-tenant "which provider" line changes the COGS on every tier. |
| 2 | Tenant template: declare local→feed + email provider explicitly, provider selected by TENANT_EMAIL_PROVIDER env | §3 — a config that omits local silently kills the Admin bell. |
| 3 | Build the Resend module provider (MIT, resend SDK) + React Email templates for order-placed / shipping / reset | §4.2; pins deliberately, not Cloud's. |
| 4 | Build the O365 Graph module provider (opt-in path) | §4.1; Mail.Send app permission + admin consent is a per-tenant onboarding step, so this cannot be the default. |
| 5 | Build the inapp channel provider + customer thread | §2.2 — feed is the Admin bell; the customer thread does not exist yet. This is the real §4.3/§5.2 build. |
| 6 | Per-channel health gate (assert a real send on feed, inapp and email separately) | §2.1 and the design's §3.6/P7: a tenant whose feed works and whose email is dead passes every page-load check. The gate must read the notification table, because the shipped subscriber swallows the error. |
| 7 | Customer-facing password-reset email | v2.21.2's reset-password workflow only emits the auth.password_reset event — it does not send mail. A subscriber is required, and today the customer cannot reset a password by email at all. |
| 8 | Wire order.created → email | The shipped configurable-notifications subscriber has order.created on channel email with template order-created-template — but no such template ships, and it is not registered by default. Decide: use it, or replace with our own subscriber. |
7. Honest limits
- Probes ran on prod (
161.33.71.67) against the pinned tree (@medusajs/medusa: 2.21.2exact,apps/backend/package.json), using an isolated scratch database. The prod Medusa instance was not reconfigured and no tenant DB was written to. - The end-to-end probes ran under
NODE_ENV=developmentwith a fake redis ("A fake redis instance will be used") because the probe app had noREDIS_URL. The Notification Module itself does not use redis, so the routing result is unaffected — but this is a dev-mode run, not a production-mode boot. npx medusa db:setupprompted interactively and was abandoned in favour ofdb:migrate; the scratch DB is migrated, not seeded.- I did not test the storefront rendering of a customer thread, because no such channel exists yet (§2.2).
- The Medusa Admin bell query I quote is from the minified shipped dashboard
bundle (
@medusajs/dashboard@2.21.2), read as source. It is strong evidence of intent, not a screenshot of the running panel.
8. Sources
1 apps/backend/package.json on prod — @medusajs/medusa: 2.21.2 (the pin)
2 node_modules/@medusajs/utils/dist/common/define-config.js — default local→feed provider
3 node_modules/@medusajs/notification/dist/loaders/providers.js — provider registration + one-per-channel validation
4 node_modules/@medusajs/notification/dist/services/notification-module-service.js — send path, provider_id/status semantics
5 node_modules/@medusajs/notification-local/dist/services/local.js — Local provider logs only
6 node_modules/@medusajs/medusa/dist/subscribers/configurable-notifications.js — shipped order.created→email subscriber, error swallowed
7 node_modules/@medusajs/medusa/dist/api/admin/notifications/* — read-only admin notification API
8 node_modules/@medusajs/dashboard/dist/app.js — Admin bell queries channel: "feed" with to: [me.id, me.email, ""]9 node_modules/@medusajs/core-flows/dist/auth/workflows/generate-reset-password-token.js — emits auth.password_reset, sends no mail
10 https://docs.medusajs.com/resources/infrastructure-modules/notification — provider/channel model
11 https://docs.medusajs.com/resources/infrastructure-modules/notification/local — Local provider, default feed channel
12 https://docs.medusajs.com/resources/references/notification-provider-module — custom provider guide
13 https://docs.medusajs.com/resources/integrations/guides/resend — Resend provider guide
14 https://resend.com/pricing — Free 3,000/mo; Pro US$20/mo @50k
15 https://resend.com/docs/knowledge-base/resend-sending-limits — Free 100/day; paid 5× quota overage cap
16 https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits — 10,000/day, 30 msg/min, TERRL
17 https://learn.microsoft.com/en-us/graph/api/user-sendmail — Graph Mail.Send, 202 Accepted ≠ delivered
18 https://learn.microsoft.com/en-us/graph/outlook-send-mail-from-other-user — Send As / Send on Behalf, app tokens send as any user
19 https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-sending-limits-troubleshoot — Exchange not suited to bulk mail
Marketing & Social add-ons — ruling of record
Ruling: add-ons on the existing ladder. Not a new tier. Solo A$119 and Shop A$229 do not change.
Prod consolidation — measured state + what moved + what waits for Tam
Young, 2026-10-13: Tam + Mira to consolidate Docs-website to match the LAB vault (Pulse + Docs), clean up prod vault and .solos\docs\vault, clean up eva_rerender_backup. Work with Tam.

