Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 4 additions & 41 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,47 +114,10 @@ jobs:
bun-version: 1.3.14
- name: Install locked dependencies
run: bun install --frozen-lockfile
- name: Test in isolated local mode
run: |
tmp_home="$(mktemp -d)"
trap 'rm -rf "$tmp_home"' EXIT
env -u MAILERY_MODE -u HASNA_MAILERY_MODE \
-u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \
-u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \
-u MAILERY_API_URL -u MAILERY_API_KEY \
-u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \
-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \
-u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \
-u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \
-u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \
-u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \
-u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \
-u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \
-u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \
-u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \
-u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \
-u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \
-u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \
-u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \
-u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \
-u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \
-u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \
AWS_EC2_METADATA_DISABLED=true \
HOME="$tmp_home" EMAILS_MODE=local EMAILS_DB_PATH=:memory: \
bash -euo pipefail <<'BASH'
mapfile -d '' -t test_files < <(
find . \
\( -path './.git' -o -path './node_modules' -o -path './dist' \) -prune -o \
-type f \( -name '*.test.ts' -o -name '*.test.tsx' \) \
-print0 | sort -z
)
test "${#test_files[@]}" -gt 0
printf 'Running %d test files in isolated Bun processes\n' "${#test_files[@]}"
for test_file in "${test_files[@]}"; do
printf '\n=== %s ===\n' "$test_file"
bun test --max-concurrency 1 "$test_file"
done
BASH
- name: Test the whole local suite in one process
run: bun run test:shared
- name: Diagnose every local test file in isolation
run: bun run test:hermetic
- name: Build
run: bun run build
- name: Verify generated self-hosted SDK signatures
Expand Down
190 changes: 190 additions & 0 deletions .github/workflows/package-provenance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
name: Attest @hasna/emails 1.3.2 release evidence

"on":
workflow_dispatch:

jobs:
attest-published-package:
if: github.repository == 'hasna/emails' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
actions: read
contents: read
id-token: write
attestations: write

steps:
- name: Verify source and CI evidence
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
umask 077

readonly repository='hasna/emails'
readonly source_merge_commit='fe61a466a28115f33efda1ecc7632dbc7c6525c7'
readonly ci_run_id='30212897836'
verification_dir="$(mktemp -d)"
readonly verification_dir
readonly commit_response="${verification_dir}/source-commit.json"
readonly run_response="${verification_dir}/ci-run.json"

cleanup_verification() {
rm -rf -- "$verification_dir"
}
trap cleanup_verification EXIT

command -v gh >/dev/null
command -v jq >/dev/null

gh api \
--method GET \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
"/repos/${repository}/commits/${source_merge_commit}" \
>"$commit_response"
jq --exit-status \
--arg source_merge_commit "$source_merge_commit" \
'.sha == $source_merge_commit' \
"$commit_response" >/dev/null

gh api \
--method GET \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2022-11-28' \
"/repos/${repository}/actions/runs/${ci_run_id}" \
>"$run_response"
jq --exit-status \
--arg repository "$repository" \
--arg source_merge_commit "$source_merge_commit" \
--argjson ci_run_id "$ci_run_id" '
.id == $ci_run_id and
.repository.full_name == $repository and
.head_repository.full_name == $repository and
.head_sha == $source_merge_commit and
.status == "completed" and
.conclusion == "success" and
.event == "push" and
.head_branch == "main" and
.path == ".github/workflows/ci.yml"
' "$run_response" >/dev/null

- name: Download and verify published tarball
shell: bash
run: |
set -euo pipefail
umask 077

readonly artifact_dir='attestation-input'
readonly artifact="${artifact_dir}/emails-1.3.2.tgz"
readonly tarball_url='https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz'
readonly expected_sha256='8f5e166e73ae7aebeb49a5eeae6dd199d0be63a9931a35981373e67b9ccfe431'
readonly expected_shasum='87c933255f5e95e7db8bf30bb606e07c1132f01e'
readonly expected_integrity='sha512-nGwS4AoZH2NwTV8Xoop2XupAubyq4bHuawYZX5itCjVwa/3U4hE6t+tBdnKZ9p72/BuUxmAL4iLEZ79eWlkCHg=='

mkdir --mode=700 "$artifact_dir"
curl --disable \
--fail \
--silent \
--show-error \
--proto '=https' \
--proto-redir '=https' \
--tlsv1.2 \
--connect-timeout 30 \
--max-time 300 \
--output "$artifact" \
"$tarball_url"

printf '%s %s\n' "$expected_sha256" "$artifact" |
sha256sum --check --strict
printf '%s %s\n' "$expected_shasum" "$artifact" |
sha1sum --check --strict

actual_integrity="sha512-$(openssl dgst -sha512 -binary "$artifact" | openssl base64 -A)"
readonly actual_integrity
if [[ "$actual_integrity" != "$expected_integrity" ]]; then
echo 'npm integrity verification failed' >&2
exit 1
fi

- name: Create release evidence predicate
shell: bash
run: |
set -euo pipefail

readonly predicate='attestation-input/npm-release-evidence.json'

cat >"$predicate" <<'JSON'
{
"schemaVersion": 1,
"kind": "npm-release-evidence",
"package": {
"ecosystem": "npm",
"name": "@hasna/emails",
"version": "1.3.2",
"tarballUrl": "https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz",
"sha256": "8f5e166e73ae7aebeb49a5eeae6dd199d0be63a9931a35981373e67b9ccfe431",
"npmShasum": "87c933255f5e95e7db8bf30bb606e07c1132f01e",
"npmIntegrity": "sha512-nGwS4AoZH2NwTV8Xoop2XupAubyq4bHuawYZX5itCjVwa/3U4hE6t+tBdnKZ9p72/BuUxmAL4iLEZ79eWlkCHg=="
},
"sourceEvidence": {
"repository": "https://github.com/hasna/emails",
"sourceMergeCommit": "fe61a466a28115f33efda1ecc7632dbc7c6525c7",
"reviewedHeadCommit": "4330ff214f53a41de681d595d188861bb3d36e13"
},
"mainCiEvidence": {
"runId": "30212897836",
"url": "https://github.com/hasna/emails/actions/runs/30212897836",
"workflow": ".github/workflows/ci.yml",
"event": "push",
"branch": "main",
"headSha": "fe61a466a28115f33efda1ecc7632dbc7c6525c7",
"conclusion": "success"
},
"attestationScope": {
"evidenceType": "post-publication-association",
"subjectOrigin": "downloaded-from-npm-registry",
"subjectBuiltByThisWorkflow": false,
"subjectPublishedByThisWorkflow": false,
"statement": "This workflow downloaded and digest-verified the npm tarball bytes, then attested the recorded release evidence. It did not build or publish the package."
}
}
JSON

jq --exit-status '
.schemaVersion == 1 and
.kind == "npm-release-evidence" and
.package.ecosystem == "npm" and
.package.name == "@hasna/emails" and
.package.version == "1.3.2" and
.package.tarballUrl == "https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz" and
.package.sha256 == "8f5e166e73ae7aebeb49a5eeae6dd199d0be63a9931a35981373e67b9ccfe431" and
.package.npmShasum == "87c933255f5e95e7db8bf30bb606e07c1132f01e" and
.package.npmIntegrity == "sha512-nGwS4AoZH2NwTV8Xoop2XupAubyq4bHuawYZX5itCjVwa/3U4hE6t+tBdnKZ9p72/BuUxmAL4iLEZ79eWlkCHg==" and
.sourceEvidence.repository == "https://github.com/hasna/emails" and
.sourceEvidence.sourceMergeCommit == "fe61a466a28115f33efda1ecc7632dbc7c6525c7" and
.sourceEvidence.reviewedHeadCommit == "4330ff214f53a41de681d595d188861bb3d36e13" and
.mainCiEvidence.runId == "30212897836" and
.mainCiEvidence.url == "https://github.com/hasna/emails/actions/runs/30212897836" and
.mainCiEvidence.workflow == ".github/workflows/ci.yml" and
.mainCiEvidence.event == "push" and
.mainCiEvidence.branch == "main" and
.mainCiEvidence.headSha == "fe61a466a28115f33efda1ecc7632dbc7c6525c7" and
.mainCiEvidence.conclusion == "success" and
.attestationScope.evidenceType == "post-publication-association" and
.attestationScope.subjectOrigin == "downloaded-from-npm-registry" and
.attestationScope.subjectBuiltByThisWorkflow == false and
.attestationScope.subjectPublishedByThisWorkflow == false and
.attestationScope.statement == "This workflow downloaded and digest-verified the npm tarball bytes, then attested the recorded release evidence. It did not build or publish the package."
' "$predicate" >/dev/null

- name: Attest downloaded npm tarball
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: attestation-input/emails-1.3.2.tgz
predicate-type: https://github.com/hasna/emails/attestations/npm-release-evidence/v1
predicate-path: attestation-input/npm-release-evidence.json
push-to-registry: false
create-storage-record: false
22 changes: 11 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,6 @@ All notable changes to `@hasna/emails` are documented here.

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

## 1.3.2 (2026-07-26)

- fail closed on malformed JSON, wrong response envelopes, and missing required
fields from successful self-hosted API responses before repositories, mailbox
status/context/sync projections, or the generated SDK can synthesize empty
rows, lists, or counts.
- share one config-driven wire validator across the synchronous resource store,
asynchronous inbox data source, and generated `@hasna/emails/selfhost` client;
validation errors identify the endpoint and invalid field without including
credentials or response-body contents.

- **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`.
- **test(status): the two "never proposes a refused command" guards were self-referential and could not fail.** `agent-context.local.test.ts` asserted `isCommandAvailableInMode(action.command, "local") === true` — validating the payload against the same registry that filtered it, so a command missing from the registry passed the test and threw at the terminal. `agent-context.self-hosted.test.ts` only checked two hardcoded command names. Both now use `src/test-support/cli-refusals.ts`, which parses every `serverOnly(...)`/`notImplementedAnywhere(...)` call site out of `src/cli/commands/*.ts` — the CLI is the oracle, not the registry under test. New `src/lib/status-commands-coverage.test.ts` fails if the registry does not cover a scanned refusal, carries a positive control so it cannot pass over an empty scan, and carries a counter-control that the real remedies (`emails domain list --json`, `emails address add`, …) are still reported available, so "never propose a refusal" cannot be satisfied by proposing nothing. Against the pre-fix registry the new guards fail and name all fifteen missing commands plus `emails domain status --json (refused by emails domain status)`.
- **fix(status): a lower bound is no longer reported as a read failure.** `statusGapClass` answers with three classes, and `agent-context.ts` tested only for `"structural"`, so the third — `"bound"` (`enumeration_cap_exceeded`/`enumeration_unstable`) — fell through into `failures[]`. A count the server DID answer was therefore published under `Read failures (N) — these numbers could not be measured` and in a field documented as "caused by a live read failure"; `gaps["domains.usable[].ready_addresses"]` hit this on every shifted address window. Bound gaps now join `incomplete[]` beside the block-level bounds, render under `Lower bounds`, and still set `degraded: true` — the caller asked for a total and got a floor.
Expand Down Expand Up @@ -53,6 +42,17 @@ All notable changes to `@hasna/emails` are documented here.
- perf(cli): `emails domain warm-list` reads the sent-mail ledger **once per page** instead of once per row, via a new `getTodaySentCountsByDomain`. In self-hosted mode each read is a synchronous `curl` spawn over today's messages, so a default 20-row page cost 20 identical requests.
- refactor(warming): ramp position (`current_day`, `total_days`, `progress_percent`, `today_limit`, `today_sent`) is computed once in `describeWarmingProgress` and shared by the CLI, the MCP tools, the local `GET /api/warming/:domain` route, and `formatWarmingStatus` — replacing four copies of the same date math, one of which had already drifted from the server. `formatWarmingStatus` and `describeWarmingProgress` accept precomputed inputs so a single command does not read the sent-mail ledger more than once.

## 1.3.2 (2026-07-26)

- fail closed on malformed JSON, wrong response envelopes, and missing required
fields from successful self-hosted API responses before repositories, mailbox
status/context/sync projections, or the generated SDK can synthesize empty
rows, lists, or counts.
- share one config-driven wire validator across the synchronous resource store,
asynchronous inbox data source, and generated `@hasna/emails/selfhost` client;
validation errors identify the endpoint and invalid field without including
credentials or response-body contents.

## 1.3.1 (2026-07-26)

### Security
Expand Down
Loading
Loading