@upyo/ses 0.2.0-dev.19

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/README.md ADDED
@@ -0,0 +1,255 @@
1
+ <!-- deno-fmt-ignore-file -->
2
+
3
+ @upyo/ses
4
+ =========
5
+
6
+ [![JSR][JSR badge]][JSR]
7
+ [![npm][npm badge]][npm]
8
+
9
+ [Amazon SES] transport for the [Upyo] email library.
10
+
11
+ [JSR]: https://jsr.io/@upyo/ses
12
+ [JSR badge]: https://jsr.io/badges/@upyo/ses
13
+ [npm]: https://www.npmjs.com/package/@upyo/ses
14
+ [npm badge]: https://img.shields.io/npm/v/@upyo/ses?logo=npm
15
+ [Amazon SES]: https://aws.amazon.com/ses/
16
+ [Upyo]: https://upyo.org/
17
+
18
+
19
+ Features
20
+ --------
21
+
22
+ - **Multiple Authentication Methods**: Support for AWS credentials and session
23
+ tokens
24
+ - **Zero Dependencies**: Uses built-in APIs for cross-runtime compatibility
25
+ - **AWS Signature v4**: Full implementation of AWS authentication protocol
26
+ - **Rich Message Support**: HTML, text, attachments, tags, and priority
27
+ handling
28
+ - **Cross-Runtime**: Works on Node.js, Deno, Bun, and edge functions
29
+ - **Type Safety**: Discriminated union types for mutually exclusive
30
+ authentication methods
31
+
32
+
33
+ Installation
34
+ ------------
35
+
36
+ ~~~~ sh
37
+ npm add @upyo/core @upyo/ses
38
+ pnpm add @upyo/core @upyo/ses
39
+ yarn add @upyo/core @upyo/ses
40
+ deno add --jsr @upyo/core @upyo/ses
41
+ bun add @upyo/core @upyo/ses
42
+ ~~~~
43
+
44
+
45
+ Usage
46
+ -----
47
+
48
+ ### Basic Usage with AWS Credentials
49
+
50
+ ~~~~ typescript
51
+ import { createMessage } from "@upyo/core";
52
+ import { SesTransport } from "@upyo/ses";
53
+
54
+ const transport = new SesTransport({
55
+ authentication: {
56
+ type: "credentials",
57
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
58
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
59
+ },
60
+ region: "us-east-1",
61
+ });
62
+
63
+ const message = createMessage({
64
+ from: "sender@example.com",
65
+ to: "recipient@example.net",
66
+ subject: "Hello from Upyo!",
67
+ content: { text: "This is a test email sent via Amazon SES." },
68
+ });
69
+
70
+ const receipt = await transport.send(message);
71
+ if (receipt.successful) {
72
+ console.log("Message sent with ID:", receipt.messageId);
73
+ } else {
74
+ console.error("Send failed:", receipt.errorMessages.join(", "));
75
+ }
76
+ ~~~~
77
+
78
+ ### Using Session Token (Temporary Credentials)
79
+
80
+ ~~~~ typescript
81
+ import { SesTransport } from "@upyo/ses";
82
+
83
+ const transport = new SesTransport({
84
+ authentication: {
85
+ type: "session",
86
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
87
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
88
+ sessionToken: process.env.AWS_SESSION_TOKEN!,
89
+ },
90
+ region: "us-west-2",
91
+ });
92
+ ~~~~
93
+
94
+ ### Using IAM Roles
95
+
96
+ For IAM role-based authentication, perform the AssumeRole operation externally
97
+ (e.g., using AWS CLI or SDK) and use the resulting temporary credentials with
98
+ the `session` authentication type:
99
+
100
+ ~~~~ typescript
101
+ import { SesTransport } from "@upyo/ses";
102
+
103
+ // First, assume the role externally:
104
+ // aws sts assume-role --role-arn "arn:aws:iam::123456789012:role/SesRole" --role-session-name "ses-session"
105
+
106
+ const transport = new SesTransport({
107
+ authentication: {
108
+ type: "session",
109
+ accessKeyId: "ASIAXYZ...", // From AssumeRole response
110
+ secretAccessKey: "abc123...", // From AssumeRole response
111
+ sessionToken: "FwoGZXIv...", // From AssumeRole response
112
+ },
113
+ region: "eu-west-1",
114
+ });
115
+ ~~~~
116
+
117
+ ### HTML Email with Attachments
118
+
119
+ ~~~~ typescript
120
+ import { createMessage } from "@upyo/core";
121
+ import { SesTransport } from "@upyo/ses";
122
+ import fs from "node:fs/promises";
123
+
124
+ const transport = new SesTransport({
125
+ authentication: {
126
+ type: "credentials",
127
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
128
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
129
+ },
130
+ });
131
+
132
+ const message = createMessage({
133
+ from: { address: "sender@example.com", name: "John Sender" },
134
+ to: [
135
+ { address: "recipient1@example.net", name: "Jane Doe" },
136
+ "recipient2@example.net",
137
+ ],
138
+ cc: "manager@example.com",
139
+ replyTo: "support@example.com",
140
+ subject: "Monthly Report",
141
+ content: {
142
+ html: "<h1>Monthly Report</h1><p>Please find the report attached.</p>",
143
+ text: "Monthly Report\n\nPlease find the report attached.",
144
+ },
145
+ attachments: [
146
+ new File(
147
+ [await fs.readFile("report.pdf")],
148
+ "report.pdf",
149
+ { type: "application/pdf" }
150
+ ),
151
+ ],
152
+ tags: ["report", "monthly"],
153
+ priority: "high",
154
+ });
155
+
156
+ const receipt = await transport.send(message);
157
+ ~~~~
158
+
159
+ ### Bulk Sending
160
+
161
+ ~~~~ typescript
162
+ import { SesTransport } from "@upyo/ses";
163
+
164
+ const transport = new SesTransport({
165
+ authentication: {
166
+ type: "credentials",
167
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
168
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
169
+ },
170
+ });
171
+
172
+ const messages = [
173
+ createMessage({ /* message 1 */ }),
174
+ createMessage({ /* message 2 */ }),
175
+ createMessage({ /* message 3 */ }),
176
+ ];
177
+
178
+ for await (const receipt of transport.sendMany(messages)) {
179
+ if (receipt.successful) {
180
+ console.log("Sent:", receipt.messageId);
181
+ } else {
182
+ console.error("Failed:", receipt.errorMessages);
183
+ }
184
+ }
185
+ ~~~~
186
+
187
+
188
+ Configuration
189
+ -------------
190
+
191
+ | Option | Type | Default | Description |
192
+ | ---------------------- | ------------------------ | ------------- | --------------------------------------------------------------- |
193
+ | `authentication` | `SesAuthentication` | Required | Authentication configuration (credentials or session) |
194
+ | `region` | `string` | `"us-east-1"` | AWS region for SES API endpoints |
195
+ | `timeout` | `number` | `30000` | HTTP request timeout in milliseconds |
196
+ | `retries` | `number` | `3` | Number of retry attempts for failed requests |
197
+ | `validateSsl` | `boolean` | `true` | Whether to validate SSL certificates |
198
+ | `headers` | `Record<string, string>` | `{}` | Additional HTTP headers to include |
199
+ | `configurationSetName` | `string` | `undefined` | SES configuration set name for tracking |
200
+ | `defaultTags` | `Record<string, string>` | `{}` | Default tags to apply to all messages |
201
+ | `batchSize` | `number` | `50` | Maximum number of messages to send concurrently in `sendMany()` |
202
+
203
+ ### Authentication Types
204
+
205
+ The `authentication` field accepts one of two mutually exclusive types:
206
+
207
+ #### Credentials Authentication
208
+ ~~~~ typescript
209
+ {
210
+ type: "credentials",
211
+ accessKeyId: string,
212
+ secretAccessKey: string,
213
+ }
214
+ ~~~~
215
+
216
+ #### Session Token Authentication
217
+ ~~~~ typescript
218
+ {
219
+ type: "session",
220
+ accessKeyId: string,
221
+ secretAccessKey: string,
222
+ sessionToken: string,
223
+ }
224
+ ~~~~
225
+
226
+ > [!NOTE]
227
+ > For IAM role-based authentication, use external tools to perform AssumeRole
228
+ > and provide the resulting temporary credentials via the `session`
229
+ > authentication type.
230
+
231
+
232
+ Error Handling
233
+ --------------
234
+
235
+ The transport returns a `Receipt` object that uses a discriminated union for
236
+ type-safe error handling:
237
+
238
+ ~~~~ typescript
239
+ const receipt = await transport.send(message);
240
+
241
+ if (receipt.successful) {
242
+ // Type is { successful: true; messageId: string }
243
+ console.log("Message ID:", receipt.messageId);
244
+ } else {
245
+ // Type is { successful: false; errorMessages: readonly string[] }
246
+ console.error("Errors:", receipt.errorMessages);
247
+ }
248
+ ~~~~
249
+
250
+ Common SES errors include:
251
+
252
+ - `InvalidParameterValue`: Invalid email addresses or configuration
253
+ - `LimitExceededException`: Send rate or quota limits exceeded
254
+ - `AccountSuspendedException`: SES account suspended
255
+ - `ConfigurationSetDoesNotExistException`: Invalid configuration set name
package/dist/index.cjs ADDED
@@ -0,0 +1,433 @@
1
+
2
+ //#region src/config.ts
3
+ /**
4
+ * Creates a resolved SES configuration with default values applied.
5
+ *
6
+ * This function takes a partial SES configuration and fills in default values
7
+ * for all optional fields, ensuring the transport has all necessary configuration.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * const config = createSesConfig({
12
+ * authentication: {
13
+ * type: "credentials",
14
+ * accessKeyId: "AKIA...",
15
+ * secretAccessKey: "wJal..",
16
+ * },
17
+ * region: "eu-west-1",
18
+ * });
19
+ *
20
+ * // config.timeout is now 30000 (default)
21
+ * // config.retries is now 3 (default)
22
+ * // config.batchSize is now 50 (default)
23
+ * ```
24
+ *
25
+ * @param config The SES configuration object.
26
+ * @returns A resolved configuration with all defaults applied.
27
+ */
28
+ function createSesConfig(config) {
29
+ return {
30
+ authentication: config.authentication,
31
+ region: config.region ?? "us-east-1",
32
+ timeout: config.timeout ?? 3e4,
33
+ retries: config.retries ?? 3,
34
+ validateSsl: config.validateSsl ?? true,
35
+ headers: config.headers ?? {},
36
+ configurationSetName: config.configurationSetName,
37
+ defaultTags: config.defaultTags ?? {},
38
+ batchSize: config.batchSize ?? 50
39
+ };
40
+ }
41
+
42
+ //#endregion
43
+ //#region src/http-client.ts
44
+ var SesHttpClient = class {
45
+ config;
46
+ constructor(config) {
47
+ this.config = config;
48
+ }
49
+ sendMessage(messageData, signal) {
50
+ const region = this.config.region;
51
+ const url = `https://email.${region}.amazonaws.com/v2/email/outbound-emails`;
52
+ return this.makeRequest(url, {
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify(messageData),
56
+ signal
57
+ });
58
+ }
59
+ async makeRequest(url, options) {
60
+ let lastError = null;
61
+ for (let attempt = 0; attempt <= this.config.retries; attempt++) try {
62
+ const response = await this.fetchWithAuth(url, options);
63
+ const text = await response.text();
64
+ if (response.status === 200) return {
65
+ statusCode: response.status,
66
+ body: text,
67
+ headers: this.headersToRecord(response.headers)
68
+ };
69
+ let errorData;
70
+ try {
71
+ errorData = JSON.parse(text);
72
+ } catch {
73
+ errorData = { message: text || `HTTP ${response.status}` };
74
+ }
75
+ throw new SesApiError(errorData.message || `HTTP ${response.status}`, response.status, errorData.errors);
76
+ } catch (error) {
77
+ lastError = error instanceof Error ? error : new Error(String(error));
78
+ if (error instanceof SesApiError && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) throw error;
79
+ if (attempt === this.config.retries) throw error;
80
+ const delay = Math.pow(2, attempt) * 1e3;
81
+ await new Promise((resolve) => setTimeout(resolve, delay));
82
+ }
83
+ throw lastError || /* @__PURE__ */ new Error("Request failed after all retries");
84
+ }
85
+ async fetchWithAuth(url, options) {
86
+ const credentials = await this.getCredentials();
87
+ const headers = new Headers(options.headers);
88
+ const signedHeaders = await this.signRequest(url, {
89
+ ...options,
90
+ headers
91
+ }, credentials);
92
+ for (const [key, value] of Object.entries(this.config.headers)) signedHeaders.set(key, value);
93
+ const controller = new AbortController();
94
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
95
+ let signal = controller.signal;
96
+ if (options.signal) {
97
+ signal = options.signal;
98
+ if (options.signal.aborted) controller.abort();
99
+ else options.signal.addEventListener("abort", () => controller.abort());
100
+ }
101
+ try {
102
+ return await globalThis.fetch(url, {
103
+ ...options,
104
+ headers: signedHeaders,
105
+ signal
106
+ });
107
+ } finally {
108
+ clearTimeout(timeoutId);
109
+ }
110
+ }
111
+ async getCredentials() {
112
+ const auth = this.config.authentication;
113
+ switch (auth.type) {
114
+ case "credentials": return {
115
+ accessKeyId: auth.accessKeyId,
116
+ secretAccessKey: auth.secretAccessKey
117
+ };
118
+ case "session": return {
119
+ accessKeyId: auth.accessKeyId,
120
+ secretAccessKey: auth.secretAccessKey,
121
+ sessionToken: auth.sessionToken
122
+ };
123
+ default: throw new Error(`Unsupported authentication type: ${auth.type}`);
124
+ }
125
+ }
126
+ async signRequest(url, options, credentials) {
127
+ const parsedUrl = new URL(url);
128
+ const method = options.method || "GET";
129
+ const headers = new Headers(options.headers);
130
+ const host = parsedUrl.host;
131
+ const path = parsedUrl.pathname + parsedUrl.search;
132
+ const service = "ses";
133
+ const region = this.config.region;
134
+ const now = /* @__PURE__ */ new Date();
135
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
136
+ const dateStamp = amzDate.substring(0, 8);
137
+ headers.set("Host", host);
138
+ headers.set("X-Amz-Date", amzDate);
139
+ if (credentials.sessionToken) headers.set("X-Amz-Security-Token", credentials.sessionToken);
140
+ const signedHeaders = "host;x-amz-date" + (credentials.sessionToken ? ";x-amz-security-token" : "");
141
+ const canonicalHeaders = `host:${host}\nx-amz-date:${amzDate}\n` + (credentials.sessionToken ? `x-amz-security-token:${credentials.sessionToken}\n` : "");
142
+ const payloadHash = await this.sha256(options.body || "");
143
+ const canonicalRequest = `${method}\n${path}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`;
144
+ const algorithm = "AWS4-HMAC-SHA256";
145
+ const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
146
+ const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${await this.sha256(canonicalRequest)}`;
147
+ const signingKey = await this.getSignatureKey(credentials.secretAccessKey, dateStamp, region, service);
148
+ const signature = await this.hmacSha256(signingKey, stringToSign);
149
+ const authorizationHeader = `${algorithm} Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
150
+ headers.set("Authorization", authorizationHeader);
151
+ return headers;
152
+ }
153
+ async sha256(data) {
154
+ const encoder = new TextEncoder();
155
+ const dataBuffer = encoder.encode(data);
156
+ const hashBuffer = await crypto.subtle.digest("SHA-256", dataBuffer);
157
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
158
+ }
159
+ async hmacSha256(key, data) {
160
+ const encoder = new TextEncoder();
161
+ const dataBuffer = encoder.encode(data);
162
+ const cryptoKey = await crypto.subtle.importKey("raw", key, {
163
+ name: "HMAC",
164
+ hash: "SHA-256"
165
+ }, false, ["sign"]);
166
+ const signature = await crypto.subtle.sign("HMAC", cryptoKey, dataBuffer);
167
+ return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join("");
168
+ }
169
+ async getSignatureKey(key, dateStamp, regionName, serviceName) {
170
+ const encoder = new TextEncoder();
171
+ let keyBuffer = new Uint8Array(encoder.encode("AWS4" + key)).buffer;
172
+ keyBuffer = await this.hmacSha256Buffer(keyBuffer, dateStamp);
173
+ keyBuffer = await this.hmacSha256Buffer(keyBuffer, regionName);
174
+ keyBuffer = await this.hmacSha256Buffer(keyBuffer, serviceName);
175
+ keyBuffer = await this.hmacSha256Buffer(keyBuffer, "aws4_request");
176
+ return keyBuffer;
177
+ }
178
+ async hmacSha256Buffer(key, data) {
179
+ const encoder = new TextEncoder();
180
+ const dataBuffer = encoder.encode(data);
181
+ const cryptoKey = await crypto.subtle.importKey("raw", key, {
182
+ name: "HMAC",
183
+ hash: "SHA-256"
184
+ }, false, ["sign"]);
185
+ return await crypto.subtle.sign("HMAC", cryptoKey, dataBuffer);
186
+ }
187
+ headersToRecord(headers) {
188
+ const record = {};
189
+ for (const [key, value] of headers.entries()) record[key] = value;
190
+ return record;
191
+ }
192
+ };
193
+ var SesApiError = class extends Error {
194
+ statusCode;
195
+ errors;
196
+ constructor(message, statusCode, errors) {
197
+ super(message);
198
+ this.name = "SesApiError";
199
+ this.statusCode = statusCode;
200
+ this.errors = errors;
201
+ }
202
+ };
203
+
204
+ //#endregion
205
+ //#region src/message-converter.ts
206
+ async function convertMessage(message, config) {
207
+ const destination = {};
208
+ if (message.recipients.length > 0) destination.ToAddresses = message.recipients.map(formatAddress);
209
+ if (message.ccRecipients.length > 0) destination.CcAddresses = message.ccRecipients.map(formatAddress);
210
+ if (message.bccRecipients.length > 0) destination.BccAddresses = message.bccRecipients.map(formatAddress);
211
+ const sesMessage = {
212
+ Destination: destination,
213
+ Content: {},
214
+ FromEmailAddress: formatAddress(message.sender)
215
+ };
216
+ if (message.replyRecipients.length > 0) sesMessage.ReplyToAddresses = message.replyRecipients.map(formatAddress);
217
+ if (config.configurationSetName) sesMessage.ConfigurationSetName = config.configurationSetName;
218
+ sesMessage.Content.Simple = await createSimpleContent(message);
219
+ const tags = [];
220
+ if (message.tags.length > 0) for (const tag of message.tags) tags.push({
221
+ Name: "category",
222
+ Value: tag
223
+ });
224
+ if (message.priority !== "normal") tags.push({
225
+ Name: "priority",
226
+ Value: message.priority
227
+ });
228
+ for (const [name, value] of Object.entries(config.defaultTags)) tags.push({
229
+ Name: name,
230
+ Value: value
231
+ });
232
+ if (tags.length > 0) sesMessage.Tags = tags;
233
+ return sesMessage;
234
+ }
235
+ async function createSimpleContent(message) {
236
+ const content = {
237
+ Subject: {
238
+ Data: message.subject,
239
+ Charset: "UTF-8"
240
+ },
241
+ Body: {}
242
+ };
243
+ if ("html" in message.content) {
244
+ content.Body.Html = {
245
+ Data: message.content.html,
246
+ Charset: "UTF-8"
247
+ };
248
+ if (message.content.text) content.Body.Text = {
249
+ Data: message.content.text,
250
+ Charset: "UTF-8"
251
+ };
252
+ } else content.Body.Text = {
253
+ Data: message.content.text,
254
+ Charset: "UTF-8"
255
+ };
256
+ if (message.attachments.length > 0) content.Attachments = await Promise.all(message.attachments.map(async (attachment) => {
257
+ const contentBytes = await attachment.content;
258
+ const base64Content = btoa(String.fromCharCode(...contentBytes));
259
+ return {
260
+ FileName: attachment.filename,
261
+ ContentType: attachment.contentType || "application/octet-stream",
262
+ ContentDisposition: attachment.inline ? "INLINE" : "ATTACHMENT",
263
+ ContentId: attachment.contentId,
264
+ ContentTransferEncoding: "BASE64",
265
+ RawContent: base64Content
266
+ };
267
+ }));
268
+ return content;
269
+ }
270
+ function formatAddress(address) {
271
+ if (address.name) return `"${address.name}" <${address.address}>`;
272
+ return address.address;
273
+ }
274
+
275
+ //#endregion
276
+ //#region src/ses-transport.ts
277
+ /**
278
+ * Amazon SES email transport implementation.
279
+ *
280
+ * This transport sends emails through the AWS Simple Email Service (SES) using
281
+ * the v2 API. It supports AWS Signature v4 authentication, configurable
282
+ * retries, and concurrent batch sending.
283
+ *
284
+ * @example
285
+ * ```typescript
286
+ * import { SesTransport } from "@upyo/ses";
287
+ * import { createMessage } from "@upyo/core";
288
+ *
289
+ * const transport = new SesTransport({
290
+ * authentication: {
291
+ * type: "credentials",
292
+ * accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
293
+ * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
294
+ * },
295
+ * region: "us-east-1",
296
+ * });
297
+ *
298
+ * const message = createMessage({
299
+ * from: "sender@example.com",
300
+ * to: "recipient@example.com",
301
+ * subject: "Hello from SES",
302
+ * content: { text: "This is a test message" },
303
+ * });
304
+ *
305
+ * const receipt = await transport.send(message);
306
+ * if (receipt.successful) {
307
+ * console.log("Email sent with ID:", receipt.messageId);
308
+ * }
309
+ * ```
310
+ */
311
+ var SesTransport = class {
312
+ /** Resolved configuration with defaults applied */
313
+ config;
314
+ /** HTTP client for SES API requests */
315
+ httpClient;
316
+ /**
317
+ * Creates a new SES transport instance.
318
+ *
319
+ * @param config SES configuration options.
320
+ */
321
+ constructor(config) {
322
+ this.config = createSesConfig(config);
323
+ this.httpClient = new SesHttpClient(this.config);
324
+ }
325
+ /**
326
+ * Sends a single email message through Amazon SES.
327
+ *
328
+ * This method converts the message to SES format, sends it via the SES v2 API,
329
+ * and returns a receipt indicating success or failure.
330
+ *
331
+ * @example
332
+ * ```typescript
333
+ * const message = createMessage({
334
+ * from: "sender@example.com",
335
+ * to: "recipient@example.com",
336
+ * subject: "Hello",
337
+ * content: { text: "Hello, world!" },
338
+ * attachments: [{
339
+ * filename: "document.pdf",
340
+ * content: Promise.resolve(pdfBytes),
341
+ * contentType: "application/pdf",
342
+ * }],
343
+ * });
344
+ *
345
+ * const receipt = await transport.send(message);
346
+ * ```
347
+ *
348
+ * @param message The email message to send.
349
+ * @param options Optional transport options (e.g., abort signal).
350
+ * @returns A promise that resolves to a receipt with the result.
351
+ */
352
+ async send(message, options) {
353
+ options?.signal?.throwIfAborted();
354
+ try {
355
+ const sesMessage = await convertMessage(message, this.config);
356
+ options?.signal?.throwIfAborted();
357
+ const response = await this.httpClient.sendMessage(sesMessage, options?.signal);
358
+ const messageId = this.extractMessageId(response);
359
+ return {
360
+ successful: true,
361
+ messageId
362
+ };
363
+ } catch (error) {
364
+ const errorMessage = error instanceof Error ? error.message : String(error);
365
+ return {
366
+ successful: false,
367
+ errorMessages: [errorMessage]
368
+ };
369
+ }
370
+ }
371
+ /**
372
+ * Sends multiple email messages concurrently through Amazon SES.
373
+ *
374
+ * This method processes messages in batches (configurable via `batchSize`)
375
+ * and sends each batch concurrently to improve performance. It yields
376
+ * receipts as they become available, allowing for streaming processing of
377
+ * results.
378
+ *
379
+ * @example
380
+ * ```typescript
381
+ * const messages = [
382
+ * createMessage({ from: "sender@example.com", to: "user1@example.com", subject: "Hello 1", content: { text: "Message 1" } }),
383
+ * createMessage({ from: "sender@example.com", to: "user2@example.com", subject: "Hello 2", content: { text: "Message 2" } }),
384
+ * createMessage({ from: "sender@example.com", to: "user3@example.com", subject: "Hello 3", content: { text: "Message 3" } }),
385
+ * ];
386
+ *
387
+ * for await (const receipt of transport.sendMany(messages)) {
388
+ * if (receipt.successful) {
389
+ * console.log("Sent:", receipt.messageId);
390
+ * } else {
391
+ * console.error("Failed:", receipt.errorMessages);
392
+ * }
393
+ * }
394
+ * ```
395
+ *
396
+ * @param messages An iterable or async iterable of messages to send.
397
+ * @param options Optional transport options (e.g., abort signal).
398
+ * @returns Individual receipts for each message as they complete.
399
+ */
400
+ async *sendMany(messages, options) {
401
+ options?.signal?.throwIfAborted();
402
+ const isAsyncIterable = Symbol.asyncIterator in messages;
403
+ const messageArray = [];
404
+ if (isAsyncIterable) for await (const message of messages) messageArray.push(message);
405
+ else for (const message of messages) messageArray.push(message);
406
+ const batchSize = this.config.batchSize;
407
+ for (let i = 0; i < messageArray.length; i += batchSize) {
408
+ options?.signal?.throwIfAborted();
409
+ const batch = messageArray.slice(i, i + batchSize);
410
+ const receipts = await this.sendConcurrent(batch, options);
411
+ for (const receipt of receipts) yield receipt;
412
+ }
413
+ }
414
+ async sendConcurrent(messages, options) {
415
+ options?.signal?.throwIfAborted();
416
+ const sendPromises = messages.map((message) => this.send(message, options));
417
+ return await Promise.all(sendPromises);
418
+ }
419
+ extractMessageId(response) {
420
+ if (response.body) try {
421
+ const parsed = JSON.parse(response.body);
422
+ if (parsed.MessageId) return parsed.MessageId;
423
+ } catch {}
424
+ const messageIdHeader = response.headers?.["x-amzn-requestid"] || response.headers?.["X-Amzn-RequestId"];
425
+ if (messageIdHeader) return messageIdHeader;
426
+ const timestamp = Date.now();
427
+ const random = Math.random().toString(36).substring(2, 8);
428
+ return `ses-${timestamp}-${random}`;
429
+ }
430
+ };
431
+
432
+ //#endregion
433
+ exports.SesTransport = SesTransport;