@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.
@@ -0,0 +1,464 @@
1
+ import { sesClient, sesClientOptions } from "../src/sesClient";
2
+ import { emailSes } from "../src/schemas/emailSes";
3
+
4
+ // Mock AWS SESv2Client
5
+ function createMockAwsClient(overrides?: {
6
+ sendResponse?: any;
7
+ sendError?: any;
8
+ configRegion?: string;
9
+ }) {
10
+ const mock = {
11
+ send: jasmine
12
+ .createSpy("send")
13
+ .and.callFake(async (command: any) => {
14
+ if (overrides?.sendError) throw overrides.sendError;
15
+ return overrides?.sendResponse || { MessageId: "mock-message-id-123" };
16
+ }),
17
+ config: {
18
+ region: () => Promise.resolve(overrides?.configRegion || "eu-north-1"),
19
+ },
20
+ };
21
+ return mock as any;
22
+ }
23
+
24
+ describe("sesClient", () => {
25
+ describe("send()", () => {
26
+ it("should send a simple email and return true", async () => {
27
+ const mockAws = createMockAwsClient();
28
+ const client = new sesClient(
29
+ { region: "eu-north-1" },
30
+ mockAws
31
+ );
32
+
33
+ const result = await client.send({
34
+ from: "sender@example.com",
35
+ to: ["recipient@example.com"],
36
+ subject: "Test",
37
+ html: "<p>Hello</p>",
38
+ });
39
+
40
+ expect(result).toBe(true);
41
+ expect(mockAws.send).toHaveBeenCalledTimes(1);
42
+
43
+ const command = mockAws.send.calls.first().args[0];
44
+ expect(command.input.FromEmailAddress).toBe("sender@example.com");
45
+ expect(command.input.Destination.ToAddresses).toEqual([
46
+ "recipient@example.com",
47
+ ]);
48
+ expect(command.input.Content.Simple.Subject.Data).toBe("Test");
49
+ expect(command.input.Content.Simple.Body.Html.Data).toBe("<p>Hello</p>");
50
+ });
51
+
52
+ it("should return false on AWS error", async () => {
53
+ const mockAws = createMockAwsClient({
54
+ sendError: { name: "MessageRejected", message: "Email rejected" },
55
+ });
56
+ const client = new sesClient(
57
+ { region: "eu-north-1" },
58
+ mockAws
59
+ );
60
+
61
+ const result = await client.send({
62
+ from: "sender@example.com",
63
+ to: ["recipient@example.com"],
64
+ subject: "Test",
65
+ html: "<p>Hello</p>",
66
+ });
67
+
68
+ expect(result).toBe(false);
69
+ });
70
+
71
+ it("should use defaultFrom when email has no from", async () => {
72
+ const mockAws = createMockAwsClient();
73
+ const client = new sesClient(
74
+ { region: "eu-north-1", defaultFrom: "default@example.com" },
75
+ mockAws
76
+ );
77
+
78
+ await client.send({
79
+ from: "",
80
+ to: ["recipient@example.com"],
81
+ subject: "Test",
82
+ text: "Hello",
83
+ } as any);
84
+
85
+ // send will use defaultFrom since from is falsy
86
+ const command = mockAws.send.calls.first().args[0];
87
+ expect(command.input.FromEmailAddress).toBe("default@example.com");
88
+ });
89
+
90
+ it("should return false when no from address is available", async () => {
91
+ const mockAws = createMockAwsClient();
92
+ const client = new sesClient({ region: "eu-north-1" }, mockAws);
93
+
94
+ const result = await client.send({
95
+ from: "",
96
+ to: ["recipient@example.com"],
97
+ subject: "Test",
98
+ text: "Hello",
99
+ } as any);
100
+
101
+ expect(result).toBe(false);
102
+ expect(mockAws.send).not.toHaveBeenCalled();
103
+ });
104
+ });
105
+
106
+ describe("sendEmail()", () => {
107
+ it("should return rich result with messageId on success", async () => {
108
+ const mockAws = createMockAwsClient({
109
+ sendResponse: { MessageId: "ses-msg-456" },
110
+ });
111
+ const client = new sesClient(
112
+ { region: "eu-north-1" },
113
+ mockAws
114
+ );
115
+
116
+ const result = await client.sendEmail({
117
+ from: "sender@example.com",
118
+ to: ["recipient@example.com"],
119
+ subject: "Test",
120
+ text: "Hello",
121
+ });
122
+
123
+ expect(result.success).toBe(true);
124
+ expect(result.messageId).toBe("ses-msg-456");
125
+ expect(result.error).toBeUndefined();
126
+ });
127
+
128
+ it("should return structured error on failure", async () => {
129
+ const mockAws = createMockAwsClient({
130
+ sendError: {
131
+ name: "AccountSendingPausedException",
132
+ message: "Sending is paused",
133
+ },
134
+ });
135
+ const client = new sesClient(
136
+ { region: "eu-north-1" },
137
+ mockAws
138
+ );
139
+
140
+ const result = await client.sendEmail({
141
+ from: "sender@example.com",
142
+ to: ["recipient@example.com"],
143
+ subject: "Test",
144
+ html: "<p>Hello</p>",
145
+ });
146
+
147
+ expect(result.success).toBe(false);
148
+ expect(result.error?.code).toBe("AccountSendingPausedException");
149
+ expect(result.error?.message).toBe("Sending is paused");
150
+ });
151
+
152
+ it("should send both text and html", async () => {
153
+ const mockAws = createMockAwsClient();
154
+ const client = new sesClient(
155
+ { region: "eu-north-1" },
156
+ mockAws
157
+ );
158
+
159
+ await client.sendEmail({
160
+ from: "sender@example.com",
161
+ to: ["recipient@example.com"],
162
+ subject: "Test",
163
+ text: "Hello plain",
164
+ html: "<p>Hello html</p>",
165
+ });
166
+
167
+ const command = mockAws.send.calls.first().args[0];
168
+ expect(command.input.Content.Simple.Body.Text.Data).toBe("Hello plain");
169
+ expect(command.input.Content.Simple.Body.Html.Data).toBe(
170
+ "<p>Hello html</p>"
171
+ );
172
+ });
173
+
174
+ it("should include cc, bcc, and replyTo", async () => {
175
+ const mockAws = createMockAwsClient();
176
+ const client = new sesClient(
177
+ { region: "eu-north-1" },
178
+ mockAws
179
+ );
180
+
181
+ await client.sendEmail({
182
+ from: "sender@example.com",
183
+ to: ["to@example.com"],
184
+ cc: ["cc@example.com"],
185
+ bcc: ["bcc@example.com"],
186
+ replyTo: ["reply@example.com"],
187
+ subject: "Test",
188
+ text: "Hello",
189
+ });
190
+
191
+ const command = mockAws.send.calls.first().args[0];
192
+ expect(command.input.Destination.CcAddresses).toEqual([
193
+ "cc@example.com",
194
+ ]);
195
+ expect(command.input.Destination.BccAddresses).toEqual([
196
+ "bcc@example.com",
197
+ ]);
198
+ expect(command.input.ReplyToAddresses).toEqual(["reply@example.com"]);
199
+ });
200
+
201
+ it("should pass configuration set and tags", async () => {
202
+ const mockAws = createMockAwsClient();
203
+ const client = new sesClient(
204
+ { region: "eu-north-1", configurationSet: "default-set" },
205
+ mockAws
206
+ );
207
+
208
+ await client.sendEmail({
209
+ from: "sender@example.com",
210
+ to: ["recipient@example.com"],
211
+ subject: "Test",
212
+ text: "Hello",
213
+ tags: { campaign: "welcome", env: "prod" },
214
+ });
215
+
216
+ const command = mockAws.send.calls.first().args[0];
217
+ expect(command.input.ConfigurationSetName).toBe("default-set");
218
+ expect(command.input.EmailTags).toEqual([
219
+ { Name: "campaign", Value: "welcome" },
220
+ { Name: "env", Value: "prod" },
221
+ ]);
222
+ });
223
+
224
+ it("should override default configuration set per-email", async () => {
225
+ const mockAws = createMockAwsClient();
226
+ const client = new sesClient(
227
+ { region: "eu-north-1", configurationSet: "default-set" },
228
+ mockAws
229
+ );
230
+
231
+ await client.sendEmail({
232
+ from: "sender@example.com",
233
+ to: ["recipient@example.com"],
234
+ subject: "Test",
235
+ text: "Hello",
236
+ configurationSet: "override-set",
237
+ });
238
+
239
+ const command = mockAws.send.calls.first().args[0];
240
+ expect(command.input.ConfigurationSetName).toBe("override-set");
241
+ });
242
+
243
+ it("should use Raw content for emails with attachments", async () => {
244
+ const mockAws = createMockAwsClient();
245
+ const client = new sesClient(
246
+ { region: "eu-north-1" },
247
+ mockAws
248
+ );
249
+
250
+ await client.sendEmail({
251
+ from: "sender@example.com",
252
+ to: ["recipient@example.com"],
253
+ subject: "Test",
254
+ html: "<p>See attached</p>",
255
+ attachments: [
256
+ {
257
+ filename: "test.txt",
258
+ content: Buffer.from("hello world"),
259
+ contentType: "text/plain",
260
+ },
261
+ ],
262
+ });
263
+
264
+ const command = mockAws.send.calls.first().args[0];
265
+ expect(command.input.Content.Raw).toBeDefined();
266
+ expect(command.input.Content.Simple).toBeUndefined();
267
+
268
+ // Verify MIME message contains expected parts
269
+ const rawMessage = new TextDecoder().decode(command.input.Content.Raw.Data);
270
+ expect(rawMessage).toContain("From: sender@example.com");
271
+ expect(rawMessage).toContain("To: recipient@example.com");
272
+ expect(rawMessage).toContain("multipart/mixed");
273
+ expect(rawMessage).toContain("text/html");
274
+ expect(rawMessage).toContain("<p>See attached</p>");
275
+ expect(rawMessage).toContain('filename="test.txt"');
276
+ expect(rawMessage).toContain(
277
+ Buffer.from("hello world").toString("base64")
278
+ );
279
+ });
280
+ });
281
+
282
+ describe("sendTemplated()", () => {
283
+ it("should send a templated email", async () => {
284
+ const mockAws = createMockAwsClient({
285
+ sendResponse: { MessageId: "tmpl-msg-789" },
286
+ });
287
+ const client = new sesClient(
288
+ { region: "eu-north-1", defaultFrom: "noreply@example.com" },
289
+ mockAws
290
+ );
291
+
292
+ const result = await client.sendTemplated({
293
+ to: ["recipient@example.com"],
294
+ template: "WelcomeEmail",
295
+ templateData: { name: "Joel", company: "Frost" },
296
+ });
297
+
298
+ expect(result.success).toBe(true);
299
+ expect(result.messageId).toBe("tmpl-msg-789");
300
+
301
+ const command = mockAws.send.calls.first().args[0];
302
+ expect(command.input.FromEmailAddress).toBe("noreply@example.com");
303
+ expect(command.input.Content.Template.TemplateName).toBe("WelcomeEmail");
304
+ expect(command.input.Content.Template.TemplateData).toBe(
305
+ JSON.stringify({ name: "Joel", company: "Frost" })
306
+ );
307
+ });
308
+
309
+ it("should return error when no from address", async () => {
310
+ const mockAws = createMockAwsClient();
311
+ const client = new sesClient({ region: "eu-north-1" }, mockAws);
312
+
313
+ const result = await client.sendTemplated({
314
+ to: ["recipient@example.com"],
315
+ template: "WelcomeEmail",
316
+ templateData: { name: "Joel" },
317
+ });
318
+
319
+ expect(result.success).toBe(false);
320
+ expect(result.error?.code).toBe("MISSING_FROM");
321
+ });
322
+ });
323
+
324
+ describe("sendBulk()", () => {
325
+ it("should send bulk emails", async () => {
326
+ const mockAws = createMockAwsClient({
327
+ sendResponse: {
328
+ BulkEmailEntryResults: [
329
+ { Status: "SUCCESS", MessageId: "bulk-1" },
330
+ { Status: "SUCCESS", MessageId: "bulk-2" },
331
+ ],
332
+ },
333
+ });
334
+ const client = new sesClient(
335
+ { region: "eu-north-1", defaultFrom: "noreply@example.com" },
336
+ mockAws
337
+ );
338
+
339
+ const result = await client.sendBulk({
340
+ template: "Newsletter",
341
+ defaultTemplateData: { company: "Frost" },
342
+ destinations: [
343
+ { to: ["a@example.com"], templateData: { name: "Alice" } },
344
+ { to: ["b@example.com"], templateData: { name: "Bob" } },
345
+ ],
346
+ });
347
+
348
+ expect(result.success).toBe(true);
349
+ expect(result.successCount).toBe(2);
350
+ expect(result.failureCount).toBe(0);
351
+ expect(result.results.length).toBe(2);
352
+ expect(result.results[0].messageId).toBe("bulk-1");
353
+
354
+ const command = mockAws.send.calls.first().args[0];
355
+ expect(command.input.DefaultContent.Template.TemplateName).toBe(
356
+ "Newsletter"
357
+ );
358
+ expect(command.input.BulkEmailEntries.length).toBe(2);
359
+ });
360
+
361
+ it("should handle partial failures", async () => {
362
+ const mockAws = createMockAwsClient({
363
+ sendResponse: {
364
+ BulkEmailEntryResults: [
365
+ { Status: "SUCCESS", MessageId: "bulk-1" },
366
+ { Status: "FAILED", Error: "MailFromDomainNotVerified" },
367
+ ],
368
+ },
369
+ });
370
+ const client = new sesClient(
371
+ { region: "eu-north-1", defaultFrom: "noreply@example.com" },
372
+ mockAws
373
+ );
374
+
375
+ const result = await client.sendBulk({
376
+ template: "Newsletter",
377
+ destinations: [
378
+ { to: ["a@example.com"] },
379
+ { to: ["b@example.com"] },
380
+ ],
381
+ });
382
+
383
+ expect(result.success).toBe(false);
384
+ expect(result.successCount).toBe(1);
385
+ expect(result.failureCount).toBe(1);
386
+ expect(result.results[0].success).toBe(true);
387
+ expect(result.results[1].success).toBe(false);
388
+ expect(result.results[1].error?.code).toBe("MailFromDomainNotVerified");
389
+ });
390
+
391
+ it("should handle complete AWS failure", async () => {
392
+ const mockAws = createMockAwsClient({
393
+ sendError: { name: "ServiceUnavailable", message: "Try later" },
394
+ });
395
+ const client = new sesClient(
396
+ { region: "eu-north-1", defaultFrom: "noreply@example.com" },
397
+ mockAws
398
+ );
399
+
400
+ const result = await client.sendBulk({
401
+ template: "Newsletter",
402
+ destinations: [{ to: ["a@example.com"] }],
403
+ });
404
+
405
+ expect(result.success).toBe(false);
406
+ expect(result.successCount).toBe(0);
407
+ expect(result.failureCount).toBe(1);
408
+ });
409
+ });
410
+
411
+ describe("verifySetup()", () => {
412
+ it("should return verified status", async () => {
413
+ const mockAws = createMockAwsClient({
414
+ sendResponse: { ProductionAccessEnabled: true },
415
+ configRegion: "eu-north-1",
416
+ });
417
+ const client = new sesClient(
418
+ { region: "eu-north-1" },
419
+ mockAws
420
+ );
421
+
422
+ const status = await client.verifySetup();
423
+
424
+ expect(status.verified).toBe(true);
425
+ expect(status.sendingEnabled).toBe(true);
426
+ expect(status.region).toBe("eu-north-1");
427
+ expect(status.error).toBeUndefined();
428
+ });
429
+
430
+ it("should report sandbox mode", async () => {
431
+ const mockAws = createMockAwsClient({
432
+ sendResponse: { ProductionAccessEnabled: false },
433
+ });
434
+ const client = new sesClient(
435
+ { region: "eu-north-1" },
436
+ mockAws
437
+ );
438
+
439
+ const status = await client.verifySetup();
440
+
441
+ expect(status.verified).toBe(true);
442
+ expect(status.sendingEnabled).toBe(false);
443
+ });
444
+
445
+ it("should handle credential errors", async () => {
446
+ const mockAws = createMockAwsClient({
447
+ sendError: {
448
+ name: "InvalidClientTokenId",
449
+ message: "The security token included in the request is invalid",
450
+ },
451
+ });
452
+ const client = new sesClient(
453
+ { region: "eu-north-1" },
454
+ mockAws
455
+ );
456
+
457
+ const status = await client.verifySetup();
458
+
459
+ expect(status.verified).toBe(false);
460
+ expect(status.sendingEnabled).toBe(false);
461
+ expect(status.error).toContain("security token");
462
+ });
463
+ });
464
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "spec_dir": "spec",
3
+ "spec_files": ["**/*[sS]pec.ts"],
4
+ "helpers": ["helpers/**/*.ts"],
5
+ "stopSpecOnExpectationFailure": false,
6
+ "random": true
7
+ }
package/src/index.ts CHANGED
@@ -2,8 +2,11 @@ import { FlinkApp, FlinkPlugin } from "@flink-app/flink";
2
2
  import { client } from "./schemas/client";
3
3
  export { sendgridClient } from "./sendgridClient";
4
4
  export { smtpClient } from "./smtpClient";
5
- export { flowMailerClient } from "./flowmailerClient"
5
+ export { flowMailerClient } from "./flowmailerClient";
6
+ export { sesClient } from "./sesClient";
6
7
  export type { email } from "./schemas/email";
8
+ export type { emailSes } from "./schemas/emailSes";
9
+ export * from "./ses-types";
7
10
 
8
11
  export type emailPluginOptions = {
9
12
  /**
@@ -1,7 +1,8 @@
1
1
  import { email } from "./email";
2
2
  import { emailFlowmailer } from "./emailFlowmailer";
3
3
  import { emailSendgrid } from "./emailSendgrid";
4
+ import { emailSes } from "./emailSes";
4
5
 
5
6
  export interface client {
6
- send(email: email | emailSendgrid | emailFlowmailer): Promise<boolean>;
7
+ send(email: email | emailSendgrid | emailFlowmailer | emailSes): Promise<boolean>;
7
8
  }
@@ -0,0 +1,73 @@
1
+ export type emailSes = {
2
+ /**
3
+ * From address used to send the email
4
+ */
5
+ from: string;
6
+
7
+ /**
8
+ * Email addresses to send to
9
+ */
10
+ to: string[];
11
+
12
+ /**
13
+ * Reply-to addresses
14
+ */
15
+ replyTo?: string[];
16
+
17
+ /**
18
+ * CC addresses
19
+ */
20
+ cc?: string[];
21
+
22
+ /**
23
+ * Email addresses to add as BCC
24
+ */
25
+ bcc?: string[];
26
+
27
+ /**
28
+ * Subject of email
29
+ */
30
+ subject: string;
31
+
32
+ /**
33
+ * Plain text body
34
+ */
35
+ text?: string;
36
+
37
+ /**
38
+ * HTML body
39
+ */
40
+ html?: string;
41
+
42
+ /**
43
+ * File attachments
44
+ */
45
+ attachments?: SesAttachment[];
46
+
47
+ /**
48
+ * SES configuration set for tracking (opens, clicks, bounces)
49
+ */
50
+ configurationSet?: string;
51
+
52
+ /**
53
+ * SES tags for event filtering
54
+ */
55
+ tags?: Record<string, string>;
56
+ };
57
+
58
+ export interface SesAttachment {
59
+ /**
60
+ * Filename shown to recipient
61
+ */
62
+ filename: string;
63
+
64
+ /**
65
+ * Raw content as Buffer or base64 string
66
+ */
67
+ content: Buffer | string;
68
+
69
+ /**
70
+ * MIME content type (e.g. "application/pdf"). Defaults to "application/octet-stream"
71
+ */
72
+ contentType?: string;
73
+ }
@@ -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
+ }