@aws-blocks/bb-email-client 0.1.0

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/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@aws-blocks/bb-email-client",
3
+ "version": "0.1.0",
4
+ "author": "Amazon Web Services",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "DESIGN.md",
11
+ "src",
12
+ "LICENSE"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "browser": "./dist/index.browser.js",
17
+ "cdk": {
18
+ "types": "./dist/index.cdk.d.ts",
19
+ "default": "./dist/index.cdk.js"
20
+ },
21
+ "aws-runtime": "./dist/index.aws.js",
22
+ "types": "./dist/index.mock.d.ts",
23
+ "default": "./dist/index.mock.js"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "prebuild": "node ../../scripts/generate-version.mjs EmailClient",
28
+ "build": "tsc --build",
29
+ "test": "node --test dist/*.test.js"
30
+ },
31
+ "dependencies": {
32
+ "@aws-blocks/core": "^0.1.0",
33
+ "@aws-blocks/bb-logger": "^0.1.0",
34
+ "@aws-sdk/client-sesv2": "^3.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.0.0",
38
+ "typescript": "^5.3.0"
39
+ },
40
+ "peerDependencies": {
41
+ "aws-cdk-lib": "^2.257.0",
42
+ "constructs": "^10.6.0"
43
+ }
44
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Typed error constants for Email. Use with `isBlocksError()` in catch blocks.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * try {
10
+ * await email.send('user@example.com', { subject: 'Hi', body: 'Hello' });
11
+ * } catch (e: unknown) {
12
+ * if (isBlocksError(e, EmailErrors.SendFailed)) {
13
+ * // General send failure — check error message for details
14
+ * }
15
+ * if (isBlocksError(e, EmailErrors.InvalidInput)) {
16
+ * // malformed input (e.g. invalid email address)
17
+ * }
18
+ * throw e;
19
+ * }
20
+ * ```
21
+ */
22
+ export const EmailErrors = {
23
+ SendFailed: 'EmailSendFailedException',
24
+ InvalidInput: 'InvalidInputException',
25
+ DomainNotVerified: 'DomainNotVerifiedException',
26
+ AccountPaused: 'AccountSendingPausedException',
27
+ RateLimited: 'RateLimitedException',
28
+ } as const;
@@ -0,0 +1,220 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { SESv2Client, SendEmailCommand, SendBulkEmailCommand } from '@aws-sdk/client-sesv2';
5
+ import { Scope } from '@aws-blocks/core';
6
+ import type { ScopeParent } from '@aws-blocks/core';
7
+ import { BB_NAME, BB_VERSION } from './version.js';
8
+
9
+ // Re-export public types and errors
10
+ export { EmailErrors } from './errors.js';
11
+ export type { EmailOptions, EmailMessage, SendResult, SendBatchResult } from './types.js';
12
+
13
+ import type { EmailOptions, EmailMessage, SendResult, SendBatchResult } from './types.js';
14
+ import { EmailErrors } from './errors.js';
15
+ import { Logger } from '@aws-blocks/bb-logger';
16
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
17
+
18
+ const BATCH_CHUNK_SIZE = 50;
19
+
20
+ function blocksError(name: string, message: string): Error {
21
+ const err = new Error(`${name}: ${message}`);
22
+ err.name = name;
23
+ return err;
24
+ }
25
+
26
+ function mapSesError(err: any): Error {
27
+ const message = err.message ?? 'Unknown SES error';
28
+
29
+ if (err.name === 'MailFromDomainNotVerifiedException') {
30
+ return blocksError(EmailErrors.DomainNotVerified, message);
31
+ }
32
+
33
+ if (err.name === 'MessageRejected') {
34
+ const lower = message.toLowerCase();
35
+ if (lower.includes('not verified') || lower.includes('identity')) {
36
+ return blocksError(EmailErrors.DomainNotVerified, message);
37
+ }
38
+ return blocksError(EmailErrors.SendFailed, message);
39
+ }
40
+
41
+ if (err.name === 'AccountSuspendedException' || err.name === 'SendingPausedException') {
42
+ return blocksError(EmailErrors.AccountPaused, message);
43
+ }
44
+
45
+ if (err.name === 'TooManyRequestsException' || err.name === 'ThrottlingException') {
46
+ return blocksError(EmailErrors.RateLimited, message);
47
+ }
48
+
49
+ if (err.name === 'BadRequestException') {
50
+ return blocksError(EmailErrors.InvalidInput, message);
51
+ }
52
+
53
+ return blocksError(EmailErrors.SendFailed, message);
54
+ }
55
+
56
+ /**
57
+ * Send transactional emails via Amazon SES.
58
+ *
59
+ * **When to use:** You need to send transactional emails (welcome messages,
60
+ * password resets, notifications, order confirmations).
61
+ *
62
+ * **When NOT to use:** For bulk marketing campaigns, use a dedicated ESP.
63
+ * For in-app notifications, use a notification service.
64
+ *
65
+ * **Best practices:**
66
+ * - Verify your sending domain in SES before production use
67
+ * - Use a configuration set for delivery tracking
68
+ * - Keep email content under 40 MB
69
+ * - Each message is limited to 50 recipients (To + CC + BCC combined)
70
+ *
71
+ * **Scaling:** SES handles up to 200 emails/second by default (can request increase).
72
+ * No infrastructure to manage.
73
+ */
74
+ export class EmailClient extends Scope {
75
+ private client: SESv2Client;
76
+ private fromAddress: string;
77
+ private replyTo?: string[];
78
+ private configurationSet?: string;
79
+
80
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
81
+ protected log: ChildLogger;
82
+
83
+ constructor(scope: ScopeParent, id: string, options: EmailOptions) {
84
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
85
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
86
+ this.client = new SESv2Client({
87
+ maxAttempts: 3,
88
+ customUserAgent: this.buildUserAgentChain(),
89
+ });
90
+ this.fromAddress = options.fromAddress;
91
+ this.replyTo = options.replyTo;
92
+ this.configurationSet = options.configurationSet;
93
+ }
94
+
95
+ /**
96
+ * Send an email to one or more recipients.
97
+ *
98
+ * Uses the SDK's built-in adaptive retry with maxAttempts: 3.
99
+ *
100
+ * @param message - The email message to send (to, subject, body, optional html/cc/bcc).
101
+ * @returns The SES message ID for the sent email.
102
+ * @throws {EmailErrors.SendFailed} General send failure.
103
+ * @throws {EmailErrors.InvalidInput} If input is invalid (e.g. malformed address, too many recipients).
104
+ * @throws {EmailErrors.DomainNotVerified} If the sending domain is not verified.
105
+ * @throws {EmailErrors.AccountPaused} If account sending is paused.
106
+ * @throws {EmailErrors.RateLimited} If rate limit is exceeded.
107
+ */
108
+ async send(message: EmailMessage): Promise<SendResult> {
109
+ const { to, subject, body, html, cc, bcc } = message;
110
+ const recipients = Array.isArray(to) ? to : [to];
111
+
112
+ const command = new SendEmailCommand({
113
+ FromEmailAddress: this.fromAddress,
114
+ Destination: {
115
+ ToAddresses: recipients,
116
+ ...(cc?.length ? { CcAddresses: cc } : {}),
117
+ ...(bcc?.length ? { BccAddresses: bcc } : {}),
118
+ },
119
+ ...(this.replyTo && { ReplyToAddresses: this.replyTo }),
120
+ Content: {
121
+ Simple: {
122
+ Subject: { Data: subject },
123
+ Body: {
124
+ Text: { Data: body },
125
+ ...(html ? { Html: { Data: html } } : {}),
126
+ },
127
+ },
128
+ },
129
+ ...(this.configurationSet ? { ConfigurationSetName: this.configurationSet } : {}),
130
+ });
131
+
132
+ try {
133
+ const response = await this.client.send(command);
134
+ return { messageId: response.MessageId ?? '' };
135
+ } catch (err: any) {
136
+ if (err.name && err.name in EmailErrors) throw err;
137
+ throw mapSesError(err);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Send a batch of email messages using the SES SendBulkEmail API with inline passthrough templates.
143
+ *
144
+ * Messages are chunked into groups of 50 destinations per API call (SES limit).
145
+ *
146
+ * TODO: Add retry logic for transient failures (throttling, 5xx).
147
+ *
148
+ * @param messages - Array of email messages to send.
149
+ * @returns Result with per-message status in the same order as the input array.
150
+ */
151
+ async sendBatch(messages: EmailMessage[]): Promise<SendBatchResult> {
152
+ const results: Array<{ status: 'success' | 'failed'; messageId?: string; error?: string }> =
153
+ new Array(messages.length);
154
+
155
+ // Process in chunks of BATCH_CHUNK_SIZE
156
+ for (let chunkStart = 0; chunkStart < messages.length; chunkStart += BATCH_CHUNK_SIZE) {
157
+ const chunkIndices = messages
158
+ .slice(chunkStart, chunkStart + BATCH_CHUNK_SIZE)
159
+ .map((_, i) => chunkStart + i);
160
+ const chunk = chunkIndices.map(idx => messages[idx]);
161
+
162
+ const command = new SendBulkEmailCommand({
163
+ FromEmailAddress: this.fromAddress,
164
+ ...(this.replyTo && { ReplyToAddresses: this.replyTo }),
165
+ ...(this.configurationSet ? { ConfigurationSetName: this.configurationSet } : {}),
166
+ DefaultContent: {
167
+ Template: {
168
+ TemplateContent: {
169
+ Subject: '{{subject}}',
170
+ Html: '{{html}}',
171
+ Text: '{{body}}',
172
+ },
173
+ TemplateData: JSON.stringify({ subject: '', body: '', html: '' }),
174
+ },
175
+ },
176
+ BulkEmailEntries: chunk.map(msg => ({
177
+ Destination: {
178
+ ToAddresses: Array.isArray(msg.to) ? msg.to : [msg.to],
179
+ CcAddresses: msg.cc || [],
180
+ BccAddresses: msg.bcc || [],
181
+ },
182
+ ReplacementEmailContent: {
183
+ ReplacementTemplate: {
184
+ ReplacementTemplateData: JSON.stringify({
185
+ subject: msg.subject,
186
+ body: msg.body,
187
+ ...(msg.html ? { html: msg.html } : { html: msg.body }),
188
+ }),
189
+ },
190
+ },
191
+ })),
192
+ });
193
+
194
+ try {
195
+ const response = await this.client.send(command);
196
+ const bulkResults = response.BulkEmailEntryResults ?? [];
197
+
198
+ for (let i = 0; i < chunkIndices.length; i++) {
199
+ const globalIdx = chunkIndices[i];
200
+ const entry = bulkResults[i];
201
+
202
+ if (entry?.Status === 'SUCCESS') {
203
+ results[globalIdx] = { status: 'success', messageId: entry.MessageId ?? '' };
204
+ } else {
205
+ const errorMsg = entry?.Error ?? 'Unknown bulk send error';
206
+ results[globalIdx] = { status: 'failed', error: errorMsg };
207
+ }
208
+ }
209
+ } catch (err: any) {
210
+ // Entire chunk failed — mark all as failed
211
+ for (const globalIdx of chunkIndices) {
212
+ const mapped = mapSesError(err);
213
+ results[globalIdx] = { status: 'failed', error: mapped.message };
214
+ }
215
+ }
216
+ }
217
+
218
+ return { results };
219
+ }
220
+ }
@@ -0,0 +1,7 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Browser stub - Email runs server-side only
5
+ export class EmailClient {
6
+ constructor(...args: any[]) {}
7
+ }
@@ -0,0 +1,49 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { CfnConfigurationSet } from 'aws-cdk-lib/aws-ses';
5
+ import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
6
+ import { Stack } from 'aws-cdk-lib';
7
+ import { Scope } from '@aws-blocks/core/cdk';
8
+ import type { ScopeParent } from '@aws-blocks/core';
9
+
10
+ // Re-export public types and errors (no runtime dependencies)
11
+ export { EmailErrors } from './errors.js';
12
+ export type { EmailOptions, EmailMessage, SendResult, SendBatchResult } from './types.js';
13
+
14
+ import type { EmailOptions } from './types.js';
15
+
16
+ export class EmailClient extends Scope {
17
+ constructor(scope: ScopeParent, id: string, options: EmailOptions) {
18
+ super(id, { parent: scope });
19
+
20
+ console.warn(
21
+ `\n⚠️ [Email] Prerequisite: Domain for "${options.fromAddress}" must be verified in SES.\n` +
22
+ ` Guide: https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html\n`
23
+ );
24
+
25
+ // TODO: Add a CDK custom resource that validates the SES email identity at deploy time.
26
+ // Deploy time is the earliest point where AWS credentials are available to check SES state.
27
+ // The custom resource should:
28
+ // 1. Call sesv2:GetEmailIdentity for the domain extracted from fromAddress
29
+ // 2. If not verified: emit a CloudFormation warning (do not fail the deployment)
30
+ // 3. Include a link to the SES identity setup guide in the warning message
31
+
32
+ // Grant the Lambda handler permission to send emails
33
+ // Scoped to this account's SES identities rather than '*'
34
+ this.handler.addToRolePolicy(new PolicyStatement({
35
+ effect: Effect.ALLOW,
36
+ actions: [
37
+ 'ses:SendEmail',
38
+ 'ses:SendBulkEmail',
39
+ 'ses:SendRawEmail',
40
+ 'ses:SendTemplatedEmail',
41
+ 'ses:SendBulkTemplatedEmail',
42
+ ],
43
+ resources: [
44
+ `arn:aws:ses:*:${Stack.of(this).account}:identity/*`,
45
+ `arn:aws:ses:*:${Stack.of(this).account}:configuration-set/*`,
46
+ ],
47
+ }));
48
+ }
49
+ }
@@ -0,0 +1,231 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Scope } from '@aws-blocks/core';
5
+ import { getMockDataDir } from '@aws-blocks/core/bb-utils';
6
+ import type { ScopeParent } from '@aws-blocks/core';
7
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { BB_NAME, BB_VERSION } from './version.js';
10
+
11
+ // ── Public types ────────────────────────────────────────────────────────────
12
+
13
+ export {
14
+ EmailErrors,
15
+ } from './errors.js';
16
+ export type {
17
+ EmailOptions,
18
+ EmailMessage,
19
+ SendResult,
20
+ SendBatchResult,
21
+ } from './types.js';
22
+
23
+ import type { EmailOptions, EmailMessage, SendResult, SendBatchResult } from './types.js';
24
+ import { EmailErrors } from './errors.js';
25
+ import { Logger } from '@aws-blocks/bb-logger';
26
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
27
+
28
+ // ── Helpers ─────────────────────────────────────────────────────────────────
29
+
30
+ const MAX_RECIPIENTS_PER_MESSAGE = 50;
31
+ const MAX_MESSAGE_BYTES = 40 * 1024 * 1024; // 40 MB
32
+ const LOG_TRUNCATE_LENGTH = 80;
33
+
34
+ function truncate(text: string, maxLen: number = LOG_TRUNCATE_LENGTH): string {
35
+ const oneLine = text.replace(/\n/g, ' ').trim();
36
+ if (oneLine.length <= maxLen) return oneLine;
37
+ return oneLine.substring(0, maxLen) + '...';
38
+ }
39
+
40
+ // Basic RFC 5322 email regex
41
+ const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
42
+
43
+ function blocksError(name: string, message: string): Error {
44
+ const err = new Error(`${name}: ${message}`);
45
+ err.name = name;
46
+ return err;
47
+ }
48
+
49
+ function validateEmailAddress(address: string): void {
50
+ if (!EMAIL_REGEX.test(address)) {
51
+ throw blocksError(EmailErrors.InvalidInput, `Invalid email address: ${address}`);
52
+ }
53
+ }
54
+
55
+ function validateAddresses(addresses: string | string[]): void {
56
+ const list = Array.isArray(addresses) ? addresses : [addresses];
57
+ for (const addr of list) {
58
+ validateEmailAddress(addr);
59
+ }
60
+ }
61
+
62
+ function countRecipients(msg: { to: string | string[]; cc?: string[]; bcc?: string[] }): number {
63
+ const toCount = Array.isArray(msg.to) ? msg.to.length : 1;
64
+ const ccCount = msg.cc?.length ?? 0;
65
+ const bccCount = msg.bcc?.length ?? 0;
66
+ return toCount + ccCount + bccCount;
67
+ }
68
+
69
+ function validateRecipientCount(msg: { to: string | string[]; cc?: string[]; bcc?: string[] }): void {
70
+ const count = countRecipients(msg);
71
+ if (count > MAX_RECIPIENTS_PER_MESSAGE) {
72
+ throw blocksError(
73
+ EmailErrors.InvalidInput,
74
+ `Recipient count exceeds ${MAX_RECIPIENTS_PER_MESSAGE}.`,
75
+ );
76
+ }
77
+ }
78
+
79
+ function generateMockMessageId(): string {
80
+ return `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`;
81
+ }
82
+
83
+ interface StoredEmail {
84
+ to: string | string[];
85
+ subject: string;
86
+ body: string;
87
+ html?: string;
88
+ from: string;
89
+ messageId: string;
90
+ timestamp: string;
91
+ }
92
+
93
+ // ── Email (mock) ────────────────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Send transactional emails via Amazon SES.
97
+ *
98
+ * **When to use:** You need to send transactional emails (welcome messages,
99
+ * password resets, notifications, order confirmations).
100
+ *
101
+ * **When NOT to use:** For bulk marketing campaigns, use a dedicated ESP.
102
+ * For in-app notifications, use a notification service.
103
+ *
104
+ * **Best practices:**
105
+ * - Verify your sending domain in SES before production use
106
+ * - Use a configuration set for delivery tracking
107
+ * - Keep email content under 40 MB
108
+ * - Each message is limited to 50 recipients (To + CC + BCC combined)
109
+ * - Batch sends use the SES SendBulkEmail API (max 50 destinations per API call)
110
+ *
111
+ * **Scaling:** SES handles up to 200 emails/second by default (can request increase).
112
+ * No infrastructure to manage.
113
+ */
114
+ export class EmailClient extends Scope {
115
+ private filePath: string;
116
+ private emails: StoredEmail[];
117
+ private fromAddress: string;
118
+ private replyTo?: string[];
119
+
120
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
121
+ protected log: ChildLogger;
122
+
123
+ constructor(scope: ScopeParent, id: string, options: EmailOptions) {
124
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
125
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
126
+ this.fromAddress = options.fromAddress;
127
+ this.replyTo = options.replyTo;
128
+ this.filePath = join(getMockDataDir(this), 'emails.json');
129
+ this.emails = this.loadFromDisk();
130
+ }
131
+
132
+ /**
133
+ * Send an email to one or more recipients.
134
+ *
135
+ * @param message - The email message to send (to, subject, body, optional html/cc/bcc).
136
+ * @returns The mock message ID for the sent email.
137
+ * @throws {EmailErrors.InvalidInput} If any address fails validation.
138
+ * @throws {EmailErrors.SendFailed} If the message exceeds 40 MB or recipient count exceeds 50.
139
+ */
140
+ async send(message: EmailMessage): Promise<SendResult> {
141
+ const { to, subject, body, html, cc, bcc } = message;
142
+
143
+ validateAddresses(to);
144
+ validateEmailAddress(this.fromAddress);
145
+ if (cc) validateAddresses(cc);
146
+ if (bcc) validateAddresses(bcc);
147
+
148
+ validateRecipientCount({ to, cc, bcc });
149
+
150
+ const messageSize = Buffer.byteLength(
151
+ JSON.stringify(message),
152
+ 'utf8',
153
+ );
154
+ if (messageSize > MAX_MESSAGE_BYTES) {
155
+ throw blocksError(EmailErrors.SendFailed, `Message size ${messageSize} bytes exceeds the 40 MB limit`);
156
+ }
157
+
158
+ const recipients = Array.isArray(to) ? to : [to];
159
+ const messageId = generateMockMessageId();
160
+ const lines = [
161
+ `[Email:${this.id}]`,
162
+ ` Recipient: ${recipients.join(', ')}`,
163
+ ` Subject: ${subject}`,
164
+ ` Body: ${truncate(body)}`,
165
+ ];
166
+ if (html) {
167
+ lines.push(` HTML: ${truncate(html)}`);
168
+ }
169
+ console.log(lines.join('\n'));
170
+
171
+ const stored: StoredEmail = {
172
+ to,
173
+ subject,
174
+ body,
175
+ html,
176
+ from: this.fromAddress,
177
+ messageId,
178
+ timestamp: new Date().toISOString(),
179
+ };
180
+ this.emails.push(stored);
181
+ this.flushToDisk();
182
+
183
+ return { messageId };
184
+ }
185
+
186
+ /**
187
+ * Send a batch of email messages.
188
+ *
189
+ * Each individual message must not exceed 50 recipients (To + CC + BCC combined).
190
+ * Messages exceeding this limit are marked as failed in the results (not thrown).
191
+ * This matches SES SendBulkEmail behavior which returns per-entry status.
192
+ *
193
+ * @param messages - Array of email messages to send.
194
+ * @returns Result with per-message status in the same order as the input array.
195
+ * Each entry has status ('success' | 'failed'), messageId (on success), or error (on failure).
196
+ */
197
+ async sendBatch(messages: EmailMessage[]): Promise<SendBatchResult> {
198
+ const results: Array<{ status: 'success' | 'failed'; messageId?: string; error?: string }> = [];
199
+
200
+ for (let i = 0; i < messages.length; i++) {
201
+ const msg = messages[i];
202
+ if (countRecipients(msg) > MAX_RECIPIENTS_PER_MESSAGE) {
203
+ results.push({ status: 'failed', error: `Recipient count exceeds ${MAX_RECIPIENTS_PER_MESSAGE}.` });
204
+ continue;
205
+ }
206
+ try {
207
+ const sendResult = await this.send(msg);
208
+ results.push({ status: 'success', messageId: sendResult.messageId });
209
+ } catch (err: any) {
210
+ results.push({ status: 'failed', error: err.message ?? 'Unknown error' });
211
+ }
212
+ }
213
+
214
+ return { results };
215
+ }
216
+
217
+ // ── Disk persistence ──────────────────────────────────────────────────
218
+
219
+ private loadFromDisk(): StoredEmail[] {
220
+ if (!existsSync(this.filePath)) return [];
221
+ try {
222
+ return JSON.parse(readFileSync(this.filePath, 'utf8'));
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ private flushToDisk(): void {
229
+ writeFileSync(this.filePath, JSON.stringify(this.emails, null, 2));
230
+ }
231
+ }