@iskra-bun/mailer-kit 0.1.0 → 0.2.1

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +1 -1
  3. package/dist/{chunk-P5HWUNVS.js → chunk-4OJHFX3J.js} +26 -29
  4. package/dist/chunk-4OJHFX3J.js.map +1 -0
  5. package/dist/{chunk-H6SSV4G4.js → chunk-IACNO3YI.js} +31 -8
  6. package/dist/chunk-IACNO3YI.js.map +1 -0
  7. package/dist/chunk-U4ZHXPGW.js +65 -0
  8. package/dist/chunk-U4ZHXPGW.js.map +1 -0
  9. package/dist/chunk-URXCRWND.js +74 -0
  10. package/dist/chunk-URXCRWND.js.map +1 -0
  11. package/dist/{chunk-4Y2FQY2L.js → chunk-VRF3QYGZ.js} +24 -7
  12. package/dist/chunk-VRF3QYGZ.js.map +1 -0
  13. package/dist/index.d.ts +40 -10
  14. package/dist/index.js +24 -10
  15. package/dist/index.js.map +1 -1
  16. package/dist/mailgun-4CTSFUTK.js +8 -0
  17. package/dist/sendgrid-T6ZZERVV.js +8 -0
  18. package/dist/ses-WL4MNF4F.js +8 -0
  19. package/dist/smtp-J33QQBJ7.js +8 -0
  20. package/package.json +6 -3
  21. package/src/factory.ts +11 -11
  22. package/src/headers.ts +112 -0
  23. package/src/index.ts +9 -8
  24. package/src/mock.ts +14 -4
  25. package/src/providers/mailgun.ts +47 -54
  26. package/src/providers/sendgrid.ts +47 -19
  27. package/src/providers/ses.ts +44 -18
  28. package/src/providers/smtp.ts +35 -17
  29. package/src/types.ts +23 -6
  30. package/dist/chunk-4256VN6L.js +0 -40
  31. package/dist/chunk-4256VN6L.js.map +0 -1
  32. package/dist/chunk-4Y2FQY2L.js.map +0 -1
  33. package/dist/chunk-H6SSV4G4.js.map +0 -1
  34. package/dist/chunk-P5HWUNVS.js.map +0 -1
  35. package/dist/mailgun-CKVSFBJE.js +0 -7
  36. package/dist/sendgrid-EYSREU2M.js +0 -7
  37. package/dist/ses-KGJG5IB5.js +0 -7
  38. package/dist/smtp-GZ4DZMCQ.js +0 -7
  39. /package/dist/{mailgun-CKVSFBJE.js.map → mailgun-4CTSFUTK.js.map} +0 -0
  40. /package/dist/{sendgrid-EYSREU2M.js.map → sendgrid-T6ZZERVV.js.map} +0 -0
  41. /package/dist/{ses-KGJG5IB5.js.map → ses-WL4MNF4F.js.map} +0 -0
  42. /package/dist/{smtp-GZ4DZMCQ.js.map → smtp-J33QQBJ7.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,23 +1,35 @@
1
1
  import {
2
2
  SmtpEmailAdapter
3
- } from "./chunk-4Y2FQY2L.js";
3
+ } from "./chunk-VRF3QYGZ.js";
4
4
  import {
5
5
  SendGridEmailAdapter
6
- } from "./chunk-4256VN6L.js";
6
+ } from "./chunk-U4ZHXPGW.js";
7
7
  import {
8
8
  MailgunEmailAdapter
9
- } from "./chunk-P5HWUNVS.js";
9
+ } from "./chunk-4OJHFX3J.js";
10
10
  import {
11
11
  SesEmailAdapter
12
- } from "./chunk-H6SSV4G4.js";
12
+ } from "./chunk-IACNO3YI.js";
13
+ import {
14
+ checkRecipients,
15
+ checkReplyTo
16
+ } from "./chunk-URXCRWND.js";
13
17
 
14
18
  // src/mock.ts
15
19
  var MockEmailAdapter = class {
16
- async send(_message) {
20
+ async send(message) {
21
+ checkRecipients(message.to);
22
+ checkRecipients(message.cc, "cc recipient");
23
+ checkRecipients(message.bcc, "bcc recipient");
24
+ checkReplyTo(message.replyTo);
17
25
  return { messageId: `mock-${Date.now()}`, success: true };
18
26
  }
19
27
  async sendTemplate(name, to, data) {
20
- return this.send({ to, subject: `Template: ${name}`, html: `Template ${name} with data: ${JSON.stringify(data)}` });
28
+ return this.send({
29
+ to,
30
+ subject: `Template: ${name}`,
31
+ html: `Template ${name} with data: ${JSON.stringify(data)}`
32
+ });
21
33
  }
22
34
  };
23
35
 
@@ -27,19 +39,19 @@ async function createEmailAdapter(config) {
27
39
  case "mock":
28
40
  return new MockEmailAdapter();
29
41
  case "smtp": {
30
- const { SmtpEmailAdapter: SmtpEmailAdapter2 } = await import("./smtp-GZ4DZMCQ.js");
42
+ const { SmtpEmailAdapter: SmtpEmailAdapter2 } = await import("./smtp-J33QQBJ7.js");
31
43
  return new SmtpEmailAdapter2(config);
32
44
  }
33
45
  case "sendgrid": {
34
- const { SendGridEmailAdapter: SendGridEmailAdapter2 } = await import("./sendgrid-EYSREU2M.js");
46
+ const { SendGridEmailAdapter: SendGridEmailAdapter2 } = await import("./sendgrid-T6ZZERVV.js");
35
47
  return new SendGridEmailAdapter2(config);
36
48
  }
37
49
  case "mailgun": {
38
- const { MailgunEmailAdapter: MailgunEmailAdapter2 } = await import("./mailgun-CKVSFBJE.js");
50
+ const { MailgunEmailAdapter: MailgunEmailAdapter2 } = await import("./mailgun-4CTSFUTK.js");
39
51
  return new MailgunEmailAdapter2(config);
40
52
  }
41
53
  case "ses": {
42
- const { SesEmailAdapter: SesEmailAdapter2 } = await import("./ses-KGJG5IB5.js");
54
+ const { SesEmailAdapter: SesEmailAdapter2 } = await import("./ses-WL4MNF4F.js");
43
55
  return new SesEmailAdapter2(config);
44
56
  }
45
57
  default:
@@ -52,6 +64,8 @@ export {
52
64
  SendGridEmailAdapter,
53
65
  SesEmailAdapter,
54
66
  SmtpEmailAdapter,
67
+ checkRecipients,
68
+ checkReplyTo,
55
69
  createEmailAdapter
56
70
  };
57
71
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/mock.ts","../src/factory.ts"],"sourcesContent":["import type { EmailAdapter, EmailMessage, TemplateData } from \"./types\";\n\n/**\n * In-memory email adapter used for tests and local development.\n * It performs no network I/O and stays silent (no stdout) so it is\n * safe to use inside library code and background workers.\n */\nexport class MockEmailAdapter implements EmailAdapter {\n async send(_message: EmailMessage) {\n return { messageId: `mock-${Date.now()}`, success: true };\n }\n\n async sendTemplate(name: string, to: string | string[], data: TemplateData) {\n return this.send({ to, subject: `Template: ${name}`, html: `Template ${name} with data: ${JSON.stringify(data)}` });\n }\n}\n","import type { EmailAdapter, EmailConfig } from \"./types\";\nimport { MockEmailAdapter } from \"./mock\";\n\n/**\n * Builds the email adapter for the given provider. Providers are loaded\n * lazily via dynamic `import()` so an app only pays for (and only needs\n * installed) the SDK of the provider it actually uses.\n */\nexport async function createEmailAdapter(config: EmailConfig): Promise<EmailAdapter> {\n switch (config.provider) {\n case \"mock\":\n return new MockEmailAdapter();\n case \"smtp\": {\n const { SmtpEmailAdapter } = await import(\"./providers/smtp\");\n return new SmtpEmailAdapter(config);\n }\n case \"sendgrid\": {\n const { SendGridEmailAdapter } = await import(\"./providers/sendgrid\");\n return new SendGridEmailAdapter(config);\n }\n case \"mailgun\": {\n const { MailgunEmailAdapter } = await import(\"./providers/mailgun\");\n return new MailgunEmailAdapter(config);\n }\n case \"ses\": {\n const { SesEmailAdapter } = await import(\"./providers/ses\");\n return new SesEmailAdapter(config);\n }\n default:\n throw new Error(`Provider ${config.provider} not implemented`);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAOO,IAAM,mBAAN,MAA+C;AAAA,EAClD,MAAM,KAAK,UAAwB;AAC/B,WAAO,EAAE,WAAW,QAAQ,KAAK,IAAI,CAAC,IAAI,SAAS,KAAK;AAAA,EAC5D;AAAA,EAEA,MAAM,aAAa,MAAc,IAAuB,MAAoB;AACxE,WAAO,KAAK,KAAK,EAAE,IAAI,SAAS,aAAa,IAAI,IAAI,MAAM,YAAY,IAAI,eAAe,KAAK,UAAU,IAAI,CAAC,GAAG,CAAC;AAAA,EACtH;AACJ;;;ACPA,eAAsB,mBAAmB,QAA4C;AACjF,UAAQ,OAAO,UAAU;AAAA,IACrB,KAAK;AACD,aAAO,IAAI,iBAAiB;AAAA,IAChC,KAAK,QAAQ;AACT,YAAM,EAAE,kBAAAA,kBAAiB,IAAI,MAAM,OAAO,oBAAkB;AAC5D,aAAO,IAAIA,kBAAiB,MAAM;AAAA,IACtC;AAAA,IACA,KAAK,YAAY;AACb,YAAM,EAAE,sBAAAC,sBAAqB,IAAI,MAAM,OAAO,wBAAsB;AACpE,aAAO,IAAIA,sBAAqB,MAAM;AAAA,IAC1C;AAAA,IACA,KAAK,WAAW;AACZ,YAAM,EAAE,qBAAAC,qBAAoB,IAAI,MAAM,OAAO,uBAAqB;AAClE,aAAO,IAAIA,qBAAoB,MAAM;AAAA,IACzC;AAAA,IACA,KAAK,OAAO;AACR,YAAM,EAAE,iBAAAC,iBAAgB,IAAI,MAAM,OAAO,mBAAiB;AAC1D,aAAO,IAAIA,iBAAgB,MAAM;AAAA,IACrC;AAAA,IACA;AACI,YAAM,IAAI,MAAM,YAAY,OAAO,QAAQ,kBAAkB;AAAA,EACrE;AACJ;","names":["SmtpEmailAdapter","SendGridEmailAdapter","MailgunEmailAdapter","SesEmailAdapter"]}
1
+ {"version":3,"sources":["../src/mock.ts","../src/factory.ts"],"sourcesContent":["import type { EmailAdapter, EmailMessage, EmailRecipient, TemplateData } from './types';\nimport { checkRecipients, checkReplyTo } from './headers';\n\n/**\n * In-memory email adapter used for tests and local development.\n * It performs no network I/O and stays silent (no stdout) so it is\n * safe to use inside library code and background workers.\n */\nexport class MockEmailAdapter implements EmailAdapter {\n async send(message: EmailMessage) {\n // Checked like the real providers, so a test fails where production would.\n checkRecipients(message.to);\n checkRecipients(message.cc, 'cc recipient');\n checkRecipients(message.bcc, 'bcc recipient');\n checkReplyTo(message.replyTo);\n return { messageId: `mock-${Date.now()}`, success: true };\n }\n\n async sendTemplate(name: string, to: EmailRecipient | EmailRecipient[], data: TemplateData) {\n return this.send({\n to,\n subject: `Template: ${name}`,\n html: `Template ${name} with data: ${JSON.stringify(data)}`,\n });\n }\n}\n","import type { EmailAdapter, EmailConfig } from './types';\nimport { MockEmailAdapter } from './mock';\n\n/**\n * Builds the email adapter for the given provider. Providers are loaded\n * lazily via dynamic `import()` so an app only pays for (and only needs\n * installed) the SDK of the provider it actually uses.\n */\nexport async function createEmailAdapter(config: EmailConfig): Promise<EmailAdapter> {\n switch (config.provider) {\n case 'mock':\n return new MockEmailAdapter();\n case 'smtp': {\n const { SmtpEmailAdapter } = await import('./providers/smtp');\n return new SmtpEmailAdapter(config);\n }\n case 'sendgrid': {\n const { SendGridEmailAdapter } = await import('./providers/sendgrid');\n return new SendGridEmailAdapter(config);\n }\n case 'mailgun': {\n const { MailgunEmailAdapter } = await import('./providers/mailgun');\n return new MailgunEmailAdapter(config);\n }\n case 'ses': {\n const { SesEmailAdapter } = await import('./providers/ses');\n return new SesEmailAdapter(config);\n }\n default:\n throw new Error(`Provider ${config.provider} not implemented`);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAQO,IAAM,mBAAN,MAA+C;AAAA,EAClD,MAAM,KAAK,SAAuB;AAE9B,oBAAgB,QAAQ,EAAE;AAC1B,oBAAgB,QAAQ,IAAI,cAAc;AAC1C,oBAAgB,QAAQ,KAAK,eAAe;AAC5C,iBAAa,QAAQ,OAAO;AAC5B,WAAO,EAAE,WAAW,QAAQ,KAAK,IAAI,CAAC,IAAI,SAAS,KAAK;AAAA,EAC5D;AAAA,EAEA,MAAM,aAAa,MAAc,IAAuC,MAAoB;AACxF,WAAO,KAAK,KAAK;AAAA,MACb;AAAA,MACA,SAAS,aAAa,IAAI;AAAA,MAC1B,MAAM,YAAY,IAAI,eAAe,KAAK,UAAU,IAAI,CAAC;AAAA,IAC7D,CAAC;AAAA,EACL;AACJ;;;ACjBA,eAAsB,mBAAmB,QAA4C;AACjF,UAAQ,OAAO,UAAU;AAAA,IACrB,KAAK;AACD,aAAO,IAAI,iBAAiB;AAAA,IAChC,KAAK,QAAQ;AACT,YAAM,EAAE,kBAAAA,kBAAiB,IAAI,MAAM,OAAO,oBAAkB;AAC5D,aAAO,IAAIA,kBAAiB,MAAM;AAAA,IACtC;AAAA,IACA,KAAK,YAAY;AACb,YAAM,EAAE,sBAAAC,sBAAqB,IAAI,MAAM,OAAO,wBAAsB;AACpE,aAAO,IAAIA,sBAAqB,MAAM;AAAA,IAC1C;AAAA,IACA,KAAK,WAAW;AACZ,YAAM,EAAE,qBAAAC,qBAAoB,IAAI,MAAM,OAAO,uBAAqB;AAClE,aAAO,IAAIA,qBAAoB,MAAM;AAAA,IACzC;AAAA,IACA,KAAK,OAAO;AACR,YAAM,EAAE,iBAAAC,iBAAgB,IAAI,MAAM,OAAO,mBAAiB;AAC1D,aAAO,IAAIA,iBAAgB,MAAM;AAAA,IACrC;AAAA,IACA;AACI,YAAM,IAAI,MAAM,YAAY,OAAO,QAAQ,kBAAkB;AAAA,EACrE;AACJ;","names":["SmtpEmailAdapter","SendGridEmailAdapter","MailgunEmailAdapter","SesEmailAdapter"]}
@@ -0,0 +1,8 @@
1
+ import {
2
+ MailgunEmailAdapter
3
+ } from "./chunk-4OJHFX3J.js";
4
+ import "./chunk-URXCRWND.js";
5
+ export {
6
+ MailgunEmailAdapter
7
+ };
8
+ //# sourceMappingURL=mailgun-4CTSFUTK.js.map
@@ -0,0 +1,8 @@
1
+ import {
2
+ SendGridEmailAdapter
3
+ } from "./chunk-U4ZHXPGW.js";
4
+ import "./chunk-URXCRWND.js";
5
+ export {
6
+ SendGridEmailAdapter
7
+ };
8
+ //# sourceMappingURL=sendgrid-T6ZZERVV.js.map
@@ -0,0 +1,8 @@
1
+ import {
2
+ SesEmailAdapter
3
+ } from "./chunk-IACNO3YI.js";
4
+ import "./chunk-URXCRWND.js";
5
+ export {
6
+ SesEmailAdapter
7
+ };
8
+ //# sourceMappingURL=ses-WL4MNF4F.js.map
@@ -0,0 +1,8 @@
1
+ import {
2
+ SmtpEmailAdapter
3
+ } from "./chunk-VRF3QYGZ.js";
4
+ import "./chunk-URXCRWND.js";
5
+ export {
6
+ SmtpEmailAdapter
7
+ };
8
+ //# sourceMappingURL=smtp-J33QQBJ7.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iskra-bun/mailer-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Envio de correo de Iskra, agnostico del transporte, con adaptadores de SMTP, SendGrid, Mailgun y SES.",
5
5
  "keywords": [
6
6
  "iskra",
@@ -20,6 +20,9 @@
20
20
  },
21
21
  "homepage": "https://github.com/fearful/iskra/tree/main/packages/mailer-kit#readme",
22
22
  "bugs": "https://github.com/fearful/iskra/issues",
23
+ "engines": {
24
+ "bun": ">=1.3.0"
25
+ },
23
26
  "type": "module",
24
27
  "main": "./dist/index.js",
25
28
  "module": "./dist/index.js",
@@ -47,10 +50,10 @@
47
50
  "build": "tsup --config ../../tsup.config.ts"
48
51
  },
49
52
  "dependencies": {
50
- "@iskra-bun/core": "0.1.1",
53
+ "@iskra-bun/core": "^0.3.0",
51
54
  "@aws-sdk/client-sesv2": "^3.600.0",
52
55
  "@sendgrid/mail": "^8.1.0",
53
- "nodemailer": "^6.9.1"
56
+ "nodemailer": "^10.0.10"
54
57
  },
55
58
  "devDependencies": {
56
59
  "@types/bun": "^1.3.5",
package/src/factory.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { EmailAdapter, EmailConfig } from "./types";
2
- import { MockEmailAdapter } from "./mock";
1
+ import type { EmailAdapter, EmailConfig } from './types';
2
+ import { MockEmailAdapter } from './mock';
3
3
 
4
4
  /**
5
5
  * Builds the email adapter for the given provider. Providers are loaded
@@ -8,22 +8,22 @@ import { MockEmailAdapter } from "./mock";
8
8
  */
9
9
  export async function createEmailAdapter(config: EmailConfig): Promise<EmailAdapter> {
10
10
  switch (config.provider) {
11
- case "mock":
11
+ case 'mock':
12
12
  return new MockEmailAdapter();
13
- case "smtp": {
14
- const { SmtpEmailAdapter } = await import("./providers/smtp");
13
+ case 'smtp': {
14
+ const { SmtpEmailAdapter } = await import('./providers/smtp');
15
15
  return new SmtpEmailAdapter(config);
16
16
  }
17
- case "sendgrid": {
18
- const { SendGridEmailAdapter } = await import("./providers/sendgrid");
17
+ case 'sendgrid': {
18
+ const { SendGridEmailAdapter } = await import('./providers/sendgrid');
19
19
  return new SendGridEmailAdapter(config);
20
20
  }
21
- case "mailgun": {
22
- const { MailgunEmailAdapter } = await import("./providers/mailgun");
21
+ case 'mailgun': {
22
+ const { MailgunEmailAdapter } = await import('./providers/mailgun');
23
23
  return new MailgunEmailAdapter(config);
24
24
  }
25
- case "ses": {
26
- const { SesEmailAdapter } = await import("./providers/ses");
25
+ case 'ses': {
26
+ const { SesEmailAdapter } = await import('./providers/ses');
27
27
  return new SesEmailAdapter(config);
28
28
  }
29
29
  default:
package/src/headers.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { EmailAddress, EmailRecipient } from './types';
2
+
3
+ /**
4
+ * Outbound custom mail headers callers may set on every provider. Anything
5
+ * else is rejected so a caller cannot spoof Sender / routing headers via the
6
+ * generic `headers` map. Reply-To is not one: `message.replyTo` sets it, with
7
+ * its address checked like a recipient's.
8
+ */
9
+ export const ALLOWED_HEADERS = [
10
+ 'in-reply-to',
11
+ 'references',
12
+ 'list-unsubscribe',
13
+ 'list-unsubscribe-post',
14
+ 'list-id',
15
+ ] as const;
16
+
17
+ /**
18
+ * Truncate a header value at the first CR/LF. Anything after a line break is an
19
+ * injected header (or folded continuation) and must be dropped, not preserved.
20
+ */
21
+ export const stripCrlf = (value: string): string => value.split(/[\r\n]/)[0] ?? '';
22
+
23
+ /** The `headers` of a message, checked against `allowed` and without CR/LF. */
24
+ export function checkHeaders(
25
+ headers: Record<string, string> | undefined,
26
+ allowed: readonly string[] = ALLOWED_HEADERS,
27
+ ): Record<string, string> | undefined {
28
+ if (!headers) return undefined;
29
+ const checked: Record<string, string> = {};
30
+ for (const [key, value] of Object.entries(headers)) {
31
+ if (key.toLowerCase() === 'reply-to') {
32
+ throw new Error(`Header "${key}" is not allowed: use message.replyTo`);
33
+ }
34
+ if (!allowed.includes(key.toLowerCase())) {
35
+ throw new Error(`Header "${key}" is not allowed`);
36
+ }
37
+ checked[key] = stripCrlf(value);
38
+ }
39
+ return checked;
40
+ }
41
+
42
+ /**
43
+ * What an address may not contain unquoted: whitespace and control characters,
44
+ * and what starts a display name, comment, group or list (`<>()[]\,;:"`).
45
+ * `a@evil.test:b@x.com` is a group that mails b@x.com only.
46
+ */
47
+ const ADDRESS_SPECIALS = /[\s\p{Cc}<>()[\]\\,;:"]/u;
48
+
49
+ /** A bare addr-spec: one `@`, no display name, brackets, separators, quotes or whitespace. */
50
+ export function checkEmail(email: string): string {
51
+ const trimmed = typeof email === 'string' ? email.trim() : '';
52
+ const at = trimmed.indexOf('@');
53
+ if (at < 1 || at !== trimmed.lastIndexOf('@') || at === trimmed.length - 1 || ADDRESS_SPECIALS.test(trimmed)) {
54
+ throw new Error(`Invalid email address: ${JSON.stringify(email)}`);
55
+ }
56
+ return trimmed;
57
+ }
58
+
59
+ /** A display name on one line (CR/LF would start a new header). */
60
+ export const cleanName = (name: string): string => name.replace(/[\r\n]+/g, ' ').trim();
61
+
62
+ /**
63
+ * The recipients of a `to`/`cc`/`bcc`/`replyTo` field, one mailbox each. A
64
+ * string is a single bare address: one value such as
65
+ * `"bob@x.com <spy@evil.test>, y@x.com"` or `"list: spy@evil.test;"` mailed
66
+ * other people than the ones an allowlist checked. An object's address is
67
+ * checked the same way and its name may not hold control characters.
68
+ */
69
+ export function checkRecipients(
70
+ value: EmailRecipient | EmailRecipient[] | undefined,
71
+ field = 'recipient',
72
+ ): EmailAddress[] {
73
+ // null too: an optional field often arrives as null from JSON.
74
+ if (value === undefined || value === null) return [];
75
+ return (Array.isArray(value) ? value : [value]).map((recipient) => {
76
+ if (typeof recipient === 'string') return { address: checkEmail(recipient) };
77
+ if (typeof recipient !== 'object' || recipient === null || typeof recipient.address !== 'string') {
78
+ throw new Error(`Invalid ${field}: expected an email address or { name, address }`);
79
+ }
80
+ const { name, address } = recipient;
81
+ if (name !== undefined && (typeof name !== 'string' || /\p{Cc}/u.test(name))) {
82
+ throw new Error(`Invalid ${field} name: control characters (CR/LF) are not allowed`);
83
+ }
84
+ return name ? { name, address: checkEmail(address) } : { address: checkEmail(address) };
85
+ });
86
+ }
87
+
88
+ /** `replyTo`, checked like the recipients: one address at most. */
89
+ export function checkReplyTo(value: EmailRecipient | undefined): EmailAddress | undefined {
90
+ const list = checkRecipients(value, 'replyTo');
91
+ if (list.length > 1) throw new Error('Invalid replyTo: expected a single address');
92
+ return list[0];
93
+ }
94
+
95
+ /** A checked recipient as an RFC 5322 mailbox (see {@link formatAddress}). */
96
+ export const formatRecipient = ({ name, address }: EmailAddress): string => formatAddress({ name, email: address });
97
+
98
+ /**
99
+ * `from` as an RFC 5322 mailbox. A display name with special characters is
100
+ * quoted (with `"` and `\` escaped), or RFC 2047-encoded when it is not
101
+ * ASCII. Interpolated as is, a name such as `Ana" <ceo@bank.com>, "x` added a
102
+ * sender of the caller's choice.
103
+ */
104
+ export function formatAddress(from: { name?: string; email: string }): string {
105
+ const email = checkEmail(from.email);
106
+ const name = from.name ? cleanName(from.name) : '';
107
+ if (!name) return email;
108
+ // Words of RFC 5322 atext need no quoting.
109
+ if (/^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~ ]+$/.test(name)) return `${name} <${email}>`;
110
+ if (/^[\x20-\x7e]*$/.test(name)) return `"${name.replace(/(["\\])/g, '\\$1')}" <${email}>`;
111
+ return `=?UTF-8?B?${Buffer.from(name, 'utf8').toString('base64')}?= <${email}>`;
112
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
- export type { EmailConfig, EmailMessage, TemplateData, EmailAdapter } from "./types";
2
- export { MockEmailAdapter } from "./mock";
3
- export { createEmailAdapter } from "./factory";
4
- export { SmtpEmailAdapter } from "./providers/smtp";
5
- export { SendGridEmailAdapter } from "./providers/sendgrid";
6
- export { MailgunEmailAdapter } from "./providers/mailgun";
7
- export { SesEmailAdapter } from "./providers/ses";
8
- export type { SesClient, SesCommand, SesCommandFactory, SesAdapterDeps } from "./providers/ses";
1
+ export type { EmailConfig, EmailMessage, EmailAddress, EmailRecipient, TemplateData, EmailAdapter } from './types';
2
+ export { checkRecipients, checkReplyTo } from './headers';
3
+ export { MockEmailAdapter } from './mock';
4
+ export { createEmailAdapter } from './factory';
5
+ export { SmtpEmailAdapter } from './providers/smtp';
6
+ export { SendGridEmailAdapter } from './providers/sendgrid';
7
+ export { MailgunEmailAdapter } from './providers/mailgun';
8
+ export { SesEmailAdapter } from './providers/ses';
9
+ export type { SesClient, SesCommand, SesCommandFactory, SesAdapterDeps } from './providers/ses';
package/src/mock.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { EmailAdapter, EmailMessage, TemplateData } from "./types";
1
+ import type { EmailAdapter, EmailMessage, EmailRecipient, TemplateData } from './types';
2
+ import { checkRecipients, checkReplyTo } from './headers';
2
3
 
3
4
  /**
4
5
  * In-memory email adapter used for tests and local development.
@@ -6,11 +7,20 @@ import type { EmailAdapter, EmailMessage, TemplateData } from "./types";
6
7
  * safe to use inside library code and background workers.
7
8
  */
8
9
  export class MockEmailAdapter implements EmailAdapter {
9
- async send(_message: EmailMessage) {
10
+ async send(message: EmailMessage) {
11
+ // Checked like the real providers, so a test fails where production would.
12
+ checkRecipients(message.to);
13
+ checkRecipients(message.cc, 'cc recipient');
14
+ checkRecipients(message.bcc, 'bcc recipient');
15
+ checkReplyTo(message.replyTo);
10
16
  return { messageId: `mock-${Date.now()}`, success: true };
11
17
  }
12
18
 
13
- async sendTemplate(name: string, to: string | string[], data: TemplateData) {
14
- return this.send({ to, subject: `Template: ${name}`, html: `Template ${name} with data: ${JSON.stringify(data)}` });
19
+ async sendTemplate(name: string, to: EmailRecipient | EmailRecipient[], data: TemplateData) {
20
+ return this.send({
21
+ to,
22
+ subject: `Template: ${name}`,
23
+ html: `Template ${name} with data: ${JSON.stringify(data)}`,
24
+ });
15
25
  }
16
26
  }
@@ -1,26 +1,16 @@
1
- import type { EmailAdapter, EmailMessage, EmailConfig, TemplateData } from "../types";
1
+ import type { EmailAdapter, EmailMessage, EmailConfig, TemplateData } from '../types';
2
+ import {
3
+ ALLOWED_HEADERS,
4
+ checkHeaders,
5
+ checkRecipients,
6
+ checkReplyTo,
7
+ formatAddress,
8
+ formatRecipient,
9
+ stripCrlf,
10
+ } from '../headers';
2
11
 
3
- /**
4
- * Outbound custom mail headers callers are permitted to set. Anything outside
5
- * this set is rejected so a caller cannot spoof Reply-To / Sender / routing
6
- * headers via the generic `headers` map.
7
- */
8
- const ALLOWED_HEADERS = new Set([
9
- "reply-to",
10
- "in-reply-to",
11
- "references",
12
- "list-unsubscribe",
13
- "list-unsubscribe-post",
14
- "list-id",
15
- "x-mailgun-variables",
16
- "x-mailgun-tag",
17
- ]);
18
-
19
- /**
20
- * Truncate a header value at the first CR/LF. Anything after a line break is an
21
- * injected header (or folded continuation) and must be dropped, not preserved.
22
- */
23
- const stripCrlf = (value: string): string => value.split(/[\r\n]/)[0] ?? "";
12
+ /** Mailgun's own headers, on top of the ones every provider allows. */
13
+ const MAILGUN_HEADERS = [...ALLOWED_HEADERS, 'x-mailgun-variables', 'x-mailgun-tag'];
24
14
 
25
15
  export class MailgunEmailAdapter implements EmailAdapter {
26
16
  private apiKey: string;
@@ -29,57 +19,56 @@ export class MailgunEmailAdapter implements EmailAdapter {
29
19
  private defaultFrom?: { name?: string; email: string };
30
20
 
31
21
  constructor(config: EmailConfig) {
32
- if (!config.apiKey) throw new Error("Mailgun requires apiKey");
33
- if (!config.domain) throw new Error("Mailgun requires domain");
22
+ if (!config.apiKey) throw new Error('Mailgun requires apiKey');
23
+ if (!config.domain) throw new Error('Mailgun requires domain');
34
24
 
35
25
  this.apiKey = config.apiKey;
36
26
  this.domain = config.domain;
37
- this.baseUrl = config.baseUrl || "https://api.mailgun.net/v3";
27
+ this.baseUrl = config.baseUrl || 'https://api.mailgun.net/v3';
38
28
  this.defaultFrom = config.from;
39
29
  }
40
30
 
41
31
  async send(message: EmailMessage): Promise<{ messageId: string; success: boolean }> {
32
+ const from = message.from || this.defaultFrom;
33
+ if (!from) throw new Error('From address required');
34
+
42
35
  const form = new FormData();
36
+ form.append('from', formatAddress(from));
43
37
 
44
- const from = message.from || this.defaultFrom;
45
- if (from) {
46
- form.append("from", from.name ? `${from.name} <${from.email}>` : from.email);
47
- }
38
+ // Mailgun parses each field as an address list and builds the headers
39
+ // itself: every value is one checked mailbox, and nothing carries CR/LF.
40
+ const list = (field: EmailMessage['to'] | undefined, label?: string) =>
41
+ checkRecipients(field, label).map(formatRecipient).join(',');
42
+ const cc = list(message.cc, 'cc recipient');
43
+ const bcc = list(message.bcc, 'bcc recipient');
44
+ const replyTo = checkReplyTo(message.replyTo);
48
45
 
49
- const to = Array.isArray(message.to) ? message.to.join(",") : message.to;
50
- form.append("to", to);
51
- form.append("subject", message.subject);
46
+ form.append('to', list(message.to));
47
+ form.append('subject', stripCrlf(message.subject));
52
48
 
53
- if (message.text) form.append("text", message.text);
54
- if (message.html) form.append("html", message.html);
55
- if (message.cc) form.append("cc", Array.isArray(message.cc) ? message.cc.join(",") : message.cc);
56
- if (message.bcc) form.append("bcc", Array.isArray(message.bcc) ? message.bcc.join(",") : message.bcc);
57
- if (message.replyTo) form.append("h:Reply-To", message.replyTo);
49
+ if (message.text) form.append('text', message.text);
50
+ if (message.html) form.append('html', message.html);
51
+ if (cc) form.append('cc', cc);
52
+ if (bcc) form.append('bcc', bcc);
53
+ if (replyTo) form.append('h:Reply-To', formatRecipient(replyTo));
58
54
 
59
- if (message.headers) {
60
- for (const [key, value] of Object.entries(message.headers)) {
61
- if (!ALLOWED_HEADERS.has(key.toLowerCase())) {
62
- throw new Error(`Header "${key}" is not allowed`);
63
- }
64
- form.append(`h:${key}`, stripCrlf(value));
65
- }
55
+ for (const [key, value] of Object.entries(checkHeaders(message.headers, MAILGUN_HEADERS) ?? {})) {
56
+ form.append(`h:${key}`, value);
66
57
  }
67
58
 
68
59
  if (message.attachments) {
69
60
  for (const att of message.attachments) {
70
- const content = typeof att.content === "string"
71
- ? new TextEncoder().encode(att.content)
72
- : att.content;
61
+ const content = typeof att.content === 'string' ? new TextEncoder().encode(att.content) : att.content;
73
62
  const bytes = new Uint8Array(content);
74
- const blob = new Blob([bytes], { type: att.contentType || "application/octet-stream" });
75
- form.append("attachment", blob, att.filename);
63
+ const blob = new Blob([bytes], { type: att.contentType || 'application/octet-stream' });
64
+ form.append('attachment', blob, att.filename);
76
65
  }
77
66
  }
78
67
 
79
68
  const response = await fetch(`${this.baseUrl}/${this.domain}/messages`, {
80
- method: "POST",
69
+ method: 'POST',
81
70
  headers: {
82
- Authorization: "Basic " + btoa(`api:${this.apiKey}`),
71
+ Authorization: 'Basic ' + btoa(`api:${this.apiKey}`),
83
72
  },
84
73
  body: form,
85
74
  });
@@ -89,13 +78,17 @@ export class MailgunEmailAdapter implements EmailAdapter {
89
78
  throw new Error(`Mailgun API error (${response.status}): ${errorText}`);
90
79
  }
91
80
 
92
- const result = await response.json() as { id: string; message: string };
81
+ const result = (await response.json()) as { id: string; message: string };
93
82
  return { messageId: result.id, success: true };
94
83
  }
95
84
 
96
- async sendTemplate(_templateName: string, _to: string | string[], _data: TemplateData): Promise<{ messageId: string; success: boolean }> {
85
+ async sendTemplate(
86
+ _templateName: string,
87
+ _to: string | string[],
88
+ _data: TemplateData,
89
+ ): Promise<{ messageId: string; success: boolean }> {
97
90
  // No template engine is implemented yet; fail loudly rather than
98
91
  // silently sending a placeholder that looks like a real send.
99
- throw new Error("sendTemplate not supported by mailgun");
92
+ throw new Error('sendTemplate not supported by mailgun');
100
93
  }
101
94
  }
@@ -1,40 +1,68 @@
1
- import type { EmailAdapter, EmailConfig, EmailMessage, TemplateData } from "../types";
2
- import sgMail from "@sendgrid/mail";
1
+ import type { EmailAdapter, EmailAddress, EmailConfig, EmailMessage, TemplateData } from '../types';
2
+ import { MailService } from '@sendgrid/mail';
3
+ import { checkEmail, checkHeaders, checkRecipients, checkReplyTo, cleanName } from '../headers';
4
+
5
+ /** SendGrid's form of a checked recipient (it parses a string as `Name <address>`). */
6
+ const toSendGrid = ({ name, address }: EmailAddress) => (name ? { email: address, name } : address);
7
+
8
+ /** The mail object MailService.send() takes (typed by @sendgrid/helpers, not a direct dependency). */
9
+ type SendGridMail = Extract<Parameters<MailService['send']>[0], { from: unknown }>;
3
10
 
4
11
  export class SendGridEmailAdapter implements EmailAdapter {
12
+ /**
13
+ * A client per adapter: the package's default export is a process-wide
14
+ * singleton, so the last adapter created set the API key for all of them
15
+ * (one tenant's mail sent with another tenant's account).
16
+ */
17
+ private readonly client = new MailService();
18
+
5
19
  constructor(private config: EmailConfig) {
6
- if (!config.apiKey) throw new Error("SendGrid API Key required");
7
- sgMail.setApiKey(config.apiKey);
20
+ if (!config.apiKey) throw new Error('SendGrid API Key required');
21
+ this.client.setApiKey(config.apiKey);
8
22
  }
9
23
 
10
24
  async send(message: EmailMessage) {
11
25
  const from = message.from || this.config.from;
12
- if (!from) throw new Error("From address required");
26
+ if (!from) throw new Error('From address required');
27
+ const cc = checkRecipients(message.cc, 'cc recipient').map(toSendGrid);
28
+ const bcc = checkRecipients(message.bcc, 'bcc recipient').map(toSendGrid);
29
+ const replyTo = checkReplyTo(message.replyTo);
13
30
 
14
31
  const msg = {
15
- to: message.to,
16
- from: from.name ? { email: from.email, name: from.name } : from.email,
32
+ to: checkRecipients(message.to).map(toSendGrid),
33
+ from: from.name ? { email: checkEmail(from.email), name: cleanName(from.name) } : checkEmail(from.email),
17
34
  subject: message.subject,
18
35
  text: message.text,
19
36
  html: message.html,
20
- cc: message.cc as any,
21
- bcc: message.bcc as any,
22
- replyTo: message.replyTo,
23
- attachments: message.attachments?.map(a => ({
37
+ cc: cc.length > 0 ? cc : undefined,
38
+ bcc: bcc.length > 0 ? bcc : undefined,
39
+ replyTo: replyTo && toSendGrid(replyTo),
40
+ attachments: message.attachments?.map((a) => ({
24
41
  filename: a.filename,
25
- content: typeof a.content === 'string' ? a.content : Buffer.from(a.content).toString("base64"),
42
+ // SendGrid takes base64; a string is text, as with the other
43
+ // providers (it was sent as is and arrived corrupted).
44
+ content: Buffer.from(
45
+ typeof a.content === 'string' ? Buffer.from(a.content, 'utf8') : a.content,
46
+ ).toString('base64'),
26
47
  type: a.contentType,
27
- disposition: "attachment"
28
- }))
29
- } as any;
48
+ disposition: 'attachment',
49
+ })),
50
+ headers: checkHeaders(message.headers),
51
+ // SendGrid's type wants text or html statically present; here both
52
+ // are optional, and SendGrid's API rejects a mail with neither.
53
+ } as SendGridMail;
30
54
 
31
- const [response] = await sgMail.send(msg);
32
- return { messageId: response.headers["x-message-id"] as string, success: true };
55
+ const [response] = await this.client.send(msg);
56
+ return { messageId: response.headers['x-message-id'] as string, success: true };
33
57
  }
34
58
 
35
- async sendTemplate(_templateName: string, _to: string | string[], _data: TemplateData): Promise<{ messageId: string; success: boolean }> {
59
+ async sendTemplate(
60
+ _templateName: string,
61
+ _to: string | string[],
62
+ _data: TemplateData,
63
+ ): Promise<{ messageId: string; success: boolean }> {
36
64
  // No template engine is implemented yet; fail loudly rather than
37
65
  // silently sending a placeholder body that looks like a real send.
38
- throw new Error("sendTemplate not supported by sendgrid");
66
+ throw new Error('sendTemplate not supported by sendgrid');
39
67
  }
40
68
  }