Email

@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.

Configure Email

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.

Author a template

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.

Connect a domain Event

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.

Preview, capture, and inspect

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.

Share Email with other plugins

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.

Delivery and recovery

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.