@flink-app/email-plugin 2.0.0-alpha.73 → 2.0.0-alpha.75
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/CHANGELOG.md +14 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +18 -1
- package/dist/schemas/client.d.ts +2 -1
- package/dist/schemas/emailSes.d.ts +60 -0
- package/dist/schemas/emailSes.js +2 -0
- package/dist/ses-types.d.ts +94 -0
- package/dist/ses-types.js +2 -0
- package/dist/sesClient.d.ts +48 -0
- package/dist/sesClient.js +388 -0
- package/package.json +7 -5
- package/readme.md +199 -4
- package/spec/sesClient.spec.ts +464 -0
- package/spec/support/jasmine.json +7 -0
- package/src/index.ts +4 -1
- package/src/schemas/client.ts +2 -1
- package/src/schemas/emailSes.ts +73 -0
- package/src/ses-types.ts +93 -0
- package/src/sesClient.ts +352 -0
package/src/sesClient.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESv2Client,
|
|
3
|
+
SendEmailCommand,
|
|
4
|
+
SendBulkEmailCommand,
|
|
5
|
+
GetAccountCommand,
|
|
6
|
+
type SendEmailCommandInput,
|
|
7
|
+
type SendBulkEmailCommandInput,
|
|
8
|
+
type MessageTag,
|
|
9
|
+
} from "@aws-sdk/client-sesv2";
|
|
10
|
+
import { client } from "./schemas/client";
|
|
11
|
+
import { emailSes } from "./schemas/emailSes";
|
|
12
|
+
import {
|
|
13
|
+
SesTemplatedEmailParams,
|
|
14
|
+
SesBulkEmailParams,
|
|
15
|
+
SesResult,
|
|
16
|
+
SesBulkResult,
|
|
17
|
+
SesSetupStatus,
|
|
18
|
+
} from "./ses-types";
|
|
19
|
+
|
|
20
|
+
export interface sesClientOptions {
|
|
21
|
+
/** AWS region (defaults to AWS_REGION env var) */
|
|
22
|
+
region?: string;
|
|
23
|
+
/** Explicit credentials. Omit to use default AWS credential chain (env vars, IAM roles, etc.) */
|
|
24
|
+
credentials?: {
|
|
25
|
+
accessKeyId: string;
|
|
26
|
+
secretAccessKey: string;
|
|
27
|
+
sessionToken?: string;
|
|
28
|
+
};
|
|
29
|
+
/** Default "from" address when not specified per-email */
|
|
30
|
+
defaultFrom?: string;
|
|
31
|
+
/** Default SES configuration set name */
|
|
32
|
+
configurationSet?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class sesClient implements client {
|
|
36
|
+
private awsClient: SESv2Client;
|
|
37
|
+
private defaultFrom?: string;
|
|
38
|
+
private defaultConfigurationSet?: string;
|
|
39
|
+
|
|
40
|
+
constructor(options: sesClientOptions, awsClient?: SESv2Client) {
|
|
41
|
+
this.defaultFrom = options.defaultFrom;
|
|
42
|
+
this.defaultConfigurationSet = options.configurationSet;
|
|
43
|
+
|
|
44
|
+
this.awsClient =
|
|
45
|
+
awsClient ??
|
|
46
|
+
new SESv2Client({
|
|
47
|
+
region: options.region,
|
|
48
|
+
credentials: options.credentials,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Send an email via SES. Implements the base `client` interface.
|
|
54
|
+
* Returns `true` on success, `false` on failure.
|
|
55
|
+
* For richer results (messageId, error details), use `sendEmail()` instead.
|
|
56
|
+
*/
|
|
57
|
+
async send(email: emailSes): Promise<boolean> {
|
|
58
|
+
const result = await this.sendEmail(email);
|
|
59
|
+
return result.success;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Send an email via SES with rich result including messageId and error details.
|
|
64
|
+
*/
|
|
65
|
+
async sendEmail(email: emailSes): Promise<SesResult> {
|
|
66
|
+
const from = email.from || this.defaultFrom;
|
|
67
|
+
if (!from) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: {
|
|
71
|
+
code: "MISSING_FROM",
|
|
72
|
+
message:
|
|
73
|
+
"No 'from' address specified. Set it on the email or use defaultFrom in sesClientOptions.",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const hasAttachments = email.attachments && email.attachments.length > 0;
|
|
80
|
+
|
|
81
|
+
const content = hasAttachments
|
|
82
|
+
? { Raw: { Data: this.buildRawMimeMessage(email, from) } }
|
|
83
|
+
: {
|
|
84
|
+
Simple: {
|
|
85
|
+
Subject: { Data: email.subject, Charset: "UTF-8" },
|
|
86
|
+
Body: {
|
|
87
|
+
...(email.text
|
|
88
|
+
? { Text: { Data: email.text, Charset: "UTF-8" } }
|
|
89
|
+
: {}),
|
|
90
|
+
...(email.html
|
|
91
|
+
? { Html: { Data: email.html, Charset: "UTF-8" } }
|
|
92
|
+
: {}),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const input: SendEmailCommandInput = {
|
|
98
|
+
FromEmailAddress: from,
|
|
99
|
+
Destination: {
|
|
100
|
+
ToAddresses: email.to,
|
|
101
|
+
CcAddresses: email.cc,
|
|
102
|
+
BccAddresses: email.bcc,
|
|
103
|
+
},
|
|
104
|
+
ReplyToAddresses: email.replyTo,
|
|
105
|
+
ConfigurationSetName:
|
|
106
|
+
email.configurationSet || this.defaultConfigurationSet,
|
|
107
|
+
EmailTags: this.toMessageTags(email.tags),
|
|
108
|
+
Content: content,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const response = await this.awsClient.send(new SendEmailCommand(input));
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
success: true,
|
|
115
|
+
messageId: response.MessageId,
|
|
116
|
+
};
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
return {
|
|
119
|
+
success: false,
|
|
120
|
+
error: {
|
|
121
|
+
code: err.name || err.code || "UNKNOWN",
|
|
122
|
+
message: err.message || "Unknown error",
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Send a templated email using an SES template.
|
|
130
|
+
*/
|
|
131
|
+
async sendTemplated(params: SesTemplatedEmailParams): Promise<SesResult> {
|
|
132
|
+
const from = params.from || this.defaultFrom;
|
|
133
|
+
if (!from) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
error: {
|
|
137
|
+
code: "MISSING_FROM",
|
|
138
|
+
message:
|
|
139
|
+
"No 'from' address specified. Set it on params or use defaultFrom in sesClientOptions.",
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const input: SendEmailCommandInput = {
|
|
146
|
+
FromEmailAddress: from,
|
|
147
|
+
Destination: {
|
|
148
|
+
ToAddresses: params.to,
|
|
149
|
+
CcAddresses: params.cc,
|
|
150
|
+
BccAddresses: params.bcc,
|
|
151
|
+
},
|
|
152
|
+
ReplyToAddresses: params.replyTo,
|
|
153
|
+
ConfigurationSetName:
|
|
154
|
+
params.configurationSet || this.defaultConfigurationSet,
|
|
155
|
+
EmailTags: this.toMessageTags(params.tags),
|
|
156
|
+
Content: {
|
|
157
|
+
Template: {
|
|
158
|
+
TemplateName: params.template,
|
|
159
|
+
TemplateData: JSON.stringify(params.templateData),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const response = await this.awsClient.send(new SendEmailCommand(input));
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
success: true,
|
|
168
|
+
messageId: response.MessageId,
|
|
169
|
+
};
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
error: {
|
|
174
|
+
code: err.name || err.code || "UNKNOWN",
|
|
175
|
+
message: err.message || "Unknown error",
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Send bulk emails using an SES template.
|
|
183
|
+
*/
|
|
184
|
+
async sendBulk(params: SesBulkEmailParams): Promise<SesBulkResult> {
|
|
185
|
+
const from = params.from || this.defaultFrom;
|
|
186
|
+
if (!from) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
results: [],
|
|
190
|
+
successCount: 0,
|
|
191
|
+
failureCount: params.destinations.length,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const input: SendBulkEmailCommandInput = {
|
|
197
|
+
FromEmailAddress: from,
|
|
198
|
+
ReplyToAddresses: params.replyTo,
|
|
199
|
+
ConfigurationSetName:
|
|
200
|
+
params.configurationSet || this.defaultConfigurationSet,
|
|
201
|
+
DefaultEmailTags: this.toMessageTags(params.tags),
|
|
202
|
+
DefaultContent: {
|
|
203
|
+
Template: {
|
|
204
|
+
TemplateName: params.template,
|
|
205
|
+
TemplateData: params.defaultTemplateData
|
|
206
|
+
? JSON.stringify(params.defaultTemplateData)
|
|
207
|
+
: undefined,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
BulkEmailEntries: params.destinations.map((dest) => ({
|
|
211
|
+
Destination: {
|
|
212
|
+
ToAddresses: dest.to,
|
|
213
|
+
CcAddresses: dest.cc,
|
|
214
|
+
BccAddresses: dest.bcc,
|
|
215
|
+
},
|
|
216
|
+
ReplacementEmailContent: dest.templateData
|
|
217
|
+
? {
|
|
218
|
+
ReplacementTemplate: {
|
|
219
|
+
ReplacementTemplateData: JSON.stringify(dest.templateData),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
: undefined,
|
|
223
|
+
})),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const response = await this.awsClient.send(
|
|
227
|
+
new SendBulkEmailCommand(input)
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const results = (response.BulkEmailEntryResults || []).map((entry) => {
|
|
231
|
+
const success = entry.Status === "SUCCESS";
|
|
232
|
+
return {
|
|
233
|
+
success,
|
|
234
|
+
messageId: success ? entry.MessageId : undefined,
|
|
235
|
+
error: !success
|
|
236
|
+
? {
|
|
237
|
+
code: entry.Error || "UNKNOWN",
|
|
238
|
+
message: entry.Error || "Bulk send entry failed",
|
|
239
|
+
}
|
|
240
|
+
: undefined,
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const successCount = results.filter((r) => r.success).length;
|
|
245
|
+
const failureCount = results.filter((r) => !r.success).length;
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
success: failureCount === 0,
|
|
249
|
+
results,
|
|
250
|
+
successCount,
|
|
251
|
+
failureCount,
|
|
252
|
+
};
|
|
253
|
+
} catch (err: any) {
|
|
254
|
+
return {
|
|
255
|
+
success: false,
|
|
256
|
+
results: [],
|
|
257
|
+
successCount: 0,
|
|
258
|
+
failureCount: params.destinations.length,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Verify that the SES setup is working — checks credentials and account status.
|
|
265
|
+
*/
|
|
266
|
+
async verifySetup(): Promise<SesSetupStatus> {
|
|
267
|
+
try {
|
|
268
|
+
const response = await this.awsClient.send(new GetAccountCommand({}));
|
|
269
|
+
const region =
|
|
270
|
+
(await this.awsClient.config.region()) || "unknown";
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
verified: true,
|
|
274
|
+
sendingEnabled: response.ProductionAccessEnabled === true,
|
|
275
|
+
region,
|
|
276
|
+
};
|
|
277
|
+
} catch (err: any) {
|
|
278
|
+
return {
|
|
279
|
+
verified: false,
|
|
280
|
+
sendingEnabled: false,
|
|
281
|
+
region: "unknown",
|
|
282
|
+
error: err.message || "Failed to verify SES setup",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private toMessageTags(
|
|
288
|
+
tags?: Record<string, string>
|
|
289
|
+
): MessageTag[] | undefined {
|
|
290
|
+
if (!tags) return undefined;
|
|
291
|
+
return Object.entries(tags).map(([Name, Value]) => ({ Name, Value }));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private buildRawMimeMessage(email: emailSes, from: string): Uint8Array {
|
|
295
|
+
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
|
296
|
+
const lines: string[] = [];
|
|
297
|
+
|
|
298
|
+
lines.push(`From: ${from}`);
|
|
299
|
+
lines.push(`To: ${email.to.join(", ")}`);
|
|
300
|
+
if (email.cc?.length) lines.push(`Cc: ${email.cc.join(", ")}`);
|
|
301
|
+
if (email.replyTo?.length) lines.push(`Reply-To: ${email.replyTo.join(", ")}`);
|
|
302
|
+
lines.push(`Subject: =?UTF-8?B?${Buffer.from(email.subject).toString("base64")}?=`);
|
|
303
|
+
lines.push("MIME-Version: 1.0");
|
|
304
|
+
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
305
|
+
lines.push("");
|
|
306
|
+
|
|
307
|
+
// Text part
|
|
308
|
+
if (email.text) {
|
|
309
|
+
lines.push(`--${boundary}`);
|
|
310
|
+
lines.push("Content-Type: text/plain; charset=UTF-8");
|
|
311
|
+
lines.push("Content-Transfer-Encoding: 7bit");
|
|
312
|
+
lines.push("");
|
|
313
|
+
lines.push(email.text);
|
|
314
|
+
lines.push("");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// HTML part
|
|
318
|
+
if (email.html) {
|
|
319
|
+
lines.push(`--${boundary}`);
|
|
320
|
+
lines.push("Content-Type: text/html; charset=UTF-8");
|
|
321
|
+
lines.push("Content-Transfer-Encoding: 7bit");
|
|
322
|
+
lines.push("");
|
|
323
|
+
lines.push(email.html);
|
|
324
|
+
lines.push("");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Attachments
|
|
328
|
+
for (const attachment of email.attachments || []) {
|
|
329
|
+
const contentType = attachment.contentType || "application/octet-stream";
|
|
330
|
+
const base64Content =
|
|
331
|
+
Buffer.isBuffer(attachment.content)
|
|
332
|
+
? attachment.content.toString("base64")
|
|
333
|
+
: attachment.content;
|
|
334
|
+
|
|
335
|
+
lines.push(`--${boundary}`);
|
|
336
|
+
lines.push(
|
|
337
|
+
`Content-Type: ${contentType}; name="${attachment.filename}"`
|
|
338
|
+
);
|
|
339
|
+
lines.push(
|
|
340
|
+
`Content-Disposition: attachment; filename="${attachment.filename}"`
|
|
341
|
+
);
|
|
342
|
+
lines.push("Content-Transfer-Encoding: base64");
|
|
343
|
+
lines.push("");
|
|
344
|
+
lines.push(base64Content);
|
|
345
|
+
lines.push("");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
lines.push(`--${boundary}--`);
|
|
349
|
+
|
|
350
|
+
return new TextEncoder().encode(lines.join("\r\n"));
|
|
351
|
+
}
|
|
352
|
+
}
|