@hasna/emails
v1.8.1
Published
Email management through a shared API: CLI, terminal UI, MCP server, and library
Maintainers
Readme
@hasna/emails
Open-source email management through a shared API, with a terminal UI, CLI, MCP server, and library. Manage mailboxes, domains, providers, and messages across machines using the same account registry. Integrations include Resend, AWS SES, and Cloudflare-routed inbound mail.
This product is Hasna Emails. Mailery is not a name for it. (Owner ruling, 2026-07-27 — see docs/NAMING.md.)
This package publishes as
@hasna/emailsand ships theemails,emails-mcp, andemails-servebins.@hasna/maileryon npm is the abandoned 0.6.x line, and themailery*bins belong to the separate cloud CLI — neither is this package. The env prefix isEMAILS_*. This stays a public OSS package: Mailery is a separate, unrelated product, not a hosted version of this one.
Install
Emails is built for the Bun runtime. Install Bun 1.3 or newer, then install the CLI with Bun.
bun install -g @hasna/emailsDeployment
The CLI, terminal UI, and MCP tools connect to the authenticated Emails /v1
API. The selected account's PostgreSQL registry holds its providers, domains,
addresses, sources, and mail. Signing into the same account on another machine
exposes that registry without registering the same resources again.
Configure API access through the shared credential resolver: canonical
HASNA_EMAILS_API_URL / HASNA_EMAILS_API_KEY settings, the macOS Keychain
items for this app (api-url / api-key), or the
~/.hasna/emails/config/credentials file. The retired
EMAILS_SELF_HOSTED_URL / EMAILS_SELF_HOSTED_API_KEY aliases are no longer read and are refused by name if still exported.
A live user session (EMAILS_SESSION_TOKEN) or agent identity token
(EMAILS_IDP_TOKEN) takes precedence as the bearer credential. Missing or
invalid credentials produce an error; an empty mailbox is not a substitute for
a failed connection.
Ordinary CLI, terminal UI, and MCP clients are hosted-only: a nonblank
HASNA_EMAILS_DB_PATH / EMAILS_DB_PATH or the local opt-in
HASNA_EMAILS_LOCAL / EMAILS_LOCAL is rejected by name. Remove those settings and configure API access instead.
Automatic selection through planEmailStore() / createConfiguredEmailStore()
reaches the on-box SQLite store ONLY behind the standard opt-in HASNA_EMAILS_LOCAL=1 (alias EMAILS_LOCAL=1), answered from the
environment before any Keychain or credentials-file read and only when the
environment configures no API authority or credential — a configured environment
outranks the flag. A database path alone selects nothing (it is only the file
location); nothing configured is a boot error, never a local fallback. Explicit
low-level constructors that receive a caller-owned Database remain compatibility
surfaces and do not invoke automatic selection.
Provider credentials and inbound infrastructure belong to the API service. An operator may run that service on their own PostgreSQL and object storage; see Self-Hosted Runtime. Client machines need account access, not independent provider registrations or mail databases.
Client env table
| Variable | Meaning |
|---|---|
| HASNA_EMAILS_API_URL | Emails API origin (canonical). Overrides the Keychain api-url item and the credentials file. |
| HASNA_EMAILS_API_KEY | Emails API key (canonical). One of the five resolver tiers. |
| EMAILS_SELF_HOSTED_URL | RETIRED. Refused by name; set HASNA_EMAILS_API_URL. |
| EMAILS_SELF_HOSTED_API_KEY | RETIRED. Refused by name; set HASNA_EMAILS_API_KEY. |
| HASNA_EMAILS_LOCAL / EMAILS_LOCAL | The ONLY client-side local opt-in, for the storage library; honoured only when no API authority or credential is configured. Rejected by the CLI/MCP clients. |
| HASNA_EMAILS_DB_PATH / EMAILS_DB_PATH | Location of the local SQLite file under the opt-in. Alone it selects nothing and is refused. |
| EMAILS_SESSION_TOKEN | The app's own user session (issued by emails auth login). Wins as the bearer credential. |
| EMAILS_IDP_TOKEN | The app's own agent identity token (ADR-0002). Wins over the resolved key. |
| EMAILS_CLIENT_ENV_SECRET | Secrets-vault pointer that persists EMAILS_SESSION_TOKEN / EMAILS_IDP_TOKEN across processes. No longer delivers the URL or API key. |
| HASNA_HOME / HASNA_CONFIG_HOME | Relocate ~/.hasna/emails/config/credentials (never XDG). |
| HASNA_STATION | Keychain account (falls back to hostname -s, then $USER). |
The resolver tiers, in order: the deliberate HASNA_EMAILS_API_KEY_OVERRIDE
(a literal key) or HASNA_PROFILE (reads ~/.hasna/emails/config/credentials-<profile>)
selections — a blank override or an absent profile REFUSES, it is never resolved
around; the macOS Keychain items for this app (api-key, and api-url for the
authority); the ~/.hasna/emails/config/credentials file (0600); then
HASNA_EMAILS_API_KEY. A HASNA_EMAILS_API_KEY_REF secrets-vault pointer is
recognised as a deliberate selection but refused by name: this client resolves
its credential synchronously and cannot complete a vault pointer per request.
The emails CLI has no --api-key / --profile resolver flags (--profile
on inbox/provisioning commands is a legacy provider selector, not an account
credential; --api-key on provider add is the Resend key). The authority follows HASNA_EMAILS_API_URL → Keychain
api-url → credentials file → the shared default gateway once a credential
resolves. Nothing configured fails closed; a URL without a credential refuses
rather than falling back to local data.
Quick Start
# Inspect the providers and domains already registered to your account
emails provider list
emails domain list
emails address list
# Connect a domain you own through a configured server provider binding
emails domain connect example.com --provider <provider-id> --dry-run
emails domain connect example.com --provider <provider-id>
# See the DNS records the domain must publish, then confirm what is live
emails domain dns example.com --provider <id>
emails domain check example.com
# Review DNS publication for an owned domain; existing MX is preserved by default
emails domain setup example.com --provider <provider-id> --dry-run
# Send an email
emails send --from [email protected] --to [email protected] --subject "Hi" --body "Hello"
# Poll an existing server-bound SES/S3 ingestion source
emails inbox sources
emails inbox sync-s3 --source <source-id>
# Inspect mailbox folders and ingestion sources
emails inbox mailboxes
emails inbox sources
emails inbox list --folder unread --source provider:<id>
# Check sent email log
emails email list
# Operate a self-hosted PostgreSQL service
EMAILS_DATABASE_URL=postgres://... EMAILS_API_SIGNING_KEY=... emails db migrate
EMAILS_DATABASE_URL=postgres://... EMAILS_API_SIGNING_KEY=... emails self-hosted key createMessage body files
For multiline mail, write a UTF-8 file with actual line breaks, review the complete
file, then use emails send --body-file /absolute/path/body.txt --dry-run with the
sender, recipients, and subject. Send the same reviewed file after authorization.
For a private structured message and durable receipt, send-controlled apply
accepts text_file and html_file body sources in its descriptor, together
with authorization_file: the private record of the granted permission that
fixes the recipients, thread, body version, attachment disclosure, expiry and
supersession for that one package. A descriptor whose package differs from the
bound permission — a changed recipient or body, an added or altered document —
is refused before any provider call, and an unbound apply is refused as well.
Readback does not need it, and the ordinary emails send path is unchanged.
See docs/CONTROLLED_SEND_FILES.md.
The CLI (including dry-run), SDK send paths, and API send/enqueue routes reject
invalid_body_url_boundary when an HTTP(S) token contains literal backslash-plus-n
or backslash-plus-r, such as https://example.com/file\nRegards. The API refuses
before reserving a send intent, enqueueing, or calling a provider. The diagnostic
contains no body or URL. Body bytes are never unescaped or repaired automatically.
Actual LF/CRLF, percent-encoded URL data such as %5Cn, and ordinary backslashes
outside URLs remain valid. Tokens end at whitespace or <, >, double quote,
single quote, or backtick. Literal malformed-URL examples inside prose or code are
also rejected; use a valid encoded URL or describe the example without an HTTP(S)
token. This check does not prove that a link exists or that its recipient can open it.
A shared link is not a MIME attachment: use the attachment option or descriptor when promising an attached file, and verify the send receipt rather than treating a dry run as delivery proof.
Domains and readiness
Emails is a multi-domain aggregator. DNS, inbound routing, outbound permission,
and readiness evidence belong to each domain in the shared account registry.
source_of_truth is reported as postgres for API-managed domains and is not a
client input.
emails domain connect registers an already-owned domain against a server-bound
provider and returns its DNS tasks. emails domain setup, domain setup-cloudflare,
and provision domain can publish sending DNS through the configured Cloudflare
zone binding. These operations preserve existing MX unless an explicit inbound
change is requested. Pending DNS verification remains pending in the receipt.
See Domain DNS for the required server configuration.
emails address provision checks configured receiving infrastructure before
ensuring an address in the registry. emails provision up composes owned-domain
DNS, address readiness, and optional delivery probes as a durable API job.
provision daemon advances existing jobs; provision retry reuses their saved
intent and send identities. See Provision up. Registrar
purchases belong to the separate Domains workflow.
Authentication records are required only for the capability you enable:
- Inbound aggregation needs an inbound route, usually MX plus SES/S3 or another configured source.
- Outbound sending needs ownership verification plus DKIM and SPF/custom MAIL FROM alignment for the selected provider.
- DMARC is per sending domain. It does not block message viewing or inbound
aggregation, but it should be present before production sending and monitored
before moving from
p=noneto stricter policies.
Clients use the API access settings above. The service uses
EMAILS_DATABASE_URL, EMAILS_API_SIGNING_KEY,
EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS, and EMAILS_AUTH_FROM; PostgreSQL is the
shared source of truth.
After applying migrations, issue client keys on the operator host with
emails self-hosted key create. The plaintext token is displayed once and only
its SHA-256 hash plus lifecycle metadata is stored in Postgres. Inspect metadata
with emails self-hosted key list and immediately disable a key with
emails self-hosted key revoke <kid> --reason "rotation". The service denies
validly signed but unrecorded keys.
emails self-hosted key rotate issues an Emails key while retaining the active
Mailery-era key for rollback. Verify clients on the new key, then explicitly
revoke the old key after the rollback window closes.
Emails UI (emails ui)
A full-screen Solid/OpenTUI mail client with a persistent mailbox sidebar and a
workspace for message lists, the reader, and domain status. Inbox can be scoped
to all addresses or one address and filtered by ingestion source, folder,
label, and search. Grouping, digests, attachment/link/raw views, live read
state, automatic refresh, and auto/light/dark themes are available.
An address scope includes inbound mail delivered through To or Cc and sent mail
from that address. With an older server, broad address scopes may report an
unavailable count when the bounded scan cannot reach its end; they never show
a partial count as exact or clear a partial set.
emails ui
emails ui --mailbox unreadThe app uses visible buttons and the Shortcuts command palette for actions. Click the mailbox name at the top left to switch mailboxes or choose All mailboxes. Search mail from the top bar or with Ctrl+F; Compose stays at the top of the sidebar. Message rows show sender names, subjects, and previews.
The reader renders HTML and Markdown with headings, emphasis, links, lists, and tables. Code blocks and quoted history start collapsed; click their header to expand or collapse them. In a thread, the selected message opens while other messages stay collapsed. Use arrows or Page Up/Down to scroll, Home/End to jump, and Tab then Enter/Space to toggle a section. Long messages reflow to the terminal width; unusually large bodies show a clipping notice and remain available in Raw.
The mailbox and source dialogs select scope; sidebar labels filter mailbox content, and mail categories show Primary, Social, Promotions, Updates, and Forums separately from custom labels. Reader dialogs expose attachments, links, and raw details. Composer writes markdown rendered to HTML on send. Empty folders and searches show a helpful empty state with a relevant next action; message-only actions and unavailable page buttons are hidden.
Settings uses a sidebar and descriptive controls for automatic refresh, mailbox
selection, appearance, reading preferences, Priority Inbox, and keyboard shortcuts.
Use Ctrl+Left/Right to switch sections, Tab to focus controls, and Enter to change
them. View preferences apply to the current session. Priority Inbox automatically
shows incoming mail from exact addresses you emailed in the last 30 days, including
To, CC, and BCC recipients where recorded. Existing saved priority-sender rules
remain in the account but no longer classify mail. On older hosted servers the
client builds a bounded Priority page from completed sent To/CC history and
shows ? for its total; historical BCC-only recipients need server support.
Oldest-first Priority sorting also requires an updated server.
Private Bcc evidence is recorded for new hosted sends after this version is
deployed; older Bcc recipients cannot be reconstructed from the public
sent-message ledger. No local email database is created.
Folders: Inbox · Priority Inbox · Unread · Starred · Sent · Archived · Spam · Trash.
Developers can capture the native TUI with synthetic mail, without a live account:
bun scripts/capture-tui-review.tsx /tmp/emails-tui-review from this package.
This writes character frames and colored span JSON at normal and narrow sizes.
Command Structure
The table below covers every primary root namespace. Standalone compatibility
aliases and the full subcommand matrix are in docs/CLI.md; the
runtime emails <command> --help output remains the option-level source of
truth.
emails ui # Mailbox UI - inbox, compose, domains, settings
emails provider # provider credentials/capabilities (ses, resend, sandbox)
emails domain / domains # shared domain records, DNS checks, warming
emails domain warm # domain warming schedules: warm, warm-status, warm-list,
# warm-pause, warm-resume, warm-complete, warm-delete
emails address # manage sender addresses (add, suspend, activate, quota)
emails status # redacted system status + next useful actions
emails agent context # agent-oriented context snapshot and workflows
emails daemon # worker status and configured supervisor operations
emails logs tail # bounded API worker lifecycle logs
emails owner # ownership: register human/agent owners
emails alias # per-domain aliases + catch-all routing
emails forwarding # forwarding for messages received in the shared registry
emails sendkey # scoped send keys (restrict an agent to its own addresses)
emails send-intent # inspect/reconcile uncertain self-hosted send outcomes
emails send # send an email
emails reply / forward # reply (in-thread) or forward a sent/inbound email
emails email # sent email: list, search, show, replies, thread
emails inbox # mailbox folders, sources, sync, read/star/archive/label, watch
emails template # email templates
emails contact # contacts (suppression list)
emails group # recipient groups
emails sequence # drip sequences
emails schedule # scheduled emails: list, cancel, run
emails db # self-hosted PostgreSQL migration and status commands
emails self-hosted key # operator API-key create/list/rotate/revoke
emails auth # self-hosted signup/login/logout/tenant sessions
emails keys # tenant-scoped API keys for the active organization
emails whoami # current self-hosted principal and organization
emails aws # AWS setup: SES receipt rules, S3 inbound bucket
emails stats # delivery statistics (--inbox for received mail)
emails analytics # email analytics
emails doctor # system diagnostics
emails doctor delivery # diagnose missing inbound mail for one address
emails provision # durable DNS/address jobs, up, retry, daemon, roundtrip
emails serve # operator HTTP service; standalone dashboard compatibility
emails mcp # install MCP server
emails remove # remove MCP configuration from supported agent clientsCompact Output and Gradual Disclosure
Emails CLI commands are compact by default so agent terminals do not fill with large records. List and status commands show essential fields, bounded row counts, and hints for the next detail command. Use these flags when you need more:
emails address list # compact table
emails address list --verbose # expanded owner/admin/quota rows
emails domains status # per-domain records and DNS state
emails provider list --limit 50 # explicit larger page
emails contact list --suppressed # compact filtered contact list
emails template show <name> # detail path for template bodies
emails sequence show <name> # detail path for steps/enrollments
emails forwarding list --source [email protected]
emails agent context # compact agent context summary
emails agent context --verbose # full redacted context snapshot
emails agent context --json # full machine-readable context
emails email show <id> # detail path for one sent email
emails inbox read <id> # detail path for one inbound email
emails inbox attachments --limit 100 --direction inbound --json
emails inbox attachments --cursor <opaque-next-cursor> --json
emails inbox attachment <exact-id> --download --index 0 --output-dir ./attachmentsSelf-hosted attachment inventory is cursor-based, not offset-based. Each page
contains items and an opaque next_cursor; pass that cursor back unchanged to
continue. Inventory rows contain only safe metadata:
message_id, attachment_index, filename, content_type, size_bytes,
sha256, content_available, direction, and received_at. A missing or
unparseable size is null in JSON and is displayed as unknown size; it is
never rewritten as zero. content_available: false means metadata exists but
the stored bytes do not. Downloading that attachment returns HTTP 409 with
code attachment_content_unavailable, rather than a successful empty file.
Attachment byte downloads use descriptor-relative, no-overwrite writes so an
output-directory symlink swap cannot redirect bytes. That secure filesystem
primitive currently requires Linux (/proc/self/fd); macOS and other platforms
fail closed before writing a file. Metadata-only attachment reads remain
cross-platform.
--json remains the machine-readable path. Broad MCP list tools default to
their existing bounded summary page size for compatibility; use each tool's
limit/offset inputs or the matching detail tool/resource for larger or full
records. emails://agent/context is sampled for orientation; use
emails://agent/context/full for the full redacted MCP resource.
Principals, aliases and scoped send keys
Every address can have an owner that is a human or an agent. A human-owned address must be administered by an agent (the agent operates it on the human's behalf); agent-owned addresses are self-administered.
# Register owners and assign an address (human-owned, agent-administered)
emails owner register Morgan --type human --email [email protected]
emails owner register Atlas --type agent
emails address add [email protected] --provider <ses-id>
emails address owner [email protected]
emails address set-owner [email protected] --owner Morgan --administrator Atlas
emails address transfer-owner [email protected] --owner Atlas --reason "handoff" --yes
emails address unassign-owner [email protected] --reason "retired" --yes
emails address owner-history [email protected]
# Scoped send keys — an agent can only send from addresses it owns/administers
emails sendkey create Atlas --label ci # prints the esk_... token ONCE
emails sendkey check Atlas [email protected] # authorized
emails sendkey list / revoke <id>
# Per-domain aliases + catch-all
emails alias add [email protected] [email protected]
emails alias catch-all example.com [email protected] # *@example.com -> inbox@
emails alias global [email protected] # protected global catch-all (ALL domains)
emails alias resolve [email protected] # show where it routes
# App-level forwarding: forwards only mail already received into the account.
# Use provider-native forwarding when the mailbox provider owns root MX.
emails forwarding explain [email protected]
emails forwarding add [email protected] [email protected] --provider <provider-id>
emails forwarding run --provider <provider-id> # future mail only
emails forwarding run --provider <provider-id> --backfill # intentionally include older synced mail
# Address lifecycle
emails address add [email protected] --provider <ses-id>
emails address set-owner [email protected] --owner Atlas
emails address suggest --domain example.com
emails address suspend <id> # block sending from this address
emails address activate <id>
emails address quota <id> 200 # max 200 sends/day (use 'none' to clear)Domain warming
A new sending domain that jumps straight to full volume gets throttled or
filtered. A warming schedule ramps it: 50/day on day 1, roughly doubling every
two days until it reaches the target. While a schedule is active, sends from
that domain are refused once the day's limit is reached; paused and completed
schedules impose no cap.
emails domain warm example.com --target 5000 # start the ramp (today)
emails domain warm example.com --target 5000 --start-date 2026-08-01
emails domain warm-status example.com # day N/total, today's limit vs sent
emails domain warm-list # every schedule, one row each
emails domain warm-list --status active --json
emails domain warm-pause example.com # suspend the cap (deliverability issue)
emails domain warm-resume example.com
emails domain warm-complete example.com # graduated: no cap applies
emails domain warm-delete example.com --yes # remove it (needed to retarget)One schedule per domain: warm refuses to shadow an existing one. --target and
--start-date are fixed at creation, so retargeting means warm-delete then
warm again. The ramp is anchored on the UTC calendar date, the same anchor
the self-hosted server enforces the cap with, so warm-status reports the limit
that will actually be applied regardless of your machine's timezone.
Schedules live in the shared warming_schedules registry. The API enforces
the limit on /v1/messages/send for every client. The MCP twins are
create_warming_schedule, get_warming_status, list_warming_schedules, and
update_warming_status.
DNS and inbound safety
emails domain check <domain> detects common root MX owners, including Google
Workspace, Microsoft 365, Cloudflare Email Routing, Zoho, Proton, and AWS SES.
SES send-only provisioning does not require changing root MX and is the safest
path when an existing mailbox provider already receives mail.
Publishing SES inbound MX is only for domains that should receive through
SES/S3. emails domain adopt refuses to wire SES inbound when public MX already
belongs to another provider; --force-mx-switch overrides it for intentional
migrations after confirming mailbox ownership can move. emails aws
setup-inbound writes no DNS at all — it prints the MX record for you to
publish.
Optional verification-code selection
emails code, emails inbox code, and emails inbox wait-code retain their
numeric heuristic by default. Opt in to semantic selection with
--decision-provider openrouter; the default model is typesafe/jev-1.13.
The provider reads OPENROUTER_API_KEY from the environment. A credential
manager can project it without placing the value in command arguments:
secrets exec YOUR/OPENROUTER/REFERENCE --as OPENROUTER_API_KEY -- \
emails inbox wait-code [email protected] --from [email protected] \
--since 2026-09-21T10:00:00Z --decision-provider openrouter --json
# Extract from an explicit UTF-8 text file, without importing or reading mail.
emails inbox extract-code --input message.txt --decision-provider openrouter --json
# Use --input - for standard input; add --html for an HTML body.Emails finds numeric and alphanumeric candidates locally. It replaces their values with IDs and sends Jev only candidate type/length plus a restricted vocabulary of contextual words. Unknown words, addresses, links and unrelated numbers are suppressed. Detected candidate values stay local; an accepted choice maps back to the exact string, preserving leading zeros and letter case. The projection is deliberately lossy: unclear or unsupported wording can lead to abstention. The current scanner supports 4–10 digit values, spaced or hyphenated 3+3/4+4 digit groups, and 4–16 character mixed letter/digit or uppercase tokens. Uppercase words are masked even when they also mean something in context; fully capitalized messages can therefore lose useful context and cause abstention. Alphabetic-only codes that are not uppercase are unsupported; words matching the contextual vocabulary may remain in the projection.
JSON adds an assessment with selected, none, ambiguous, or unavailable,
provider identity and call count. Scores are advisory; extraction does not
establish that a service will accept a code. Enabled mode never silently falls
back to the numeric heuristic. Exit status is 0 for a match, 1 for no match,
and 2 for ambiguity or unavailable assistance. Omit the provider option or use
--decision-provider none for the original mailbox behavior.
Provider calls time out after 5 seconds by default (--decision-timeout-ms,
100–15000). Mailbox commands allow at most 5 calls per invocation
(--decision-max-calls, 1–20), caching unchanged message assessments only for
that invocation. Text extraction allows one call. Mailbox authorization,
recipient and time filters remain owned by Emails; extraction does not change
messages. The --from filter matches text and does not authenticate a sender.
MCP wait_for_code and wait_for_verification_code expose the equivalent
decision_provider, decision_model, decision_timeout_ms, and
decision_max_calls fields. Library consumers can inject a CodeDecisionProvider
into CodeDecisionSession for another service or local model. No web interface
or provider dependency is required for the default behavior.
MCP Server
100+ tools for AI agents — send/read mail, provisioning, ownership, aliases, scoped send keys, inbound read-state, real-time sync, agent context, source-aware mailbox status, ownership lookup/assignment/transfer audit, and verification-code waiting.
Terminology used by the CLI, REST API, MCP tools, and TUI:
- Provider: credentials and capability, such as SES send rights, Resend API access, or a sandbox.
- Source: an ingestion stream that brings mail into the shared registry, such as
provider:<id>,s3:<bucket>, Cloudflare-routed inbound storage,legacy, ororphaned:<id>. - Mailbox: the user-visible scope being browsed, such as all mail, one address, or one domain.
- Folder: a mailbox view such as
inbox,unread,sent,starred,archived,spam, ortrash.
Useful source-aware surfaces:
emails inbox sources --json
emails inbox mailboxes --source provider:<id> --json
emails inbox search invoice --folder sent --source provider:<id> --json
emails inbox attachments --limit 100 --direction inbound --jsonThe MCP tool list_attachments accepts limit, opaque cursor, direction,
and since. Its production transport envelope contains exactly items,
next_cursor, and cli_equivalent; the latter is the equivalent compatibility
CLI command, including the JSON flag, for auditability and handoff. Attachment
content is never included in inventory output.
emails-mcp # stdio transport (default)REST API
The shared service exposes authenticated /v1 routes and publishes its wire
contract at /openapi.json. Operators configure EMAILS_DATABASE_URL for
PostgreSQL, apply migrations, and launch emails-serve; the PostgreSQL service
binds to 0.0.0.0:8080 by default. See the deployment instructions below for
signing keys, tenant authentication, provider bindings, and HTTPS access.
Full-text search over /v1/messages is passed as the q query parameter
(the native spelling; search is accepted as the original alias). A
term parameter does not exist and is ignored, so requests built against it
return an unfiltered page instead of a search — callers should use q (or
search).
Message searches share a bounded concurrency budget across all tenants and saved
filters in each API process. The default admits eight simultaneous searches with
the default ten-connection PostgreSQL pool. Set EMAILS_SEARCH_CONCURRENCY to a
positive integer from 1 to 64 to change it; EMAILS_PG_POOL_MAX must leave at least
one additional connection for ordinary operations. An unconfigured search budget
shrinks to fit a smaller pool; a single-connection pool permits one search and
cannot reserve a separate connection. Invalid explicit budgets refuse startup.
For example, concurrency 16 requires a pool of at least 17 connections. Size the
pool and replica count against measured database and task capacity before raising
these settings.
Excess searches refuse immediately with HTTP 429 search_busy and
Retry-After: 5; admitted searches retain PostgreSQL's 30-second statement limit
and return HTTP 504 search_timeout on cancellation. Ordinary list/read requests
do not acquire search slots. This is a concurrency budget, not a requests-per-minute
quota. Authentication throttles, daily send limits, provider quotas and warming
limits remain separate. The CLI/SDK report search retry advice without automatically
replaying a send or other write.
Standalone compatibility surfaces
The package still includes a legacy SQLite dashboard under /api/* and explicit
storage exports for existing consumers. Running emails-serve without
EMAILS_DATABASE_URL starts that standalone dashboard on 127.0.0.1:3900.
Exposing it beyond loopback requires EMAILS_ALLOW_REMOTE=1 and an explicit
host, with authentication supplied by the surrounding proxy/firewall. It does
not become the shared registry used by the ordinary CLI, terminal UI, or MCP
workflow. Existing operator AWS and registrar helper commands also remain
separate from account mailbox operations; they can require operator-host cloud
configuration. They are not a prerequisite for signing another client into
the shared registry.
Library API
The generated @hasna/emails/selfhost client describes the authenticated API.
The public @hasna/emails entrypoint also exports domain, address, sending,
provisioning, and mail helpers.
Existing low-level database exports remain available for compatibility and isolated fixtures. The following example explicitly opens that legacy database; it does not configure ordinary CLI or MCP clients:
import {
sendWithFailover,
createProvider,
createAddress,
storeInboundEmail,
createTemplate,
suppressContact,
createSequence,
exportEmailsJson,
createOwner,
setAddressOwnerByRef,
createSendKey,
getDatabase,
closeDatabase,
runInTransaction,
resolvePartialId,
} from "@hasna/emails";
const db = getDatabase();
runInTransaction(db, () => {
// CRUD helpers accept an optional Database for isolated local workflows.
});
closeDatabase();The provider factory and the DNS helpers are exported too. providerDnsPublishing
answers whether a provider type publishes DNS records at all, so an empty record
list renders as "nothing to publish here" rather than "nothing found" — a
sandbox provider captures mail locally and has no DKIM/SPF/DMARC of its own:
import { getAdapter, providerDnsPublishing, formatDnsTable } from "@hasna/emails";
const records = await getAdapter(provider).getDnsRecords("example.com");
console.log(formatDnsTable(records, providerDnsPublishing(provider)));Inbound Email (AWS SES -> S3)
# Inspect and poll an existing server-bound S3 source
emails inbox sources
emails inbox sync-s3 --source <source-id>
# Read-state / organize (works for SES-S3, SMTP, Cloudflare-routed, and legacy imported mail)
emails inbox list --unread # filters: --unread/--read/--starred/--archived/--label <l>
emails inbox latest [email protected] --json
emails inbox wait [email protected] --timeout 120
emails inbox wait [email protected] --from [email protected] --subject invoice --timeout 300
emails inbox wait-code [email protected] --from openai --timeout 120
emails inbox sync-status --json # S3 and realtime status
emails inbox explain <id> # route/owner/readiness trace
emails inbox read <id> # opening marks it read
emails inbox star|archive|label <id> # --undo / --remove to reverseemails inbox wait holds a POSITION, one per scope (the address plus --from/--subject),
in this client's state directory, so re-arming it resumes instead of handing back mail it
already delivered: mail that arrived while nothing was waiting is delivered oldest first,
and an arm killed after it reported a message re-reports that same message with
replayed: true rather than losing it. Every result carries wait metadata (resumed,
state_read, deliveries, state_file), --since is a floor on top of the stored
position, --state-file picks the file explicitly, and --fresh is the deliberate way past
a position you have decided to skip. --limit is both the candidates read per poll and the
window whose fullness makes the wait refuse rather than answer "no new mail" from a read
that cannot see the oldest unhandled message.
Two properties of that position are worth knowing before you rely on it:
- A position that EXISTS BUT CANNOT BE TRUSTED (unreadable, not JSON, from another version
or another wait scope) REFUSES the arm —
inbox wait refuses to start a new position— and hands nothing over. It is never treated as "no position yet": starting a new position over a real one would put every older unhandled message behind it and could re-hand a delivered message over as new. Repair or move the file, pass--state-fileto use another, or pass--freshto deliberately start a new position and treat the current backlog as handled. - A FIRST ARM (no position file yet, or
--fresh) delivers the NEWEST matching message and commits the position to it, which is the behaviour this command has always had: arming a wait for a message that just arrived must still receive it. Mail older than that point in that scope is then reachable throughemails inbox list/read, not through further waits. The result reportsseeded: trueandstate_read: "empty"so a caller can tell a first arm from a resumed one.
Two arms of the same scope polling inside one interval can both select the same message; delivery is at-least-once across arms and the message id in the result is the dedupe key.
The wait reads candidate ROWS, not bodies: one paged listing per poll for identity and
position, and a single message read only for the message it hands over. --limit is
therefore the window of rows considered per poll (up to 1000); it does not cost a body per
candidate, which is what an earlier cut of this change did and what an independent review
measured as a per-poll amplification.
Real-time inbound (no manual sync)
setup-realtime configures delivery across the SES, SNS, and SQS resources
named by the API service binding. watch polls that bound queue and imports
messages into the shared registry:
emails inbox setup-realtime example.com # wire the resources configured on the server
emails inbox watch # auto-delivers new mail in real-time (--once to poll once)Alternatively, point an SNS HTTP subscription at POST /webhook/ses-inbound on
emails serve. Configure EMAILS_SNS_TOPIC_ARNS and
EMAILS_AWS_ACCOUNT_IDS; the route verifies the AWS signature and exact
allowlists before it confirms or syncs a notification.
Self-Hosted Runtime (PostgreSQL/S3/SES)
The server uses operator-owned Postgres and provider accounts. A client
resolves the hosted API through the shared credential resolver — the canonical
HASNA_EMAILS_API_URL / HASNA_EMAILS_API_KEY, the macOS
Keychain, or ~/.hasna/emails/config/credentials (the retired
EMAILS_SELF_HOSTED_* aliases are refused). The service requires EMAILS_DATABASE_URL,
EMAILS_API_SIGNING_KEY, EMAILS_SEND_PROVIDER=ses|resend,
EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS, and EMAILS_AUTH_FROM. SES uses the
deployment IAM role; Resend uses RESEND_API_KEY. See
docs/AUTHENTICATION.md for signup, sessions,
tenant-scoped keys, and optional IdP verification.
The ingest worker's /ready probe checks receive-cycle progress and independently
samples SQS ApproximateNumberOfMessages. A stalled loop stays ready only when a
fresh, valid sample proves no visible work; missing or malformed visibility fails
closed. /health reports oldest_age_seconds: null: oldest-message age is a
CloudWatch metric, not a GetQueueAttributes field. Operators must configure and
verify a separate AWS/SQS ApproximateAgeOfOldestMessage alarm; the worker does
not provision or evaluate that alarm. EMAILS_INGEST_QUEUE_AGE_POLL_SECONDS keeps
its legacy name but controls visibility sampling. The legacy
EMAILS_INGEST_QUEUE_AGE_ALARM_SECONDS value is diagnostic only.
Self-hosted client commands fail closed when the URL or credential is missing
or invalid — a configured authority with no credential refuses rather than
falling back to local data. With --json, the CLI emits one structured error object on
stderr, exits nonzero, and leaves stdout empty. It does not open, create, or
fall back to the local SQLite database.
EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS is the allowlist of email domains that may sign up, log in, or be invited (comma- or space-separated globs, * matching one DNS label — e.g. example.com or example.*), and EMAILS_AUTH_FROM is the sender identity for confirmation/reset/invite mail. Neither has a default and the service refuses to boot without them: this package ships no domain and no sender of its own, so a default would either lock your auth surface to someone else's organisation or open signup to everyone. See docs/SELF_HOSTED_RUNTIME.md.
Expose the service through an HTTPS reverse proxy or load balancer with edge
rate limits, the 1 MiB request limit, bounded upstream timeouts, and network
rules that keep Postgres and the container port private. The generated client
rejects remote plaintext HTTP. Self-hosted sends require an idempotency key and
support at most five inline attachments (10 MiB each, 20 MiB total).
Scheduled sends use a durable API queue and stable send identities. Mailbox read,
star, archive, label, delete, bulk-by-explicit-id, and authenticated attachment
retrieval are supported. Outbound rows carrying a send idempotency key are a
durable delivery ledger and return 409 on delete so their replay fence cannot
be erased; ordinary inbound and non-idempotent rows remain deletable.
For an operator retry, reuse the same emails send --idempotency-key <key>.
Changing the payload under that key is rejected, and an uncertain provider
outcome must be reconciled before another send.
Legacy attachment repair
The self-hosted repair ledger is tenant-scoped, bounded, idempotent, and
checkpointed. Create a repair with
POST /v1/attachments/repairs; omitting apply is a dry run. Each manifest
entry may contain only object_key, recipients, and
canary_message_ids. The canary IDs must be the exact complete set currently
bound to that canonical object, not a sample. Caller-supplied buckets and raw
payloads are rejected: the service uses only its configured canonical S3
bucket, fetches only named object keys, never scans the bucket, and never emits
object keys, recipients, payload bytes, or internal errors in the repair
summary.
Inspect a repair with GET /v1/attachments/repairs/{id} and resume one bounded
page with POST /v1/attachments/repairs/{id}/resume. The response reports
inventory counters repaired, would_repair, unavailable, pending, and
retrying, plus the matching entry_* counters, attempts, and checkpoint.
retrying is the transient subset of pending; terminally invalid or
unrepairable rows become unavailable. A malformed repair UUID returns HTTP
400 with code invalid_attachment_repair_id.
An apply is fail-closed and must prove the exact reviewed dry run for the same tenant and current manifest:
- Create the dry run with a stable
idempotency_key, omitapply, and do not send anyreviewed_dry_run_*fields. - Resume/read it until
statusiscompletedand both item and entry totals have zeropending,retrying,unavailable, andoperator_action. - Review the returned public
repairsummary and compute its lowercase SHA-256 using recursively key-sorted canonical JSON (object keys sorted, array order preserved). This is the sameattachmentRepairRunResultSha256calculation enforced by the service. - Submit the same exact
entrieswith a separate applyidempotency_key,apply: true, and the two proof fieldsreviewed_dry_run_idandreviewed_dry_run_result_sha256.
Before creating the apply ledger, the service tenant-scopes the reviewed ID,
checks the completed zero-failure result and hash, then recreates or looks up
the reviewed dry run using the deployment-owned canonical bucket, the exact
current entries, and apply: false. This manifest check is read-only and does
not create an alias or consume repair quota. Any wrong tenant, ID, manifest,
hash, or unfinished/failed result returns the same non-leaking HTTP 409
attachment_repair_review_mismatch; no apply run is created or processed.
Recipient routing trust boundary
For SES/S3 ingestion, explicit envelope recipients are authoritative. The
worker may derive a catch-all address from the trusted SES-written S3 key path
only when the notification contains no recipients and normal routing resolved
no group. That derived address is still revalidated through tenant routing.
Malformed, partial, or unresolved explicit recipients are quarantined and are
never replaced by S3-key fallback. MIME To/Cc headers are
sender-controlled and are never used to select a tenant.
# Hosted client — canonical names (the stale EMAILS_SELF_HOSTED_* aliases
# are retired and refused)
export HASNA_EMAILS_API_URL="https://emails.example.com"
export HASNA_EMAILS_API_KEY="..."
# On the self-hosted server
export EMAILS_DATABASE_URL="postgresql://..."
export EMAILS_API_SIGNING_KEY="..."
export EMAILS_SEND_PROVIDER=ses
export EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS="example.com" # your own signup domains
export EMAILS_AUTH_FROM="[email protected]" # a verified sender identity
emails db migrate
emails-serveClients read and write the shared API registry; no mailbox database synchronization is required between machines.
The emails-serve ingest-worker uses the same SELECT-only migration inventory
check as the API's /ready probe. Missing, unknown or mismatched migrations
(including dependency migrations), or an unreadable ledger, refuse startup
before any queue or object request. Published historical checksum compatibility
is shared with the API; the worker never applies migrations automatically.
It rechecks before each receive, after the long poll and before visibility
sampling. Observed drift stops new work and closes worker health and database
resources; an already processing message retains its normal acknowledgment.
These batch checks do not replace coordinated writer shutdown for migrations.
Data
Mail, addresses, domains, provider references, sources, and provisioning jobs belong to the account's API registry. Provider credential material is held by the service, and attachment content uses its configured storage. Client disks may hold access configuration, view preferences, and files explicitly downloaded by the user.
Historical SQLite paths and migrations remain in the standalone compatibility code and storage exports. Existing files are not evidence of a current API mailbox, and connecting another machine does not require copying those files. Retirement of old data is a separate operation; see Station-local retirement.
Transport
stdio is the default (one server per client, no listening socket). The shared
Streamable HTTP transport is opt-in via --http, because it publishes every MCP
tool — send_email, add_forwarding_rule, set_config, create_send_key — to
anything that can reach the port:
emails-mcp # stdio (default)
emails-mcp --stdio # same, explicit
MCP_STDIO=1 emails-mcp # same
# HTTP: opt-in, and refuses to start without a bearer token
EMAILS_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" emails-mcp --http
EMAILS_MCP_HTTP_TOKEN=... emails-mcp --http --port 8861 # or --port=8861- Health:
GET http://127.0.0.1:8861/health->{"status":"ok","name":"emails"} - Override port with
MCP_HTTP_PORT,--port <n>, or--port=<n>
HTTP transport authorization
A loopback bind is not an authorization boundary. Any local process can reach the
port, and a web page can reach it too by re-resolving its own hostname to
127.0.0.1 (DNS rebinding) — at which point the browser considers the request
same-origin and CORS never applies. So the HTTP transport:
- Requires
Authorization: Bearer <token>on/mcp, matchingEMAILS_MCP_HTTP_TOKEN(minimum 16 characters). Without the variable set, the server refuses to start rather than listening unauthenticated. - Validates the
Hostheader on every path. Default allowlist is the loopback names at the bound port (127.0.0.1:<port>,localhost:<port>,[::1]:<port>). Override withEMAILS_MCP_ALLOWED_HOSTS(comma-separated) — required if you bind a wildcard address or front the server with a proxy. - Validates
Originwhen the client sends one, againstEMAILS_MCP_ALLOWED_ORIGINS(default: the loopback origins at the bound port). Native MCP clients send noOriginand are unaffected.
/health stays unauthenticated so liveness probes keep working; it returns only
the static {"status":"ok","name":"emails"} and is still behind the Host
allowlist.
Client configuration, e.g. for the MCP TypeScript SDK:
new StreamableHTTPClientTransport(new URL("http://127.0.0.1:8861/mcp"), {
requestInit: { headers: { Authorization: `Bearer ${process.env.EMAILS_MCP_HTTP_TOKEN}` } },
});License
Apache-2.0 — see LICENSE
Reply-To and threaded replies
emails reply <message-id> --body "Reply text" and emails send --in-reply-to <message-id> send the parent record as the typed API field reply_to_message_id. The API derives In-Reply-To and References; custom X-* headers cannot override them. The parent must belong to the authenticated tenant, the sender must be the original outbound sender or an inbound recipient, and the subject must retain the parent's subject with an optional Re: prefix. Scheduled sends persist the parent and validate it again when executing. An older API that cannot advertise this field refuses before sending.
Incoming Reply-To takes precedence over From when choosing a reply target. Reply-all adds the visible To/CC recipients, removes the sender and deduplicates canonical addresses, including quoted display names. Explicit --reply-to controls the header on a new outgoing message; it does not identify a parent. Configure sender names on the address's display_name field through the address API/SDK; the provider renders the configured name while authorization retains the canonical address.
A provider receipt ID is distinct from an RFC Message-ID. A reply requires the latter. Resend can read the actual message_id through its normal retrieval API, bounded to five seconds and a 1 MiB response. The parent provider binding is used even if the reply selects a different provider. Identity admission compares tenant, parent/provider identity and sent state, preserves concurrent header changes, and stores provenance. An unavailable, unbound legacy or conflicting parent identity refuses before a new send intent or queue entry.
SES rewrites Message-ID. No suffix is guessed from the sending region, custom MAIL FROM domain or opaque receipt. Operators may configure EMAILS_SES_MESSAGE_ID_DOMAINS as a JSON object keyed by exact SES region. Each entry must contain domain, evidence_sha256 (64 lowercase hex characters) and verified_at (UTC ISO timestamp). First observe a received message's original headers, compare its full RFC Message-ID to the corresponding SES send receipt, and retain that evidence. The map is an explicit operator attestation, not a provider readback; its provenance remains attached to the recorded identity. It applies only to the named region, including managed SES bindings. Leave it unset when no mapping has been verified. The map is limited to 8 KiB and 32 entries; unsupported entries fail at startup.
Replies with malformed or oversized References refuse rather than silently dropping ancestry (the supported joined header is at most 900 characters). Recipient applications decide conversation grouping. A local subject-based conversation ID is not the recipient's Gmail thread ID, and correct headers alone do not prove how an inbox displays a message.
