@flink-app/email-plugin 1.0.0 → 2.0.0-alpha.100

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,93 @@
1
+ /**
2
+ * Parameters for sending a templated email via SES
3
+ */
4
+ export interface SesTemplatedEmailParams {
5
+ /** Sender address (overrides defaultFrom) */
6
+ from?: string;
7
+ /** Recipient addresses */
8
+ to: string[];
9
+ /** CC addresses */
10
+ cc?: string[];
11
+ /** BCC addresses */
12
+ bcc?: string[];
13
+ /** Reply-to addresses */
14
+ replyTo?: string[];
15
+ /** SES template name */
16
+ template: string;
17
+ /** Template data as key-value pairs (will be JSON.stringify'd for SES) */
18
+ templateData: Record<string, unknown>;
19
+ /** SES configuration set (overrides client default) */
20
+ configurationSet?: string;
21
+ /** SES tags for event filtering */
22
+ tags?: Record<string, string>;
23
+ }
24
+
25
+ /**
26
+ * Parameters for sending bulk emails via SES template
27
+ */
28
+ export interface SesBulkEmailParams {
29
+ /** Sender address (overrides defaultFrom) */
30
+ from?: string;
31
+ /** SES template name */
32
+ template: string;
33
+ /** Default template data applied to all destinations */
34
+ defaultTemplateData?: Record<string, unknown>;
35
+ /** Individual recipients with optional per-recipient template overrides */
36
+ destinations: SesBulkDestination[];
37
+ /** SES configuration set (overrides client default) */
38
+ configurationSet?: string;
39
+ /** Reply-to addresses */
40
+ replyTo?: string[];
41
+ /** SES tags for event filtering */
42
+ tags?: Record<string, string>;
43
+ }
44
+
45
+ export interface SesBulkDestination {
46
+ to: string[];
47
+ cc?: string[];
48
+ bcc?: string[];
49
+ /** Per-destination template data (merged with defaultTemplateData) */
50
+ templateData?: Record<string, unknown>;
51
+ }
52
+
53
+ /**
54
+ * Result from a single send operation
55
+ */
56
+ export interface SesResult {
57
+ success: boolean;
58
+ /** AWS MessageId, present on success */
59
+ messageId?: string;
60
+ /** Error details, present on failure */
61
+ error?: { code: string; message: string };
62
+ }
63
+
64
+ /**
65
+ * Result from a bulk send operation
66
+ */
67
+ export interface SesBulkResult {
68
+ success: boolean;
69
+ /** Per-destination results */
70
+ results: Array<{
71
+ success: boolean;
72
+ messageId?: string;
73
+ error?: { code: string; message: string };
74
+ }>;
75
+ /** Number of successful sends */
76
+ successCount: number;
77
+ /** Number of failed sends */
78
+ failureCount: number;
79
+ }
80
+
81
+ /**
82
+ * Result from verifySetup()
83
+ */
84
+ export interface SesSetupStatus {
85
+ /** Whether credentials are valid and SES is accessible */
86
+ verified: boolean;
87
+ /** Whether the account is out of the SES sandbox */
88
+ sendingEnabled: boolean;
89
+ /** The configured AWS region */
90
+ region: string;
91
+ /** Error message if verification failed */
92
+ error?: string;
93
+ }
@@ -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
+ }