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 | |
| 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.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, truncated to body_chars |
| {{rule}}, {{keywords}}, {{fields}} | Which rule fired and why |
| {{*_q}} | The same value single-quoted for a POSIX shell |
command = 'logger -t mails "$MAILS_SUBJECT"'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_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 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 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 MailsErrorasync 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_refresh_token(path : String, account : Account, refresh_token : String, key : String) -> Unit raise MailsErrorfn shell_quote(text : String) -> 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