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.1.4
    License
    Apache-2.0
    Last updated
    2 hours ago
    Downloads
    5

    #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.
    • 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.1.4, --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.1.4). 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

    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.

    #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
    Dovecot / self-hostedyour server993truetls_verify = false only for self-signed certs

    #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 Microsoft, register an app in Entra ID, add the delegated permission IMAP.AccessAsUser.All (Office 365 Exchange Online) and enable Allow public client flows. 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.

    [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, truncated to body_chars
    {{rule}}, {{keywords}}, {{fields}}Which rule fired and why
    {{*_q}}The same value single-quoted for a POSIX shell

    {{subject}} is inserted raw, so a subject containing $(...) is dangerous in a shell command. Use {{subject_q}}, or read $MAILS_SUBJECT, which is passed through the environment and never parsed by a shell:

    command = 'logger -t mails "$MAILS_SUBJECT"'

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

    #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 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

    #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_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.

    If the server rejects IDLE, the loop falls back to polling every 60 seconds. UIDVALIDITY changes reset the cursor and rescan the mailbox. Connection failures reconnect with exponential backoff, capped at five minutes. startup_sync = false means mail that already exists when the process starts is skipped; true scans it from the beginning, which can be slow on large mailboxes. mails sync 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, US-ASCII and ISO-8859-1 are decoded; other charsets fall back to a lossy UTF-8 interpretation, so keywords in, say, GBK mail may not match.

    #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

    #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 only. JMAP and POP3 are not implemented. Authentication is LOGIN or AUTHENTICATE XOAUTH2 (both with TLS); see Providers that require OAuth2.
    • 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.
    • No persistent cursor. The last seen UID lives in memory. After a restart with startup_sync = false, mail that arrived while mails was down is skipped; run mails sync (or set startup_sync = true) if you need it.
    • Text charsets. UTF-8, US-ASCII and ISO-8859-1 are decoded. Other charsets fall back to a lossy UTF-8 interpretation, so keywords in, for example, GBK mail may not match.
    • 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.
    • 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
    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
    }

    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.

    EnvFile

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

    EnvFile::get

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

    FetchedMessage

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

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

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

    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::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 {
    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
    }

    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]
    }

    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_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

    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.

    decode_charset

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

    Decode bytes in the named charset well enough for keyword matching.

    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

    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.

    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.

    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_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.

    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.

    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.

    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_headers

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

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

    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.

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

    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_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.

    split_header_body

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

    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_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.

    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.