A production-ready Postal notification provider for Medusa. Designed for reliable transactional email delivery through Postal's HTTP API, strong configuration validation, template-based workflows, and seamless integration with Medusa’s notification system.
- Current package version:
- License:
MIT - Changelog:
CHANGELOG.md
The package has two sets of options: the provider options, under the Notification Module, and the plugin options, on the plugins entry. See Usage for where each goes.
auth_type- Postal API modefrom- default sender e-mail addressbase_url- Postal base URL, for examplehttps://postal.example.comapi_key- Postal server API key used inX-Server-API-Keyrequest_timeout_ms- outbound Postal HTTP timeout in ms. Optional. It takes precedence overPOSTAL_REQUEST_TIMEOUT_MS, falls back to that variable and then to10000, and is clamped to 1000-60000.
auth_type only accepts smtp-api (the Postal HTTP API); any other value is rejected at startup.
Every plugin option is off unless you set it, so upgrading changes nothing for a store that does not configure them.
| Option | Default | Effect |
|---|---|---|
notification_retention_days |
unset (off) | A daily job (postal-purge-expired-notifications, 03:30) hard-deletes every row in Medusa's core notification table created more than this many days ago, whatever its provider, channel, template, resource or status. |
webhook_event_retention_days |
unset (off) | A daily job (postal-purge-webhook-events, 03:40) hard-deletes redacted postal_webhook_events delivery metadata created more than this many days ago. Recipient addresses and raw Postal payloads are never retained. |
ignore_engagement_webhooks |
false |
MessageLinkClicked and MessageLoaded callbacks are acknowledged but neither stored nor emitted as postal.clicked / postal.loaded. Delivery outcomes (sent, delayed, failed, held, bounced, DNS errors) are still recorded. |
The retention options take a whole number of days from 1 to 7300, as a number or a numeric string. Any other value leaves that purge off, and the job logs a warning that names the option but not the value. Job schedules are fixed in the job files; the periods are read from the plugin module at run time. The jobs log counts and cutoffs only, never recipients or content.
notification_retention_days is deliberately provider-neutral. The notification table is shared by every notification provider in the store, and each row is delivery metadata (recipient, template, provider message id, sometimes a Reply-To address) with no retention of its own. Before you set it, check that nothing in your store relies on old rows: a notification idempotency_key only deduplicates while its row exists.
The provider options above are typically wired from environment variables. The plugin also reads the following at runtime:
| Variable | Secret | Purpose |
|---|---|---|
POSTAL_AUTH_TYPE |
no | Auth mode; only smtp-api is supported (default smtp-api). |
POSTAL_FROM |
no | Default sender address (from option). |
POSTAL_BASE_URL |
no | Postal base URL (base_url option). Must be http/https. localhost and literal loopback, private or link-local IP addresses are refused when a request is sent; use a hostname. |
POSTAL_API_KEY |
yes | Postal server API key (api_key option). |
POSTAL_WEBHOOK_TOKEN |
yes | Shared secret in the tokenized webhook path; generated if unset. |
POSTAL_WEBHOOK_PUBLIC_KEY |
no | Postal's RSA public key (PEM, JWK, or the single-key JWKS from /.well-known/jwks.json). When set, webhooks must also carry a valid Postal signature. |
POSTAL_REQUEST_TIMEOUT_MS |
no | Outbound Postal HTTP timeout in ms (default 10000, clamped to 1000-60000). The request_timeout_ms provider option wins when set. |
POSTAL_TEST_TO |
no | Recipient for admin test sends, and the address sandbox mode redirects to. |
POSTAL_SANDBOX |
no | true redirects every recipient to POSTAL_TEST_TO (see Sandbox mode). Off unless set. |
MEDUSA_BACKEND_URL |
no | Fallback origin for the absolute webhook callback URL shown in the admin, when the request's host cannot be used. VITE_BACKEND_URL is the second fallback and also the admin extension's backend URL. |
No other variables are read. The template registry, the webhook tag prefix (uhlhosting.medusa-notification-postal:), the webhook events table (postal_webhook_events), the provider identifier (notification-postal) and the module name (postalPlugin) are fixed in code and cannot be overridden from the environment.
Keep the secret variables out of logs and client-visible surfaces; the admin settings endpoint never returns them.
A staging or preview deployment sends the same mail a production one does — order confirmations, quotes, contact enquiries — to the same real customers. Set POSTAL_SANDBOX=true there and every recipient is replaced by POSTAL_TEST_TO instead:
to,ccandbccall collapse to the single sandbox address, so nobody else is written to.- The original addresses are preserved in
X-Postal-Sandbox-To,-Ccand-Bcc, alongsideX-Postal-Sandbox: true. These are applied after the caller's own headers, so a notification cannot forge or overwrite them. - The subject is prefixed with the address it was meant for —
[sandbox: customer@example.com +3] Ihre Bestellung— because one inbox now receives mail addressed to many different people, and the subject is the only part of that a mailbox list shows.
The switch is explicit and off by default, for two reasons:
NODE_ENVcannot stand in for it. A typical Medusa container image setsNODE_ENV=productionin its runtime stage, so staging, preview and production are all "production" to the running process and the value carries no signal.- Neither can the presence of
POSTAL_TEST_TO. Production sets that too — it is the recipient of the admin's send test email button — so defaulting to on would turn one forgotten variable in production into every customer's order confirmation landing in an internal test inbox. Silently swallowing real mail is a worse failure than the one sandbox mode prevents, so it fails closed.
If sandbox mode is on but POSTAL_TEST_TO is empty, the provider logs the misconfiguration once and sends as addressed; refusing would leave the environment unable to send anything at all.
Non-secret settings edited in the admin (from, base_url, test_to) persist in the postal_setting table via the plugin module — run medusa db:migrate after installing. Secrets (POSTAL_API_KEY, POSTAL_WEBHOOK_TOKEN) are sourced from the environment/provider options only, are read-only in the admin UI, and are never written to disk. Changes that affect the constructed provider take effect after a backend restart.
Register the plugin package so its module, migrations, routes, and Admin extension are loaded. Then register the explicit Postal provider subpath under the Notification Module:
module.exports = defineConfig({
plugins: [
{
resolve: "@uhlhosting/medusa-notification-postal",
// Plugin options; all optional and off by default.
options: {
notification_retention_days: 90,
webhook_event_retention_days: 90,
ignore_engagement_webhooks: true,
},
},
],
modules: [
{
resolve: "@medusajs/medusa/notification",
options: {
providers: [
{
resolve:
"@uhlhosting/medusa-notification-postal/providers/postal",
id: "postal",
options: {
channels: ["email"],
auth_type: "smtp-api",
from: process.env.POSTAL_FROM,
base_url: process.env.POSTAL_BASE_URL,
api_key: process.env.POSTAL_API_KEY,
request_timeout_ms: 10000,
},
},
],
},
},
],
})Use Medusa notification workflows and pass workflow metadata in provider_data:
await notificationModuleService.createNotifications({
channel: "email",
to: "cosmin@example.com",
template: "order-placed",
content: {
subject: "Order confirmation",
html: "<p>Thanks for your order</p>",
text: "Thanks for your order",
},
provider_data: {
workflow_event: "order.placed",
workflow_run_id: "wf_run_123",
},
})The provider logs workflow_event and workflow_run_id for traceability in Medusa runtime logs.
The plugin now exposes a public ingestion endpoint for Postal delivery lifecycle webhooks:
POST /postal/webhooks/<postal-webhook-token>
The exact tokenized URL is shown in the Postal admin activity page after you save settings. The settings screen intentionally only shows the callback path so the secret stays out of the configuration surface.
It accepts the Postal message status events documented by Postal:
MessageSentMessageDelayedMessageDeliveryFailedMessageHeldMessageBouncedMessageLinkClickedMessageLoadedDomainDNSError
Incoming webhook payloads are stored as raw JSON with normalized status metadata, so you can inspect delivery state changes in the admin Postal page after Postal calls back into Medusa.
The admin page shows a redacted webhook activity log and the endpoint to configure inside Postal. It exposes event type, status, provider message ID, and timestamps only - never recipients or raw callbacks.
Postal's HTTP payload docs are separate from webhook delivery callbacks and are mainly useful if you are also handling inbound mail by HTTP. Postal's auto-responder, bounce, wildcard, and address-tag docs are relevant when you want to route inbound mail or reason about delivery replies, but they do not change the webhook callback contract itself.
The URL token is always required. To also verify that each webhook really came from your Postal installation, set POSTAL_WEBHOOK_PUBLIC_KEY to Postal's public signing key. Fetch it without authentication from https://<postal-host>/.well-known/jwks.json and paste the JSON as-is, or paste the key as PEM. On the Postal host, postal default-dkim-record also prints it: wrap the p= value in -----BEGIN PUBLIC KEY----- / -----END PUBLIC KEY----- lines. PEM with literal \n escapes (single-line env files) is accepted.
When the key is set, the plugin verifies the signature over the exact raw request body:
X-Postal-Signature-256(RSA SHA256, Postal ≥ 3.2) is checked whenever present. A failed SHA256 check is never retried with SHA1.X-Postal-Signature(RSA SHA1, legacy Postal 2.x) is used only when the SHA256 header is absent.- Requests with a missing or invalid signature are rejected with
400. An unparseable key fails closed: every token-authenticated webhook gets a500and the backend log says why.
Leave the variable unset to rely on the URL token alone.
The plugin includes a built-in template registry for common notification flows:
| Template | Purpose | Typical Medusa event | Notes |
|---|---|---|---|
default |
Generic fallback preview | Any custom template name | Used when no registry match exists and content is incomplete. |
postal-test |
Provider transport validation | postal.example.test |
Used for operator sends and transport checks. |
postal-admin-test |
Admin settings validation | admin.postal.test |
Used by the admin test-send form. |
order-placed |
Customer order confirmation | order.placed |
Shared transactional order mail. |
admin-invite |
Native Medusa Admin invitation | invite.created, invite.resent |
Use application-rendered content so the environment-specific invite URL is delivered without logging the token. |
password-reset |
Account password reset | customer.password_reset |
Shared auth email template. |
email-verification |
Account email verification | customer.email_verification |
Shared auth email template. |
welcome |
Customer onboarding | customer.welcome |
Shared onboarding and first-contact template. |
abandoned-cart |
Cart recovery | cart.abandoned |
Shared recovery reminder template. |
restock-available |
Back-in-stock alert | restock.available |
Shared inventory alert template. |
If a template key is not in the registry, Postal still uses the provided template string and falls back to the passed content. You can also pass extra tracing data through provider_data.metadata and provider_data.custom_args:
provider_data: {
subject: "Order confirmation",
html: "<p>Thanks for your order</p>",
text: "Thanks for your order",
workflow_event: "order.placed",
workflow_run_id: "wf_run_123",
metadata: {
store: "main",
environment: "production",
},
custom_args: {
order_id: "ord_123",
customer_group: "vip",
},
}custom_args are normalized into safe email headers for transport-level traceability. metadata stays available in Medusa-side notification data and logs.
The admin Postal settings page uses the same registry for test-send template selection, so the built-in examples stay aligned across the backend and admin UI.
The selected template also shows a preview of its subject, text, and HTML in the admin test-send panel.
That panel also includes a full example payload with recipient, sender identity, workflow metadata, and sample custom args for the selected template.
The same panel now lets you load the example values into the test form with one click, edit the message subject/text/HTML/custom args/metadata, or copy the example JSON directly.
It also exposes cc, bcc, and custom headers so test sends match the provider contract more closely.
You can also set sender identity fields when you need branded mail or a separate reply path:
provider_data: {
from: "no-reply@example.com",
from_name: "Postal Admin",
reply_to: "support@example.com",
cc: "billing@example.com",
bcc: ["archive@example.com"],
headers: {
"X-Trace-Id": "trace_123",
},
}from_name formats the sender as Name <email>. reply_to is forwarded to Postal as both the reply_to field and the Reply-To header when it is exactly one bare printable-ASCII address (surrounding whitespace is trimmed). Anything else (a display name, a list, CR/LF, non-ASCII or invisible characters) is dropped and a warning is logged that names no address; the message is still sent, without a reply path. A Reply-To entry in provider_data.headers (in any letter case) gets the same check and is dropped the same way when it is not one plain address. When reply_to is valid it replaces any such header, and at most one Reply-To is sent. This makes it safe to pass a customer-supplied address, such as a contact-form enquirer's, either way.
You can trigger a direct email notification through the Postal provider programmatically using the sendPostalEmailWorkflow. This ensures the mail goes through the provider's standard channel and logs full delivery metadata.
import { sendPostalEmailWorkflow } from "@uhlhosting/medusa-notification-postal"
const { result } = await sendPostalEmailWorkflow(req.scope).run({
input: {
to: "cosmin@example.com",
from: "custom-sender@example.com", // Optional, defaults to POSTAL_FROM
template: "custom-template-id", // Optional
provider_data: {
subject: "Test Programmatic Email",
html: "<p>Hello, this is a test email sent programmatically.</p>",
text: "Hello, this is a test email sent programmatically.",
cc: "billing@example.com",
workflow_event: "admin.test_send",
workflow_run_id: "wf_run_manual_123"
}
}
})
// Result returns the delivery info:
// { success: true, delivery: { message_id: "123", ... } }