typedmailer 1.2.0 → 1.3.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
@@ -176,6 +176,32 @@ Provider capabilities differ. Unsupported fields return a `MailError` with code
176
176
  | Amazon SES | No | No | Yes | Yes | Unsupported |
177
177
  | SMTP | Yes | No | No | Yes | Yes |
178
178
 
179
+ ### Custom providers
180
+
181
+ 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.
182
+
183
+ ```ts
184
+ import { createMailer, type ProviderAdapter } from 'typedmailer';
185
+
186
+ const adapter: ProviderAdapter<'acme'> = {
187
+ name: 'acme',
188
+ async send(message) {
189
+ const response = await acmeClient.sendEmail(message);
190
+ return { messageId: response.id };
191
+ },
192
+ async close() {
193
+ await acmeClient.close();
194
+ },
195
+ };
196
+
197
+ const mailer = createMailer({ provider: adapter, from: 'noreply@example.com' });
198
+ const result = await mailer.send({ to: 'person@example.com', subject: 'Hello', text: 'Hi' });
199
+ // result.provider is typed as "acme".
200
+ await mailer.close();
201
+ ```
202
+
203
+ 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.
204
+
179
205
  ```ts
180
206
  await mailer.send({
181
207
  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() {
@@ -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<{
@@ -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,18 +10,18 @@ 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),
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[];
@@ -15,6 +15,7 @@ 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;
18
19
  readonly now?: Date;
19
20
  readonly toleranceSeconds?: number;
20
21
  }
package/dist/webhooks.js CHANGED
@@ -4,6 +4,11 @@ import { normalizeMailgun, normalizeProviderEvents, normalizeResend, normalizeSe
4
4
  export { WebhookVerificationError } from './webhooks/types.js';
5
5
  /** Authenticates a provider webhook before returning normalized, typed email events. */
6
6
  export async function verifyWebhook(input) {
7
+ const rawBodyByteLength = typeof input.rawBody === 'string' ? Buffer.byteLength(input.rawBody) : input.rawBody.byteLength;
8
+ if (input.maxBodyBytes !== undefined &&
9
+ (!Number.isSafeInteger(input.maxBodyBytes) || input.maxBodyBytes < 1 || rawBodyByteLength > input.maxBodyBytes)) {
10
+ throw new WebhookVerificationError('invalid_payload');
11
+ }
7
12
  const rawBody = Buffer.from(input.rawBody);
8
13
  let payload;
9
14
  try {
@@ -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. You can also set `maxBodyBytes` on `verifyWebhook()` to reject an oversized payload before JSON parsing; it has no default because provider event batches vary in size.
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.3.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",