mail

← All adapters

mail(folder: string, options: Partial<MailServerOptions>): Source<MailMessage>
mail(folder: string): Destination<unknown, MailFetchResult>
mail(options: Partial<MailServerOptions>): Destination<unknown, MailFetchResult>
mail(action: MailAction): Destination<unknown, void>
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.

craft()
  .id('inbox-watcher')
  .from(mail('INBOX', { markSeen: true }))
  .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. If the IMAP connection drops mid-subscription the source reconnects automatically with exponential backoff; auth failures stop the subscription immediately.
  • 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.

Fetch destination (IMAP pull): Pass a folder string or server options to fetch messages. Use with .enrich() to pull mail on demand.

craft()
  .id('check-inbox')
  .from(cron('0 */5 * * * *'))
  .enrich(mail('INBOX'))
  .to(log())

Send destination (SMTP): Call with no arguments or client options 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
craft()
  .id('mail-forwarder')
  .from(mail('INBOX', { unseen: true, markSeen: true }))
  .transform((msg) => ({
    to: 'team@example.com',
    subject: `Fwd: ${msg.subject}`,
    text: msg.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
folderstring'INBOX'IMAP mailbox folder
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)
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)

MailMessage (exchange body in source/fetch modes):

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 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.body.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)
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, MailMessage, MailAttachment, MailSendPayload, MailSendResult, MailFetchResult, MailContextConfig, MailAccountConfig, MailAction, MailSender, EmailAddress, ForwardHop, ForwardType, TrustLevel, MailClientManager, MAIL_CLIENT_MANAGER. Helpers: analyzeHeaders, parseAuthResults.