@pathmx/email turns authored .email.md Sources into transactional messages.
It captures messages by default and uses Resend when production sending is enabled.
Durable jobs preserve preparation and submission across server restarts.
Add @pathmx/email to your project's dependencies, aligned with its other PathMX
packages, and configure it in package.json:
{
"pathmx": {
"plugins": {
"@pathmx/email": {
"from": "Teacher <teacher@example.edu>",
"origin": "https://school.example.edu",
"retainDays": 30
}
}
}
}
from is one mailbox; optional replyTo is one reply mailbox. origin resolves
relative message links and must be an HTTPS origin, or HTTP localhost in development.
retainDays defaults to 30 and controls retained message payloads and terminal details.
Keep API keys outside authored Sources and package configuration.
Production sending requires PATHMX_EMAIL_MODE=send and RESEND_API_KEY in the
server environment. Development always captures background deliveries, even when
credentials exist. Static builds do not execute jobs or send messages. A queued
capture retains its capture policy when later opened in production.
Create paths/emails/feedback.email.md:
---
subject: "Feedback is ready for {{ assignment.title }}"
---
Hi {{ recipient.name }},
Your feedback for **{{ assignment.title }}** is ready.
[Read your feedback]({{ feedback.url }})
Templates support conservative Markdown, escaped scalar {{ dotted.values }},
and safe links. Missing values fail preparation. Relative links become absolute
using the configured origin. Email generates both HTML and plain text; templates
cannot execute code, embed browser components, or inject raw HTML. Email Sources
remain available to trusted server code and CLI inspection but have no public
Source route, rendered fragment, or search entry.
Compose one Email service and install its Plugin once. A domain Plugin can use
email.on to return a durable job. This complete factory receives the Event's
immutable publication reader from your domain integration:
import { defineEvent, definePlugin } from "@pathmx/core"
import { Email } from "@pathmx/email"
import { z } from "zod"
export const FeedbackPublished = defineEvent({
data: z.strictObject({ publication: z.string() }),
})
type Publication = {
recipientEmail: string
recipientName: string
assignment: string
url: string
}
export function feedbackPlugins(
readPublication: (id: string) => Promise<Publication>,
) {
const email = Email({
from: "Teacher <teacher@example.edu>",
origin: "https://school.example.edu",
})
return [
email.plugin,
definePlugin({
id: "feedback",
requires: ["email"],
events: { published: FeedbackPublished },
jobs: {
notify: email.on(FeedbackPublished, {
version: "1",
async message(event) {
const publication = await readPublication(event.data.publication)
return {
template: "/emails/feedback.email",
to: publication.recipientEmail,
data: {
recipient: { name: publication.recipientName },
assignment: { title: publication.assignment },
feedback: { url: publication.url },
},
}
},
}),
},
}),
]
}
The trusted publication Action saves a revision and calls
events.record(FeedbackPublished, { publication }) in the same prepared Action.
Use an immutable revision so a delayed job reads the intended publication. The
resolver owns authorization, recipient selection, and validation. Once prepared,
the recipient, sender, subject, HTML, and text are saved together; retries reuse
that message even if the template changes.
The factory above captures. A server integration enables sending by passing
{ mode: "send", transport: resendTransport(apiKey) } as Email's second argument;
import resendTransport from @pathmx/email and obtain the key from the server's
secret configuration. Install this composed service instead of also enabling the
package-discovered Email Plugin.
bun run pmx email preview /emails/feedback.email --to ada@example.edu \
--data '{"recipient":{"name":"Ada"},"assignment":{"title":"First essay"},"feedback":{"url":"/feedback/first"}}'
bun run pmx email send /emails/feedback.email --to ada@example.edu \
--data '{"recipient":{"name":"Ada"},"assignment":{"title":"First essay"},"feedback":{"url":"/feedback/first"}}'
bun run pmx email list
bun run pmx email inspect <delivery-id>
bun run pmx jobs list
Preview prints JSON containing rendered HTML and text without storing a delivery.
Send saves and captures the message by default. email send --live requests one
real submission; it does not enable other queued messages. Without a running
server, the command submits it using configured credentials. With pmx dev or
pmx serve running, the command saves the delivery and the server submits it:
a production server with sending enabled sends it, and any other server leaves it
blocked. The command then reports the saved status, such as prepared. All Email
commands work alongside the server.
The server runs due jobs automatically. bun run pmx jobs drain processes a
bounded batch now, through the running server when there is one. Without a server
it uses development policy, and jobs drain --production uses production
configuration. A send-mode record stays blocked until sending is enabled.
email retry <delivery-id> queues the original record; without a running server,
--live submits that one send-mode delivery. Captures cannot be converted into
sends, and accepted or unknown deliveries cannot be manually retried.
Create one Email service and pass it to plugins that need it. email.render
resolves a current Source template or accepts custom content; it returns a
validated message using the configured sender. It does not save or send anything.
email.submit submits that message once without saving its payload or scheduling
retries. Call these methods after the service's plugin is installed and ready.
import { Email, resendTransport } from "@pathmx/email"
import AuthPlugin from "@pathmx/auth"
export default function mailAndAuth() {
const apiKey = process.env.RESEND_API_KEY
const email = Email({
from: "Course <course@example.edu>",
origin: process.env.PATHMX_AUTH_ORIGIN!,
}, {
mode: process.env.PATHMX_EMAIL_MODE === "send" ? "send" : "capture",
...(apiKey ? { transport: resendTransport(apiKey) } : {}),
})
return [
email.plugin,
AuthPlugin({
magicLink: {
email: { service: email, template: "/emails/sign-in.email" },
},
}),
]
}
Auth still reads its mode, origin, and secret from the Auth environment. Email
owns the sender and transport, so this shared setup does not need
PATHMX_AUTH_EMAIL_FROM. Install this composition instead of separately enabling
an automatic Email service. The same email instance can supply .on(...) jobs
for feedback or other transactional messages.
Supplying a service transfers Email configuration to this composition. Auth does
not choose its transport or force it into send mode. Validate the sender, origin
and any transport credentials in the factory, and choose send explicitly for
live sign-in emails. capture is appropriate for local verification; a captured
magic link is not delivered. A custom transport need not use RESEND_API_KEY.
A plugin can render a Source, an inline Markdown template, or pre-rendered content:
const message = email.render({
to: "ada@example.edu",
template: "/emails/feedback.email",
data: {
recipient: { name: "Ada" },
assignment: { title: "First essay" },
feedback: { url: "/feedback/first" },
},
})
const outcome = await email.submit(message)
An inline template uses template: { subject, body } with data. Custom HTML
uses content: { subject, html, text } instead of template and data; the caller
owns HTML escaping. Both paths validate the recipient, subject, sender, and
message shape. submit rejects a different sender or reply address.
Direct submission returns accepted, failed, unknown, retry, or blocked.
retry reports a transport result; it does not start a retry loop. Every call is
one submission with a fresh identity. Use .on(...) when retries must reuse a
frozen message and stable submission identity. Direct submission is blocked in
static builds, development, and capture mode; it creates no capture record.
Preview with synthetic data when content contains credentials.
Auth uses direct submission so rendered sign-in URLs do not enter Email delivery
records, jobs, or capture output. Feedback's .on(...) flow keeps its existing
saved preparation, capture policy, and durable retry behavior.
accepted means Resend accepted the message, not that it reached an inbox.
Configure RESEND_WEBHOOK_SECRET and Resend's webhook destination
https://your-site.example/api/email/webhook on the production server to record
signed delivery updates. Repeated or older updates do not overwrite newer state.
Submission retries use the saved message and stable provider identity. Interrupted
or ambiguous submissions can retry only within the provider idempotency window;
a conservative cutoff before 24 hours stops them as unknown. Unknown outcomes
need reconciliation, and no command silently clears that state. This is recoverable
submission with provider deduplication, not a universal exactly-once guarantee.
Delivery records and payloads are private operational data. email inspect can
show recipient information and rendered content. Pruning runs during Email
operations and production startup; email prune also runs it explicitly.
Expired payloads and eligible terminal details are removed while delivery identity
tombstones remain to prevent replay from creating a second message. Durable job
history has separate storage and is not compacted by Email retention.