mail

← All adapters

mail(folder: string, options: Partial<MailServerOptions>): Source<MailBody>
mail(folder: string): Destination<unknown, MailFetchResult>
mail(action: MailAction): Destination<unknown, void>
mail(options: MailServerOptions & { folder: string }): Destination<unknown, MailFetchResult>
mail(options?: Partial<MailClientOptions>): Destination<MailSendPayload, MailSendResult>

Read email via IMAP, send via SMTP, or perform IMAP operations. The adapter has four modes determined by the arguments you pass.

Source mode (IMAP push): Pass a folder and options to receive new messages via IMAP IDLE or polling. Each new email becomes a separate exchange.

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 \Seen flag 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 pollIntervalMs to fetch on a cadence instead of IDLE. Required whenever you opt out of the \Seen dedupe model (markSeen: false or unseen: 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 throws RC5003 at 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())

Fetch destination (IMAP pull): Pass a folder string, or server options containing folder, to fetch messages. Use with .enrich() to pull mail on demand. 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.

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: 'team@example.com',
    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):

OptionTypeDefaultDescription
hoststringIMAP host (e.g. 'imap.gmail.com')
portnumber993IMAP port
securebooleantrueUse TLS
authMailAuth{ user, pass } credentials
folderstringIMAP mailbox folder. Required in the object-form fetch destination, where it is the fetch/send discriminator; passed positionally in the source form
markSeenbooleantrueMark fetched messages as seen
sinceDateOnly fetch messages since this date
unseenbooleantrueOnly fetch unseen messages
fromstring | string[]Filter by sender (IMAP FROM search). Array = OR
tostring | string[]Filter by recipient (IMAP TO search). Array = OR
subjectstring | string[]Filter by subject text (IMAP SUBJECT search). Array = OR
bodystring | string[]Filter by body text (IMAP TEXT search). Array = OR
headerRecord<string, string | string[]>Filter by arbitrary IMAP headers. Array values = OR
includeHeaderstrue | string[]Raw headers to include on fetched messages. true = all
verify'off' | 'headers' | 'strict''headers'Sender analysis. 'headers' reads Authentication-Results/ARC/List-Id the receiving server wrote (no network). 'strict' additionally runs cryptographic verification via optional mailauth (DNS lookups). 'off' skips analysis.
limitnumberMaximum messages per fetch
pollIntervalMsnumberPoll interval in ms (default: IMAP IDLE)
accountstringNamed account from context config (uses default if omitted)
reconnectMailReconnectOptions | false{ maxAttempts: 30, baseDelayMs: 1000, maxDelayMs: 60000 }Source-mode connection recovery. maxAttempts caps consecutive failed attempts (Infinity = never give up), delays grow exponentially from baseDelayMs to maxDelayMs with full jitter. false disables recovery (fail on the first connection error). See the connection recovery notes above.
onParseError'fail' | 'abort' | 'drop''fail'How to handle a per-message MIME parse failure. See parse error handling. All three modes mark the malformed message Seen so it does not refetch forever. 'fail' routes the failure through the route's .error() handler (or exchange:failed if no handler is set). 'drop' does NOT invoke .error(); it emits exchange:dropped with reason: 'parse-failed' so subscribers can count parse drops as a structured event without scraping logs. Pre-#187 behaviour was equivalent to a silent 'drop' (logged at debug, no event); set onParseError: 'drop' to keep lossy-ingest semantics with structured observability.

Client options (MailClientOptions):

OptionTypeDefaultDescription
hoststringSMTP host (e.g. 'smtp.gmail.com')
portnumber465SMTP port
securebooleantrueUse TLS
authMailAuth{ user, pass } credentials
fromstringDefault sender address
replyTostringDefault reply-to address
ccstring | string[]Default CC recipients
bccstring | string[]Default BCC recipients
accountstringNamed account from context config (uses default if omitted)

MailBody (source exchange body):

In source mode (.from(mail(...))) the exchange body is just the parsed message content. The envelope lives on headers.

FieldTypeDescription
textstring?Plain text body, when the message included a text/plain part.
htmlstring?HTML body, when the message included a text/html part.
attachmentsMailAttachment[]?File attachments. Attachments are message content (not envelope), so they stay on the body alongside text/html, mirroring how the HTTP source keeps multipart files on the body.

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, ...).

HeaderTypeDescription
routecraft.mail.uidnumberIMAP UID
routecraft.mail.folderstringThe IMAP folder this message was fetched from
routecraft.mail.messageIdstringMessage-ID header
routecraft.mail.fromstringLiteral From: header. For mailing-list forwards this is the rewritten list address; use routecraft.mail.sender for the real sender.
routecraft.mail.tostring[]Recipient address(es), always normalised to an array
routecraft.mail.ccstring[]?CC recipients (absent when none)
routecraft.mail.bccstring[]?BCC recipients (absent when none)
routecraft.mail.subjectstringSubject line
routecraft.mail.dateDateDate sent
routecraft.mail.replyTostring?Reply-to address
routecraft.mail.flagsReadonlySet<string>IMAP flags (e.g. \Seen, \Flagged)
routecraft.mail.senderMailSender?Computed effective sender and forward chain (see below). Absent when verify: 'off'.
routecraft.mail.rawHeadersRecord<string, string | string[]>?Raw email headers (when includeHeaders is set)

MailMessage (fetch destination result):

In fetch mode (.enrich(mail(...))) the result body is a MailMessage[]. 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.

FieldTypeDescription
uidnumberIMAP UID
messageIdstringMessage-ID header
fromstringLiteral From: header. For mailing-list forwards this is the rewritten list address; use sender.address for the real sender.
tostring | string[]Recipient address(es)
subjectstringSubject line
dateDateDate sent
body{ text?: string; html?: string }Message body. Both, either, or neither may be populated depending on what the sender composed (multipart/alternative vs single-part).
ccstring[]?CC recipients
bccstring[]?BCC recipients
replyTostring?Reply-to address
attachmentsMailAttachment[]?File attachments
rawHeadersRecord<string, string | string[]>?Raw email headers (when includeHeaders is set)
flagsSet<string>IMAP flags (e.g. \Seen, \Flagged)
folderstringThe IMAP folder this message was fetched from
senderMailSender?Computed effective sender and forward chain (see below). Omitted when verify: 'off'.

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.

FieldTypeDescription
addressstringEffective sender address, after unwinding list / auto-forward rewrites.
namestring?Display name, when present.
domainstringDomain portion of address.
forwardType'direct' | 'auto-forward' | 'mailing-list'How the message reached the recipient.
forwardChainForwardHop[]Hops between original sender and final recipient, nearest hop first. Empty for direct mail.
trust'verified' | 'unverified' | 'failed'Trust state. Direct mail is verified when dmarc=pass; forwarded mail is verified when ARC cv=pass.
reasonstringMachine-readable slug (e.g. 'list-forward-arc-verified', 'direct-dmarc-aligned').
authentication{ dkim, spf, dmarc, arc }Per-method verdicts (pass / fail / neutral / none; ARC is pass / fail / none).
headerFromEmailAddress?Literal From: header, only set when it differs from the effective sender.

Filter on the effective sender:

craft()
  .from(mail('INBOX'))
  .filter((ex) => {
    const s = ex.headers['routecraft.mail.sender'];
    if (s?.address === 'alice@allowed.com' && s.trust === 'verified') {
      return true;
    }
    return { reason: s?.reason ?? 'no sender info' };
  })
  .to(log())

MailSendPayload (exchange body for .to(mail())):

FieldTypeDescription
tostring | string[]Recipient address(es)
subjectstringSubject line
textstring?Plain text body
htmlstring?HTML body
ccstring | string[]?CC recipients
bccstring | string[]?BCC recipients
fromstring?Sender (overrides option-level from)
replyTostring?Reply-to (overrides option-level replyTo)
inReplyTostring?Message-ID of the message being replied to. Sets In-Reply-To and, when references is not set, also seeds References so mail clients stitch the thread. The inbound side exposes the value as the routecraft.mail.messageId header
referencesstring | string[]?Explicit References chain (oldest first). Overrides the chain derived from inReplyTo
headersRecord<string, string>?Custom RFC 5322 headers on the outgoing message (e.g. X-Auto-Response-Suppress). The threading fields above win over the same keys given here
attachmentsArray<{ filename, content, contentType? }>?File attachments

MailSendResult:

FieldTypeDescription
messageIdstringMessage-ID of the sent email
acceptedstring[]Accepted recipient addresses
rejectedstring[]Rejected recipient addresses
responsestringSMTP server response string

Exported types: MailAuth, MailServerOptions, MailClientOptions, MailOptions, MailBody, MailMessage, MailAttachment, MailSendPayload, MailSendResult, 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). Helpers: analyzeHeaders, parseAuthResults.