@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/dist/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { normalizeAddress, parseConfig } from "./config.js";
|
|
5
|
+
import { EmailMessage, matches } from "./email.js";
|
|
6
|
+
import { S3MailStore } from "./store.js";
|
|
7
|
+
export * from "./config.js";
|
|
8
|
+
export * from "./email.js";
|
|
9
|
+
export * from "./store.js";
|
|
10
|
+
export class EmailTimeoutError extends Error {
|
|
11
|
+
constructor(timeoutMs) {
|
|
12
|
+
super(`No matching email was observed within ${timeoutMs}ms. Check DNS, the active SES receipt rule, and the ingestion DLQ. Message contents are redacted.`);
|
|
13
|
+
this.name = "EmailTimeoutError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function positive(value, name) {
|
|
17
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
18
|
+
throw new Error(`${name} must be positive.`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export class Inbox {
|
|
22
|
+
store;
|
|
23
|
+
consumed = new Set();
|
|
24
|
+
cache = new Map();
|
|
25
|
+
address;
|
|
26
|
+
constructor(address, store) {
|
|
27
|
+
this.store = store;
|
|
28
|
+
this.address = normalizeAddress(address);
|
|
29
|
+
}
|
|
30
|
+
async checkpoint() {
|
|
31
|
+
const startedAt = new Date();
|
|
32
|
+
const refs = await this.store.list(this.address, AbortSignal.timeout(20_000));
|
|
33
|
+
return { startedAt, seen: new Set(refs.map((ref) => ref.id)) };
|
|
34
|
+
}
|
|
35
|
+
async waitForEmail(options = {}) {
|
|
36
|
+
return (await this.waitForEmails(1, options))[0];
|
|
37
|
+
}
|
|
38
|
+
async waitForEmails(count, options = {}) {
|
|
39
|
+
if (!Number.isInteger(count) || count < 1)
|
|
40
|
+
throw new Error("Email count must be a positive integer.");
|
|
41
|
+
const timeoutMs = positive(options.timeoutMs ?? 60_000, "timeoutMs");
|
|
42
|
+
const interval = positive(options.pollIntervalMs ?? 500, "pollIntervalMs");
|
|
43
|
+
const after = options.after instanceof Date ? options.after : options.after?.startedAt;
|
|
44
|
+
if (after && !Number.isFinite(after.getTime()))
|
|
45
|
+
throw new Error("The email checkpoint must have a valid timestamp.");
|
|
46
|
+
const expiry = AbortSignal.timeout(Math.ceil(timeoutMs));
|
|
47
|
+
const signal = options.signal
|
|
48
|
+
? AbortSignal.any([options.signal, expiry])
|
|
49
|
+
: expiry;
|
|
50
|
+
const start = performance.now();
|
|
51
|
+
try {
|
|
52
|
+
while (performance.now() - start < timeoutMs) {
|
|
53
|
+
signal.throwIfAborted();
|
|
54
|
+
const refs = await this.store.list(this.address, signal);
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const found = [];
|
|
57
|
+
for (const ref of refs) {
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
if (seen.has(ref.id) || this.consumed.has(ref.id))
|
|
60
|
+
continue;
|
|
61
|
+
seen.add(ref.id);
|
|
62
|
+
if (options.after &&
|
|
63
|
+
!(options.after instanceof Date) &&
|
|
64
|
+
options.after.seen.has(ref.id))
|
|
65
|
+
continue;
|
|
66
|
+
let email = this.cache.get(ref.id);
|
|
67
|
+
if (!email) {
|
|
68
|
+
const stored = await this.store.get(ref, this.address, signal);
|
|
69
|
+
if (stored.recipient !== this.address)
|
|
70
|
+
throw new Error("Storage returned a message for a different recipient.");
|
|
71
|
+
email = await EmailMessage.parse(stored);
|
|
72
|
+
this.cache.set(ref.id, email);
|
|
73
|
+
}
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
if (after && email.receivedAt < after)
|
|
76
|
+
continue;
|
|
77
|
+
if (matches(email.subject, options.subject) &&
|
|
78
|
+
matches(email.from, options.from))
|
|
79
|
+
found.push(email);
|
|
80
|
+
}
|
|
81
|
+
found.sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime() ||
|
|
82
|
+
a.id.localeCompare(b.id));
|
|
83
|
+
if (found.length >= count) {
|
|
84
|
+
const result = found.slice(0, count);
|
|
85
|
+
if (options.consume !== false)
|
|
86
|
+
result.forEach((email) => this.consumed.add(email.id));
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
await delay(Math.min(interval, Math.max(1, timeoutMs - (performance.now() - start))), undefined, { signal });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (options.signal?.aborted)
|
|
94
|
+
throw options.signal.reason;
|
|
95
|
+
if (!expiry.aborted ||
|
|
96
|
+
(error !== expiry.reason &&
|
|
97
|
+
!(error instanceof Error &&
|
|
98
|
+
error.name === "AbortError" &&
|
|
99
|
+
signal.aborted)))
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
throw new EmailTimeoutError(timeoutMs);
|
|
103
|
+
}
|
|
104
|
+
async expectNoEmail(options) {
|
|
105
|
+
try {
|
|
106
|
+
await this.waitForEmail({
|
|
107
|
+
...options,
|
|
108
|
+
timeoutMs: options.forMs,
|
|
109
|
+
consume: false,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof EmailTimeoutError)
|
|
114
|
+
return;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
throw new Error("Unexpected matching email arrived during the observation window. Contents are redacted.");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export class Hail {
|
|
121
|
+
config;
|
|
122
|
+
store;
|
|
123
|
+
constructor(config, store) {
|
|
124
|
+
this.config = parseConfig(config);
|
|
125
|
+
this.store = store ?? new S3MailStore(this.config);
|
|
126
|
+
}
|
|
127
|
+
createInbox(label = "test") {
|
|
128
|
+
const prefix = label
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
131
|
+
.replace(/^-+/, "")
|
|
132
|
+
.slice(0, 20) || "test";
|
|
133
|
+
return this.inbox(`${prefix}-${randomBytes(16).toString("hex")}@${this.config.domain}`);
|
|
134
|
+
}
|
|
135
|
+
inbox(address) {
|
|
136
|
+
const normalized = normalizeAddress(address);
|
|
137
|
+
if (normalized.split("@")[1] !== this.config.domain)
|
|
138
|
+
throw new Error("Mailbox is outside the configured test domain.");
|
|
139
|
+
return new Inbox(normalized, this.store);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { expect, type Page, type Response } from "@playwright/test";
|
|
2
|
+
import { Hail, type HailConfig, type Inbox } from "./index.js";
|
|
3
|
+
export interface HailOptions {
|
|
4
|
+
hailConfig: HailConfig | undefined;
|
|
5
|
+
}
|
|
6
|
+
export interface HailFixtures {
|
|
7
|
+
hail: Hail;
|
|
8
|
+
inbox: Inbox;
|
|
9
|
+
}
|
|
10
|
+
export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & HailOptions & HailFixtures, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
|
|
11
|
+
export { expect };
|
|
12
|
+
/** Prevent Playwright navigation errors from printing token-bearing URLs. */
|
|
13
|
+
export declare function visitAuthLink(page: Page, url: string): Promise<Response | null>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { test as base, expect, } from "@playwright/test";
|
|
2
|
+
import { Hail, loadConfig } from "./index.js";
|
|
3
|
+
export const test = base.extend({
|
|
4
|
+
hailConfig: [undefined, { option: true }],
|
|
5
|
+
hail: async ({ hailConfig }, use) => {
|
|
6
|
+
await use(new Hail(hailConfig ?? (await loadConfig())));
|
|
7
|
+
},
|
|
8
|
+
inbox: async ({ hail }, use, testInfo) => {
|
|
9
|
+
const inbox = hail.createInbox(`w${testInfo.workerIndex}-r${testInfo.retry}`);
|
|
10
|
+
// Do not attach raw MIME, subjects, codes, or authentication URLs to reports.
|
|
11
|
+
await use(inbox);
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
export { expect };
|
|
15
|
+
/** Prevent Playwright navigation errors from printing token-bearing URLs. */
|
|
16
|
+
export async function visitAuthLink(page, url) {
|
|
17
|
+
try {
|
|
18
|
+
return await page.goto(url);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error("Authentication link navigation failed. URL is redacted.");
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface InitOptions {
|
|
2
|
+
dns: "cloudflare" | "route53" | "manual";
|
|
3
|
+
domain: string;
|
|
4
|
+
zoneName: string;
|
|
5
|
+
zoneId?: string;
|
|
6
|
+
region: string;
|
|
7
|
+
name: string;
|
|
8
|
+
out: string;
|
|
9
|
+
file?: string;
|
|
10
|
+
localModules?: boolean;
|
|
11
|
+
existingRuleSet?: string;
|
|
12
|
+
activateNewRuleSet?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function validateInit(options: InitOptions): InitOptions & {
|
|
15
|
+
file: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function scaffold(input: InitOptions): Promise<string>;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdir, cp, readFile, writeFile, lstat } from "node:fs/promises";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { normalizeDomain } from "./config.js";
|
|
5
|
+
export function validateInit(options) {
|
|
6
|
+
const file = options.file ?? "main.tf";
|
|
7
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.tf$/.test(file))
|
|
8
|
+
throw new Error("Choose a Terraform file name ending in .tf, without a path.");
|
|
9
|
+
if (!["cloudflare", "route53", "manual"].includes(options.dns))
|
|
10
|
+
throw new Error("Choose cloudflare, route53, or manual DNS.");
|
|
11
|
+
const domain = normalizeDomain(options.domain);
|
|
12
|
+
const zoneName = normalizeDomain(options.zoneName);
|
|
13
|
+
if (domain === zoneName || !domain.endsWith(`.${zoneName}`))
|
|
14
|
+
throw new Error("Use a dedicated subdomain inside the selected DNS zone, not its apex.");
|
|
15
|
+
if (options.dns !== "manual" && !options.zoneId)
|
|
16
|
+
throw new Error("Managed DNS requires --zone-id.");
|
|
17
|
+
if (!/^[a-z][a-z0-9-]{2,31}$/.test(options.name))
|
|
18
|
+
throw new Error("Resource name must be 3-32 lowercase characters.");
|
|
19
|
+
if (![
|
|
20
|
+
"us-east-1",
|
|
21
|
+
"us-east-2",
|
|
22
|
+
"us-west-2",
|
|
23
|
+
"ca-central-1",
|
|
24
|
+
"eu-west-1",
|
|
25
|
+
].includes(options.region))
|
|
26
|
+
throw new Error("Choose a receiving region supported by this release.");
|
|
27
|
+
if (Boolean(options.existingRuleSet) === Boolean(options.activateNewRuleSet))
|
|
28
|
+
throw new Error("Choose exactly one: --existing-rule-set NAME or --activate-new-rule-set.");
|
|
29
|
+
return { ...options, domain, zoneName, file };
|
|
30
|
+
}
|
|
31
|
+
export async function scaffold(input) {
|
|
32
|
+
const options = validateInit(input);
|
|
33
|
+
const destination = resolve(options.out);
|
|
34
|
+
const templates = fileURLToPath(new URL("../terraform/", import.meta.url));
|
|
35
|
+
const parent = resolve(destination, "..");
|
|
36
|
+
await mkdir(parent, { recursive: true });
|
|
37
|
+
await mkdir(destination, { recursive: true });
|
|
38
|
+
const targets = [options.file, "terraform.tfvars.json", "example.spec.ts"];
|
|
39
|
+
if (options.localModules)
|
|
40
|
+
targets.push("modules");
|
|
41
|
+
for (const target of targets) {
|
|
42
|
+
try {
|
|
43
|
+
await lstat(join(destination, target));
|
|
44
|
+
throw Object.assign(new Error(`File already exists: ${target}`), {
|
|
45
|
+
code: "EEXIST",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== "ENOENT")
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const template = await readFile(join(templates, "examples", options.dns, "main.tf"), "utf8");
|
|
54
|
+
const packageVersion = options.localModules
|
|
55
|
+
? undefined
|
|
56
|
+
: JSON.parse(await readFile(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
|
|
57
|
+
const source = options.localModules
|
|
58
|
+
? "./modules/receiver"
|
|
59
|
+
: `git::https://github.com/everydaydevopsio/hail.git//terraform/modules/receiver?ref=v${packageVersion}`;
|
|
60
|
+
await writeFile(join(destination, options.file), template.replace("../../modules/receiver", source), { flag: "wx" });
|
|
61
|
+
if (options.localModules)
|
|
62
|
+
await cp(join(templates, "modules", "receiver"), join(destination, "modules", "receiver"), {
|
|
63
|
+
recursive: true,
|
|
64
|
+
force: false,
|
|
65
|
+
errorOnExist: true,
|
|
66
|
+
filter: (path) => !path.includes(".terraform") &&
|
|
67
|
+
!path.includes("__pycache__") &&
|
|
68
|
+
!path.endsWith(".zip"),
|
|
69
|
+
});
|
|
70
|
+
const variables = {
|
|
71
|
+
name: options.name,
|
|
72
|
+
domain: options.domain,
|
|
73
|
+
zone_name: options.zoneName,
|
|
74
|
+
region: options.region,
|
|
75
|
+
...(options.dns === "manual" ? {} : { zone_id: options.zoneId }),
|
|
76
|
+
existing_rule_set_name: options.existingRuleSet ?? null,
|
|
77
|
+
manage_rule_set_activation: options.activateNewRuleSet ?? false,
|
|
78
|
+
retention_days: 3,
|
|
79
|
+
reader_principal_arns: [],
|
|
80
|
+
external_indexer_role_arn: null,
|
|
81
|
+
external_reader_role_arn: null,
|
|
82
|
+
permissions_boundary_arn: null,
|
|
83
|
+
tags: {},
|
|
84
|
+
};
|
|
85
|
+
await writeFile(join(destination, "terraform.tfvars.json"), JSON.stringify(variables, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
86
|
+
try {
|
|
87
|
+
await writeFile(join(destination, ".gitignore"), ".terraform/\n*.tfstate*\n*.tfvars*\n*.tfplan\n**/lambda.zip\n", { flag: "wx" });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code !== "EEXIST")
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
await writeFile(join(destination, "example.spec.ts"), `import { test, expect, visitAuthLink } from '@everydaydevopsio/hail/playwright';\n\ntest('magic-link login', async ({ page, inbox }) => {\n test.setTimeout(90_000);\n await page.goto('/login');\n const after = await inbox.checkpoint();\n await page.getByLabel('Email').fill(inbox.address);\n await page.getByRole('button', { name: /send magic link/i }).click();\n const email = await inbox.waitForEmail({ after, subject: /sign in/i });\n const origin = new URL(page.url()).origin;\n await visitAuthLink(page, email.getLink({ text: /sign in/i, allowedOrigins: [origin] }));\n await expect(page.getByTestId('current-user-email')).toHaveText(inbox.address);\n});\n`, { flag: "wx" });
|
|
94
|
+
return destination;
|
|
95
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import { type HailConfig } from "./config.js";
|
|
3
|
+
export interface MessageRef {
|
|
4
|
+
id: string;
|
|
5
|
+
key: string;
|
|
6
|
+
receivedAt: Date;
|
|
7
|
+
}
|
|
8
|
+
export interface StoredMail {
|
|
9
|
+
id: string;
|
|
10
|
+
recipient: string;
|
|
11
|
+
receivedAt: Date;
|
|
12
|
+
raw: Uint8Array | string;
|
|
13
|
+
delivery?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
export interface MailStore {
|
|
16
|
+
list(recipient: string, signal?: AbortSignal): Promise<MessageRef[]>;
|
|
17
|
+
get(ref: MessageRef, recipient: string, signal?: AbortSignal): Promise<StoredMail>;
|
|
18
|
+
}
|
|
19
|
+
export declare function awsCredentials(config: HailConfig): import("@aws-sdk/types").RuntimeConfigAwsCredentialIdentityProvider | undefined;
|
|
20
|
+
export declare class S3MailStore implements MailStore {
|
|
21
|
+
readonly config: HailConfig;
|
|
22
|
+
private readonly maxMessageBytes;
|
|
23
|
+
private readonly client;
|
|
24
|
+
constructor(config: HailConfig, client?: S3Client, maxMessageBytes?: number);
|
|
25
|
+
private prefix;
|
|
26
|
+
list(recipient: string, signal?: AbortSignal): Promise<MessageRef[]>;
|
|
27
|
+
private read;
|
|
28
|
+
get(ref: MessageRef, recipient: string, signal?: AbortSignal): Promise<StoredMail>;
|
|
29
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { S3Client, GetObjectCommand, ListObjectsV2Command, } from "@aws-sdk/client-s3";
|
|
2
|
+
import { fromTemporaryCredentials } from "@aws-sdk/credential-providers";
|
|
3
|
+
import { normalizeAddress } from "./config.js";
|
|
4
|
+
export function awsCredentials(config) {
|
|
5
|
+
return config.roleArn
|
|
6
|
+
? fromTemporaryCredentials({
|
|
7
|
+
clientConfig: { region: config.region },
|
|
8
|
+
params: {
|
|
9
|
+
RoleArn: config.roleArn,
|
|
10
|
+
RoleSessionName: "hail-email-reader",
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
export class S3MailStore {
|
|
16
|
+
config;
|
|
17
|
+
maxMessageBytes;
|
|
18
|
+
client;
|
|
19
|
+
constructor(config, client, maxMessageBytes = 16 * 1024 * 1024) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.maxMessageBytes = maxMessageBytes;
|
|
22
|
+
this.client =
|
|
23
|
+
client ??
|
|
24
|
+
new S3Client({
|
|
25
|
+
region: config.region,
|
|
26
|
+
credentials: awsCredentials(config),
|
|
27
|
+
maxAttempts: 3,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
prefix(recipient) {
|
|
31
|
+
const address = normalizeAddress(recipient);
|
|
32
|
+
if (address.split("@")[1] !== this.config.domain)
|
|
33
|
+
throw new Error("Mailbox is outside the configured test domain.");
|
|
34
|
+
return this.config.layout === "legacy"
|
|
35
|
+
? `${address}/`
|
|
36
|
+
: `index/${address}/`;
|
|
37
|
+
}
|
|
38
|
+
async list(recipient, signal) {
|
|
39
|
+
const prefix = this.prefix(recipient);
|
|
40
|
+
const messages = [];
|
|
41
|
+
let token;
|
|
42
|
+
do {
|
|
43
|
+
const response = await this.client.send(new ListObjectsV2Command({
|
|
44
|
+
Bucket: this.config.bucketName,
|
|
45
|
+
Prefix: prefix,
|
|
46
|
+
ContinuationToken: token,
|
|
47
|
+
}), { abortSignal: signal });
|
|
48
|
+
for (const object of response.Contents ?? []) {
|
|
49
|
+
const extension = this.config.layout === "legacy" ? ".eml" : ".json";
|
|
50
|
+
if (object.Key?.startsWith(prefix) && object.Key.endsWith(extension)) {
|
|
51
|
+
messages.push({
|
|
52
|
+
id: object.Key,
|
|
53
|
+
key: object.Key,
|
|
54
|
+
receivedAt: object.LastModified ?? new Date(0),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (response.IsTruncated &&
|
|
59
|
+
(!response.NextContinuationToken ||
|
|
60
|
+
response.NextContinuationToken === token)) {
|
|
61
|
+
throw new Error("S3 returned an invalid pagination cursor.");
|
|
62
|
+
}
|
|
63
|
+
token = response.IsTruncated ? response.NextContinuationToken : undefined;
|
|
64
|
+
} while (token);
|
|
65
|
+
return messages.sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime() ||
|
|
66
|
+
a.key.localeCompare(b.key));
|
|
67
|
+
}
|
|
68
|
+
async read(key, limit, signal) {
|
|
69
|
+
const response = await this.client.send(new GetObjectCommand({ Bucket: this.config.bucketName, Key: key }), { abortSignal: signal });
|
|
70
|
+
if (!response.Body)
|
|
71
|
+
throw new Error("Stored email has no content.");
|
|
72
|
+
if ((response.ContentLength ?? 0) > limit)
|
|
73
|
+
throw new Error("Stored email exceeds the configured size limit.");
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let bytes = 0;
|
|
76
|
+
for await (const chunk of response.Body) {
|
|
77
|
+
signal?.throwIfAborted();
|
|
78
|
+
const buffer = Buffer.from(chunk);
|
|
79
|
+
bytes += buffer.length;
|
|
80
|
+
if (bytes > limit)
|
|
81
|
+
throw new Error("Stored email exceeds the configured size limit.");
|
|
82
|
+
chunks.push(buffer);
|
|
83
|
+
}
|
|
84
|
+
return Buffer.concat(chunks);
|
|
85
|
+
}
|
|
86
|
+
async get(ref, recipient, signal) {
|
|
87
|
+
const address = normalizeAddress(recipient);
|
|
88
|
+
const prefix = this.prefix(address);
|
|
89
|
+
if (!ref.key.startsWith(prefix) ||
|
|
90
|
+
ref.key.slice(prefix.length).includes("/"))
|
|
91
|
+
throw new Error("Message key is outside this mailbox.");
|
|
92
|
+
let rawKey = ref.key;
|
|
93
|
+
let receivedAt = ref.receivedAt;
|
|
94
|
+
let delivery;
|
|
95
|
+
if (this.config.layout === "indexed-v1") {
|
|
96
|
+
const metadata = JSON.parse((await this.read(ref.key, 64 * 1024, signal)).toString("utf8"));
|
|
97
|
+
if (metadata.schemaVersion !== 1 ||
|
|
98
|
+
metadata.recipient !== address ||
|
|
99
|
+
typeof metadata.rawKey !== "string" ||
|
|
100
|
+
!/^incoming\/[a-zA-Z0-9_-]+$/.test(metadata.rawKey) ||
|
|
101
|
+
typeof metadata.receivedAt !== "string")
|
|
102
|
+
throw new Error("Invalid mailbox metadata.");
|
|
103
|
+
rawKey = metadata.rawKey;
|
|
104
|
+
receivedAt = new Date(metadata.receivedAt);
|
|
105
|
+
if (!Number.isFinite(receivedAt.getTime()))
|
|
106
|
+
throw new Error("Invalid receipt timestamp.");
|
|
107
|
+
if (metadata.delivery && typeof metadata.delivery === "object") {
|
|
108
|
+
delivery = Object.fromEntries(Object.entries(metadata.delivery).filter((entry) => typeof entry[1] === "string"));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
id: ref.id,
|
|
113
|
+
recipient: address,
|
|
114
|
+
receivedAt,
|
|
115
|
+
delivery,
|
|
116
|
+
raw: await this.read(rawKey, this.maxMessageBytes, signal),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,91 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everydaydevopsio/hail",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Playwright-native testing for real application email workflows",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"hail": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./playwright": {
|
|
19
|
+
"types": "./dist/playwright.d.ts",
|
|
20
|
+
"import": "./dist/playwright.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"terraform/examples/cloudflare/main.tf",
|
|
26
|
+
"terraform/examples/cloudflare/.terraform.lock.hcl",
|
|
27
|
+
"terraform/examples/route53/main.tf",
|
|
28
|
+
"terraform/examples/route53/.terraform.lock.hcl",
|
|
29
|
+
"terraform/examples/manual/main.tf",
|
|
30
|
+
"terraform/examples/manual/.terraform.lock.hcl",
|
|
31
|
+
"terraform/modules/receiver/main.tf",
|
|
32
|
+
"terraform/modules/receiver/variables.tf",
|
|
33
|
+
"terraform/modules/receiver/outputs.tf",
|
|
34
|
+
"terraform/modules/receiver/.terraform.lock.hcl",
|
|
35
|
+
"terraform/modules/receiver/lambda/handler.py",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.build.json",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "node --import tsx --test tests/unit/*.test.ts",
|
|
43
|
+
"test:python": "python3 -m unittest discover -s tests/python -v",
|
|
44
|
+
"test:e2e": "playwright test",
|
|
45
|
+
"test:live": "HAIL_LIVE=1 playwright test",
|
|
46
|
+
"test:package": "node scripts/check-package.mjs",
|
|
47
|
+
"format": "prettier --write src tests '*.json' '*.ts' README.md docs .github/workflows/ci.yml",
|
|
48
|
+
"prepack": "npm run build",
|
|
49
|
+
"test:live:full": "python3 scripts/live-run.py",
|
|
50
|
+
"lint": "eslint src tests scripts *.mjs *.ts",
|
|
51
|
+
"lint:fix": "npm run lint -- --fix",
|
|
52
|
+
"prettier": "prettier --check src tests scripts/*.mjs *.json *.ts *.mjs .github .pre-commit-config.yaml .trivyignore.yaml",
|
|
53
|
+
"prettier:fix": "npm run prettier -- --write",
|
|
54
|
+
"test:coverage": "node --import tsx --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-lines=50 --test-coverage-functions=50 --test-coverage-branches=50 --test tests/unit/*.test.ts",
|
|
55
|
+
"test:smoke": "npm run build && node --import tsx --test tests/unit/cli.test.ts && npm run test:package",
|
|
56
|
+
"test:prepush": "npm run build && npm run typecheck && npm test && npm run test:smoke"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
60
|
+
"@aws-sdk/client-ses": "^3.700.0",
|
|
61
|
+
"@aws-sdk/credential-providers": "^3.700.0",
|
|
62
|
+
"@types/node": "22.20.4",
|
|
63
|
+
"cheerio": "^1.0.0",
|
|
64
|
+
"mailparser": "^3.7.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@playwright/test": ">=1.55.0 <2"
|
|
68
|
+
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"@playwright/test": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@aws-sdk/client-sts": "3.1142.0",
|
|
76
|
+
"@aws-sdk/types": "3.974.6",
|
|
77
|
+
"@eslint/js": "^10.0.1",
|
|
78
|
+
"@playwright/test": "^1.55.0",
|
|
79
|
+
"@types/mailparser": "^3.4.6",
|
|
80
|
+
"eslint": "^10.12.0",
|
|
81
|
+
"globals": "^16.5.0",
|
|
82
|
+
"prettier": "^3.6.2",
|
|
83
|
+
"tsx": "^4.20.5",
|
|
84
|
+
"typescript": "^5.9.2",
|
|
85
|
+
"typescript-eslint": "^8.71.1"
|
|
86
|
+
},
|
|
87
|
+
"repository": {
|
|
88
|
+
"type": "git",
|
|
89
|
+
"url": "https://github.com/everydaydevopsio/hail"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# This file is maintained automatically by "terraform init".
|
|
2
|
+
# Manual edits may be lost in future updates.
|
|
3
|
+
|
|
4
|
+
provider "registry.terraform.io/cloudflare/cloudflare" {
|
|
5
|
+
version = "5.26.0"
|
|
6
|
+
constraints = "~> 5.0"
|
|
7
|
+
hashes = [
|
|
8
|
+
"h1:cn9FioXphqL0gQ9ICt+nhylZm6GBWPm10v20nOQeqgg=",
|
|
9
|
+
"zh:5935d0ea1abc7cfeeb6f6c80b1b5c51c2d72f11e3d0ee273a9bd6267c0821e6d",
|
|
10
|
+
"zh:759bb1cc0e6daeb54def90f13357e8d09ed0ff7101d13726be9f42504d6ffad2",
|
|
11
|
+
"zh:7e109aa1e20dccef154b680363451e9197ed31ece5bd91b53b0a1a512943b852",
|
|
12
|
+
"zh:83b0b43e16d60926fd96227c5902ad104ab11c1b1c42465aaffb61a393d9c12b",
|
|
13
|
+
"zh:845c84b1ca907678515ed8843ea1d7a91b3442af4d8e047be754b9769a3eaf2e",
|
|
14
|
+
"zh:9012426308af3a651c2a1f612cb884774cab4009a29fef67b3e056fbf16e4c87",
|
|
15
|
+
"zh:c6cad449e522ccd4bad73d41671ab2c4461097d8506f44026a056320daf4a462",
|
|
16
|
+
"zh:f199deaba10e25a5d4ebd76bc1c5688ae35a38dab4162fbc7cdef20599e6b562",
|
|
17
|
+
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
provider "registry.terraform.io/hashicorp/archive" {
|
|
22
|
+
version = "2.8.1"
|
|
23
|
+
constraints = ">= 2.4.0, < 3.0.0"
|
|
24
|
+
hashes = [
|
|
25
|
+
"h1:KfIRyazppfpvRKIW5URe4sIVRPPdcbtt4ddkYd1Bw3Y=",
|
|
26
|
+
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
|
|
27
|
+
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
|
|
28
|
+
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
|
|
29
|
+
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
|
|
30
|
+
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
|
|
31
|
+
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
|
|
32
|
+
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
|
33
|
+
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
|
|
34
|
+
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
|
|
35
|
+
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
|
|
36
|
+
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
|
|
37
|
+
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
|
|
38
|
+
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
provider "registry.terraform.io/hashicorp/aws" {
|
|
43
|
+
version = "6.66.0"
|
|
44
|
+
constraints = ">= 5.50.0, < 7.0.0"
|
|
45
|
+
hashes = [
|
|
46
|
+
"h1:hBEaeBm9nm7A/u1nnD0nfolTPP55/BoKRFWk8zG8/fk=",
|
|
47
|
+
"zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523",
|
|
48
|
+
"zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9",
|
|
49
|
+
"zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f",
|
|
50
|
+
"zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd",
|
|
51
|
+
"zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740",
|
|
52
|
+
"zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706",
|
|
53
|
+
"zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4",
|
|
54
|
+
"zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd",
|
|
55
|
+
"zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230",
|
|
56
|
+
"zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb",
|
|
57
|
+
"zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632",
|
|
58
|
+
"zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb",
|
|
59
|
+
"zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca",
|
|
60
|
+
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
|
61
|
+
"zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f",
|
|
62
|
+
"zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283",
|
|
63
|
+
]
|
|
64
|
+
}
|