Watch IMAP mailboxes and run commands when a message matches a keyword rule
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)mkdir -p ~/.config/mails
export MAILS_CONFIG=~/.config/mails/mails.toml
mails initmoon install kupas/mails/cmd/mailscurl -LsSf https://raw.githubusercontent.com/tomasky/mails/master/install.sh | bashcurl -L -o mails \
https://github.com/tomasky/mails/releases/latest/download/mails-linux-x86_64
chmod +x mails
./mails version| Platform | Release asset |
|---|---|
| Linux x86-64 | mails-linux-x86_64 (static, musl) |
| Linux arm64 | mails-linux-aarch64 (static, musl) |
| macOS Apple silicon | mails-darwin-aarch64 |
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.sha256git 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/mailsmails 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[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| Provider | host | port | tls | Notes |
|---|---|---|---|---|
| Gmail / Google Workspace | imap.gmail.com | 993 | true | Needs an App Password with 2FA; "less secure apps" is gone |
| Outlook / Microsoft 365 | outlook.office365.com | 993 | true | OAuth2 only — basic auth is permanently disabled, see below |
| Yahoo Mail | imap.mail.yahoo.com | 993 | true | App Password required |
| iCloud Mail | imap.mail.me.com | 993 | true | Apple ID app-specific password |
| Fastmail | imap.fastmail.com | 993 | true | App password recommended |
| Proton Mail | 127.0.0.1 | 1143 | true | Via Proton Mail Bridge (local, self-signed → tls_verify = false) |
| Zoho Mail | imap.zoho.com | 993 | true | App-specific password with 2FA |
| GMX | imap.gmx.com | 993 | true | |
| AOL Mail | imap.aol.com | 993 | true | |
| Yandex Mail | imap.yandex.com | 993 | true | Create an app password in the Yandex ID settings |
| Mail.ru | imap.mail.ru | 993 | true | App password required |
| QQ Mail | imap.qq.com | 993 | true | Enable IMAP and create an authorisation code (授权码) in the web settings; use that code as password |
| 163 / 126 Mail | imap.163.com | 993 | true | Enable IMAP and set a client authorisation password (客户端授权密码) |
| Tencent Exmail (企业微信邮箱) | imap.exmail.qq.com | 993 | true | Admin must allow IMAP in the WeCom console; then enable it under 设置 → 收发信设置 and generate a client password (客户端专用密码) |
| Sina Mail | imap.sina.com | 993 | true | Enable IMAP in the web settings |
| Dovecot / self-hosted | your server | 993 | true | tls_verify = false only for self-signed certs |
server rejected 'LOGIN "you@outlook.com" <redacted>': NO Basic authentication is disabled.[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 portmails login --account outlookoutlook: 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[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"]| Key | Meaning |
|---|---|
| auth | password (default) or xoauth2 |
| oauth_provider | microsoft (default), google, or custom |
| oauth_client_id | client id of a public app with the device code flow enabled |
| oauth_tenant | Microsoft tenant: common (default), consumers, organizations or a tenant id |
| oauth_scope | override the scope; defaults cover IMAP for Microsoft and Google |
| oauth_device_endpoint | device code endpoint, required for custom |
| oauth_token_endpoint | token endpoint, required for custom |
| oauth_token_file | where the refresh token lives; default .<account>.oauth |
[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"]| Symptom | Cause and fix |
|---|---|
| AADSTS50020 user account does not exist in tenant | the 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 tenant | oauth_tenant does not match the app's supported account types; use consumers, organizations, common or the tenant id |
| AADSTS7000218 client authentication failed | Allow public client flows is still No |
| AADSTS65001 consent required | add the IMAP.AccessAsUser.All permission, or have a tenant admin grant consent |
| AADSTS500011 resource not found | the app is single-tenant but oauth_tenant = "common"; set the tenant id |
| AUTHENTICATE XOAUTH2 rejected, token is fresh | IMAP is disabled on the mailbox (Exchange: Set-CASMailbox -ImapEnabled $true) |
| LOGIN ... NO Basic authentication is disabled | the account still uses auth = "password"; switch it to xoauth2 |
| token works but SELECT fails | the 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| Key | Default | Meaning |
|---|---|---|
| name | rule-N | Name used in logs and {{rule}} |
| keywords | required | Array 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_sensitive | false | ASCII/Unicode lowercase comparison |
| regex | false | Compile keywords as regexes ((?i:...) when case-insensitive) |
| command | – | Shell string, run with sh -c |
| exec | – | argv array; the first element is the program |
| timeout | command_timeout | Kill the command after this duration |
| workdir | – | Working directory for the command |
| body_chars | general.body_chars | Truncate {{body}} and MAILS_BODY |
| accounts | all | Restrict the rule to these account names |
| mailboxes | all | Restrict the rule to these mailboxes |
| Placeholder | Value |
|---|---|
| {{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 |
# 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'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"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 firstmails <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| Option | Effect |
|---|---|
| -c, --config PATH | Configuration file (default: mails.toml next to the binary) |
| -a, --account NAME | Operate on one account only |
| --once | For watch: process existing mail, then exit (same as sync) |
| --write | For encrypt: update mails.toml in place |
| --new-key | For encrypt: rotate the key before sealing |
| -v, --verbose | Debug logging |
| -q, --quiet | Warnings and errors only |
| Variable | Read by | Effect |
|---|---|---|
| MAILS_CONFIG | mails | Config path when --config is not passed |
| MAILS_KEY | mails | Encryption key fallback when .env does not define general.key_env |
| MAILS_QUIET | mails | 1 silences everything but errors (same as -q, also dropping warnings) |
| MAILS_BINARY, MAILS_CACHE_DIR, … | npm launcher | Control the downloaded binary; see npmjs/README.md |
| Status | Meaning |
|---|---|
| 0 | The command did what it was asked. For sync / watch --once, every mailbox synced |
| 1 | Bad arguments or config, a missing or wrong key, a failed login, or at least one mailbox failed |
# ~/.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.targetsystemctl --user daemon-reload
systemctl --user enable --now mails
journalctl --user -u mails -fmoon build --target native --release
# binary: _build/native/release/build/cmd/mails/mails.exemoon build --target native
./_build/native/debug/build/cmd/mails/mails.exe versionmoon test --target native
moon check --target native --deny-warn
moon fmt --checksudo apt-get install musl-tools
./scripts/build-linux-static.sh dist/mails-linux-x86_64| Variable | Meaning |
|---|---|
| MAILS_MUSL_WORKDIR | scratch directory (default: a fresh temp dir) |
| MAILS_OPENSSL_VERSION | OpenSSL to download and build (default 3.5.5) |
| MAILS_OPENSSL_SRC | build the static libs from this source tree |
| MAILS_OPENSSL_MUSL | use a prebuilt musl static OpenSSL prefix (include/, lib/) |
| MAILS_NO_TLS=1 | build without TLS (smaller; IMAP over TLS won't work) |
pub suberror MailsError {
MailsError(String)
}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
}pub struct ActionContext {
account : String
mailbox : String
message : MailMessage
rule : Rule
keywords : Array[String]
}pub enum Credential {
Password(String)
Bearer(String)
}pub struct Defaults {
startup_sync : Bool
idle_timeout_ms : Int
reconnect_ms : Int
max_body_bytes : Int
command_timeout_ms : Int
body_chars : Int
}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
}pub struct FetchedMessage {
uid : String
size : Int
header : Bytes
body : Bytes
}pub struct ImapClient {
stream : ImapStream
host : String
tag_counter : Int
capabilities : Array[String]
}async fn ImapClient::authenticate_xoauth2(client : ImapClient, username : String, access_token : String) -> Unitasync fn ImapClient::connect(host : String, port : Int, use_tls : Bool, verify_tls : Bool, username : String, password : String) -> ImapClientasync fn ImapClient::connect_with(host : String, port : Int, use_tls : Bool, verify_tls : Bool, username : String, credential : Credential) -> ImapClientasync fn ImapClient::fetch_message(client : ImapClient, uid : String, max_body_bytes : Int) -> FetchedMessage?pub struct MailMessage {
uid : String
mailbox : String
subject : String
from : String
to : String
date : String
message_id : String
body : String
raw_bytes : Int
}pub enum MatchField {
Subject
Body
From
To
}pub struct OAuthSettings {
provider : String
client_id : String
tenant : String
scope : String
device_endpoint : String
token_endpoint : String
token_file : String
}pub struct TokenSet {
access_token : String
refresh_token : String
expires_at_ms : Int64
}async fn access_token_for(account : Account, config_dir : String, key : String) -> String raise MailsErrorfn build_message(mailbox : String, uid : String, raw_size : Int, header : Bytes, body : Bytes) -> MailMessagefn collapse_whitespace(text : String) -> Stringasync fn credential_for(account : Account, config_dir : String, key : String) -> Credential raise MailsErrorfn cursor_path(config_dir : String, account_name : String, mailbox : String) -> Stringfn decode_charset(data : Bytes, charset : String) -> Stringfn decode_encoded_words(value : String) -> Stringfn decode_modified_utf7(name : String) -> Stringfn error_message(err : Error) -> Stringfn imap_quote(text : String) -> Stringasync fn load_cursor(config_dir : String, account_name : String, mailbox : String) -> (Int, Int)async fn main_cli() -> Boolfn match_rules(rules : Array[Rule], account : String, mailbox : String, message : MailMessage) -> Array[RuleMatch]fn message_body_text(raw_body : Bytes) -> Stringfn needs_key(configured : String) -> Boolfn oauth_provider_defaults(provider : String, tenant : String) -> (String, String, String) raise MailsErrorfn parse_duration_ms(text : String) -> Int?async fn poll_device_token(settings : OAuthSettings, authorization : DeviceAuthorization, on_pending : () -> Unit) -> TokenSet raise MailsErrorfn raw_placeholder_advice(name : String) -> Stringasync fn refresh_access_token(settings : OAuthSettings, refresh_token : String) -> TokenSet raise MailsErrorasync fn run_action(account : String, mailbox : String, message : MailMessage, hit : RuleMatch) -> ActionOutcomefn sample_config() -> Stringasync fn save_cursor(config_dir : String, account_name : String, mailbox : String, uid_validity : Int, last_uid : Int) -> Unitasync fn save_refresh_token(path : String, account : Account, refresh_token : String, key : String) -> Unit raise MailsErrorfn shell_quote(text : String) -> Stringfn truncate_bytes(text : String, max_bytes : Int) -> Stringfn truncate_chars(text : String, max_chars : Int) -> Stringfn update_account_password(source : String, account : String, value : String) -> String raise MailsErrorfn xoauth2_initial_response(username : String, access_token : String) -> StringInstall
Download zipWatch IMAP mailboxes and run commands when a message matches a keyword rule