mail(folder: string, options?: MailServerOptions): MailFolderAdapter
mail(options: MailServerOptions & { folder: string }): MailFolderAdapter
mail(action: MailAction): Destination<unknown>
mail(options?: MailClientOptions): Destination<MailSendPayload>
Read email via IMAP, send via SMTP, or perform IMAP operations. Which role you get is selected by the operation keyword and by which keys you supply, never by how many arguments you pass.
Naming a folder returns one read adapter, MailFolderAdapter, that carries both read roles. The operation keyword picks between them: .from() subscribes over IMAP IDLE or polling, .enrich() fetches a batch mid-route. The second argument only fills in options, so all four combinations below are valid:
.from(mail('INBOX')) // subscribe with defaults
.from(mail('INBOX', { markSeen: true }))
.enrich(mail('INBOX')) // fetch with defaults
.enrich(mail('INBOX', { unseen: true }))
Source role (IMAP push): reached with .from(). Each new email becomes a separate exchange, delivered via IMAP IDLE or polling.
The source follows the payload-on-body, envelope-on-headers convention shared with the HTTP source: the parsed message content (text, html, attachments) lands on exchange.body (a MailBody), and the envelope (from, to, subject, date, flags, sender, ...) lands on routecraft.mail.* headers. This means .input({ body }) validates against the message content alone, and the same .transform() / .filter() operators compose whether the payload arrived over mail or HTTP.
craft()
.id('inbox-watcher')
.from(mail('INBOX', { markSeen: true }))
.to(log())
// Read the envelope off headers, the content off the body.
craft()
.id('inbox-router')
.from(mail('INBOX', { markSeen: true }))
.filter((ex) => ex.headers['routecraft.mail.from']?.endsWith('@acme.test') ?? false)
.transform((body) => body.text ?? '')
.to(log())
Source delivery modes: the source runs in one of two modes.
- IDLE (default): the server pushes notifications when new mail arrives. The
\Seenflag is the cross-cycle dedupe state, so each message is delivered exactly once per subscription. IDLE is the right default for "process each new email once" workloads. - Poll (opt-in): set
pollIntervalMsto fetch on a cadence instead of IDLE. Required whenever you opt out of the\Seendedupe model (markSeen: falseorunseen: false), for example to re-evaluate the inbox on every cycle and rely on a folder move as the done-signal. IDLE has no cycle boundary, so combining it with those overrides would refetch the entire folder on every inbound message; the source throwsRC5003at startup to prevent this footgun.
// Re-evaluate the inbox every minute; archive a message to mark it done.
// If you later extend `matchesCriteria`, previously-unmatched mail that is
// still in INBOX is picked up on the next cycle.
craft()
.id('inbox-processor')
.from(mail('INBOX', {
pollIntervalMs: 60_000,
markSeen: false,
unseen: false,
}))
.filter(matchesCriteria)
.process(processMessage)
.to(mail({ action: 'move', folder: 'Archive' }))
The \Seen flag is written per-message after the handler resolves successfully, so a downstream failure leaves the message un-Seen and it is retried on the next cycle. limit combined with IDLE is a latency trap (backlog beyond the limit only drains when new mail arrives) and emits a warning at subscribe time.
Connection recovery: every connection-type failure on the source connection (the initial connect at route start, an IDLE drop, a failed fetch in either mode) goes through the same reconnect loop: exponential backoff with full jitter, growing from reconnect.baseDelayMs (default 1s) up to reconnect.maxDelayMs (default 60s), for up to reconnect.maxAttempts (default 30) consecutive failed attempts. After a reconnect the folder is drained immediately, so mail that arrived during the outage is delivered without waiting for the next new-arrival notification. Authentication failures never reconnect; they stop the route immediately with RC5012.
Because the initial connect retries too, the source signals readiness before the first connection succeeds: an IMAP server that is unreachable at route start leaves the route running in a degraded-but-recovering state, and route:started does not guarantee the mailbox was reachable. When the attempts cap is exhausted the source gives up with RC5010 and the route stops; subscribe to route:source:failed to alarm on a dead channel. Set reconnect: { maxAttempts: Infinity } for a channel that must never give up, or reconnect: false to disable recovery and fail on the first connection error.
// A long-lived agent channel: keep retrying forever, alarm via events.
craft()
.id('inbox-agent')
.from(mail('INBOX', { reconnect: { maxAttempts: Infinity } }))
.to(processMessage())
Enricher (IMAP pull): Pass a folder string, or server options containing folder, to fetch messages. Use with .enrich() to pull mail on demand: the fetched MailMessage[] replaces the body by default (pass an aggregator such as only() to merge instead). The folder key is required in the object form: it is what distinguishes a fetch from a send, the same way http splits on path vs url.
craft()
.id('check-inbox')
.from(cron('0 */5 * * * *'))
.enrich(mail('INBOX'))
.to(log())
// Object form: `folder` is required and marks the fetch intent
craft()
.id('check-unread')
.from(cron('0 */5 * * * *'))
.enrich(mail({ folder: 'INBOX', unseen: true, limit: 10 }))
.to(log())
Send destination (SMTP): Call with no arguments or client options (no folder) to send email. The exchange body must be a MailSendPayload. The send is void: the body flows through the .to() step unchanged, and the send receipt lands on headers (routecraft.mail.sentMessageId, routecraft.mail.accepted, routecraft.mail.rejected, routecraft.mail.response; see send receipt headers). The inbound routecraft.mail.messageId (set by the source) is left untouched, so mail-to-mail routes keep their correlation id.
craft()
.id('outbound')
.from(direct())
.to(mail())
Combined read and send:
// Forward unread mail to a different address. The incoming subject is on
// headers (envelope); the text content is on the body (payload).
craft()
.id('mail-forwarder')
.from(mail('INBOX', { unseen: true, markSeen: true }))
.transform((body, ex) => ({
to: '[email protected]',
subject: `Fwd: ${ex.headers['routecraft.mail.subject']}`,
text: body.text ?? '',
}))
.to(mail())
IMAP operations: Call with a MailAction object to move, copy, delete, flag, unflag, or append messages.
// Archive after processing
craft()
.id('archive-processed')
.from(mail('INBOX', { unseen: true }))
.tap(processMessage)
.to(mail({ action: 'move', folder: 'Archive' }))
// Flag important messages
craft()
.id('flag-important')
.from(mail('INBOX', { subject: 'URGENT' }))
.to(mail({ action: 'flag', flags: '\\Flagged' }))
Configuration via named accounts:
Mail connection details are set once in your craft.config.ts so individual routes do not need to repeat them. Each capability file re-exports the config:
// craft.config.ts
import type { CraftConfig } from '@routecraft/routecraft'
export const craftConfig: CraftConfig = {
mail: {
accounts: {
default: {
imap: {
host: 'imap.gmail.com',
auth: { user: process.env.MAIL_USER!, pass: process.env.MAIL_APP_PASSWORD! },
},
smtp: {
host: 'smtp.gmail.com',
auth: { user: process.env.MAIL_USER!, pass: process.env.MAIL_APP_PASSWORD! },
from: process.env.MAIL_USER!,
},
},
},
},
}
// capabilities/inbox-watcher.ts
export { craftConfig } from '../craft.config'
import { craft, mail, log } from '@routecraft/routecraft'
export default craft()
.id('inbox-watcher')
.from(mail('INBOX', { markSeen: true }))
.to(log())
When multiple accounts are configured, select one per adapter call with the account option:
.from(mail('INBOX', { account: 'support' }))
.to(mail({ account: 'notifications' }))
Server options (MailServerOptions):
Client options (MailClientOptions):
MailBody (source exchange body):
In source mode (.from(mail(...))) the exchange body is just the parsed message content. The envelope lives on headers.
Source headers (routecraft.mail.*):
In source mode the envelope is attached to exchange.headers under the routecraft.mail.* namespace. The keys are declaration-merged into RoutecraftHeaders (so you get autocomplete) and exported on the MailHeaders key object (MailHeaders.FROM, MailHeaders.SUBJECT, ...).
MailMessage (fetch result):
In the fetch role (.enrich(mail(...))) the fetched MailMessage[] replaces the body by default. Because a batch fetch returns many messages, each one keeps its whole envelope together in a single object rather than splitting across single-valued headers.
MailSender (on routecraft.mail.sender / MailMessage.sender):
Resolves the real sender of mailing-list and auto-forwarded messages, so apps can gate on origin without re-parsing headers. For a Google Groups forward, sender.address is the original sender and from is the rewritten list address.
Filter on the effective sender:
craft()
.from(mail('INBOX'))
.filter((ex) => {
const s = ex.headers['routecraft.mail.sender'];
if (s?.address === '[email protected]' && s.trust === 'verified') {
return true;
}
return { reason: s?.reason ?? 'no sender info' };
})
.to(log())
MailSendPayload (exchange body for .to(mail())):
Send receipt headers:
A .to(mail()) send never touches the body. The receipt is surfaced through the step's SendContext and merged onto the continuing exchange's headers:
Exported types: MailAuth, MailServerOptions, MailClientOptions, MailOptions, MailBody, MailMessage, MailAttachment, MailSendPayload, MailFetchResult, MailContextConfig, MailAccountConfig, MailAction, MailSender, EmailAddress, ForwardHop, ForwardType, TrustLevel, MailClientManager, MAIL_CLIENT_MANAGER. Header keys: the MailHeaders object (UID, FOLDER, MESSAGE_ID, FROM, TO, CC, BCC, SUBJECT, DATE, REPLY_TO, FLAGS, SENDER, RAW_HEADERS, SENT_MESSAGE_ID, ACCEPTED, REJECTED, RESPONSE). Helpers: analyzeHeaders, parseAuthResults.