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

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,13 @@ 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";
7
+ export { postmarkClient } from "./postmarkClient";
6
8
  export type { email } from "./schemas/email";
9
+ export type { emailSes } from "./schemas/emailSes";
10
+ export type { emailPostmark, PostmarkAttachment } from "./schemas/emailPostmark";
11
+ export * from "./ses-types";
7
12
 
8
13
  export type emailPluginOptions = {
9
14
  /**
@@ -12,7 +17,7 @@ export type emailPluginOptions = {
12
17
  client: client;
13
18
  };
14
19
 
15
- export interface emailPluginContext {
20
+ export interface EmailPluginContext {
16
21
  emailPlugin: {
17
22
  client: client;
18
23
  };
@@ -0,0 +1,53 @@
1
+ import { ServerClient } from "postmark";
2
+ import { emailPostmark as email } from "./schemas/emailPostmark";
3
+ import { client } from "./schemas/client";
4
+
5
+ export interface postmarkClientOptions {
6
+ /**
7
+ * Postmark server token
8
+ */
9
+ serverToken: string;
10
+
11
+ /**
12
+ * Default Postmark message stream, defaults to "outbound"
13
+ */
14
+ defaultMessageStream?: string;
15
+ }
16
+
17
+ export class postmarkClient implements client {
18
+ postmark: ServerClient;
19
+ defaultMessageStream: string;
20
+
21
+ constructor(options: postmarkClientOptions) {
22
+ this.postmark = new ServerClient(options.serverToken);
23
+ this.defaultMessageStream = options.defaultMessageStream ?? "outbound";
24
+ }
25
+
26
+ async send(email: email) {
27
+ try {
28
+ await this.postmark.sendEmail({
29
+ From: email.from,
30
+ To: email.to.join(","),
31
+ Cc: email.cc?.join(","),
32
+ Bcc: email.bcc?.join(","),
33
+ ReplyTo: email.replyTo,
34
+ Subject: email.subject,
35
+ HtmlBody: "html" in email ? email.html : undefined,
36
+ TextBody: "text" in email ? email.text : undefined,
37
+ MessageStream: email.messageStream ?? this.defaultMessageStream,
38
+ Tag: email.tag,
39
+ Metadata: email.metadata,
40
+ Attachments: email.attachments?.map(a => ({
41
+ Name: a.filename,
42
+ Content: a.content,
43
+ ContentType: a.contentType ?? "application/octet-stream",
44
+ ContentID: a.contentId ?? "",
45
+ })),
46
+ });
47
+ } catch (ex) {
48
+ console.log(JSON.stringify(ex));
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+ }
@@ -1,7 +1,9 @@
1
1
  import { email } from "./email";
2
2
  import { emailFlowmailer } from "./emailFlowmailer";
3
+ import { emailPostmark } from "./emailPostmark";
3
4
  import { emailSendgrid } from "./emailSendgrid";
5
+ import { emailSes } from "./emailSes";
4
6
 
5
7
  export interface client {
6
- send(email: email | emailSendgrid | emailFlowmailer): Promise<boolean>;
8
+ send(email: email | emailSendgrid | emailFlowmailer | emailSes | emailPostmark): Promise<boolean>;
7
9
  }
@@ -0,0 +1,73 @@
1
+ export type emailPostmark = {
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 address
14
+ */
15
+ replyTo?: string;
16
+
17
+ /**
18
+ * CC addresses
19
+ */
20
+ cc?: string[];
21
+
22
+ /**
23
+ * BCC addresses
24
+ */
25
+ bcc?: string[];
26
+
27
+ /**
28
+ * Subject of email
29
+ */
30
+ subject: string;
31
+
32
+ /**
33
+ * Postmark message stream id, defaults to "outbound"
34
+ */
35
+ messageStream?: string;
36
+
37
+ /**
38
+ * Postmark tag for categorization
39
+ */
40
+ tag?: string;
41
+
42
+ /**
43
+ * Custom metadata sent along with the email
44
+ */
45
+ metadata?: Record<string, string>;
46
+
47
+ /**
48
+ * File attachments
49
+ */
50
+ attachments?: PostmarkAttachment[];
51
+ } & ({ text: string } | { html: string } | { text: string; html: string });
52
+
53
+ export interface PostmarkAttachment {
54
+ /**
55
+ * Base64 encoded content
56
+ */
57
+ content: string;
58
+
59
+ /**
60
+ * Filename shown to recipient
61
+ */
62
+ filename: string;
63
+
64
+ /**
65
+ * MIME content type (e.g. "application/pdf"). Defaults to "application/octet-stream"
66
+ */
67
+ contentType?: string;
68
+
69
+ /**
70
+ * Content-ID for inline attachments, e.g. "cid:image1"
71
+ */
72
+ contentId?: string;
73
+ }
@@ -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
+ }