Skip to content

Email: one user, one runtime, one mailbox

Two things carry the word "email", and the gateway names them differently everywhere:

  • your email address — where sign-in codes, "Forgot your token?" and notifications go, and the first address your agents may write to. It has no password. An administrator sets it when creating your user, or you set it yourself (PUT /api/gateway/me/email/address);
  • your mailbox — a connection you make (Google or Microsoft sign-in, or address + password for other providers) so that your agents and automations can read and send mail as you. Only you connect it; administrators never see or touch it.

Every signed-in person on an AbstractGateway can connect their own mailbox. It is stored encrypted in that user's data home and is used for:

  • automations on new mail — the email.received@1 trigger (for example "forward invoices", "tell me when X writes", or an AI triage that opens a session with you);
  • notifications — "Job failed" and "Approval needed", emailed to you through your own mailbox, plus the automation results and job completions you ask for per automation or per run;
  • sign-in by email — "Forgot your token? Email me a sign-in code" on the sign-in page.

Nothing reads your mail by default: no watcher runs until you create an email-triggered automation, and your agents get no email tools until you switch Agent email tools on. Without an account there is no sending and no email notification; the gateway has no shared "system" sender. The mailbox is only read: nothing marks messages read, moves or deletes them.

This page is the user and operator guide. The HTTP surface is listed in api.md, the security model in security.md, and the consoles in console.md.

Connect your mailbox

You can connect from any of these surfaces; they share the same fields and words:

  • Web console → Accounts → Email on your own row (every signed-in user): the Mailbox card, tab IMAP (the default) for address + password, Google or Microsoft to sign in with the provider.
  • Terminal console (abstractgateway-console) → 2 Accounts → @ on your own row.

In both consoles the IMAP pane shows the incoming (IMAP) and outgoing (SMTP) server, port and security. They are filled in with the standard imap.<domain> / smtp.<domain> values as soon as the address has a domain, then replaced by what discovery finds, except a field you edited. There is no user name or display name field: a small link ("My provider uses a different login name", Ctrl+O in the terminal) shows a Login field for the few providers that need one. When your email address is empty, the pane's Mailbox address is the only address field and connecting fills your email address; when it is set, the pane reads "Mailbox account: x@y" with Use a different account. - HTTP: PUT /api/gateway/me/email.

Connecting stores both servers: a leg you leave empty is the domain's standard one (imap./smtp.<domain>, 993/465 SSL, or the one discovery found), and the connect test signs in to both — a mailbox is never connected "receive only" by accident; an outgoing server that cannot be reached refuses the connect and names its step. A mailbox stored before gateway 0.10.1 without its outgoing server shows receive only (reads work, sends do not) until you connect it again. Connecting, disconnecting or pausing a mailbox also reloads your workflows, so your agents' tool lists follow at once (the host re-checks at every run start as well).

Give the address and the password (or app password). The gateway finds the IMAP and SMTP servers from the address — a table of common providers, the domain's own autoconfig file, the Thunderbird ISPDB, DNS SRV records, then the domain's MX hosts (AbstractCore's deterministic discovery):

curl -sS -X PUT -H "Authorization: Bearer <your gateway token>" -H "Content-Type: application/json" \
  "$BASE_URL/api/gateway/me/email" -d '{"address": "me@fastmail.com", "password": "<app password>"}'

POST /api/gateway/me/email/discover {"address": "..."} shows what discovery finds without connecting (found, source, imap, smtp, username, and tried: every step with its result), plus defaults: what the mailbox form pre-fills — the discovered servers, else the standard imap.<domain> (993, SSL) and smtp.<domain> (465, SSL) — with the login and one sentence ("Settings found for fastmail.com." / "Standard settings for example.com — change them if your provider uses others."). When no step finds both servers, the connect answers 400 email_discovery_failed with the same tried list and the message "Couldn't find the mail servers for ." (the fix: "Standard settings are filled in: check them and change any your provider does differently."); give the servers yourself then (imap and smtp: host, port, security). username defaults to the form the provider's configuration names, else the address; display_name is optional: an empty one keeps the stored name, else the address's local part (it is the name on the From line of mail you send). Connecting a mailbox sets your email address when it is empty (recorded as email.address_changed, reason mailbox_connected); an address you already set is never replaced.

Connect saves and tests in one call: the gateway signs in to both servers first and stores nothing when a step fails. The error names the step (detail.step: imap or smtp) and says it the way the consoles show it — "Sign-in refused by imap.example.com — check the password." or "Couldn't reach smtp.example.com:465." — next to the cause and the fix (for example email_auth_failed: "many providers need an app password when two-step verification is on"). TLS certificates and host names are always verified; there is no unencrypted mode.

Many providers (Gmail, iCloud, Fastmail, most hosted IMAP) accept an app password when two-step verification is on.

OAuth2 sign-in (Google, Microsoft)

Microsoft 365 / Outlook needs OAuth2. In the Mailbox card, pick the Google or Microsoft tab and select Sign in with Google / Sign in with Microsoft (your own client id and secret go under the tab's Sign-in app):

  • Microsoft uses a device code by default: open the link shown in any browser, on any machine, and enter the code.
  • Google uses a browser on the gateway's own computer (the provider redirects to a one-shot listener on 127.0.0.1 there). Google does not allow the Gmail scope in its device flow.

Which OAuth client signs in: the client id you give, else the gateway's client (an administrator setting, see below), else the built-in AbstractFramework client when one is registered for that provider. Tokens are refreshed automatically and stored encrypted, like passwords.

Google and Microsoft sign-ins always use the provider's built-in endpoints and scopes. A request that gives token_endpoint, authorization_endpoint, device_authorization_endpoint or scopes for them is refused with 403 email_oauth_override_refused (the answer names the fields), for administrators too, and a tenant must be a tenant id or domain. Explicit endpoints and scopes are only for provider: "custom", which only an administrator may use, and which always brings its own client id. The gateway's client and the built-in client are only ever sent to their provider's own endpoints, so no request can send the administrator's client secret, or make the gateway connect, to a host a user chose.

Administrators set a bring-your-own client per provider over HTTP with PUT /api/gateway/admin/email/oauth-clients/{google|microsoft} (client_id, client_secret, tenant); the consoles do not edit OAuth clients yet. The secret is sealed at rest and never returned; the read shows client_secret_set.

GET /api/gateway/me/email lists oauth_providers ([{id, available, reason}]): a provider is available with the gateway's own client or a built-in one; otherwise reason says so.

While mailboxes are off for a user, that user's Connect, Test and OAuth sign-in are refused (409 email_disabled, "Your admin turned mailboxes off for your account.") before any connection is made; the stored settings are kept.

What GET /me/email tells the account page

Besides the mailbox settings and status (never a secret):

Field Meaning
email_address where your sign-in codes and notifications go: your email address as stored on your user record, else your connected mailbox's own address ("" when neither)
mailbox {"state": "connected" \| "not_connected" \| "paused" \| "unavailable", "address", "provider", "reason"} — the same value the administrator's Accounts table shows for you
registered_address "self" for your runs: your email address, else your connected mailbox's own address
email_available your administrator allows mailboxes ("Mailboxes for users")
notifications {"job_failed": bool, "approval_needed": bool}; notifications_unavailable_reason says why they cannot send yet ("Connect a mailbox first.")
agent_tools {"on", "available", "unavailable_reason", "active"}: your switch, whether it can be switched on now, and why not ("Connect a mailbox first.", "Your admin turned mailboxes off.", "Your admin turned agent email tools off.")
oauth_providers [{"id": "google" \| "microsoft", "available", "reason"}]

The mailbox's Active switch, next to its status once connected (PUT /api/gateway/me/email/enabled {"enabled": false}), keeps the settings but stops watching, sending and notifications until you switch it back on. Folder (PUT /api/gateway/me/email/folder {"folder": "Archive"}, empty = INBOX) changes the folder your agents and the mail watcher read without reconnecting; the watcher starts that folder from mail that arrives after the change.

Agent email tools (off by default)

Your agents and workflows — chats, workflow runs and automations, from every client (Code, Assistant, Observer, the consoles) — get the email tools (list and search mail, read a message, list folders, send, reply, download an attachment) only when all of these hold:

  1. Agent email tools for users is on (an administrator's setting; on by default);
  2. your mailbox is connected and in use, and mailboxes are allowed for you;
  3. you switched Agent email tools on (your account page in the web console, the terminal console, or PUT /api/gateway/me/email/agent-tools {"enabled": true}). Your own switch is off by default; while it cannot be switched on, it shows the reason.

The rule is applied twice: when your toolsets are built (the tools are listed only then; turning the switch reloads your workflows so the change applies at once) and again when a tool runs (a call without all three is refused with the cause and the fix). GET /api/gateway/discovery/tools shows the email tools as enabled only for a caller whose agent tools are active; otherwise each disabled email row names why: not available to you (ask your administrator), email turned off for you by an administrator, no connected account, or your own switch is off. Every email tool call is an ordinary tool call recorded in the run's ledger. Your own send-email actions in automations (fixed templates you wrote), notifications and recovery codes do not need the switch: they only need a connected, allowed account.

A run started without a tool list (POST /runs/start with no input_data.tools) gets its workflow's default tools plus the email tools while your agent email tools are active; an explicit tool list, even an empty one, is used as given.

Recipient policy and send limits

Every send — an agent's send_email, an automation's send action, a notification, a recovery code — goes through the same checks, in this order:

  1. email is on (your switch and the administrator's);
  2. the recipient rules: two lists, Always allowed and Always denied, each holding exact addresses (name@example.com) or domains (example.com, which also covers its subdomains), and one mode for recipients on neither list: Only the Allowed list (allowlist: refused) or Anyone not on the Denied list (denylist: allowed). Your own address is always allowed, and denied always wins over allowed. The rules apply to To, Cc and Bcc; a message with any refused recipient is refused whole, and the refusal names the recipient and the rule ("Not sent: x@xxx.gov is on your Always denied list (xxx.gov)."). A new account starts with Only the Allowed list, holding your registered address. A policy stored by an older version keeps its meaning: an allowlist's entries are the Always allowed list, a denylist's entries the Always denied list;
  3. the send limits: 100 messages per rolling hour and 1000 per day by default, editable by you. Limits you set are kept across upgrades; a mailbox where nobody set them follows the defaults. Until AbstractCore 2.21 the defaults were 20 and 100 and connecting a mailbox stored them unmarked; an upgrade treats exactly that pair as the old defaults, so the mailbox follows the new ones. Any other unmarked value is kept (the limits document's source is legacy; default and user are the other values). Set new values under Recipients and limits in your email settings to move on.

On top of the policy, the approval gate still decides whether an agent's send runs unattended: a send to anyone but you (or an automation's pre-authorised recipients) waits for your approval, and a send to you runs without asking in chats and automations alike. "You" is your registered address: the email on your user account (or the gateway's registered address for the operator), else the connected mailbox's own address.

curl -sS -X PUT -H "$AUTH" "$BASE_URL/api/gateway/me/email/policy" \
  -d '{"mode": "allowlist", "always_allow": ["mycompany.com"], "always_deny": ["xxx.gov"]}'
curl -sS -X PUT -H "$AUTH" "$BASE_URL/api/gateway/me/email/limits" -d '{"per_hour": 100, "per_day": 1000}'

Automations on new mail

The mail watcher reads your mailbox only while at least one of your automations is active on the email.received@1 trigger, checking every 60 seconds. It is read-only (IMAP EXAMINE and BODY.PEEK), keeps a durable cursor, survives a server rebuilding the folder (UIDVALIDITY change: it re-reads by date and passes messages it already has), and moves the cursor past a message only after that message is durably stored in your runtime's event inbox. A message that cannot be read on three polls in a row is recorded and passed, so one bad message never blocks your mailbox. Connection problems back off from 60 seconds to 15 minutes; nothing is paused, and the status shows the cause and the fix.

Mail that is already in your mailbox when the watcher starts is never an event. The watcher marks where new mail starts when you connect an account and whenever an email automation becomes active after a time with none (creating or resuming one takes that mark within seconds, so a message you send to test it right after counts); mail that arrived while none of your email automations was active is not processed later. A gateway restart keeps the mark: mail that arrives while the gateway is down is read when it is back.

How often an automation runs on new mail is its own trigger setting: every 60 seconds when it needs no model ("uses_model": false), once an hour by default when it runs a model (summarise, classify, draft replies, AI triage), on the batch of messages received since its last run. Each automation reads a message at most once. See automations.md for the trigger configuration and the send-email action.

Inbound mail is data, never instructions: it reaches a model inside a fixed "untrusted" frame, and nothing it says can widen who a run may mail or which tools it has.

An automation never runs on mail the framework sent itself. Every message sent automatically through your account (notifications, recovery codes, and anything an automation sends, including its send-email action) carries Auto-Submitted: auto-generated (RFC 3834) and an X-AbstractFramework-Automation header, and its Message-ID is recorded in your outbox. The watcher skips such messages from your own address, and a recorded Message-ID even when a server dropped the headers, so a filter that matches an automation's own result email ("Email me the result") never re-triggers it. By default the trigger also ignores automatic mail from others (auto-replies, vacation notices, other automations); set "auto_submitted": "admit" in its configuration to run on those too.

Notifications

Two switches, both on by default; nothing is sent until your mailbox is connected and in use. Set them on your account page or with PUT /api/gateway/me/email/notifications ({"job_failed"?: bool, "approval_needed"?: bool}):

Switch Emails you when
Job failed (job_failed) an automation of yours failed after its retries
Approval needed (approval_needed) one of your runs waits for your approval or answer

Two options stand on their own, with no switch involved:

Option Emails you when
an automation's Email result (notify.channels holds email) every completed occurrence of that automation; full result to notify.recipients (default self)
a run's email me when done (_runtime.notify = {"on": ["finished", "failed"], "channels": ["email"]}) that run finished or failed, as asked

GET/PUT /api/gateway/me/notifications keep working: the earlier five-event body is accepted, automation_failed counts for job_failed, and automation_result / job_finished are ignored as preferences (the per-automation and per-run options above decide). Saved preferences carry over the same way: job_failed is on when job_failed or automation_failed was on, approval_needed keeps its value, and preferences never saved take the new defaults.

Notices go to your registered address (else your mailbox address), except automation results with explicit notify.recipients. They are sent by your own account from a durable outbox: each notice is queued once and sent once. If the gateway stops in the middle of a send, that notice is marked unknown and never resent automatically. Temporary SMTP refusals are retried with backoff; sign-in and permanent refusals are shown with their cause and fix. Over your send limits, the waiting notices for each recipient set go out as a digest when the window allows; the waiting notices keep why they wait and when they go (GET /me/notifications → outbox.rate_limited {count, cause, resets_at}). Send a test checks the whole path and always answers with a sentence: "Sent to x@y.", "Not sent: no mailbox connected.", "Not sent: your mailbox is paused.", "Not sent: hourly limit reached (100 of 100 this hour) — resets at 14:05.", "Queued behind 3 earlier notifications; they go out when the limit resets at 14:05." or "Not sent: smtp.x.com refused the message ()." (message, with reason_code and limit for programs; the consoles show reset times in your local time). Send a test sits under Notifications in your email settings. Notification emails use fixed templates; the only model-written text is an automation's own notify title and body, labelled as such. Replying to a notification does nothing.

Sign-in by email

When at least one account on the gateway has a connected mailbox, the sign-in page offers Forgot your token? Email me a sign-in code. Enter your Gateway user and select the link: it shows "Sending…", then the code step with what happened ("A sign-in code is on its way to l•••@•••." or why no code was sent), a field for the code, Use code, Send a new code (after 30 seconds) and Back to token. The 8-digit code is sent to your email address through your own mailbox. The code works once, expires after 10 minutes and allows 5 tries; it signs you in (purpose: "sign_in", the default), and your account page can then rotate your token. Clients may also ask for purpose: "reset_token", which issues a new token (shown once; the old one stops working) with the session.

POST /api/gateway/session/recovery/request {"user_id": "..."} answers what happened:

Answer Body
sent {"sent": true, "to": "l•••@•••", "expires_in_s": 600, "message": "A sign-in code is on its way to l•••@•••. It expires in 10 minutes."} — the first character of the address, never the domain
no address {"sent": false, "reason_code": "no_email_address", "message": "This account has no email address, so a code can't be sent. Ask your gateway admin for a token."} — also for an unknown account, a deactivated one, or one without a mailbox to send with
an address, but no mailbox {"sent": false, "reason_code": "no_mailbox", "message": "This account has an email address, but no mailbox is connected to send the code from. Ask your gateway admin for a token."}
the mail server refused or could not be reached {"sent": false, "reason_code": "send_failed", "message": "The code couldn't be emailed (<cause>). Try again, or ask your gateway admin for a token."} — the request waits up to 12 s for the real outcome; a send still in flight after that is answered as on its way
rate limited {"sent": false, "reason_code": "too_many_requests", "retry_after_s": N, "message": "Too many codes requested for this account. Try again in N minutes."}
sign-in by email off 404 recovery_off

The trade-off is deliberate: a requester can learn that an account id has an email address, which makes the page honest ("a code is on its way" or "ask your admin"). Requests are rate-limited per account (3 per 15 minutes) and per client address (10 per 15 minutes) before any lookup or background send, codes are stored only as keyed hashes, and every request, issue and use is recorded in the audit log without the code. An administrator who prefers no such answer turns Sign-in by email off (Accounts → Email for everyone); users without an email address use the administrator's token rotation.

Administrators

Administrators decide what is available to users. The Email for everyone section under the Accounts table holds three switches, directly in the card (GET/PUT /api/gateway/admin/email/capabilities, which returns each one's label and description):

Capability Label Default Meaning
email Mailboxes for users on users may connect their own mailbox for their agents, automations and notifications (off: no watcher, no sending, no notifications; settings are kept)
email_agent_tools Agent email tools for users on users may let their agents use their mailbox; each user still switches it on for themselves
email_recovery Sign-in by email on (gateway-wide only) "Forgot your token? Email me a sign-in code" on the sign-in page
curl -sS -X PUT -H "$ADMIN" "$BASE_URL/api/gateway/admin/email/capabilities" -d '{"email": false}'
curl -sS -X PUT -H "$ADMIN" "$BASE_URL/api/gateway/admin/email/capabilities" -d '{"reset": ["email"]}'

Per-user overrides (PUT /api/gateway/admin/users/{user_id}/email {"enabled"?, "agent_tools"?}) are honoured; the consoles do not create them and show an existing one (a user source in the email_account.capabilities of GET /api/gateway/admin/users) as "not allowed for this user" with a Reset action ({"inherit": ["email", "email_agent_tools"]}).

Agent email tools became available by default with capabilities.json version 3. On the first start, the gateway upgrades the file so that nobody gains tools they could not use before: a user whose own agent-tools switch was on while the tools were not available to them gets a per-user email_agent_tools: false override, recorded in the audit log as email.capabilities_migrated.

An administrator creates a user with their email address (POST /api/gateway/admin/users, "email") and changes it with PATCH /api/gateway/admin/users/{user_id} ("email"); the user can set it too. Active is "enabled" in the same PATCH (false = signed out and unable to sign in); an administrator cannot deactivate their own account (409 cannot_deactivate_self, "You can't deactivate your own account.") nor the last active administrator (409 last_admin).

Sign-in by email means that whoever controls a user's mailbox can sign in as that user; turn it off where mailboxes are not as well protected as gateway tokens. The Accounts table (GET /api/gateway/admin/accounts) shows each account's email address and mailbox (connected, not_connected, paused, unavailable with the reason) through the same resolver as the account's own email settings, so the administrator's own row always matches their card. The address shown there and on the card is where sign-in codes and notifications go: the registered email address, else the account's own connected mailbox address. Entities have no mailbox of their own: mailboxes belong to a user's runtime, and entity runs use their own runtime without the host's mailbox. Administrators see the state, the address and the last error — never messages, the user's recipient list or credentials. The administrator's server file helpers (/files/*, workspace import and export) never serve the gateway data folder, where every user's runs, received mail and sealed credentials live, even when it sits inside the server workspace.

The administrator's own account (the default runtime) is configured like everyone else's. It is the gateway's account, separate from AbstractCore's own local account (abstractcore email); on the first start, when the gateway has no account for the administrator and AbstractCore has one, that account is copied once into the gateway settings (the consoles say which account you are editing).

Where things are stored

What Where
Account settings, policy, limits <plane>/email/account/abstractcore.json
Password / OAuth tokens <plane>/email/account/email/secret.enc (AES-256-GCM; key in the OS keychain, or a 0600 key file when there is none)
Watcher state <plane>/email/watcher.json; the cursor and received mail in <runtime data dir>/event_inbox/
Notification preferences and outbox <plane>/email/notifications.json, <plane>/email/outbox.sqlite3 (created with the first notice)
What is available to users (defaults + per-user) <data_dir>/auth/capabilities.json
Agent email tools choice <plane>/email/agent_tools.json
OAuth clients (admin) <data_dir>/email/oauth_clients/secret.enc
Recovery codes (hashed) <data_dir>/auth/recovery_codes.json

<plane> is <data_dir> for the default runtime (single-user gateways and the administrator) and <data_dir>/users/<tenant>/<runtime> for every other user.

Migrating from the environment variables

Email is configured per user, never through environment variables. On the first start, a gateway that still has the ABSTRACT_EMAIL_* variables (or values saved through the process manager's environment overrides) imports that account once into the administrator's email settings; from then on the variables are ignored, and each one still set is named at startup and in the administrator's own email settings (Accounts → Email on their row) with the setting that replaced it. The email bridge (ABSTRACT_EMAIL_BRIDGE) is replaced by the per-user watcher and the email.received@1 trigger. Maintenance notices go to the administrator's registered address through the administrator's own account (ABSTRACT_BACKLOG_EMAIL_TO and the related account variables are ignored); the same notice is sent at most once per UTC day.

The admin-only /api/gateway/email/* routes remain as deprecated aliases acting on the calling administrator's own account, and will be removed in a later minor release; use /api/gateway/me/email.