Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ jobs:
if (pkg.publishConfig?.registry !== "https://registry.npmjs.org") throw new Error("npm registry must be the public registry");
if (pkg.repository?.url !== "git+https://github.com/hasna/emails.git") throw new Error("repository provenance must be hasna/emails");
'
- name: Require immutable gates on future release and deployment workflows
run: bun run deployment:policy
- name: Verify generated SDK is committed
run: |
bun run scripts/generate-selfhost-sdk.ts
Expand All @@ -141,6 +143,8 @@ jobs:
run: |
bun run no-cloud:source
bun run no-cloud:pack
- name: Verify self-hosting release and rollback contract
run: PATH=/usr/bin:/bin ./deploy/aws/tests/static_contract.sh
- name: Diff hygiene
run: git diff --check

Expand All @@ -167,7 +171,7 @@ jobs:
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- name: Verify self-hosted Postgres migrations, tenancy, RLS, message IDs, and send semantics
- name: Verify self-hosted Postgres migrations, tenancy, RLS, message IDs, send semantics, and store conformance
env:
PGHOST: 127.0.0.1
PGPORT: "5432"
Expand All @@ -190,3 +194,15 @@ jobs:
src/server/self-hosted/send-honesty-and-reconciliation.integration.test.ts \
src/server/self-hosted/webhooks.integration.test.ts
bun test src/server/self-hosted/attachment-inventory.integration.test.ts
# store-conformance gets its OWN invocation, like attachment-inventory, and it
# is not a stylistic choice: every suite above drops schema `public` in its
# `beforeAll`, so folding this one in would make its correctness depend on
# `bun test` honouring the argument order — a property no assertion here checks.
# On its own it migrates whatever it finds and takes a fresh tenant per run, so
# nothing can wipe the schema out from under it.
#
# EMAILS_REQUIRE_POSTGRES_TESTS makes the suite assert its own reachability: the
# whole file is skipped when the connection string is absent, so without this
# flag a renamed variable or a broken service container would silently delete
# the real-service conformance evidence and leave the job green.
EMAILS_REQUIRE_POSTGRES_TESTS=1 bun test src/server/self-hosted/store-conformance.integration.test.ts
27 changes: 20 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

This file guides AI coding agents working with `@hasna/emails` - an email management CLI, MCP server, and library supporting Resend, AWS SES, and Cloudflare-routed inbound mail.

## Naming (read before "fixing" any Mailery reference)

This product is **open-emails**: repo `hasna/emails`, package `@hasna/emails`,
bins `emails*`, env prefix `EMAILS_*`. **Mailery is a separate, unrelated
product and is not a name for this one** (owner ruling 2026-07-27).

The string `mailery` still appears in this tree on purpose. It is either a
guard that enforces the ruling, a frozen compatibility constant (migration IDs,
the `mailery` API-key alias slug, legacy event source, `legacy-inbound@local.mailery`),
or a banned env selector that must stay named to be rejected. **Do not
bulk-rename it.** Read [docs/NAMING.md](docs/NAMING.md) first — it lists what
breaks for each one.

## What This Package Does

`@hasna/emails` manages the full email lifecycle locally:
Expand Down Expand Up @@ -201,7 +214,7 @@ bun run dev:serve # run HTTP server in dev mode
```
src/
├── cli/
│ ├── index.tsx # thin orchestrator (~65 lines)
│ ├── index.tsx # thin, lazy-loading command orchestrator
│ ├── utils.ts # shared helpers
│ ├── tui/ # OpenTUI Emails UI dashboard
│ └── commands/ # modular command files
Expand Down Expand Up @@ -229,18 +242,18 @@ src/
├── providers/ # provider adapters
│ ├── resend.ts, ses.ts, sandbox.ts
│ └── interface.ts # ProviderAdapter interface
├── mcp/ # MCP server, modular tools, and resources
├── server/serve.ts # HTTP server + REST API
├── mcp/ # MCP server, modular tools, contracts, and resources
├── server/ # local dashboard API plus self-hosted /v1 service
└── index.ts # library exports
```

## Adding New Features

The codebase follows these patterns:
- **New DB table**: Add migration in `db/database.ts`, new CRUD file in `db/`, add `ensureTable`/`ensureIndex` in `ensureSchema`
- **New DB table**: Add the SQLite migration/ensure-schema work in `db/database.ts`; if self-hosted, also add an immutable migration under `server/self-hosted/migrations.ts` and store/RLS coverage
- **New CLI command**: Add to appropriate `cli/commands/*.ts` file
- **New MCP tool**: Add `server.tool(...)` in `mcp/index.ts` before the Start section
- **New REST endpoint**: Add route in `server/serve.ts`
- **New MCP tool**: Add `server.tool(...)` in `mcp/tools/*.ts` and wire a new registrar from `mcp/server.ts` when needed
- **New REST endpoint**: Add local dashboard routes under `server/routes/`; add self-hosted `/v1` routes and OpenAPI under `server/self-hosted/`
- **New library export**: Add to `src/index.ts`

Test: `EMAILS_DB_PATH=:memory: bun test` — must stay at 0 failures.
Test: `bun run test` — the hermetic runner owns DB isolation and must stay at 0 failures.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `@hasna/emails` are documented here.

## [Unreleased]

- **fix(ui): `emails ui` could not start in any real terminal — the packaged runtime loaded a foreign OpenTUI native library.** 1.3.4 exited immediately with `Failed to initialize OpenTUI render library: Symbol "createEventSink" not found in .../@opentui/core-linux-arm64/libopentui.so`. `@opentui/core` loads its prebuilt renderer with a bare `import("@opentui/core-<platform>")` from inside its own module, so the version-matched prebuilt in `@opentui/core/node_modules/` is what answers. `scripts/build-tui-runtime.ts` inlined core into `dist/cli/ui-runtime-bundle.js` while listing the eight platform packages as **external**, which moved that import to `dist/cli/` — it then resolved against the installed package's *parents*, never saw core's own prebuilt, and bound to whatever copy the install had hoisted (here `0.1.105`, an ABI predating the symbol core calls). The install was version-correct throughout; only the loaded `.so` was wrong. `@opentui/core` is now external — it is already a declared runtime dependency, and keeping it in `node_modules` keeps the JS and the library it `dlopen`s in one dependency tree. `web-tree-sitter` and `bun-ffi-structs` were dropped from the same list for the same reason: both are core's dependencies, neither is declared by `@hasna/emails`, so externalising them pointed at unowned copies too. Declaring the eight platform packages as our own `optionalDependencies` was rejected — it copies upstream's platform matrix into this manifest and rots on every core bump, while leaving the resolution anchor wrong. `patchBundledNativeAssetPath()` is gone with the bundling it patched around, and the runtime bundle drops from 3.4 MB to 2.1 MB.
- **test(ui): the build contract asserted the broken configuration, so the suite stayed green through a UI that could not start.** It required `scripts/build-tui-runtime.ts` to contain `"@opentui/core-linux-arm64"` and `...nativePackages` — the exact lines that caused the crash — because every assertion was a text match on the build script rather than a check of the artifact it produces. New `src/cli/tui/ui-runtime-contract.test.ts` rebuilds the bundle (never trusting a stale one), parses its imports with `Bun.Transpiler.scanImports` rather than a regex over 3 MB of bundled output, and fails if any bare import is not a declared runtime dependency of this package — the general form of the defect, not just the OpenTUI instance. It carries a positive control proving the check reports an undeclared external and passes a declared one, and a behavioural guard that a non-interactive `emails ui` exits non-zero, so a refusal can never be read as a UI that ran.
- feat(cli): every inbox and sync command now accepts `-j, --json`, emits one structured result document, and reports machine-readable failures without changing the existing human output.

- **feat(auth): IdP-token credential class — the first committed step of the ADR-0001 identity federation.** The self-hosted server now accepts EdDSA access tokens minted by the `@hasna/tenants` IdP, verified statelessly against the JWKS URL configured via `EMAILS_IDP_JWKS_URL` (unset ⇒ the class is refused with a typed `idp_not_configured`; a JWKS outage is a typed 503, never an allow). A verified token's `sub` resolves through the new additive `idp_principal_tenants` resolution table (migration 0021 — outside RLS like `api_key_tenants`, with an IdP-tenant pin and an emails-side `revoked_at` kill switch), scopes are normalized onto the existing `emails:read`/`emails:write`/`emails:*` vocabulary, and `/v1/me` gains a third modeled `principal_type: "idp"` branch. Clients can present the token via `EMAILS_IDP_TOKEN` (session > IdP token > operator key precedence) and `emails auth whoami` reports `<org> (idp agent <sub>)`. Secret-free `[idp-auth]`/`[idp-jwks]` audit lines carry sub/jti/kid/reason — never the token. Existing `hasna_`/`emss_` dispatch is byte-equivalent: the IdP branch is structural JWS detection AFTER both prefix classes.

- scope AWS module cross-account SES credentials to `EMAILS_SES_*` only; generic `AWS_*` credentials are no longer injected, so unrelated SDK clients retain the task-role default chain.

- **fix(status): the refusal registry is checked against the CLI, not against itself — `emails status` was still proposing a command that throws.** `src/lib/status-commands.ts` documented its source of truth as `grep -n 'serverOnly(' src/cli/commands/*.remote.ts`. That glob is wrong: `serverOnly()` is also defined and called in the SHARED modules `src/cli/commands/domain.ts` and `src/cli/commands/address.ts`, which `src/cli/index.tsx` loads in BOTH modes and whose helper throws unconditionally. Fifteen commands were missing from `NEVER_AVAILABLE_COMMANDS`, so `status-facts.remote.ts domainFixCommands` returned `emails domain status --json` for any failed/errored domain, `agent-context.ts buildNextActions` promoted `fix_commands[0]` into `next_actions`, and `isCommandAvailableInMode` waved it through — the exact "remedy that refuses" defect the registry exists to remove, reintroduced one command over. Local mode was hit the same way through `domain-readiness.ts` fix_commands (`emails domain check|dns|verify|setup-cloudflare`, all four unconditional refusals). Also fixed: `cli_equivalents.provision_address` and the `create_receive_address` workflow proposed `emails address provision` (refuses everywhere) — now `emails address add` plus an explicit `emails address set-owner` step, because `address add` takes only `--provider`/`--name` and the workflow was registering an owner it never attached; only one of the three workflow lists was mode-filtered, now all three are; and the `Usable domains:` footer and the MCP domains resource `cli_equivalent` both advertised `emails domain status`, now `emails domain list`.
Expand Down
Loading