@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 +255 -0
- package/dist/index.cjs +433 -0
- package/dist/index.d.cts +258 -0
- package/dist/index.d.ts +258 -0
- package/dist/index.js +432 -0
- package/package.json +72 -0
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;
|