npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hasna/emails

v1.8.1

Published

Email management through a shared API: CLI, terminal UI, MCP server, and library

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.

License

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/emails and ships the emails, emails-mcp, and emails-serve bins. @hasna/mailery on npm is the abandoned 0.6.x line, and the mailery* bins belong to the separate cloud CLI — neither is this package. The env prefix is EMAILS_*. 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/emails

Deployment

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 create

Message 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=none to 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 unread

The 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 clients

Compact 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 ./attachments

Self-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, or orphaned:<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, or trash.

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

The 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 reverse

emails 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-file to use another, or pass --fresh to 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 through emails inbox list/read, not through further waits. The result reports seeded: true and state_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:

  1. Create the dry run with a stable idempotency_key, omit apply, and do not send any reviewed_dry_run_* fields.
  2. Resume/read it until status is completed and both item and entry totals have zero pending, retrying, unavailable, and operator_action.
  3. Review the returned public repair summary and compute its lowercase SHA-256 using recursively key-sorted canonical JSON (object keys sorted, array order preserved). This is the same attachmentRepairRunResultSha256 calculation enforced by the service.
  4. Submit the same exact entries with a separate apply idempotency_key, apply: true, and the two proof fields reviewed_dry_run_id and reviewed_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-serve

Clients 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, matching EMAILS_MCP_HTTP_TOKEN (minimum 16 characters). Without the variable set, the server refuses to start rather than listening unauthenticated.
  • Validates the Host header on every path. Default allowlist is the loopback names at the bound port (127.0.0.1:<port>, localhost:<port>, [::1]:<port>). Override with EMAILS_MCP_ALLOWED_HOSTS (comma-separated) — required if you bind a wildcard address or front the server with a proxy.
  • Validates Origin when the client sends one, against EMAILS_MCP_ALLOWED_ORIGINS (default: the loopback origins at the bound port). Native MCP clients send no Origin and 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.