typedmailer 1.2.0 → 1.4.0

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/README.md CHANGED
@@ -41,6 +41,8 @@ npm install typedmailer @aws-sdk/client-sesv2
41
41
 
42
42
  Requires Node.js 22 or newer. Provider SDKs are optional peers and are loaded only when their adapter is selected.
43
43
 
44
+ For SMTP, use `secure: true` with implicit TLS (commonly port 465), or use STARTTLS with `secure: false` and `requireTLS: true`. Port 587 requires STARTTLS by default; other ports use opportunistic TLS unless `requireTLS` is set.
45
+
44
46
  Supported runtime and release guarantees are documented in [`SUPPORT.md`](SUPPORT.md). Provider behavior guarantees and their test coverage are described in [`docs/provider-contracts.md`](docs/provider-contracts.md). The scheduled and manual provider smoke workflow is documented in [`docs/integration-testing.md`](docs/integration-testing.md).
45
47
 
46
48
  TypedMailer is for trusted server-side Node.js runtimes. It is not intended for browser or mobile client bundles.
@@ -176,6 +178,32 @@ Provider capabilities differ. Unsupported fields return a `MailError` with code
176
178
  | Amazon SES | No | No | Yes | Yes | Unsupported |
177
179
  | SMTP | Yes | No | No | Yes | Yes |
178
180
 
181
+ ### Custom providers
182
+
183
+ Use `ProviderAdapter` when your provider is not built in or you need to wrap an existing mail SDK. The adapter receives normalized addresses and the configured default sender; its `send` method must resolve with the provider message ID. Implement `verifyConnection` only when the provider offers a safe connection check, and use `close` to release resources such as pooled clients.
184
+
185
+ ```ts
186
+ import { createMailer, type ProviderAdapter } from 'typedmailer';
187
+
188
+ const adapter: ProviderAdapter<'acme'> = {
189
+ name: 'acme',
190
+ async send(message) {
191
+ const response = await acmeClient.sendEmail(message);
192
+ return { messageId: response.id };
193
+ },
194
+ async close() {
195
+ await acmeClient.close();
196
+ },
197
+ };
198
+
199
+ const mailer = createMailer({ provider: adapter, from: 'noreply@example.com' });
200
+ const result = await mailer.send({ to: 'person@example.com', subject: 'Hello', text: 'Hi' });
201
+ // result.provider is typed as "acme".
202
+ await mailer.close();
203
+ ```
204
+
205
+ The custom adapter owns provider SDK setup, field support, and error handling. Throwing an error still passes through TypedMailer error normalization. Connection verification is reported as `unsupported` when the adapter does not implement it.
206
+
179
207
  ```ts
180
208
  await mailer.send({
181
209
  to: [{ email: 'person@example.com', name: 'Sam' }],
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import type { MailAddress } from './types.js';
2
3
  export declare const mailInputSchema: z.ZodObject<{
3
4
  from: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
4
5
  email: z.ZodString;
@@ -44,14 +45,8 @@ export declare const mailInputSchema: z.ZodObject<{
44
45
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
45
46
  }, z.core.$strict>;
46
47
  export declare function normalizeAddresses<T>(value: T | readonly T[] | undefined): readonly T[] | undefined;
47
- export declare function emailOf(address: string | {
48
- email: string;
49
- name?: string;
50
- }): string;
51
- export declare function addressWithName(address: string | {
52
- email: string;
53
- name?: string;
54
- }): {
48
+ export declare function emailOf(address: MailAddress): string;
49
+ export declare function addressWithName(address: MailAddress): {
55
50
  email: string;
56
51
  name?: string;
57
52
  };
package/dist/config.js CHANGED
@@ -53,8 +53,9 @@ export function emailOf(address) {
53
53
  return namedAddress?.[2] ?? address;
54
54
  }
55
55
  export function addressWithName(address) {
56
- if (typeof address !== 'string')
57
- return address;
56
+ if (typeof address !== 'string') {
57
+ return { email: address.email, ...(address.name !== undefined ? { name: address.name } : {}) };
58
+ }
58
59
  const namedAddress = address.match(/^(.+) <([^<>\s]+@[^<>\s]+)>$/);
59
60
  return namedAddress ? { email: namedAddress[2], name: namedAddress[1] } : { email: address };
60
61
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { type MailerOptions } from './providers/registry.js';
2
- import type { Mailer } from './types.js';
3
- export type { MailerOptions };
4
- export declare function createMailer(input: MailerOptions): Mailer;
1
+ import { type MailerOptions as BuiltInMailerOptions } from './providers/registry.js';
2
+ import type { CustomMailerOptions, Mailer, ProviderName } from './types.js';
3
+ export type MailerOptions = BuiltInMailerOptions | CustomMailerOptions;
4
+ export declare function createMailer<const TProvider extends string>(input: CustomMailerOptions<TProvider>): Mailer<TProvider>;
5
+ export declare function createMailer(input: BuiltInMailerOptions): Mailer<ProviderName | 'test'>;
6
+ export declare function createMailer(input: MailerOptions): Mailer<string>;
5
7
  export { MailError } from './errors.js';
6
8
  export type { MailErrorCode, MailErrorOperation, MailErrorOptions } from './errors.js';
7
- export type { MailAddress, MailAttachment, Mailer, NormalizedMailInput, ProviderName, SendMailInput, SendMailResult, } from './types.js';
9
+ export type { CustomMailerOptions, MailAddress, MailAttachment, Mailer, NormalizedMailInput, ProviderAdapter, ProviderName, ProviderSendResult, SendMailInput, SendMailResult, } from './types.js';
package/dist/index.js CHANGED
@@ -1,21 +1,59 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { z } from 'zod';
2
3
  import { MailError, normalizeProviderError } from './errors.js';
3
4
  import { mailInputSchema, normalizeAddresses } from './config.js';
4
- import { loadProvider, mailerOptionsSchema } from './providers/registry.js';
5
+ import { loadProvider, mailerBaseOptionsSchema, mailerOptionsSchema, } from './providers/registry.js';
6
+ function isProviderAdapter(value) {
7
+ if (typeof value !== 'object' || value === null)
8
+ return false;
9
+ const adapter = value;
10
+ return (typeof adapter.name === 'string' &&
11
+ adapter.name.length > 0 &&
12
+ adapter.name.trim() === adapter.name &&
13
+ typeof adapter.send === 'function' &&
14
+ (adapter.verifyConnection === undefined || typeof adapter.verifyConnection === 'function') &&
15
+ (adapter.close === undefined || typeof adapter.close === 'function'));
16
+ }
17
+ const customMailerOptionsSchema = mailerBaseOptionsSchema
18
+ .extend({ provider: z.custom(isProviderAdapter, 'Invalid custom provider adapter.') })
19
+ .strict();
20
+ function isCustomOptions(input) {
21
+ return typeof input.provider !== 'string';
22
+ }
23
+ function createCustomProvider(adapter) {
24
+ return {
25
+ send: (input) => adapter.send(input),
26
+ async verifyConnection() {
27
+ if (!adapter.verifyConnection) {
28
+ throw new MailError(`Connection verification is not supported by the custom provider "${adapter.name}".`, 'unsupported', adapter.name, false);
29
+ }
30
+ await adapter.verifyConnection();
31
+ },
32
+ async close() {
33
+ await adapter.close?.();
34
+ },
35
+ };
36
+ }
5
37
  export function createMailer(input) {
6
38
  let providerPromise;
7
39
  let closed = false;
8
40
  let closePromise;
9
41
  let activeOperations = 0;
10
42
  let resolveOperationsIdle;
11
- const parsedOptions = mailerOptionsSchema.parse(input);
43
+ const parsedOptions = isCustomOptions(input)
44
+ ? customMailerOptionsSchema.parse(input)
45
+ : mailerOptionsSchema.parse(input);
46
+ const providerName = typeof parsedOptions.provider === 'string' ? parsedOptions.provider : parsedOptions.provider.name;
12
47
  const assertOpen = () => {
13
48
  if (closed)
14
- throw new MailError('Mailer has been closed.', 'configuration', parsedOptions.provider, false);
49
+ throw new MailError('Mailer has been closed.', 'configuration', providerName, false);
15
50
  };
16
51
  const getProvider = () => {
17
52
  assertOpen();
18
- providerPromise ??= loadProvider(parsedOptions);
53
+ providerPromise ??=
54
+ typeof parsedOptions.provider === 'string'
55
+ ? loadProvider(parsedOptions)
56
+ : Promise.resolve(createCustomProvider(parsedOptions.provider));
19
57
  return providerPromise;
20
58
  };
21
59
  const runWithProvider = async (operation) => {
@@ -41,11 +79,13 @@ export function createMailer(input) {
41
79
  ? Buffer.byteLength(attachment.content)
42
80
  : attachment.content.byteLength), 0) ?? 0;
43
81
  if (parsedOptions.maxAttachmentBytes !== undefined && attachmentBytes > parsedOptions.maxAttachmentBytes) {
44
- throw new MailError(`Attachment content exceeds the configured limit of ${parsedOptions.maxAttachmentBytes} bytes.`, 'configuration', parsedOptions.provider, false);
82
+ throw new MailError(`Attachment content exceeds the configured limit of ${parsedOptions.maxAttachmentBytes} bytes.`, 'configuration', providerName, false);
45
83
  }
84
+ const cc = normalizeAddresses(parsed.cc);
85
+ const bcc = normalizeAddresses(parsed.bcc);
46
86
  const normalized = {
47
- from: (parsed.from ?? parsedOptions.from),
48
- to: (normalizeAddresses(parsed.to) ?? []),
87
+ from: parsed.from ?? parsedOptions.from,
88
+ to: normalizeAddresses(parsed.to) ?? [],
49
89
  subject: parsed.subject,
50
90
  ...(parsed.messageId ? { messageId: parsed.messageId } : {}),
51
91
  ...(parsed.text !== undefined ? { text: parsed.text } : {}),
@@ -63,24 +103,20 @@ export function createMailer(input) {
63
103
  : {}),
64
104
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
65
105
  ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
66
- ...(normalizeAddresses(parsed.cc)
67
- ? { cc: normalizeAddresses(parsed.cc) }
68
- : {}),
69
- ...(normalizeAddresses(parsed.bcc)
70
- ? { bcc: normalizeAddresses(parsed.bcc) }
71
- : {}),
106
+ ...(cc ? { cc } : {}),
107
+ ...(bcc ? { bcc } : {}),
72
108
  ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
73
109
  };
74
110
  try {
75
111
  const result = await runWithProvider((provider) => provider.send(normalized));
76
112
  return {
77
- provider: parsedOptions.provider,
113
+ provider: providerName,
78
114
  messageId: result.messageId,
79
115
  acceptedAt: new Date(),
80
116
  };
81
117
  }
82
118
  catch (error) {
83
- throw normalizeProviderError(error, parsedOptions.provider, 'send');
119
+ throw normalizeProviderError(error, providerName, 'send');
84
120
  }
85
121
  },
86
122
  async verifyConnection() {
@@ -88,7 +124,7 @@ export function createMailer(input) {
88
124
  await runWithProvider((provider) => provider.verifyConnection());
89
125
  }
90
126
  catch (error) {
91
- throw normalizeProviderError(error, parsedOptions.provider);
127
+ throw normalizeProviderError(error, providerName);
92
128
  }
93
129
  },
94
130
  async close() {
@@ -44,7 +44,7 @@ export function createBrevoProvider(options) {
44
44
  return { messageId: result.messageId };
45
45
  }
46
46
  catch (error) {
47
- throw normalizeProviderError(error, 'brevo');
47
+ throw normalizeProviderError(error, 'brevo', 'send');
48
48
  }
49
49
  },
50
50
  async verifyConnection() {
@@ -62,7 +62,7 @@ export function createMailgunProvider(options) {
62
62
  return { messageId: response.id };
63
63
  }
64
64
  catch (error) {
65
- throw normalizeProviderError(error, 'mailgun');
65
+ throw normalizeProviderError(error, 'mailgun', 'send');
66
66
  }
67
67
  },
68
68
  async verifyConnection() {
@@ -42,7 +42,7 @@ export function createPostmarkProvider(options) {
42
42
  return { messageId: response.MessageID };
43
43
  }
44
44
  catch (error) {
45
- throw normalizeProviderError(error, 'postmark');
45
+ throw normalizeProviderError(error, 'postmark', 'send');
46
46
  }
47
47
  },
48
48
  async verifyConnection() {
@@ -1,5 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import type { MailProvider } from '../types.js';
3
+ export declare const mailerBaseOptionsSchema: z.ZodObject<{
4
+ from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
5
+ email: z.ZodString;
6
+ name: z.ZodOptional<z.ZodString>;
7
+ }, z.core.$strict>]>;
8
+ maxAttachmentBytes: z.ZodOptional<z.ZodNumber>;
9
+ }, z.core.$strip>;
3
10
  export declare const providerOptions: {
4
11
  readonly resend: z.ZodObject<{
5
12
  from: z.ZodUnion<readonly [z.ZodString, z.ZodString, z.ZodObject<{
@@ -70,6 +77,7 @@ export declare const providerOptions: {
70
77
  host: z.ZodString;
71
78
  port: z.ZodNumber;
72
79
  secure: z.ZodBoolean;
80
+ requireTLS: z.ZodOptional<z.ZodBoolean>;
73
81
  user: z.ZodOptional<z.ZodString>;
74
82
  password: z.ZodOptional<z.ZodString>;
75
83
  connectionTimeout: z.ZodDefault<z.ZodNumber>;
@@ -140,6 +148,7 @@ export declare const mailerOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
140
148
  host: z.ZodString;
141
149
  port: z.ZodNumber;
142
150
  secure: z.ZodBoolean;
151
+ requireTLS: z.ZodOptional<z.ZodBoolean>;
143
152
  user: z.ZodOptional<z.ZodString>;
144
153
  password: z.ZodOptional<z.ZodString>;
145
154
  connectionTimeout: z.ZodDefault<z.ZodNumber>;
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { MailError } from '../errors.js';
3
- const baseOptions = z.object({
3
+ export const mailerBaseOptionsSchema = z.object({
4
4
  from: z.union([
5
5
  z.string().email(),
6
6
  z.string().regex(/^.+ <[^<>\s]+@[^<>\s]+>$/, 'Use a valid email address or "Name <email@example.com>".'),
@@ -10,23 +10,24 @@ const baseOptions = z.object({
10
10
  maxAttachmentBytes: z.number().int().positive().optional(),
11
11
  });
12
12
  export const providerOptions = {
13
- resend: baseOptions.extend({ provider: z.literal('resend'), apiKey: z.string().min(1) }),
14
- brevo: baseOptions.extend({ provider: z.literal('brevo'), apiKey: z.string().min(1) }),
15
- postmark: baseOptions.extend({ provider: z.literal('postmark'), apiKey: z.string().min(1) }),
16
- sendgrid: baseOptions.extend({ provider: z.literal('sendgrid'), apiKey: z.string().min(1) }),
17
- mailgun: baseOptions.extend({
13
+ resend: mailerBaseOptionsSchema.extend({ provider: z.literal('resend'), apiKey: z.string().min(1) }),
14
+ brevo: mailerBaseOptionsSchema.extend({ provider: z.literal('brevo'), apiKey: z.string().min(1) }),
15
+ postmark: mailerBaseOptionsSchema.extend({ provider: z.literal('postmark'), apiKey: z.string().min(1) }),
16
+ sendgrid: mailerBaseOptionsSchema.extend({ provider: z.literal('sendgrid'), apiKey: z.string().min(1) }),
17
+ mailgun: mailerBaseOptionsSchema.extend({
18
18
  provider: z.literal('mailgun'),
19
19
  apiKey: z.string().min(1),
20
20
  domain: z.string().min(1),
21
21
  region: z.enum(['us', 'eu']).default('us'),
22
22
  }),
23
- ses: baseOptions.extend({ provider: z.literal('ses'), region: z.string().min(1) }),
24
- smtp: baseOptions
23
+ ses: mailerBaseOptionsSchema.extend({ provider: z.literal('ses'), region: z.string().min(1) }),
24
+ smtp: mailerBaseOptionsSchema
25
25
  .extend({
26
26
  provider: z.literal('smtp'),
27
27
  host: z.string().min(1),
28
28
  port: z.number().int().min(1).max(65535),
29
29
  secure: z.boolean(),
30
+ requireTLS: z.boolean().optional(),
30
31
  user: z.string().min(1).optional(),
31
32
  password: z.string().min(1).optional(),
32
33
  connectionTimeout: z.number().int().positive().default(8_000),
@@ -80,6 +81,7 @@ export async function loadProvider(options) {
80
81
  host: options.host,
81
82
  port: options.port,
82
83
  secure: options.secure,
84
+ ...(options.requireTLS !== undefined ? { requireTLS: options.requireTLS } : {}),
83
85
  ...(options.user ? { user: options.user } : {}),
84
86
  ...(options.password ? { password: options.password } : {}),
85
87
  connectionTimeout: options.connectionTimeout,
@@ -50,7 +50,7 @@ export function createSendGridProvider(options) {
50
50
  return { messageId };
51
51
  }
52
52
  catch (error) {
53
- throw normalizeProviderError(error, 'sendgrid');
53
+ throw normalizeProviderError(error, 'sendgrid', 'send');
54
54
  }
55
55
  },
56
56
  async verifyConnection() {
@@ -55,7 +55,7 @@ export function createSesProvider(options) {
55
55
  return { messageId: response.MessageId };
56
56
  }
57
57
  catch (error) {
58
- throw normalizeProviderError(error, 'ses');
58
+ throw normalizeProviderError(error, 'ses', 'send');
59
59
  }
60
60
  },
61
61
  async verifyConnection() {
@@ -3,6 +3,7 @@ interface SmtpOptions {
3
3
  host: string;
4
4
  port: number;
5
5
  secure: boolean;
6
+ requireTLS?: boolean;
6
7
  user?: string;
7
8
  password?: string;
8
9
  connectionTimeout: number;
@@ -11,7 +11,7 @@ export function createSmtpProvider(options) {
11
11
  host: options.host,
12
12
  port: options.port,
13
13
  secure: options.secure,
14
- ...(options.port === 587 && !options.secure ? { requireTLS: true } : {}),
14
+ requireTLS: options.requireTLS ?? (options.port === 587 && !options.secure),
15
15
  connectionTimeout: options.connectionTimeout,
16
16
  greetingTimeout: options.greetingTimeout,
17
17
  socketTimeout: options.socketTimeout,
@@ -53,7 +53,7 @@ export function createSmtpProvider(options) {
53
53
  return { messageId: result.messageId };
54
54
  }
55
55
  catch (error) {
56
- throw normalizeProviderError(error, 'smtp');
56
+ throw normalizeProviderError(error, 'smtp', 'send');
57
57
  }
58
58
  },
59
59
  async verifyConnection() {
package/dist/testing.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Mailer, SendMailInput } from './types.js';
2
2
  export interface CapturedMail extends SendMailInput {
3
3
  readonly from: NonNullable<SendMailInput['from']>;
4
4
  }
5
- export interface TestMailer extends Mailer {
5
+ export interface TestMailer extends Mailer<'test'> {
6
6
  readonly sent: CapturedMail[];
7
7
  clear(): void;
8
8
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type MailAddress = string | {
2
2
  readonly email: string;
3
- readonly name?: string;
3
+ readonly name?: string | undefined;
4
4
  };
5
5
  export interface MailAttachment {
6
6
  readonly filename: string;
@@ -24,18 +24,29 @@ export interface SendMailInput {
24
24
  readonly metadata?: Readonly<Record<string, string>>;
25
25
  }
26
26
  export type ProviderName = 'resend' | 'brevo' | 'smtp' | 'postmark' | 'sendgrid' | 'mailgun' | 'ses';
27
- export interface SendMailResult {
28
- readonly provider: ProviderName | 'test';
27
+ export interface SendMailResult<TProvider extends string = ProviderName | 'test'> {
28
+ readonly provider: TProvider;
29
29
  readonly messageId: string;
30
30
  /** Time when the provider accepted the request. This does not confirm inbox delivery. */
31
31
  readonly acceptedAt: Date;
32
32
  }
33
- export interface Mailer {
34
- send(input: SendMailInput): Promise<SendMailResult>;
33
+ export interface Mailer<TProvider extends string = ProviderName | 'test'> {
34
+ send(input: SendMailInput): Promise<SendMailResult<TProvider>>;
35
35
  verifyConnection(): Promise<void>;
36
36
  /** Rejects new operations, waits for active operations, and closes the provider at most once. */
37
37
  close(): Promise<void>;
38
38
  }
39
+ export interface ProviderAdapter<TProvider extends string = string> {
40
+ readonly name: TProvider;
41
+ send(input: NormalizedMailInput): Promise<ProviderSendResult>;
42
+ verifyConnection?(): Promise<void>;
43
+ close?(): Promise<void> | void;
44
+ }
45
+ export interface CustomMailerOptions<TProvider extends string = string> {
46
+ readonly provider: ProviderAdapter<TProvider>;
47
+ readonly from: MailAddress;
48
+ readonly maxAttachmentBytes?: number;
49
+ }
39
50
  export interface NormalizedMailInput extends Omit<SendMailInput, 'from' | 'to' | 'cc' | 'bcc' | 'replyTo'> {
40
51
  readonly from: MailAddress;
41
52
  readonly to: readonly MailAddress[];
@@ -1,6 +1,6 @@
1
1
  import type { EmailWebhookEvent } from './types.js';
2
2
  export declare function normalizeResend(payload: unknown): readonly EmailWebhookEvent[];
3
3
  export declare function normalizeMailgun(payload: unknown): readonly EmailWebhookEvent[];
4
- export declare function normalizeSendGrid(payload: unknown): readonly EmailWebhookEvent[];
5
- export declare function normalizeSes(payload: unknown): readonly EmailWebhookEvent[];
6
- export declare function normalizeProviderEvents(provider: 'brevo' | 'postmark', payload: unknown): readonly EmailWebhookEvent[];
4
+ export declare function normalizeSendGrid(payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
5
+ export declare function normalizeSes(payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
6
+ export declare function normalizeProviderEvents(provider: 'brevo' | 'postmark', payload: unknown, maxEvents: number): readonly EmailWebhookEvent[];
@@ -31,9 +31,10 @@ export function normalizeMailgun(payload) {
31
31
  }),
32
32
  ];
33
33
  }
34
- export function normalizeSendGrid(payload) {
35
- if (!Array.isArray(payload))
34
+ export function normalizeSendGrid(payload, maxEvents) {
35
+ if (!Array.isArray(payload) || payload.length > maxEvents) {
36
36
  throw new WebhookVerificationError('invalid_payload');
37
+ }
37
38
  return payload.map((item) => {
38
39
  const event = asRecord(item);
39
40
  const eventName = asString(event.event);
@@ -47,7 +48,7 @@ export function normalizeSendGrid(payload) {
47
48
  });
48
49
  });
49
50
  }
50
- export function normalizeSes(payload) {
51
+ export function normalizeSes(payload, maxEvents) {
51
52
  const message = asRecord(payload);
52
53
  const eventName = asString(message.eventType) ?? asString(message.notificationType);
53
54
  const mail = asRecord(message.mail);
@@ -59,6 +60,10 @@ export function normalizeSes(payload) {
59
60
  : Array.isArray(complaint?.complainedRecipients)
60
61
  ? complaint.complainedRecipients
61
62
  : [];
63
+ if (recipients.length > maxEvents ||
64
+ (!recipients.length && Array.isArray(mail.destination) && mail.destination.length > maxEvents)) {
65
+ throw new WebhookVerificationError('invalid_payload');
66
+ }
62
67
  const to = recipients.map((recipient) => asString(asRecord(recipient).emailAddress)).filter(isString);
63
68
  const emails = to.length > 0 ? to : asStringArray(mail.destination);
64
69
  return emails.length > 0
@@ -80,8 +85,10 @@ export function normalizeSes(payload) {
80
85
  }),
81
86
  ];
82
87
  }
83
- export function normalizeProviderEvents(provider, payload) {
88
+ export function normalizeProviderEvents(provider, payload, maxEvents) {
84
89
  const items = Array.isArray(payload) ? payload : [payload];
90
+ if (items.length > maxEvents)
91
+ throw new WebhookVerificationError('invalid_payload');
85
92
  return items.map((item) => {
86
93
  const event = asRecord(item);
87
94
  const eventName = provider === 'brevo' ? asString(event.event) : asString(event.RecordType);
@@ -15,6 +15,8 @@ export type WebhookHeaders = Readonly<Record<string, string | readonly string[]
15
15
  interface RawWebhookInput {
16
16
  readonly rawBody: string | Uint8Array;
17
17
  readonly headers: WebhookHeaders;
18
+ readonly maxBodyBytes?: number;
19
+ readonly maxEvents?: number;
18
20
  readonly now?: Date;
19
21
  readonly toleranceSeconds?: number;
20
22
  }
package/dist/webhooks.js CHANGED
@@ -1,9 +1,20 @@
1
1
  import { WebhookVerificationError } from './webhooks/types.js';
2
2
  import { verifyAuthorization, verifyMailgun, verifyResend, verifySendGrid, verifySnsNotification, } from './webhooks/signatures.js';
3
3
  import { normalizeMailgun, normalizeProviderEvents, normalizeResend, normalizeSendGrid, normalizeSes, } from './webhooks/normalize.js';
4
+ const DEFAULT_MAX_BODY_BYTES = 1_048_576;
5
+ const DEFAULT_MAX_EVENTS = 1_000;
4
6
  export { WebhookVerificationError } from './webhooks/types.js';
5
7
  /** Authenticates a provider webhook before returning normalized, typed email events. */
6
8
  export async function verifyWebhook(input) {
9
+ const rawBodyByteLength = typeof input.rawBody === 'string' ? Buffer.byteLength(input.rawBody) : input.rawBody.byteLength;
10
+ const maxBodyBytes = input.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
11
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 || rawBodyByteLength > maxBodyBytes) {
12
+ throw new WebhookVerificationError('invalid_payload');
13
+ }
14
+ const maxEvents = input.maxEvents ?? DEFAULT_MAX_EVENTS;
15
+ if (!Number.isSafeInteger(maxEvents) || maxEvents < 1) {
16
+ throw new WebhookVerificationError('invalid_payload');
17
+ }
7
18
  const rawBody = Buffer.from(input.rawBody);
8
19
  let payload;
9
20
  try {
@@ -21,12 +32,12 @@ export async function verifyWebhook(input) {
21
32
  return normalizeMailgun(payload);
22
33
  case 'sendgrid':
23
34
  verifySendGrid(input, rawBody);
24
- return normalizeSendGrid(payload);
35
+ return normalizeSendGrid(payload, maxEvents);
25
36
  case 'brevo':
26
37
  case 'postmark':
27
38
  verifyAuthorization(input, input.authorizationHeader ?? 'authorization');
28
- return normalizeProviderEvents(input.provider, payload);
39
+ return normalizeProviderEvents(input.provider, payload, maxEvents);
29
40
  case 'ses':
30
- return normalizeSes(await verifySnsNotification(input, payload));
41
+ return normalizeSes(await verifySnsNotification(input, payload), maxEvents);
31
42
  }
32
43
  }
@@ -1,6 +1,6 @@
1
1
  # Provider behavior contracts
2
2
 
3
- The adapters provide one `createMailer(...).send(...)` interface, while provider APIs differ. This page records the behavior callers may rely on. The capability table in the README is the quick reference; adapter contract tests in `tests/provider-adapters.test.ts` are the executable mapping checks.
3
+ The adapters provide one `createMailer(...).send(...)` interface, while provider APIs differ. This page records the behavior callers may rely on. The capability table in the README is the quick reference; built-in adapter contract tests are organized by provider under `tests/providers/`.
4
4
 
5
5
  ## Shared guarantees
6
6
 
@@ -28,3 +28,7 @@ Attachments are buffered for provider SDK requests. Configure `maxAttachmentByte
28
28
  ## Updating a provider adapter
29
29
 
30
30
  When changing an adapter, add its configuration schema and lazy loader in `src/providers/registry.ts`, keep its implementation in `src/providers/<provider>.ts`, and update the README capability table and this contract if caller-visible behavior changes. Add or adjust adapter tests for the provider payload, unsupported fields, returned message ID, and error normalization. Keep provider SDK versions within the declared peer range and verify both the locked SDK and minimum supported SDK set. CI derives the minimum-version install list from each peer range's explicit lower bound so the support metadata and compatibility check stay aligned.
31
+
32
+ ## Custom adapters
33
+
34
+ Applications can provide a `ProviderAdapter<TName>` directly to `createMailer()` without adding a built-in provider. Its `send()` method receives `NormalizedMailInput`: `from` is always set, and `to`, `cc`, and `bcc` are arrays when present. Resolve with `{ messageId }` after the SDK accepts the message. `verifyConnection()` and `close()` are optional; verification rejects with `unsupported` when omitted, while close is a no-op. TypedMailer normalizes thrown errors and tags results with the adapter's name. The generic adapter name is preserved in `SendMailResult<TName>` for TypeScript callers. Custom adapters are responsible for documenting and enforcing unsupported message fields.
package/docs/webhooks.md CHANGED
@@ -4,7 +4,7 @@ TypedMailer exposes `verifyWebhook()` from `typedmailer/webhooks`. It authentica
4
4
 
5
5
  ## Raw request bodies
6
6
 
7
- Pass the exact bytes received by your HTTP framework. Parsing JSON and serializing it again changes the signed input and causes verification to fail. `rawBody` accepts a string or `Uint8Array`; `headers` is a case-insensitive record of request header names and values. If the framework parses request bodies automatically, configure a raw-body capture before its JSON parser.
7
+ Pass the exact bytes received by your HTTP framework. Parsing JSON and serializing it again changes the signed input and causes verification to fail. `rawBody` accepts a string or `Uint8Array`; `headers` is a case-insensitive record of request header names and values. If the framework parses request bodies automatically, configure a raw-body capture before its JSON parser. Apply an HTTP request-body size limit before buffering the body. `verifyWebhook()` also rejects bodies larger than 1 MiB by default; set `maxBodyBytes` to a suitable positive value for your provider and expected batch size. Normalized event batches are limited to 1,000 events by default; set `maxEvents` to adjust that limit. Keep both limits finite and consistent with the upstream request limit.
8
8
 
9
9
  ## Authentication configuration
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typedmailer",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "A type-safe Node.js email library with one API for Resend, Brevo, Postmark, SendGrid, Mailgun, Amazon SES, or SMTP.",
5
5
  "author": "Erol Senol",
6
6
  "license": "MIT",