@everydaydevopsio/hail 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +198 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +171 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +56 -0
- package/dist/email.d.ts +36 -0
- package/dist/email.js +119 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +141 -0
- package/dist/playwright.d.ts +13 -0
- package/dist/playwright.js +23 -0
- package/dist/setup.d.ts +17 -0
- package/dist/setup.js +95 -0
- package/dist/store.d.ts +29 -0
- package/dist/store.js +119 -0
- package/package.json +89 -4
- package/terraform/examples/cloudflare/.terraform.lock.hcl +64 -0
- package/terraform/examples/cloudflare/main.tf +95 -0
- package/terraform/examples/manual/.terraform.lock.hcl +47 -0
- package/terraform/examples/manual/main.tf +71 -0
- package/terraform/examples/route53/.terraform.lock.hcl +47 -0
- package/terraform/examples/route53/main.tf +94 -0
- package/terraform/modules/receiver/.terraform.lock.hcl +47 -0
- package/terraform/modules/receiver/lambda/handler.py +83 -0
- package/terraform/modules/receiver/main.tf +226 -0
- package/terraform/modules/receiver/outputs.tf +23 -0
- package/terraform/modules/receiver/variables.tf +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Allen and Hail contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
8
|
+
|
|
9
|
+
Original source snapshots under upstream/ retain their own notices and package license declarations. See docs/UPSTREAM.md for provenance.
|
package/README.md
CHANGED
|
@@ -1,3 +1,199 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Hail
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/everydaydevopsio/hail/actions/workflows/ci.yml) [](https://github.com/everydaydevopsio/hail/actions/workflows/release.yml) [](https://github.com/everydaydevopsio/hail/releases) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
**Send. Receive. Verify.**
|
|
6
|
+
|
|
7
|
+
Playwright-native tests for magic-link logins, invitations, one-time codes, and application email delivery. Hail receives real email in your own AWS account, gives each test a unique address, and helps your browser test complete the workflow.
|
|
8
|
+
|
|
9
|
+
**Status: release workflow available; live AWS delivery must be verified separately.** Check the [npm package](https://www.npmjs.com/package/@everydaydevopsio/hail) for published versions. The automated suite separates local browser proof from the opt-in live delivery gate.
|
|
10
|
+
|
|
11
|
+
Maintainers can use the [manual npm release workflow](docs/RELEASING.md) after its registry trust and GitHub environment are configured. Adding the workflow alone does not publish a package.
|
|
12
|
+
|
|
13
|
+
Start with the [AWS quickstart](docs/QUICKSTART.md) to deploy with Cloudflare and a GitHub-sourced Terraform module, then run your first email workflow test.
|
|
14
|
+
|
|
15
|
+
## What is included
|
|
16
|
+
|
|
17
|
+
- TypeScript email client with unique inboxes, checkpoints, sender/subject filters, bounded waits, message consumption, pagination, and legacy SES-client storage support.
|
|
18
|
+
- Playwright `hail` and `inbox` fixtures. Link extraction parses HTML, requires allowed origins, and never fetches the link.
|
|
19
|
+
- `hail init`, `hail configure`, and read-only `hail doctor` commands.
|
|
20
|
+
- Terraform receiver and Cloudflare, Route53, and manual-DNS templates. No long-lived server, database, or Kubernetes cluster.
|
|
21
|
+
- One retryable SES → S3/SNS → SQS → Lambda ingestion path. The worker retains raw MIME and publishes idempotent recipient metadata. Failed records reach the ingestion DLQ.
|
|
22
|
+
- Unit, Python worker, Terraform mock, package, and Chromium/Firefox/WebKit workflow tests.
|
|
23
|
+
|
|
24
|
+
The application keeps its existing email sender. No mailbox or Terraform apply is needed per test. Unique addresses prevent accidental collisions; they are not an IAM isolation boundary.
|
|
25
|
+
|
|
26
|
+
## Validate this checkout without AWS
|
|
27
|
+
|
|
28
|
+
Requirements: Node.js 22+, Python 3.12+, and Terraform 1.7+ for infrastructure checks.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm ci
|
|
32
|
+
npm run build
|
|
33
|
+
npm run typecheck
|
|
34
|
+
npm test
|
|
35
|
+
npm run test:python
|
|
36
|
+
npx playwright install --with-deps chromium firefox webkit
|
|
37
|
+
npm run test:e2e
|
|
38
|
+
|
|
39
|
+
terraform -chdir=terraform/modules/receiver init -backend=false
|
|
40
|
+
terraform -chdir=terraform/modules/receiver validate
|
|
41
|
+
terraform -chdir=terraform/modules/receiver test
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The browser tests use a loopback-only demo application and an in-memory MIME store. They test authenticated identity, one-time token reuse, server-side expiry, invitation roles, revocation, and isolated inboxes. They **do not prove internet email delivery** or test your application's selectors and authorization model.
|
|
45
|
+
|
|
46
|
+
## Install from this checkout
|
|
47
|
+
|
|
48
|
+
The package is named `@everydaydevopsio/hail`. A checkout can be packed locally whether or not that version has been published.
|
|
49
|
+
Its JavaScript entry points are ESM; use an ESM consumer project (`"type": "module"`) for Playwright TypeScript specs.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm ci
|
|
53
|
+
npm pack
|
|
54
|
+
# In your application repository, use the actual tarball path:
|
|
55
|
+
npm install --save-dev /path/to/hail/everydaydevopsio-hail-0.1.0.tgz @playwright/test
|
|
56
|
+
npx hail --help
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Terraform templates and the worker are included in the tarball. Original source snapshots, test output, state files, and email contents are not.
|
|
60
|
+
|
|
61
|
+
## Set up a dedicated email test domain
|
|
62
|
+
|
|
63
|
+
Start with a fresh subdomain such as `email-test.example.com`. Never point the company email domain at Hail. Use an existing authoritative DNS zone; changing registrar or nameservers is not required.
|
|
64
|
+
|
|
65
|
+
For Cloudflare, configure a zone-scoped `CLOUDFLARE_API_TOKEN` in your shell. The token needs DNS editing and permission to read the selected zone. AWS credentials use the normal SDK/provider chain.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx hail init \
|
|
69
|
+
--dns cloudflare \
|
|
70
|
+
--domain email-test.example.com \
|
|
71
|
+
--zone-name example.com \
|
|
72
|
+
--zone-id YOUR_EXISTING_CLOUDFLARE_ZONE_ID \
|
|
73
|
+
--region us-east-1 \
|
|
74
|
+
--name myapp-hail \
|
|
75
|
+
--existing-rule-set shared-inbound \
|
|
76
|
+
--out infra/hail
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Choose `--dns route53 --zone-id YOUR_PUBLIC_HOSTED_ZONE_ID` for Route53. Choose `--dns manual` and omit `--zone-id` for another provider.
|
|
80
|
+
|
|
81
|
+
For an AWS account/region with **no active receipt rule set**, replace `--existing-rule-set shared-inbound` with `--activate-new-rule-set`. The CLI checks the active set and refuses to replace it. The Terraform module defaults to no activation; direct Terraform use still requires you to review current AWS/DNS state and the plan.
|
|
82
|
+
|
|
83
|
+
`init` performs read-only AWS/DNS checks, then generates ordinary Terraform files. By default, the receiver module source uses the Git tag matching the installed package version (for example `v0.1.0` for Hail `0.1.0`); that tag must exist in the repository before Terraform can download it. Use `--local-modules` to copy the bundled module instead, such as when installing from an unreleased checkout. `--out` selects the output directory and `--file` selects the Terraform filename inside it (default `main.tf`). Existing directories are allowed, but existing generated files are never overwritten. An existing `.gitignore` is preserved; ensure it excludes Terraform variables, state, and plans. `init` never runs `terraform apply` or creates sender identities. It refuses domains with existing MX records. Existing receiver users should attach their configuration rather than rerun initialization.
|
|
84
|
+
|
|
85
|
+
Edit `infra/hail/terraform.tfvars.json` to configure `reader_principal_arns`, or attach the exported `reader_policy_arn` to your existing developer/CI role. Review your infrastructure naming, region, retention, and state backend before applying.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
terraform -chdir=infra/hail init
|
|
89
|
+
terraform -chdir=infra/hail plan
|
|
90
|
+
terraform -chdir=infra/hail apply
|
|
91
|
+
npx hail configure --terraform-dir infra/hail
|
|
92
|
+
npx hail doctor
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Manual DNS mode prints the required MX and TXT records with `terraform output dns_records`. Publish them before running `doctor`. Managed modes wait for SES identity verification, which can take time after DNS changes. DNS tokens and AWS credentials never belong in tfvars or Git.
|
|
96
|
+
|
|
97
|
+
`doctor` checks resolved MX, SES identity, the active domain/bucket receipt rule, and S3 listing access. It does **not** verify all preceding SES rules, queue health, raw-object read permissions, or actual delivery. Use the live test gate and check the DLQ before calling a deployment validated.
|
|
98
|
+
|
|
99
|
+
## Write a Playwright test
|
|
100
|
+
|
|
101
|
+
Set Playwright's `use.baseURL` to your application. Replace selectors and expected states with your app's actual contract.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { test, expect, visitAuthLink } from "@everydaydevopsio/hail/playwright";
|
|
105
|
+
|
|
106
|
+
test("sign in using the delivered magic link", async ({ page, inbox }) => {
|
|
107
|
+
test.setTimeout(90_000);
|
|
108
|
+
await page.goto("/login");
|
|
109
|
+
const after = await inbox.checkpoint();
|
|
110
|
+
await page.getByLabel("Email").fill(inbox.address);
|
|
111
|
+
await page.getByRole("button", { name: /send magic link/i }).click();
|
|
112
|
+
|
|
113
|
+
const email = await inbox.waitForEmail({
|
|
114
|
+
after,
|
|
115
|
+
subject: /sign in/i,
|
|
116
|
+
timeoutMs: 60_000,
|
|
117
|
+
});
|
|
118
|
+
const origin = new URL(page.url()).origin;
|
|
119
|
+
await visitAuthLink(
|
|
120
|
+
page,
|
|
121
|
+
email.getLink({ text: /sign in/i, allowedOrigins: [origin] }),
|
|
122
|
+
);
|
|
123
|
+
await expect(page.getByTestId("current-user-email")).toHaveText(
|
|
124
|
+
inbox.address,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Continue magic-link flows in the requesting browser when your app binds a request nonce to a cookie. Invitation recipes must use separate administrator and invitee contexts. Seed authorized test users through your test setup when the application disallows arbitrary signup; do not relax production authentication.
|
|
130
|
+
|
|
131
|
+
### Core API
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
import { Hail, loadConfig } from "@everydaydevopsio/hail";
|
|
135
|
+
const hail = new Hail(await loadConfig());
|
|
136
|
+
const inbox = hail.createInbox("login");
|
|
137
|
+
const after = await inbox.checkpoint();
|
|
138
|
+
// Trigger your application's send here.
|
|
139
|
+
const email = await inbox.waitForEmail({ after, subject: /sign in/i });
|
|
140
|
+
const code = email.getCode(); // Only for a message containing a single distinct code.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`waitForEmail` and `waitForEmails(count, options)` support `after`, `subject`, `from`, `timeoutMs`, `pollIntervalMs`, `signal`, and `consume`. String filters match exactly; regex filters match patterns. Messages are consumed within an Inbox instance by default. Use a separate inbox for concurrent actors; concurrent waits on the same Inbox are not a transactional queue.
|
|
144
|
+
|
|
145
|
+
`inbox.expectNoEmail({ forMs: 2000, subject: /unexpected/i })` asserts that no matching message was observed during that window, not that none will ever arrive.
|
|
146
|
+
|
|
147
|
+
`email.getLink({ allowedOrigins, text?, pathname? })` requires one distinct matching HTTP(S) URL and preserves signed URL encoding after normal HTML decoding. It does not follow redirects, rewrite tracking links, or change production URLs to localhost. Only allow origins you trust; redirect destinations remain the application's responsibility.
|
|
148
|
+
|
|
149
|
+
`email.text`, `email.html`, `email.attachments`, `email.receivedAt`, and `email.delivery` are available for explicit assertions. Received timestamps come from SES metadata, not the sender-controlled Date header. New storage supports up to 16 MiB per message; oversize messages fail ingestion into the DLQ.
|
|
150
|
+
|
|
151
|
+
## Attach to an existing receiver
|
|
152
|
+
|
|
153
|
+
For a Hail receiver, obtain the `hail_config` Terraform output and reader-role access. A developer does not need infrastructure or DNS write permission to run tests.
|
|
154
|
+
|
|
155
|
+
For the original recipient-folder layout, use this configuration:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"schemaVersion": 1,
|
|
160
|
+
"domain": "email-test.example.com",
|
|
161
|
+
"bucketName": "your-existing-ses-bucket",
|
|
162
|
+
"region": "us-east-1",
|
|
163
|
+
"layout": "legacy"
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
An optional `roleArn` uses refreshable assumed-role credentials. Legacy layout uses `<recipient>/<message-id>.eml` and S3 LastModified timestamps; it lacks the trusted SES receipt metadata of `indexed-v1`. Prefer fresh per-test addresses rather than reused legacy mailboxes. Importing source code does not migrate any deployed Terraform state or activate a new receiver.
|
|
168
|
+
|
|
169
|
+
## Live delivery gate
|
|
170
|
+
|
|
171
|
+
For a disposable Cloudflare receiver and the complete local/live/cleanup sequence, use the [one-command live runner](docs/LIVE-RUNNER.md):
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
npm run test:live:full -- --profile hail-bootstrap --domain example.com --region us-east-1 --execute
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This explicitly provisions resources and sends synthetic email. Select the approved profile/domain and install prerequisites first; it is never invoked by ordinary PR CI.
|
|
178
|
+
|
|
179
|
+
See [live verification](docs/LIVE-VERIFICATION.md). The live suite uses the same browser scenarios, but sends email through an explicitly configured SES sender and reads the actual receiver. It requires a deployed receiver, verified sender, appropriately restricted AWS credentials, and explicit invocation. The existing `npm run test:live` browser suite requires that receiver to be provisioned already; `test:live:full` above manages its own disposable receiver.
|
|
180
|
+
|
|
181
|
+
SES receipt proves delivery to this SES inbox, not Gmail/Outlook inbox placement or native email-client rendering. A live run against the demo application is not a substitute for running the generated recipe against your own application.
|
|
182
|
+
|
|
183
|
+
## Security and scope
|
|
184
|
+
|
|
185
|
+
Read [security and operations](docs/SECURITY.md) before granting access or enabling live CI. Raw MIME and authentication links are secrets. Hail does not auto-attach them; Playwright navigation failures and third-party logging may still include URLs. Disable auth traces/screenshots by default and keep live CI output restricted.
|
|
186
|
+
|
|
187
|
+
A hosted inbox viewer, local SMTP server, provider-specific delivery event adapters, full diagnostic queue inspection, and an MCP server are future work, not features of this PR.
|
|
188
|
+
|
|
189
|
+
## Provenance
|
|
190
|
+
|
|
191
|
+
Hail evolves [ses-email-client](https://github.com/markcallen/ses-email-client) and [ses-receiving-terraform](https://github.com/markcallen/ses-receiving-terraform). Complete unchanged snapshots are retained under `upstream/`; their historical docs are not the supported Hail setup guide. See [the exact imported commits](docs/UPSTREAM.md).
|
|
192
|
+
|
|
193
|
+
## Contributor checks
|
|
194
|
+
|
|
195
|
+
Run `make deps`, then `make setup`. Source `.dev-tools/activate` or run `make shell`; run `make check` for local validation. Setup reports missing tools and directs you to `make deps`. See the [documentation index](docs/README.md) and [development checks](docs/DEVELOPMENT.md).
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
ISC — see [LICENSE](LICENSE).
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs, promisify } from "node:util";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { writeFile } from "node:fs/promises";
|
|
5
|
+
import { resolveMx } from "node:dns/promises";
|
|
6
|
+
import { SESClient, DescribeActiveReceiptRuleSetCommand, GetIdentityVerificationAttributesCommand, } from "@aws-sdk/client-ses";
|
|
7
|
+
import { loadConfig, parseConfig } from "./config.js";
|
|
8
|
+
import { S3MailStore, awsCredentials } from "./store.js";
|
|
9
|
+
import { scaffold, validateInit } from "./setup.js";
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const help = `Hail: Send. Receive. Verify.\n\nCommands:\n hail init --dns cloudflare|route53|manual --domain email-test.example.com\n --zone-name example.com [--zone-id ID] --region us-east-1\n (--existing-rule-set NAME | --activate-new-rule-set) [--out infra/hail] [--file main.tf] [--local-modules]\n hail configure --terraform-dir infra/hail [--out hail.config.json]\n hail doctor [--config hail.config.json]\n\ninit generates Terraform pinned to the installed Hail version unless --local-modules is set. It never runs terraform apply.\nconfigure reads Terraform outputs; doctor never sends email.\n`;
|
|
12
|
+
async function activeRules(region, config) {
|
|
13
|
+
const client = new SESClient({
|
|
14
|
+
region,
|
|
15
|
+
credentials: config ? awsCredentials(config) : undefined,
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
return await client.send(new DescribeActiveReceiptRuleSetCommand({}), {
|
|
19
|
+
abortSignal: AbortSignal.timeout(20_000),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof Error &&
|
|
24
|
+
["RuleSetDoesNotExist", "RuleSetDoesNotExistException"].includes(error.name))
|
|
25
|
+
return { $metadata: {} };
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function checkNewDomain(domain) {
|
|
30
|
+
try {
|
|
31
|
+
if ((await resolveMx(domain)).length)
|
|
32
|
+
throw new Error("This domain already has MX records. Use a fresh test subdomain or explicitly migrate/import the existing records outside init.");
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (["ENODATA", "ENOTFOUND"].includes(error.code ?? ""))
|
|
36
|
+
return;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function doctor(config) {
|
|
41
|
+
const results = [];
|
|
42
|
+
const check = async (name, operation) => {
|
|
43
|
+
try {
|
|
44
|
+
await operation();
|
|
45
|
+
results.push({ check: name, ok: true, detail: "Passed" });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
results.push({
|
|
49
|
+
check: name,
|
|
50
|
+
ok: false,
|
|
51
|
+
detail: error instanceof Error && error.name === "Error"
|
|
52
|
+
? error.message
|
|
53
|
+
: `Check failed (${error instanceof Error ? error.name : "unknown error"}).`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
await check("dns-mx", async () => {
|
|
58
|
+
const expected = `inbound-smtp.${config.region}.amazonaws.com`;
|
|
59
|
+
const records = await resolveMx(config.domain);
|
|
60
|
+
if (!records.length ||
|
|
61
|
+
records.some((record) => record.exchange.toLowerCase().replace(/\.$/, "") !== expected))
|
|
62
|
+
throw new Error("MX does not exclusively target this SES receiving region.");
|
|
63
|
+
});
|
|
64
|
+
await check("ses-identity", async () => {
|
|
65
|
+
const ses = new SESClient({
|
|
66
|
+
region: config.region,
|
|
67
|
+
credentials: awsCredentials(config),
|
|
68
|
+
});
|
|
69
|
+
const result = await ses.send(new GetIdentityVerificationAttributesCommand({
|
|
70
|
+
Identities: [config.domain],
|
|
71
|
+
}), { abortSignal: AbortSignal.timeout(20_000) });
|
|
72
|
+
if (result.VerificationAttributes?.[config.domain]?.VerificationStatus !==
|
|
73
|
+
"Success")
|
|
74
|
+
throw new Error("SES receiving identity is not verified yet.");
|
|
75
|
+
});
|
|
76
|
+
await check("active-receipt-rule", async () => {
|
|
77
|
+
const active = await activeRules(config.region, config);
|
|
78
|
+
if (config.ruleSetName && active.Metadata?.Name !== config.ruleSetName)
|
|
79
|
+
throw new Error("Expected SES receipt rule set is not active.");
|
|
80
|
+
const found = active.Rules?.some((rule) => rule.Enabled &&
|
|
81
|
+
(!config.ruleName || rule.Name === config.ruleName) &&
|
|
82
|
+
rule.Recipients?.includes(config.domain) &&
|
|
83
|
+
rule.Actions?.some((action) => action.S3Action?.BucketName === config.bucketName));
|
|
84
|
+
if (!found)
|
|
85
|
+
throw new Error("No enabled receipt rule routes this domain into the configured bucket.");
|
|
86
|
+
});
|
|
87
|
+
await check("s3-reader", async () => {
|
|
88
|
+
const store = new S3MailStore(config);
|
|
89
|
+
await store.list(`hail-doctor@${config.domain}`, AbortSignal.timeout(20_000));
|
|
90
|
+
});
|
|
91
|
+
console.log(JSON.stringify({
|
|
92
|
+
results,
|
|
93
|
+
deliveryTested: false,
|
|
94
|
+
note: "Read-only checks only. Queue health, object reads, and actual email delivery require separate validation; run the opt-in live suite.",
|
|
95
|
+
}, null, 2));
|
|
96
|
+
if (results.some((result) => !result.ok))
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
}
|
|
99
|
+
async function main() {
|
|
100
|
+
const [command, ...args] = process.argv.slice(2);
|
|
101
|
+
if (!command || command === "--help" || command === "help") {
|
|
102
|
+
console.log(help);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const { values } = parseArgs({
|
|
106
|
+
args,
|
|
107
|
+
options: {
|
|
108
|
+
dns: { type: "string" },
|
|
109
|
+
domain: { type: "string" },
|
|
110
|
+
"zone-name": { type: "string" },
|
|
111
|
+
"zone-id": { type: "string" },
|
|
112
|
+
region: { type: "string" },
|
|
113
|
+
name: { type: "string" },
|
|
114
|
+
out: { type: "string" },
|
|
115
|
+
file: { type: "string" },
|
|
116
|
+
"local-modules": { type: "boolean" },
|
|
117
|
+
"existing-rule-set": { type: "string" },
|
|
118
|
+
"activate-new-rule-set": { type: "boolean" },
|
|
119
|
+
"terraform-dir": { type: "string" },
|
|
120
|
+
config: { type: "string" },
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (command === "init") {
|
|
124
|
+
const options = validateInit({
|
|
125
|
+
dns: (values.dns ?? "manual"),
|
|
126
|
+
domain: values.domain ?? "",
|
|
127
|
+
zoneName: values["zone-name"] ?? "",
|
|
128
|
+
zoneId: values["zone-id"],
|
|
129
|
+
region: values.region ?? "us-east-1",
|
|
130
|
+
name: values.name ?? "hail",
|
|
131
|
+
out: values.out ?? "infra/hail",
|
|
132
|
+
file: values.file,
|
|
133
|
+
localModules: values["local-modules"],
|
|
134
|
+
existingRuleSet: values["existing-rule-set"],
|
|
135
|
+
activateNewRuleSet: values["activate-new-rule-set"],
|
|
136
|
+
});
|
|
137
|
+
await checkNewDomain(options.domain);
|
|
138
|
+
const active = await activeRules(options.region);
|
|
139
|
+
if (options.activateNewRuleSet && active.Metadata?.Name)
|
|
140
|
+
throw new Error("An SES rule set is already active. Use --existing-rule-set instead; Hail will not replace it.");
|
|
141
|
+
if (options.existingRuleSet &&
|
|
142
|
+
active.Metadata?.Name !== options.existingRuleSet)
|
|
143
|
+
throw new Error("The requested existing SES rule set is not active in this region.");
|
|
144
|
+
const destination = await scaffold(options);
|
|
145
|
+
console.log(`Created ${destination}. Review the files, configure reader access, then run terraform init, plan, and apply there. Copy example.spec.ts into your tests and adapt its selectors. No cloud resources were changed.`);
|
|
146
|
+
}
|
|
147
|
+
else if (command === "configure") {
|
|
148
|
+
if (!values["terraform-dir"])
|
|
149
|
+
throw new Error("configure requires --terraform-dir.");
|
|
150
|
+
const { stdout } = await execute("terraform", [`-chdir=${values["terraform-dir"]}`, "output", "-json", "hail_config"], { timeout: 30_000, maxBuffer: 1024 * 1024 });
|
|
151
|
+
const config = parseConfig(JSON.parse(stdout));
|
|
152
|
+
const destination = values.out ?? "hail.config.json";
|
|
153
|
+
await writeFile(destination, JSON.stringify(config, null, 2) + "\n", {
|
|
154
|
+
mode: 0o600,
|
|
155
|
+
flag: "wx",
|
|
156
|
+
});
|
|
157
|
+
console.log(`Wrote ${destination}. Existing files are never overwritten.`);
|
|
158
|
+
}
|
|
159
|
+
else if (command === "doctor") {
|
|
160
|
+
await doctor(await loadConfig(values.config));
|
|
161
|
+
}
|
|
162
|
+
else
|
|
163
|
+
throw new Error("Unknown command. Run hail --help.");
|
|
164
|
+
}
|
|
165
|
+
main().catch((error) => {
|
|
166
|
+
const known = error instanceof Error && error.name === "Error";
|
|
167
|
+
console.error(known
|
|
168
|
+
? error.message
|
|
169
|
+
: `Hail failed (${error instanceof Error ? error.name : "unknown error"}). Check configuration and credentials.`);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface HailConfig {
|
|
2
|
+
schemaVersion: 1;
|
|
3
|
+
domain: string;
|
|
4
|
+
bucketName: string;
|
|
5
|
+
region: string;
|
|
6
|
+
layout: "indexed-v1" | "legacy";
|
|
7
|
+
roleArn?: string;
|
|
8
|
+
ruleSetName?: string;
|
|
9
|
+
ruleName?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function normalizeDomain(value: string): string;
|
|
12
|
+
export declare function normalizeAddress(value: string): string;
|
|
13
|
+
export declare function parseConfig(value: unknown): HailConfig;
|
|
14
|
+
export declare function loadConfig(path?: string): Promise<HailConfig>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
export function normalizeDomain(value) {
|
|
3
|
+
const domain = value.toLowerCase().replace(/\.$/, "");
|
|
4
|
+
if (domain.length > 253 ||
|
|
5
|
+
!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)) {
|
|
6
|
+
throw new Error("Use a valid ASCII DNS domain.");
|
|
7
|
+
}
|
|
8
|
+
return domain;
|
|
9
|
+
}
|
|
10
|
+
export function normalizeAddress(value) {
|
|
11
|
+
const address = value.toLowerCase();
|
|
12
|
+
const parts = address.split("@");
|
|
13
|
+
if (parts.length !== 2 || !/^[a-z0-9][a-z0-9._+-]{0,63}$/.test(parts[0])) {
|
|
14
|
+
throw new Error("Use a simple test mailbox address with a local part of at most 64 characters.");
|
|
15
|
+
}
|
|
16
|
+
return `${parts[0]}@${normalizeDomain(parts[1])}`;
|
|
17
|
+
}
|
|
18
|
+
export function parseConfig(value) {
|
|
19
|
+
if (!value || typeof value !== "object")
|
|
20
|
+
throw new Error("Hail configuration must be an object.");
|
|
21
|
+
const data = value;
|
|
22
|
+
if (data.schemaVersion !== 1)
|
|
23
|
+
throw new Error("Unsupported Hail configuration schemaVersion.");
|
|
24
|
+
if (typeof data.domain !== "string" ||
|
|
25
|
+
typeof data.bucketName !== "string" ||
|
|
26
|
+
typeof data.region !== "string") {
|
|
27
|
+
throw new Error("Hail configuration requires domain, bucketName, and region.");
|
|
28
|
+
}
|
|
29
|
+
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(data.bucketName))
|
|
30
|
+
throw new Error("Invalid S3 bucket name.");
|
|
31
|
+
if (!/^[a-z]{2}(?:-gov)?-[a-z]+-\d+$/.test(data.region))
|
|
32
|
+
throw new Error("Invalid AWS region.");
|
|
33
|
+
if (data.layout !== "indexed-v1" && data.layout !== "legacy")
|
|
34
|
+
throw new Error("Unsupported mailbox storage layout.");
|
|
35
|
+
const result = {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
domain: normalizeDomain(data.domain),
|
|
38
|
+
bucketName: data.bucketName,
|
|
39
|
+
region: data.region,
|
|
40
|
+
layout: data.layout,
|
|
41
|
+
};
|
|
42
|
+
for (const name of ["roleArn", "ruleSetName", "ruleName"]) {
|
|
43
|
+
if (data[name] != null) {
|
|
44
|
+
if (typeof data[name] !== "string" || !data[name])
|
|
45
|
+
throw new Error(`Invalid ${name}.`);
|
|
46
|
+
result[name] = data[name];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (result.roleArn &&
|
|
50
|
+
!/^arn:aws(?:-us-gov|-cn)?:iam::\d{12}:role\/.+$/.test(result.roleArn))
|
|
51
|
+
throw new Error("Invalid reader role ARN.");
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export async function loadConfig(path = process.env.HAIL_CONFIG ?? "hail.config.json") {
|
|
55
|
+
return parseConfig(JSON.parse(await readFile(path, "utf8")));
|
|
56
|
+
}
|
package/dist/email.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
import type { StoredMail } from "./store.js";
|
|
3
|
+
export type Pattern = string | RegExp;
|
|
4
|
+
export declare function matches(value: string, pattern?: Pattern): boolean;
|
|
5
|
+
export interface LinkQuery {
|
|
6
|
+
allowedOrigins: string[];
|
|
7
|
+
text?: Pattern;
|
|
8
|
+
pathname?: Pattern;
|
|
9
|
+
}
|
|
10
|
+
export declare class EmailMessage {
|
|
11
|
+
private readonly parsed;
|
|
12
|
+
private readonly stored;
|
|
13
|
+
private constructor();
|
|
14
|
+
static parse(stored: StoredMail): Promise<EmailMessage>;
|
|
15
|
+
get id(): string;
|
|
16
|
+
get recipient(): string;
|
|
17
|
+
get receivedAt(): Date;
|
|
18
|
+
get subject(): string;
|
|
19
|
+
get from(): string;
|
|
20
|
+
get text(): string;
|
|
21
|
+
get html(): string;
|
|
22
|
+
get attachments(): import("mailparser").Attachment[];
|
|
23
|
+
get delivery(): Record<string, string>;
|
|
24
|
+
getLink(query: LinkQuery): string;
|
|
25
|
+
getCode(pattern?: RegExp): string;
|
|
26
|
+
toJSON(): {
|
|
27
|
+
id: string;
|
|
28
|
+
receivedAt: Date;
|
|
29
|
+
attachmentCount: number;
|
|
30
|
+
};
|
|
31
|
+
[inspect.custom](): {
|
|
32
|
+
id: string;
|
|
33
|
+
receivedAt: Date;
|
|
34
|
+
attachmentCount: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/email.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
import { simpleParser } from "mailparser";
|
|
3
|
+
import { load } from "cheerio";
|
|
4
|
+
export function matches(value, pattern) {
|
|
5
|
+
if (pattern === undefined)
|
|
6
|
+
return true;
|
|
7
|
+
return typeof pattern === "string"
|
|
8
|
+
? value === pattern
|
|
9
|
+
: new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")).test(value);
|
|
10
|
+
}
|
|
11
|
+
export class EmailMessage {
|
|
12
|
+
parsed;
|
|
13
|
+
stored;
|
|
14
|
+
constructor(parsed, stored) {
|
|
15
|
+
this.parsed = parsed;
|
|
16
|
+
this.stored = stored;
|
|
17
|
+
}
|
|
18
|
+
static async parse(stored) {
|
|
19
|
+
const raw = typeof stored.raw === "string"
|
|
20
|
+
? Buffer.from(stored.raw)
|
|
21
|
+
: Buffer.from(stored.raw);
|
|
22
|
+
return new EmailMessage(await simpleParser(raw, { skipHtmlToText: true, skipTextToHtml: true }), stored);
|
|
23
|
+
}
|
|
24
|
+
get id() {
|
|
25
|
+
return this.stored.id;
|
|
26
|
+
}
|
|
27
|
+
get recipient() {
|
|
28
|
+
return this.stored.recipient;
|
|
29
|
+
}
|
|
30
|
+
get receivedAt() {
|
|
31
|
+
return this.stored.receivedAt;
|
|
32
|
+
}
|
|
33
|
+
get subject() {
|
|
34
|
+
return this.parsed.subject ?? "";
|
|
35
|
+
}
|
|
36
|
+
get from() {
|
|
37
|
+
return (this.parsed.from?.value.map((value) => value.address ?? "").join(", ") ??
|
|
38
|
+
"");
|
|
39
|
+
}
|
|
40
|
+
get text() {
|
|
41
|
+
return this.parsed.text ?? "";
|
|
42
|
+
}
|
|
43
|
+
get html() {
|
|
44
|
+
return this.parsed.html || "";
|
|
45
|
+
}
|
|
46
|
+
get attachments() {
|
|
47
|
+
return this.parsed.attachments;
|
|
48
|
+
}
|
|
49
|
+
get delivery() {
|
|
50
|
+
return this.stored.delivery ?? {};
|
|
51
|
+
}
|
|
52
|
+
getLink(query) {
|
|
53
|
+
if (!query.allowedOrigins.length)
|
|
54
|
+
throw new Error("At least one allowed link origin is required.");
|
|
55
|
+
const allowed = new Set(query.allowedOrigins.map((value) => {
|
|
56
|
+
const url = new URL(value);
|
|
57
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
58
|
+
url.username ||
|
|
59
|
+
url.password)
|
|
60
|
+
throw new Error("Invalid allowed origin.");
|
|
61
|
+
return url.origin;
|
|
62
|
+
}));
|
|
63
|
+
const candidates = [];
|
|
64
|
+
if (this.html) {
|
|
65
|
+
const document = load(this.html);
|
|
66
|
+
document("a[href]").each((_index, element) => {
|
|
67
|
+
const href = document(element).attr("href");
|
|
68
|
+
if (href)
|
|
69
|
+
candidates.push({
|
|
70
|
+
href,
|
|
71
|
+
text: document(element).text().replace(/\s+/g, " ").trim(),
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (query.text === undefined) {
|
|
76
|
+
for (const href of this.text.match(/https?:\/\/[^\s<>"']+/g) ?? [])
|
|
77
|
+
candidates.push({ href, text: "" });
|
|
78
|
+
}
|
|
79
|
+
const links = new Set();
|
|
80
|
+
for (const candidate of candidates) {
|
|
81
|
+
let url;
|
|
82
|
+
try {
|
|
83
|
+
url = new URL(candidate.href);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
89
|
+
url.username ||
|
|
90
|
+
url.password ||
|
|
91
|
+
!allowed.has(url.origin))
|
|
92
|
+
continue;
|
|
93
|
+
if (matches(candidate.text, query.text) &&
|
|
94
|
+
matches(url.pathname, query.pathname))
|
|
95
|
+
links.add(candidate.href);
|
|
96
|
+
}
|
|
97
|
+
if (links.size !== 1)
|
|
98
|
+
throw new Error(`Expected one matching safe link; found ${links.size}. Link values are redacted.`);
|
|
99
|
+
return [...links][0];
|
|
100
|
+
}
|
|
101
|
+
getCode(pattern = /\b(\d{6})\b/g) {
|
|
102
|
+
const expression = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "") + "g");
|
|
103
|
+
const content = this.text || load(this.html).text();
|
|
104
|
+
const codes = new Set([...content.matchAll(expression)].map((match) => match[1] ?? match[0]));
|
|
105
|
+
if (codes.size !== 1)
|
|
106
|
+
throw new Error(`Expected one distinct code; found ${codes.size}. Codes are redacted.`);
|
|
107
|
+
return [...codes][0];
|
|
108
|
+
}
|
|
109
|
+
toJSON() {
|
|
110
|
+
return {
|
|
111
|
+
id: this.id,
|
|
112
|
+
receivedAt: this.receivedAt,
|
|
113
|
+
attachmentCount: this.attachments.length,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
[inspect.custom]() {
|
|
117
|
+
return this.toJSON();
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type HailConfig } from "./config.js";
|
|
2
|
+
import { EmailMessage, type Pattern } from "./email.js";
|
|
3
|
+
import { type MailStore } from "./store.js";
|
|
4
|
+
export * from "./config.js";
|
|
5
|
+
export * from "./email.js";
|
|
6
|
+
export * from "./store.js";
|
|
7
|
+
export interface Checkpoint {
|
|
8
|
+
readonly startedAt: Date;
|
|
9
|
+
readonly seen: ReadonlySet<string>;
|
|
10
|
+
}
|
|
11
|
+
export interface WaitOptions {
|
|
12
|
+
after?: Checkpoint | Date;
|
|
13
|
+
subject?: Pattern;
|
|
14
|
+
from?: Pattern;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
pollIntervalMs?: number;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
consume?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare class EmailTimeoutError extends Error {
|
|
21
|
+
constructor(timeoutMs: number);
|
|
22
|
+
}
|
|
23
|
+
export declare class Inbox {
|
|
24
|
+
private readonly store;
|
|
25
|
+
private readonly consumed;
|
|
26
|
+
private readonly cache;
|
|
27
|
+
readonly address: string;
|
|
28
|
+
constructor(address: string, store: MailStore);
|
|
29
|
+
checkpoint(): Promise<Checkpoint>;
|
|
30
|
+
waitForEmail(options?: WaitOptions): Promise<EmailMessage>;
|
|
31
|
+
waitForEmails(count: number, options?: WaitOptions): Promise<EmailMessage[]>;
|
|
32
|
+
expectNoEmail(options: Omit<WaitOptions, "timeoutMs" | "consume"> & {
|
|
33
|
+
forMs: number;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export declare class Hail {
|
|
37
|
+
readonly config: HailConfig;
|
|
38
|
+
readonly store: MailStore;
|
|
39
|
+
constructor(config: HailConfig, store?: MailStore);
|
|
40
|
+
createInbox(label?: string): Inbox;
|
|
41
|
+
inbox(address: string): Inbox;
|
|
42
|
+
}
|