[@layout]: ./docs.layout.md

# 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](./extend/jobs.reference) 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`:

```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`:

```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:

```ts
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

```sh
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.

```ts
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:

```ts
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.
