@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.
@@ -0,0 +1,36 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
4
+ import { Stack } from 'aws-cdk-lib';
5
+ import { Scope } from '@aws-blocks/core/cdk';
6
+ // Re-export public types and errors (no runtime dependencies)
7
+ export { EmailErrors } from './errors.js';
8
+ export class EmailClient extends Scope {
9
+ constructor(scope, id, options) {
10
+ super(id, { parent: scope });
11
+ console.warn(`\n⚠️ [Email] Prerequisite: Domain for "${options.fromAddress}" must be verified in SES.\n` +
12
+ ` Guide: https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html\n`);
13
+ // TODO: Add a CDK custom resource that validates the SES email identity at deploy time.
14
+ // Deploy time is the earliest point where AWS credentials are available to check SES state.
15
+ // The custom resource should:
16
+ // 1. Call sesv2:GetEmailIdentity for the domain extracted from fromAddress
17
+ // 2. If not verified: emit a CloudFormation warning (do not fail the deployment)
18
+ // 3. Include a link to the SES identity setup guide in the warning message
19
+ // Grant the Lambda handler permission to send emails
20
+ // Scoped to this account's SES identities rather than '*'
21
+ this.handler.addToRolePolicy(new PolicyStatement({
22
+ effect: Effect.ALLOW,
23
+ actions: [
24
+ 'ses:SendEmail',
25
+ 'ses:SendBulkEmail',
26
+ 'ses:SendRawEmail',
27
+ 'ses:SendTemplatedEmail',
28
+ 'ses:SendBulkTemplatedEmail',
29
+ ],
30
+ resources: [
31
+ `arn:aws:ses:*:${Stack.of(this).account}:identity/*`,
32
+ `arn:aws:ses:*:${Stack.of(this).account}:configuration-set/*`,
33
+ ],
34
+ }));
35
+ }
36
+ }
@@ -0,0 +1,58 @@
1
+ import { Scope } from '@aws-blocks/core';
2
+ import type { ScopeParent } from '@aws-blocks/core';
3
+ export { EmailErrors, } from './errors.js';
4
+ export type { EmailOptions, EmailMessage, SendResult, SendBatchResult, } from './types.js';
5
+ import type { EmailOptions, EmailMessage, SendResult, SendBatchResult } from './types.js';
6
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
7
+ /**
8
+ * Send transactional emails via Amazon SES.
9
+ *
10
+ * **When to use:** You need to send transactional emails (welcome messages,
11
+ * password resets, notifications, order confirmations).
12
+ *
13
+ * **When NOT to use:** For bulk marketing campaigns, use a dedicated ESP.
14
+ * For in-app notifications, use a notification service.
15
+ *
16
+ * **Best practices:**
17
+ * - Verify your sending domain in SES before production use
18
+ * - Use a configuration set for delivery tracking
19
+ * - Keep email content under 40 MB
20
+ * - Each message is limited to 50 recipients (To + CC + BCC combined)
21
+ * - Batch sends use the SES SendBulkEmail API (max 50 destinations per API call)
22
+ *
23
+ * **Scaling:** SES handles up to 200 emails/second by default (can request increase).
24
+ * No infrastructure to manage.
25
+ */
26
+ export declare class EmailClient extends Scope {
27
+ private filePath;
28
+ private emails;
29
+ private fromAddress;
30
+ private replyTo?;
31
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
32
+ protected log: ChildLogger;
33
+ constructor(scope: ScopeParent, id: string, options: EmailOptions);
34
+ /**
35
+ * Send an email to one or more recipients.
36
+ *
37
+ * @param message - The email message to send (to, subject, body, optional html/cc/bcc).
38
+ * @returns The mock message ID for the sent email.
39
+ * @throws {EmailErrors.InvalidInput} If any address fails validation.
40
+ * @throws {EmailErrors.SendFailed} If the message exceeds 40 MB or recipient count exceeds 50.
41
+ */
42
+ send(message: EmailMessage): Promise<SendResult>;
43
+ /**
44
+ * Send a batch of email messages.
45
+ *
46
+ * Each individual message must not exceed 50 recipients (To + CC + BCC combined).
47
+ * Messages exceeding this limit are marked as failed in the results (not thrown).
48
+ * This matches SES SendBulkEmail behavior which returns per-entry status.
49
+ *
50
+ * @param messages - Array of email messages to send.
51
+ * @returns Result with per-message status in the same order as the input array.
52
+ * Each entry has status ('success' | 'failed'), messageId (on success), or error (on failure).
53
+ */
54
+ sendBatch(messages: EmailMessage[]): Promise<SendBatchResult>;
55
+ private loadFromDisk;
56
+ private flushToDisk;
57
+ }
58
+ //# sourceMappingURL=index.mock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAEzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAOpD,OAAO,EACN,WAAW,GACX,MAAM,aAAa,CAAC;AACrB,YAAY,EACX,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,eAAe,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAG1F,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAqEzD;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACrC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,OAAO,CAAC,CAAW;IAE3B,2FAA2F;IAC3F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY;IASjE;;;;;;;OAOG;IACG,IAAI,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,UAAU,CAAC;IA8CtD;;;;;;;;;;OAUG;IACG,SAAS,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC,eAAe,CAAC;IAsBnE,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,WAAW;CAGnB"}
@@ -0,0 +1,179 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Scope } from '@aws-blocks/core';
4
+ import { getMockDataDir } from '@aws-blocks/core/bb-utils';
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { BB_NAME, BB_VERSION } from './version.js';
8
+ // ── Public types ────────────────────────────────────────────────────────────
9
+ export { EmailErrors, } from './errors.js';
10
+ import { EmailErrors } from './errors.js';
11
+ import { Logger } from '@aws-blocks/bb-logger';
12
+ // ── Helpers ─────────────────────────────────────────────────────────────────
13
+ const MAX_RECIPIENTS_PER_MESSAGE = 50;
14
+ const MAX_MESSAGE_BYTES = 40 * 1024 * 1024; // 40 MB
15
+ const LOG_TRUNCATE_LENGTH = 80;
16
+ function truncate(text, maxLen = LOG_TRUNCATE_LENGTH) {
17
+ const oneLine = text.replace(/\n/g, ' ').trim();
18
+ if (oneLine.length <= maxLen)
19
+ return oneLine;
20
+ return oneLine.substring(0, maxLen) + '...';
21
+ }
22
+ // Basic RFC 5322 email regex
23
+ 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])?)*$/;
24
+ function blocksError(name, message) {
25
+ const err = new Error(`${name}: ${message}`);
26
+ err.name = name;
27
+ return err;
28
+ }
29
+ function validateEmailAddress(address) {
30
+ if (!EMAIL_REGEX.test(address)) {
31
+ throw blocksError(EmailErrors.InvalidInput, `Invalid email address: ${address}`);
32
+ }
33
+ }
34
+ function validateAddresses(addresses) {
35
+ const list = Array.isArray(addresses) ? addresses : [addresses];
36
+ for (const addr of list) {
37
+ validateEmailAddress(addr);
38
+ }
39
+ }
40
+ function countRecipients(msg) {
41
+ const toCount = Array.isArray(msg.to) ? msg.to.length : 1;
42
+ const ccCount = msg.cc?.length ?? 0;
43
+ const bccCount = msg.bcc?.length ?? 0;
44
+ return toCount + ccCount + bccCount;
45
+ }
46
+ function validateRecipientCount(msg) {
47
+ const count = countRecipients(msg);
48
+ if (count > MAX_RECIPIENTS_PER_MESSAGE) {
49
+ throw blocksError(EmailErrors.InvalidInput, `Recipient count exceeds ${MAX_RECIPIENTS_PER_MESSAGE}.`);
50
+ }
51
+ }
52
+ function generateMockMessageId() {
53
+ return `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`;
54
+ }
55
+ // ── Email (mock) ────────────────────────────────────────────────────────────
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
+ * - Batch sends use the SES SendBulkEmail API (max 50 destinations per API call)
71
+ *
72
+ * **Scaling:** SES handles up to 200 emails/second by default (can request increase).
73
+ * No infrastructure to manage.
74
+ */
75
+ export class EmailClient extends Scope {
76
+ filePath;
77
+ emails;
78
+ fromAddress;
79
+ replyTo;
80
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
81
+ log;
82
+ constructor(scope, id, options) {
83
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
84
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
85
+ this.fromAddress = options.fromAddress;
86
+ this.replyTo = options.replyTo;
87
+ this.filePath = join(getMockDataDir(this), 'emails.json');
88
+ this.emails = this.loadFromDisk();
89
+ }
90
+ /**
91
+ * Send an email to one or more recipients.
92
+ *
93
+ * @param message - The email message to send (to, subject, body, optional html/cc/bcc).
94
+ * @returns The mock message ID for the sent email.
95
+ * @throws {EmailErrors.InvalidInput} If any address fails validation.
96
+ * @throws {EmailErrors.SendFailed} If the message exceeds 40 MB or recipient count exceeds 50.
97
+ */
98
+ async send(message) {
99
+ const { to, subject, body, html, cc, bcc } = message;
100
+ validateAddresses(to);
101
+ validateEmailAddress(this.fromAddress);
102
+ if (cc)
103
+ validateAddresses(cc);
104
+ if (bcc)
105
+ validateAddresses(bcc);
106
+ validateRecipientCount({ to, cc, bcc });
107
+ const messageSize = Buffer.byteLength(JSON.stringify(message), 'utf8');
108
+ if (messageSize > MAX_MESSAGE_BYTES) {
109
+ throw blocksError(EmailErrors.SendFailed, `Message size ${messageSize} bytes exceeds the 40 MB limit`);
110
+ }
111
+ const recipients = Array.isArray(to) ? to : [to];
112
+ const messageId = generateMockMessageId();
113
+ const lines = [
114
+ `[Email:${this.id}]`,
115
+ ` Recipient: ${recipients.join(', ')}`,
116
+ ` Subject: ${subject}`,
117
+ ` Body: ${truncate(body)}`,
118
+ ];
119
+ if (html) {
120
+ lines.push(` HTML: ${truncate(html)}`);
121
+ }
122
+ console.log(lines.join('\n'));
123
+ const stored = {
124
+ to,
125
+ subject,
126
+ body,
127
+ html,
128
+ from: this.fromAddress,
129
+ messageId,
130
+ timestamp: new Date().toISOString(),
131
+ };
132
+ this.emails.push(stored);
133
+ this.flushToDisk();
134
+ return { messageId };
135
+ }
136
+ /**
137
+ * Send a batch of email messages.
138
+ *
139
+ * Each individual message must not exceed 50 recipients (To + CC + BCC combined).
140
+ * Messages exceeding this limit are marked as failed in the results (not thrown).
141
+ * This matches SES SendBulkEmail behavior which returns per-entry status.
142
+ *
143
+ * @param messages - Array of email messages to send.
144
+ * @returns Result with per-message status in the same order as the input array.
145
+ * Each entry has status ('success' | 'failed'), messageId (on success), or error (on failure).
146
+ */
147
+ async sendBatch(messages) {
148
+ const results = [];
149
+ for (let i = 0; i < messages.length; i++) {
150
+ const msg = messages[i];
151
+ if (countRecipients(msg) > MAX_RECIPIENTS_PER_MESSAGE) {
152
+ results.push({ status: 'failed', error: `Recipient count exceeds ${MAX_RECIPIENTS_PER_MESSAGE}.` });
153
+ continue;
154
+ }
155
+ try {
156
+ const sendResult = await this.send(msg);
157
+ results.push({ status: 'success', messageId: sendResult.messageId });
158
+ }
159
+ catch (err) {
160
+ results.push({ status: 'failed', error: err.message ?? 'Unknown error' });
161
+ }
162
+ }
163
+ return { results };
164
+ }
165
+ // ── Disk persistence ──────────────────────────────────────────────────
166
+ loadFromDisk() {
167
+ if (!existsSync(this.filePath))
168
+ return [];
169
+ try {
170
+ return JSON.parse(readFileSync(this.filePath, 'utf8'));
171
+ }
172
+ catch {
173
+ return [];
174
+ }
175
+ }
176
+ flushToDisk() {
177
+ writeFileSync(this.filePath, JSON.stringify(this.emails, null, 2));
178
+ }
179
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,274 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { test, beforeEach } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { rmSync, existsSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { EmailClient, EmailErrors } from './index.mock.js';
8
+ // Clean mock data between tests to avoid cross-contamination
9
+ beforeEach(() => {
10
+ try {
11
+ rmSync('.bb-data', { recursive: true, force: true });
12
+ }
13
+ catch { }
14
+ });
15
+ // ── Single recipient ────────────────────────────────────────────────────────
16
+ test('send to single recipient returns messageId', async () => {
17
+ const emailClient = new EmailClient({ id: 'root' }, 'test', {
18
+ fromAddress: 'noreply@example.com',
19
+ });
20
+ const result = await emailClient.send({
21
+ to: 'user@example.com',
22
+ subject: 'Hello',
23
+ body: 'World',
24
+ });
25
+ assert.ok(result.messageId);
26
+ assert.ok(result.messageId.startsWith('mock-'));
27
+ // Verify persistence
28
+ const dataDir = join(process.cwd(), '.bb-data', 'root-test');
29
+ assert.ok(existsSync(join(dataDir, 'emails.json')));
30
+ const stored = JSON.parse(readFileSync(join(dataDir, 'emails.json'), 'utf8'));
31
+ assert.strictEqual(stored.length, 1);
32
+ assert.strictEqual(stored[0].to, 'user@example.com');
33
+ assert.strictEqual(stored[0].subject, 'Hello');
34
+ assert.strictEqual(stored[0].body, 'World');
35
+ assert.strictEqual(stored[0].messageId, result.messageId);
36
+ });
37
+ // ── Multiple recipients ─────────────────────────────────────────────────────
38
+ test('send to multiple recipients', async () => {
39
+ const emailClient = new EmailClient({ id: 'root' }, 'multi', {
40
+ fromAddress: 'noreply@example.com',
41
+ });
42
+ const result = await emailClient.send({
43
+ to: ['alice@example.com', 'bob@example.com'],
44
+ subject: 'Team Update',
45
+ body: 'Check this out',
46
+ });
47
+ assert.ok(result.messageId);
48
+ const dataDir = join(process.cwd(), '.bb-data', 'root-multi');
49
+ const stored = JSON.parse(readFileSync(join(dataDir, 'emails.json'), 'utf8'));
50
+ assert.strictEqual(stored.length, 1);
51
+ assert.deepStrictEqual(stored[0].to, ['alice@example.com', 'bob@example.com']);
52
+ });
53
+ // ── HTML email support ──────────────────────────────────────────────────────
54
+ test('send HTML email', async () => {
55
+ const emailClient = new EmailClient({ id: 'root' }, 'html', {
56
+ fromAddress: 'noreply@example.com',
57
+ });
58
+ const result = await emailClient.send({
59
+ to: 'user@example.com',
60
+ subject: 'Rich Email',
61
+ body: 'Plain fallback',
62
+ html: '<h1>Hello</h1>',
63
+ });
64
+ assert.ok(result.messageId);
65
+ const dataDir = join(process.cwd(), '.bb-data', 'root-html');
66
+ const stored = JSON.parse(readFileSync(join(dataDir, 'emails.json'), 'utf8'));
67
+ assert.strictEqual(stored[0].html, '<h1>Hello</h1>');
68
+ assert.strictEqual(stored[0].body, 'Plain fallback');
69
+ });
70
+ // ── Invalid address rejection ───────────────────────────────────────────────
71
+ test('rejects invalid recipient address', async () => {
72
+ const emailClient = new EmailClient({ id: 'root' }, 'invalid', {
73
+ fromAddress: 'noreply@example.com',
74
+ });
75
+ await assert.rejects(() => emailClient.send({ to: 'not-an-email', subject: 'Test', body: 'Body' }), (err) => err.name === EmailErrors.InvalidInput);
76
+ });
77
+ test('rejects invalid from address', async () => {
78
+ const emailClient = new EmailClient({ id: 'root' }, 'badfrom', {
79
+ fromAddress: 'bad-address',
80
+ });
81
+ await assert.rejects(() => emailClient.send({ to: 'user@example.com', subject: 'Test', body: 'Body' }), (err) => err.name === EmailErrors.InvalidInput);
82
+ });
83
+ test('rejects if any address in array is invalid', async () => {
84
+ const emailClient = new EmailClient({ id: 'root' }, 'mixedaddr', {
85
+ fromAddress: 'noreply@example.com',
86
+ });
87
+ await assert.rejects(() => emailClient.send({ to: ['valid@example.com', 'invalid@@'], subject: 'Test', body: 'Body' }), (err) => err.name === EmailErrors.InvalidInput);
88
+ });
89
+ // ── Per-message recipient limit (50 recipients) ─────────────────────────────
90
+ test('rejects single message with more than 50 recipients', async () => {
91
+ const emailClient = new EmailClient({ id: 'root' }, 'toomany', {
92
+ fromAddress: 'noreply@example.com',
93
+ });
94
+ const recipients = Array.from({ length: 51 }, (_, i) => `user${i}@example.com`);
95
+ await assert.rejects(() => emailClient.send({ to: recipients, subject: 'Test', body: 'Body' }), (err) => {
96
+ assert.strictEqual(err.name, EmailErrors.InvalidInput);
97
+ assert.ok(err.message.includes('50'));
98
+ return true;
99
+ });
100
+ });
101
+ test('rejects message with combined To + CC + BCC exceeding 50', async () => {
102
+ const emailClient = new EmailClient({ id: 'root' }, 'combined', {
103
+ fromAddress: 'noreply@example.com',
104
+ });
105
+ const toAddrs = Array.from({ length: 20 }, (_, i) => `to${i}@example.com`);
106
+ const ccAddrs = Array.from({ length: 20 }, (_, i) => `cc${i}@example.com`);
107
+ const bccAddrs = Array.from({ length: 11 }, (_, i) => `bcc${i}@example.com`);
108
+ await assert.rejects(() => emailClient.send({ to: toAddrs, subject: 'Test', body: 'Body', cc: ccAddrs, bcc: bccAddrs }), (err) => {
109
+ assert.strictEqual(err.name, EmailErrors.InvalidInput);
110
+ assert.ok(err.message.includes('50'));
111
+ return true;
112
+ });
113
+ });
114
+ test('allows message with exactly 50 recipients', async () => {
115
+ const emailClient = new EmailClient({ id: 'root' }, 'exact50', {
116
+ fromAddress: 'noreply@example.com',
117
+ });
118
+ const recipients = Array.from({ length: 50 }, (_, i) => `user${i}@example.com`);
119
+ const result = await emailClient.send({ to: recipients, subject: 'Test', body: 'Body' });
120
+ assert.ok(result.messageId);
121
+ const dataDir = join(process.cwd(), '.bb-data', 'root-exact50');
122
+ const stored = JSON.parse(readFileSync(join(dataDir, 'emails.json'), 'utf8'));
123
+ assert.strictEqual(stored.length, 1);
124
+ });
125
+ // ── Batch: per-message recipient limit ──────────────────────────────────────
126
+ test('sendBatch marks message with more than 50 recipients as failed', async () => {
127
+ const emailClient = new EmailClient({ id: 'root' }, 'batchrecip', {
128
+ fromAddress: 'noreply@example.com',
129
+ });
130
+ const recipients = Array.from({ length: 51 }, (_, i) => `user${i}@example.com`);
131
+ const result = await emailClient.sendBatch([
132
+ { to: recipients, subject: 'Too many', body: 'Body' },
133
+ ]);
134
+ assert.strictEqual(result.results.length, 1);
135
+ assert.strictEqual(result.results[0].status, 'failed');
136
+ assert.ok(result.results[0].error.includes('50'));
137
+ });
138
+ // ── Batch: returns SendBatchResult ──────────────────────────────────────────
139
+ test('sendBatch returns SendBatchResult with results array in input order', async () => {
140
+ const emailClient = new EmailClient({ id: 'root' }, 'batchresult', {
141
+ fromAddress: 'noreply@example.com',
142
+ });
143
+ const messages = Array.from({ length: 3 }, (_, i) => ({
144
+ to: `user${i}@example.com`,
145
+ subject: `Msg ${i}`,
146
+ body: `Body ${i}`,
147
+ }));
148
+ const result = await emailClient.sendBatch(messages);
149
+ assert.strictEqual(result.results.length, 3);
150
+ for (let i = 0; i < 3; i++) {
151
+ assert.strictEqual(result.results[i].status, 'success');
152
+ assert.ok(result.results[i].messageId);
153
+ }
154
+ });
155
+ test('sendBatch reports partial failures in correct positions', async () => {
156
+ const emailClient = new EmailClient({ id: 'root' }, 'batchpartial', {
157
+ fromAddress: 'noreply@example.com',
158
+ });
159
+ const messages = [
160
+ { to: 'valid@example.com', subject: 'Good', body: 'Body' },
161
+ { to: 'invalid@@', subject: 'Bad', body: 'Body' },
162
+ { to: 'also-valid@example.com', subject: 'Good', body: 'Body' },
163
+ ];
164
+ const result = await emailClient.sendBatch(messages);
165
+ assert.strictEqual(result.results.length, 3);
166
+ assert.strictEqual(result.results[0].status, 'success');
167
+ assert.ok(result.results[0].messageId);
168
+ assert.strictEqual(result.results[1].status, 'failed');
169
+ assert.ok(result.results[1].error.includes('Invalid'));
170
+ assert.strictEqual(result.results[2].status, 'success');
171
+ assert.ok(result.results[2].messageId);
172
+ });
173
+ test('sendBatch returns all failures without throwing when ALL messages fail', async () => {
174
+ const emailClient = new EmailClient({ id: 'root' }, 'batchallfail', {
175
+ fromAddress: 'noreply@example.com',
176
+ });
177
+ const messages = [
178
+ { to: 'invalid@@', subject: 'Bad1', body: 'Body' },
179
+ { to: 'also-invalid@@', subject: 'Bad2', body: 'Body' },
180
+ ];
181
+ const result = await emailClient.sendBatch(messages);
182
+ assert.strictEqual(result.results.length, 2);
183
+ assert.strictEqual(result.results[0].status, 'failed');
184
+ assert.ok(result.results[0].error.includes('Invalid'));
185
+ assert.strictEqual(result.results[1].status, 'failed');
186
+ assert.ok(result.results[1].error.includes('Invalid'));
187
+ });
188
+ // ── Batch: many messages succeeds ───────────────────────────────────────────
189
+ test('sendBatch sends many messages (no batch size limit)', async () => {
190
+ const emailClient = new EmailClient({ id: 'root' }, 'bigbatch', {
191
+ fromAddress: 'noreply@example.com',
192
+ });
193
+ const messages = Array.from({ length: 100 }, (_, i) => ({
194
+ to: `user${i}@example.com`,
195
+ subject: `Msg ${i}`,
196
+ body: `Body ${i}`,
197
+ }));
198
+ const result = await emailClient.sendBatch(messages);
199
+ assert.strictEqual(result.results.length, 100);
200
+ for (const r of result.results) {
201
+ assert.strictEqual(r.status, 'success');
202
+ assert.ok(r.messageId);
203
+ }
204
+ const dataDir = join(process.cwd(), '.bb-data', 'root-bigbatch');
205
+ const stored = JSON.parse(readFileSync(join(dataDir, 'emails.json'), 'utf8'));
206
+ assert.strictEqual(stored.length, 100);
207
+ });
208
+ test('sendBatch sends all messages within small batch', async () => {
209
+ const emailClient = new EmailClient({ id: 'root' }, 'batchok', {
210
+ fromAddress: 'noreply@example.com',
211
+ });
212
+ const messages = Array.from({ length: 3 }, (_, i) => ({
213
+ to: `user${i}@example.com`,
214
+ subject: `Msg ${i}`,
215
+ body: `Body ${i}`,
216
+ }));
217
+ const result = await emailClient.sendBatch(messages);
218
+ assert.strictEqual(result.results.length, 3);
219
+ for (const r of result.results) {
220
+ assert.strictEqual(r.status, 'success');
221
+ }
222
+ const dataDir = join(process.cwd(), '.bb-data', 'root-batchok');
223
+ const stored = JSON.parse(readFileSync(join(dataDir, 'emails.json'), 'utf8'));
224
+ assert.strictEqual(stored.length, 3);
225
+ });
226
+ // ── File persistence ────────────────────────────────────────────────────────
227
+ test('emails persist across instances', async () => {
228
+ const emailClient1 = new EmailClient({ id: 'root' }, 'persist', {
229
+ fromAddress: 'noreply@example.com',
230
+ });
231
+ await emailClient1.send({ to: 'user@example.com', subject: 'First', body: 'First body' });
232
+ // New instance with same scope path reads from disk
233
+ const emailClient2 = new EmailClient({ id: 'root' }, 'persist', {
234
+ fromAddress: 'noreply@example.com',
235
+ });
236
+ await emailClient2.send({ to: 'user2@example.com', subject: 'Second', body: 'Second body' });
237
+ const dataDir = join(process.cwd(), '.bb-data', 'root-persist');
238
+ const stored = JSON.parse(readFileSync(join(dataDir, 'emails.json'), 'utf8'));
239
+ assert.strictEqual(stored.length, 2);
240
+ assert.strictEqual(stored[0].subject, 'First');
241
+ assert.strictEqual(stored[1].subject, 'Second');
242
+ });
243
+ // ── Message size limit ──────────────────────────────────────────────────────
244
+ test('rejects messages exceeding 40 MB', async () => {
245
+ const emailClient = new EmailClient({ id: 'root' }, 'big', {
246
+ fromAddress: 'noreply@example.com',
247
+ });
248
+ const bigBody = 'x'.repeat(41 * 1024 * 1024);
249
+ await assert.rejects(() => emailClient.send({ to: 'user@example.com', subject: 'Big', body: bigBody }), (err) => err.name === EmailErrors.SendFailed);
250
+ });
251
+ // ── Error constants ─────────────────────────────────────────────────────────
252
+ test('EmailErrors has expected constants', () => {
253
+ assert.strictEqual(EmailErrors.SendFailed, 'EmailSendFailedException');
254
+ assert.strictEqual(EmailErrors.InvalidInput, 'InvalidInputException');
255
+ assert.strictEqual(EmailErrors.DomainNotVerified, 'DomainNotVerifiedException');
256
+ assert.strictEqual(EmailErrors.AccountPaused, 'AccountSendingPausedException');
257
+ assert.strictEqual(EmailErrors.RateLimited, 'RateLimitedException');
258
+ });
259
+ // ── fullId generation ───────────────────────────────────────────────────────
260
+ test('fullId generation with parent', () => {
261
+ const emailClient = new EmailClient({ id: 'parent' }, 'child', {
262
+ fromAddress: 'noreply@example.com',
263
+ });
264
+ assert.strictEqual(emailClient.fullId, 'parent-child');
265
+ });
266
+ // ── messageId uniqueness ────────────────────────────────────────────────────
267
+ test('each send returns a unique messageId', async () => {
268
+ const emailClient = new EmailClient({ id: 'root' }, 'unique', {
269
+ fromAddress: 'noreply@example.com',
270
+ });
271
+ const r1 = await emailClient.send({ to: 'user@example.com', subject: 'A', body: 'a' });
272
+ const r2 = await emailClient.send({ to: 'user@example.com', subject: 'B', body: 'b' });
273
+ assert.notStrictEqual(r1.messageId, r2.messageId);
274
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Shared types for Email. Imported by mock, aws, cdk, and browser entry points.
3
+ * This file has zero runtime dependencies — types only.
4
+ */
5
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
6
+ /**
7
+ * Configuration options for the Email building block instance.
8
+ *
9
+ * @param fromAddress - The verified sender email address (e.g., "noreply@example.com").
10
+ * @param replyTo - Optional reply-to address(es).
11
+ * @param configurationSet - Optional SES configuration set name for tracking.
12
+ */
13
+ export interface EmailOptions {
14
+ /** The verified sender email address. */
15
+ fromAddress: string;
16
+ /** Optional reply-to address(es). */
17
+ replyTo?: string[];
18
+ /** Optional SES configuration set name for tracking/events. */
19
+ configurationSet?: string;
20
+ /** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
21
+ logger?: ChildLogger;
22
+ }
23
+ /**
24
+ * A complete email message used for both `send()` and `sendBatch()`.
25
+ *
26
+ * @param to - Recipient email address(es).
27
+ * @param subject - The email subject line.
28
+ * @param body - Plain text body content.
29
+ * @param html - Optional HTML body content.
30
+ * @param cc - Optional CC recipient address(es).
31
+ * @param bcc - Optional BCC recipient address(es).
32
+ */
33
+ export interface EmailMessage {
34
+ /** Recipient email address(es). */
35
+ to: string | string[];
36
+ /** The email subject line. */
37
+ subject: string;
38
+ /** Plain text body content. */
39
+ body: string;
40
+ /** Optional HTML body content. */
41
+ html?: string;
42
+ /** Optional CC recipient address(es). */
43
+ cc?: string[];
44
+ /** Optional BCC recipient address(es). */
45
+ bcc?: string[];
46
+ }
47
+ /**
48
+ * Result of a `send()` operation.
49
+ *
50
+ * @param messageId - The SES message ID for the sent email.
51
+ */
52
+ export interface SendResult {
53
+ /** The SES message ID for the sent email. */
54
+ messageId: string;
55
+ }
56
+ /**
57
+ * Result of a `sendBatch()` operation with per-entry status.
58
+ *
59
+ * The `results` array is in the same order as the input `messages` array,
60
+ * so callers can correlate each result to its corresponding input message by index.
61
+ *
62
+ * @param results - Array of per-message results matching input order.
63
+ */
64
+ export interface SendBatchResult {
65
+ /** Per-message results in the same order as the input messages array. */
66
+ results: Array<{
67
+ /** Whether this message was sent successfully or failed permanently. */
68
+ status: 'success' | 'failed';
69
+ /** The SES message ID, present when status is 'success'. */
70
+ messageId?: string;
71
+ /** Error description, present when status is 'failed'. */
72
+ error?: string;
73
+ }>;
74
+ }
75
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC5B,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,+DAA+D;IAC/D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,yGAAyG;IACzG,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC5B,mCAAmC;IACnC,EAAE,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACtB,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC;IACd,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IAC1B,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC/B,yEAAyE;IACzE,OAAO,EAAE,KAAK,CAAC;QACd,wEAAwE;QACxE,MAAM,EAAE,SAAS,GAAG,QAAQ,CAAC;QAC7B,4DAA4D;QAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,0DAA0D;QAC1D,KAAK,CAAC,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;CACH"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare const BB_NAME = "EmailClient";
2
+ export declare const BB_VERSION = "0.1.0";
3
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,OAAO,gBAAgB,CAAC;AACrC,eAAO,MAAM,UAAU,UAAU,CAAC"}
@@ -0,0 +1,3 @@
1
+ // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
+ export const BB_NAME = 'EmailClient';
3
+ export const BB_VERSION = '0.1.0';