typedmailer 1.0.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/CODE_OF_CONDUCT.md +20 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/SECURITY.md +9 -0
- package/SUPPORT.md +21 -0
- package/assets/typedmailer-mark.svg +7 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.js +60 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +109 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +207 -0
- package/dist/providers/brevo.d.ts +4 -0
- package/dist/providers/brevo.js +55 -0
- package/dist/providers/mailgun.d.ts +8 -0
- package/dist/providers/mailgun.js +77 -0
- package/dist/providers/postmark.d.ts +4 -0
- package/dist/providers/postmark.js +57 -0
- package/dist/providers/resend.d.ts +4 -0
- package/dist/providers/resend.js +59 -0
- package/dist/providers/sendgrid.d.ts +4 -0
- package/dist/providers/sendgrid.js +69 -0
- package/dist/providers/ses.d.ts +6 -0
- package/dist/providers/ses.js +72 -0
- package/dist/providers/smtp.d.ts +13 -0
- package/dist/providers/smtp.js +71 -0
- package/dist/testing.d.ts +12 -0
- package/dist/testing.js +30 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.js +1 -0
- package/docs/integration-testing.md +12 -0
- package/docs/provider-contracts.md +30 -0
- package/package.json +147 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { MailError, normalizeProviderError } from '../errors.js';
|
|
4
|
+
function toNodemailerAddress(address) {
|
|
5
|
+
if (typeof address === 'string')
|
|
6
|
+
return address;
|
|
7
|
+
return { address: address.email, ...(address.name !== undefined ? { name: address.name } : {}) };
|
|
8
|
+
}
|
|
9
|
+
export function createSmtpProvider(options) {
|
|
10
|
+
const transport = nodemailer.createTransport({
|
|
11
|
+
host: options.host,
|
|
12
|
+
port: options.port,
|
|
13
|
+
secure: options.secure,
|
|
14
|
+
...(options.port === 587 && !options.secure ? { requireTLS: true } : {}),
|
|
15
|
+
connectionTimeout: options.connectionTimeout,
|
|
16
|
+
greetingTimeout: options.greetingTimeout,
|
|
17
|
+
socketTimeout: options.socketTimeout,
|
|
18
|
+
...(options.user && options.password ? { auth: { user: options.user, pass: options.password } } : {}),
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
async send(input) {
|
|
22
|
+
try {
|
|
23
|
+
if (input.idempotencyKey || input.metadata) {
|
|
24
|
+
throw new MailError('SMTP does not support idempotency keys or provider metadata.', 'unsupported', 'smtp', false);
|
|
25
|
+
}
|
|
26
|
+
const result = await transport.sendMail({
|
|
27
|
+
from: toNodemailerAddress(input.from),
|
|
28
|
+
to: input.to.map(toNodemailerAddress),
|
|
29
|
+
subject: input.subject,
|
|
30
|
+
...(input.messageId ? { messageId: input.messageId } : {}),
|
|
31
|
+
...(input.text !== undefined ? { text: input.text } : {}),
|
|
32
|
+
...(input.html !== undefined ? { html: input.html } : {}),
|
|
33
|
+
...(input.replyTo ? { replyTo: toNodemailerAddress(input.replyTo) } : {}),
|
|
34
|
+
...(input.cc ? { cc: input.cc.map(toNodemailerAddress) } : {}),
|
|
35
|
+
...(input.bcc ? { bcc: input.bcc.map(toNodemailerAddress) } : {}),
|
|
36
|
+
...(input.headers ? { headers: input.headers } : {}),
|
|
37
|
+
...(input.attachments
|
|
38
|
+
? {
|
|
39
|
+
attachments: input.attachments.map((attachment) => ({
|
|
40
|
+
filename: attachment.filename,
|
|
41
|
+
content: typeof attachment.content === 'string' ? attachment.content : Buffer.from(attachment.content),
|
|
42
|
+
...(attachment.contentType ? { contentType: attachment.contentType } : {}),
|
|
43
|
+
...(attachment.contentId ? { cid: attachment.contentId } : {}),
|
|
44
|
+
})),
|
|
45
|
+
}
|
|
46
|
+
: {}),
|
|
47
|
+
});
|
|
48
|
+
if (!result.messageId) {
|
|
49
|
+
throw new MailError('SMTP returned no message identifier.', 'provider', 'smtp', false, {
|
|
50
|
+
deliveryUnknown: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { messageId: result.messageId };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw normalizeProviderError(error, 'smtp');
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async verifyConnection() {
|
|
60
|
+
try {
|
|
61
|
+
await transport.verify();
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw normalizeProviderError(error, 'smtp');
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async close() {
|
|
68
|
+
transport.close();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Mailer, SendMailInput } from './types.js';
|
|
2
|
+
export interface CapturedMail extends SendMailInput {
|
|
3
|
+
readonly from: NonNullable<SendMailInput['from']>;
|
|
4
|
+
}
|
|
5
|
+
export interface TestMailer extends Mailer {
|
|
6
|
+
readonly sent: CapturedMail[];
|
|
7
|
+
clear(): void;
|
|
8
|
+
}
|
|
9
|
+
/** In-memory mailer for application tests and local flows. It never sends network requests. */
|
|
10
|
+
export declare function createTestMailer(options: {
|
|
11
|
+
from: NonNullable<SendMailInput['from']>;
|
|
12
|
+
}): TestMailer;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MailError } from './errors.js';
|
|
2
|
+
import { mailInputSchema } from './config.js';
|
|
3
|
+
/** In-memory mailer for application tests and local flows. It never sends network requests. */
|
|
4
|
+
export function createTestMailer(options) {
|
|
5
|
+
const sent = [];
|
|
6
|
+
let closed = false;
|
|
7
|
+
const assertOpen = () => {
|
|
8
|
+
if (closed)
|
|
9
|
+
throw new MailError('Mailer has been closed.', 'configuration', 'test', false);
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
sent,
|
|
13
|
+
clear() {
|
|
14
|
+
sent.length = 0;
|
|
15
|
+
},
|
|
16
|
+
async send(input) {
|
|
17
|
+
assertOpen();
|
|
18
|
+
const message = { ...input, from: input.from ?? options.from };
|
|
19
|
+
mailInputSchema.parse(message);
|
|
20
|
+
sent.push(message);
|
|
21
|
+
return { provider: 'test', messageId: `test-${sent.length}`, acceptedAt: new Date() };
|
|
22
|
+
},
|
|
23
|
+
async verifyConnection() {
|
|
24
|
+
assertOpen();
|
|
25
|
+
},
|
|
26
|
+
async close() {
|
|
27
|
+
closed = true;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type MailAddress = string | {
|
|
2
|
+
readonly email: string;
|
|
3
|
+
readonly name?: string;
|
|
4
|
+
};
|
|
5
|
+
export interface MailAttachment {
|
|
6
|
+
readonly filename: string;
|
|
7
|
+
readonly content: string | Uint8Array;
|
|
8
|
+
readonly contentType?: string;
|
|
9
|
+
readonly contentId?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SendMailInput {
|
|
12
|
+
readonly from?: MailAddress;
|
|
13
|
+
readonly to: MailAddress | readonly MailAddress[];
|
|
14
|
+
readonly subject: string;
|
|
15
|
+
readonly messageId?: string;
|
|
16
|
+
readonly text?: string;
|
|
17
|
+
readonly html?: string;
|
|
18
|
+
readonly replyTo?: MailAddress;
|
|
19
|
+
readonly cc?: MailAddress | readonly MailAddress[];
|
|
20
|
+
readonly bcc?: MailAddress | readonly MailAddress[];
|
|
21
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
22
|
+
readonly attachments?: readonly MailAttachment[];
|
|
23
|
+
readonly idempotencyKey?: string;
|
|
24
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
25
|
+
}
|
|
26
|
+
export type ProviderName = 'resend' | 'brevo' | 'smtp' | 'postmark' | 'sendgrid' | 'mailgun' | 'ses';
|
|
27
|
+
export interface SendMailResult {
|
|
28
|
+
readonly provider: ProviderName | 'test';
|
|
29
|
+
readonly messageId: string;
|
|
30
|
+
/** Time when the provider accepted the request. This does not confirm inbox delivery. */
|
|
31
|
+
readonly acceptedAt: Date;
|
|
32
|
+
}
|
|
33
|
+
export interface Mailer {
|
|
34
|
+
send(input: SendMailInput): Promise<SendMailResult>;
|
|
35
|
+
verifyConnection(): Promise<void>;
|
|
36
|
+
/** Rejects new operations, waits for active operations, and closes the provider at most once. */
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export interface NormalizedMailInput extends Omit<SendMailInput, 'from' | 'to' | 'cc' | 'bcc' | 'replyTo'> {
|
|
40
|
+
readonly from: MailAddress;
|
|
41
|
+
readonly to: readonly MailAddress[];
|
|
42
|
+
readonly cc?: readonly MailAddress[];
|
|
43
|
+
readonly bcc?: readonly MailAddress[];
|
|
44
|
+
readonly replyTo?: MailAddress;
|
|
45
|
+
}
|
|
46
|
+
export interface ProviderSendResult {
|
|
47
|
+
readonly messageId: string;
|
|
48
|
+
}
|
|
49
|
+
export interface MailProvider {
|
|
50
|
+
send(input: NormalizedMailInput): Promise<ProviderSendResult>;
|
|
51
|
+
verifyConnection(): Promise<void>;
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Integration smoke workflow
|
|
2
|
+
|
|
3
|
+
The `Provider integration smoke` workflow runs weekly or manually. It is intentionally separate from pull request CI. It always sends one message to an isolated Mailpit service; this does not contact a real mailbox.
|
|
4
|
+
|
|
5
|
+
The workflow can also send controlled live messages through Resend or Amazon SES. To enable one, set the repository variable `TYPEDMAILER_INTEGRATION_PROVIDERS` to a comma-separated list such as `mailpit,resend` or `mailpit,ses`, then configure the following values:
|
|
6
|
+
|
|
7
|
+
| Provider | Repository variables | Repository secrets |
|
|
8
|
+
| ---------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
|
9
|
+
| Resend | `TYPEDMAILER_SMOKE_FROM`, `TYPEDMAILER_SMOKE_TO` | `TYPEDMAILER_RESEND_API_KEY` |
|
|
10
|
+
| Amazon SES | `TYPEDMAILER_SMOKE_FROM`, `TYPEDMAILER_SMOKE_TO`, `TYPEDMAILER_AWS_REGION`, `TYPEDMAILER_AWS_ROLE_ARN` | No long-lived AWS access key; configure the GitHub OIDC provider and a narrowly scoped IAM role |
|
|
11
|
+
|
|
12
|
+
For SES, the repository already has a `provider-smoke` GitHub environment limited to the `main` branch. Configure the AWS GitHub OIDC provider and a role with trust subject `repo:erolsenol/typedmailer:environment:provider-smoke`, then set `TYPEDMAILER_AWS_ROLE_ARN` to that role's ARN. Grant it only `ses:SendEmail` for the test identity. Use a verified sender and a mailbox reserved for automated tests. Enabling a provider sends a real email whenever the workflow runs. Remove that provider from the variable to stop live sends. The workflow never enables live providers implicitly.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Provider behavior contracts
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Shared guarantees
|
|
6
|
+
|
|
7
|
+
- Provider SDKs are optional peers. Only the selected adapter is loaded, and a missing selected SDK becomes a `MailError` with code `configuration`.
|
|
8
|
+
- `send()` resolves when the provider reports that it accepted the request. It does not prove inbox delivery.
|
|
9
|
+
- A successful result includes the selected provider, provider message ID, and local acceptance timestamp. If the provider gives no ID, `messageId` is an empty string.
|
|
10
|
+
- TypedMailer does not retry automatically. `retryable` and `deliveryUnknown` are diagnostic signals, not a safe retry instruction. A timeout or ambiguous provider response can mean the provider accepted the message.
|
|
11
|
+
- Unsupported fields or operations fail with `MailError` code `unsupported`; adapters must not silently discard caller data.
|
|
12
|
+
- `close()` is safe to call repeatedly, stops new work, waits for in-flight operations, and closes the underlying transport at most once.
|
|
13
|
+
|
|
14
|
+
## Capability map
|
|
15
|
+
|
|
16
|
+
| Provider | Custom message ID | Idempotency key | Metadata | Inline attachments | Connection verification |
|
|
17
|
+
| ---------- | ----------------- | --------------- | -------- | ------------------ | ----------------------- |
|
|
18
|
+
| Resend | No | Yes | Yes | Yes | Unsupported |
|
|
19
|
+
| Brevo | No | No | Yes | No | Unsupported |
|
|
20
|
+
| Postmark | No | No | Yes | Yes | Unsupported |
|
|
21
|
+
| SendGrid | No | No | Yes | Yes | Unsupported |
|
|
22
|
+
| Mailgun | No | No | Yes | Yes | Unsupported |
|
|
23
|
+
| Amazon SES | No | No | Yes | Yes | Unsupported |
|
|
24
|
+
| SMTP | Yes | No | No | Yes | SMTP connection check |
|
|
25
|
+
|
|
26
|
+
Attachments are buffered for provider SDK requests. Large-file streaming belongs in the application. `contentId` is supported by the providers listed as supporting inline attachments. `verifyConnection()` is only available for SMTP because the API providers do not offer a side-effect-free check through this interface.
|
|
27
|
+
|
|
28
|
+
## Updating a provider adapter
|
|
29
|
+
|
|
30
|
+
When changing an adapter, 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.
|
package/package.json
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "typedmailer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A type-safe Node.js email library with one API for Resend, Brevo, Postmark, SendGrid, Mailgun, Amazon SES, or SMTP.",
|
|
5
|
+
"author": "Erol Senol",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./testing": {
|
|
19
|
+
"types": "./dist/testing.d.ts",
|
|
20
|
+
"import": "./dist/testing.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"assets/typedmailer-mark.svg",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"SECURITY.md",
|
|
30
|
+
"SUPPORT.md",
|
|
31
|
+
"docs/provider-contracts.md",
|
|
32
|
+
"docs/integration-testing.md",
|
|
33
|
+
"CODE_OF_CONDUCT.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
38
|
+
"lint": "eslint .",
|
|
39
|
+
"format:check": "prettier --check .",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"release:check": "node scripts/release-preflight.mjs",
|
|
42
|
+
"examples:check": "node --check examples/resend.mjs && node --check examples/ses.mjs && node --check examples/smtp-mailpit.mjs",
|
|
43
|
+
"security:audit": "npm audit --audit-level=high",
|
|
44
|
+
"package:smoke": "node scripts/package-smoke.mjs",
|
|
45
|
+
"integration:smoke": "node scripts/live-provider-smoke.mjs",
|
|
46
|
+
"pack:check": "npm pack --dry-run --ignore-scripts && npm run package:smoke",
|
|
47
|
+
"check": "npm run lint && npm run format:check && npm run test && npm run typecheck && npm run examples:check && npm run build && npm run pack:check",
|
|
48
|
+
"prepare": "npm run build && husky"
|
|
49
|
+
},
|
|
50
|
+
"lint-staged": {
|
|
51
|
+
"*.ts": [
|
|
52
|
+
"eslint",
|
|
53
|
+
"prettier --check"
|
|
54
|
+
],
|
|
55
|
+
"*.{json,md,yml,yaml,js,mjs,cjs}": [
|
|
56
|
+
"prettier --check"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"zod": "^4.6.5"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@aws-sdk/client-sesv2": ">=3.0.0 <4",
|
|
64
|
+
"@getbrevo/brevo": ">=6.0.1 <7",
|
|
65
|
+
"@sendgrid/mail": ">=8.0.0 <9",
|
|
66
|
+
"form-data": ">=4.0.0 <5",
|
|
67
|
+
"mailgun.js": ">=14.0.0 <15",
|
|
68
|
+
"nodemailer": ">=7.0.0 <11",
|
|
69
|
+
"postmark": ">=5.0.0 <6",
|
|
70
|
+
"resend": ">=6.0.0 <7"
|
|
71
|
+
},
|
|
72
|
+
"peerDependenciesMeta": {
|
|
73
|
+
"@aws-sdk/client-sesv2": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@getbrevo/brevo": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"@sendgrid/mail": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"form-data": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"mailgun.js": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"nodemailer": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
91
|
+
"postmark": {
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"resend": {
|
|
95
|
+
"optional": true
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"devDependencies": {
|
|
99
|
+
"@aws-sdk/client-sesv2": ">=3.0.0 <4",
|
|
100
|
+
"@getbrevo/brevo": ">=6.0.1 <7",
|
|
101
|
+
"@sendgrid/mail": ">=8.0.0 <9",
|
|
102
|
+
"@types/node": "^24.5.2",
|
|
103
|
+
"@types/nodemailer": "^7.0.1",
|
|
104
|
+
"eslint": "^10.11.0",
|
|
105
|
+
"form-data": ">=4.0.0 <5",
|
|
106
|
+
"husky": "^9.1.7",
|
|
107
|
+
"lint-staged": "^17.6.0",
|
|
108
|
+
"mailgun.js": ">=14.0.0 <15",
|
|
109
|
+
"nodemailer": ">=7.0.0 <11",
|
|
110
|
+
"postmark": ">=5.0.0 <6",
|
|
111
|
+
"prettier": "^3.9.9",
|
|
112
|
+
"resend": ">=6.0.0 <7",
|
|
113
|
+
"typescript": "^5.9.2",
|
|
114
|
+
"typescript-eslint": "^8.70.1",
|
|
115
|
+
"vitest": "^5.0.2"
|
|
116
|
+
},
|
|
117
|
+
"repository": {
|
|
118
|
+
"type": "git",
|
|
119
|
+
"url": "https://github.com/erolsenol/typedmailer.git"
|
|
120
|
+
},
|
|
121
|
+
"bugs": {
|
|
122
|
+
"url": "https://github.com/erolsenol/typedmailer/issues"
|
|
123
|
+
},
|
|
124
|
+
"homepage": "https://github.com/erolsenol/typedmailer#readme",
|
|
125
|
+
"keywords": [
|
|
126
|
+
"email",
|
|
127
|
+
"email-client",
|
|
128
|
+
"email-library",
|
|
129
|
+
"email-provider",
|
|
130
|
+
"email-sending",
|
|
131
|
+
"amazon-ses",
|
|
132
|
+
"mailer",
|
|
133
|
+
"mail",
|
|
134
|
+
"node",
|
|
135
|
+
"mailgun",
|
|
136
|
+
"ses",
|
|
137
|
+
"typescript",
|
|
138
|
+
"type-safe",
|
|
139
|
+
"transactional-email",
|
|
140
|
+
"smtp",
|
|
141
|
+
"resend",
|
|
142
|
+
"brevo",
|
|
143
|
+
"postmark",
|
|
144
|
+
"sendgrid",
|
|
145
|
+
"nodemailer"
|
|
146
|
+
]
|
|
147
|
+
}
|