Young's Mind
Decisions

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.

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

#QuestionAnswer
1Does 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.
2Email 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):

ChannelCarriesProviderStatus
feedMedusa 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.2our module🔴 to build
emailreceipts, shipping confirmations, password resetResend (default) / O365 (opt-in)🔴 to build
smsoff 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:

  1. 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.md only if Mira prices it. 🔴 This is a pricing input Mira owns, not me — see §6.
  2. 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.
  3. Both are the same shape in Medusa. Each is a ModuleProvider(Modules.NOTIFICATION) with one send() method. The provider is a string key, so the tenant template picks one by env var with no image rebuild (Probe E).
  4. 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.com domains 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 in medusa-cloud-engineering-verification.md §2b, and both were absent from our pinned tree (verified: resend, @react-email/components, @react-email/render, nodemailer all ABSENT from the 720-package node_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 (in dependencies, not devDeps), plus a root overrides["@react-email/render"] = "2.1.1" so the tree holds one copy (components@1.0.12 otherwise nests 2.0.6). Proven on our stack: both templates render real HTML, and the matrix also works under React 19.2.0. Cloud's 0.3.3 pin 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 inapp channel 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.yml and docker-compose.test-tenant.yml have 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.Send application 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, the POST /users/{mailbox}/sendMail request 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 saveToSentItems at the top level, not inside message) 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)

#TaskWhy 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.
2Tenant 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.
3Build the Resend module provider (MIT, resend SDK) + React Email templates for order-placed / shipping / reset§4.2; pins deliberately, not Cloud's.
4Build 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.
5Build 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.
6Per-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.
7Customer-facing password-reset emailv2.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.
8Wire order.created → emailThe 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.2 exact, 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=development with a fake redis ("A fake redis instance will be used") because the probe app had no REDIS_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:setup prompted interactively and was abandoned in favour of db: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

Copyright © 2026