groundmail-nodemailer 0.0.0-stage → 0.14.2
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 +50 -2
- package/dist/index.d.ts +20 -0
- package/dist/index.js +187 -0
- package/package.json +61 -4
- package/src/index.ts +16 -0
- package/src/transport.ts +228 -0
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# groundmail-nodemailer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**[Nodemailer](https://nodemailer.com) transport for [Groundmail](https://groundmail.dev)** — keep `transporter.sendMail(...)` and deliver over the Groundmail HTTP API (not SMTP).
|
|
4
|
+
|
|
5
|
+
This repository is the **source of truth** for the transport implementation. Issues and PRs belong here. The HTTP client lives in [`groundmail`](https://www.npmjs.com/package/groundmail).
|
|
6
|
+
|
|
7
|
+
Works anywhere Nodemailer is accepted: Express apps, Payload, Auth.js email, Strapi, Ghost, workers, and custom stacks.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install groundmail-nodemailer groundmail nodemailer
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
GROUNDMAIL_API_KEY=gm_your_key_here
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import nodemailer from 'nodemailer'
|
|
23
|
+
import { createGroundmailTransport } from 'groundmail-nodemailer'
|
|
24
|
+
|
|
25
|
+
const transporter = nodemailer.createTransport(
|
|
26
|
+
createGroundmailTransport({
|
|
27
|
+
apiKey: process.env.GROUNDMAIL_API_KEY!,
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
await transporter.sendMail({
|
|
32
|
+
from: 'ops@yourdomain.com',
|
|
33
|
+
to: 'user@example.com',
|
|
34
|
+
subject: 'Your receipt',
|
|
35
|
+
html: '<p>Thanks for your purchase.</p>',
|
|
36
|
+
headers: {
|
|
37
|
+
'X-Groundmail-Tag': 'campaign=welcome',
|
|
38
|
+
'X-Groundmail-Idempotency-Key': 'receipt-123',
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The same transport is also available as `groundmail/nodemailer` (re-export).
|
|
44
|
+
|
|
45
|
+
## Docs
|
|
46
|
+
|
|
47
|
+
https://docs.groundmail.dev/frameworks/node
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Transport, TransportOptions } from 'nodemailer';
|
|
2
|
+
import MailMessage from 'nodemailer/lib/mailer/mail-message.js';
|
|
3
|
+
import { Groundmail, SendEmailParams } from 'groundmail';
|
|
4
|
+
|
|
5
|
+
declare const HEADER_IDEMPOTENCY = "X-Groundmail-Idempotency-Key";
|
|
6
|
+
declare const HEADER_SCHEDULED_AT = "X-Groundmail-Scheduled-At";
|
|
7
|
+
declare const HEADER_TAG = "X-Groundmail-Tag";
|
|
8
|
+
type GroundmailTransportOptions = {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/** Prebuilt client (tests / advanced wiring). */
|
|
12
|
+
client?: Groundmail;
|
|
13
|
+
};
|
|
14
|
+
declare function mailDataToSendParams(data: MailMessage['data']): Promise<SendEmailParams>;
|
|
15
|
+
/** Nodemailer transport that delivers via Groundmail HTTP API. */
|
|
16
|
+
declare function createGroundmailTransport(options: GroundmailTransportOptions): Transport & TransportOptions;
|
|
17
|
+
/** Alias matching common `createTransport(transport(...))` naming. */
|
|
18
|
+
declare const groundmailTransport: typeof createGroundmailTransport;
|
|
19
|
+
|
|
20
|
+
export { type GroundmailTransportOptions, HEADER_IDEMPOTENCY, HEADER_SCHEDULED_AT, HEADER_TAG, createGroundmailTransport, groundmailTransport, mailDataToSendParams };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// src/transport.ts
|
|
2
|
+
import {
|
|
3
|
+
Groundmail,
|
|
4
|
+
DEFAULT_API_BASE_URL,
|
|
5
|
+
GroundmailError
|
|
6
|
+
} from "groundmail";
|
|
7
|
+
var HEADER_IDEMPOTENCY = "X-Groundmail-Idempotency-Key";
|
|
8
|
+
var HEADER_SCHEDULED_AT = "X-Groundmail-Scheduled-At";
|
|
9
|
+
var HEADER_TAG = "X-Groundmail-Tag";
|
|
10
|
+
var RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
11
|
+
"from",
|
|
12
|
+
"to",
|
|
13
|
+
"cc",
|
|
14
|
+
"bcc",
|
|
15
|
+
"reply-to",
|
|
16
|
+
"subject",
|
|
17
|
+
"content-type",
|
|
18
|
+
"mime-version",
|
|
19
|
+
"date",
|
|
20
|
+
"message-id",
|
|
21
|
+
"x-groundmail-idempotency-key",
|
|
22
|
+
"x-groundmail-scheduled-at",
|
|
23
|
+
"x-groundmail-tag",
|
|
24
|
+
"idempotency-key"
|
|
25
|
+
]);
|
|
26
|
+
function formatOne(addr) {
|
|
27
|
+
if (typeof addr === "string") return addr.trim();
|
|
28
|
+
const email = addr.address?.trim();
|
|
29
|
+
if (!email) return "";
|
|
30
|
+
const name = addr.name?.trim();
|
|
31
|
+
return name ? `${name} <${email}>` : email;
|
|
32
|
+
}
|
|
33
|
+
function formatAddresses(value) {
|
|
34
|
+
if (value == null) return [];
|
|
35
|
+
const list = Array.isArray(value) ? value : [value];
|
|
36
|
+
return list.map(formatOne).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
function headerMap(headers) {
|
|
39
|
+
const out = {};
|
|
40
|
+
if (!headers) return out;
|
|
41
|
+
if (Array.isArray(headers)) {
|
|
42
|
+
for (const entry of headers) {
|
|
43
|
+
if (!entry || typeof entry !== "object") continue;
|
|
44
|
+
const key = String(entry.key || "").toLowerCase();
|
|
45
|
+
const value = String(entry.value ?? "");
|
|
46
|
+
if (!key) continue;
|
|
47
|
+
(out[key] ??= []).push(value);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
for (const [key, raw] of Object.entries(headers)) {
|
|
52
|
+
const k = key.toLowerCase();
|
|
53
|
+
if (Array.isArray(raw)) {
|
|
54
|
+
out[k] = raw.map(String);
|
|
55
|
+
} else if (raw != null) {
|
|
56
|
+
out[k] = [String(raw)];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
async function attachmentContentToBase64(content) {
|
|
62
|
+
if (content == null) {
|
|
63
|
+
throw new Error("Attachment is missing content");
|
|
64
|
+
}
|
|
65
|
+
if (typeof content === "string") {
|
|
66
|
+
return Buffer.from(content).toString("base64");
|
|
67
|
+
}
|
|
68
|
+
if (Buffer.isBuffer(content)) {
|
|
69
|
+
return content.toString("base64");
|
|
70
|
+
}
|
|
71
|
+
if (content instanceof Uint8Array) {
|
|
72
|
+
return Buffer.from(content).toString("base64");
|
|
73
|
+
}
|
|
74
|
+
throw new Error("Unsupported attachment content type");
|
|
75
|
+
}
|
|
76
|
+
async function mailDataToSendParams(data) {
|
|
77
|
+
const fromList = formatAddresses(data.from);
|
|
78
|
+
if (!fromList.length) throw new Error("Email is missing a From address.");
|
|
79
|
+
const to = formatAddresses(data.to);
|
|
80
|
+
if (!to.length) throw new Error("Email is missing a To recipient.");
|
|
81
|
+
const html = typeof data.html === "string" ? data.html : void 0;
|
|
82
|
+
const text = typeof data.text === "string" ? data.text : void 0;
|
|
83
|
+
if (!html && !text) {
|
|
84
|
+
throw new Error("Email must include html or text content.");
|
|
85
|
+
}
|
|
86
|
+
const headers = headerMap(data.headers);
|
|
87
|
+
const params = {
|
|
88
|
+
from: fromList[0],
|
|
89
|
+
to: to.length === 1 ? to[0] : to
|
|
90
|
+
};
|
|
91
|
+
if (typeof data.subject === "string" && data.subject) params.subject = data.subject;
|
|
92
|
+
if (html) params.html = html;
|
|
93
|
+
if (text) params.text = text;
|
|
94
|
+
const reply = formatAddresses(data.replyTo);
|
|
95
|
+
if (reply.length) params.replyTo = reply.length === 1 ? reply[0] : reply;
|
|
96
|
+
const cc = formatAddresses(data.cc);
|
|
97
|
+
if (cc.length) params.cc = cc;
|
|
98
|
+
const bcc = formatAddresses(data.bcc);
|
|
99
|
+
if (bcc.length) params.bcc = bcc;
|
|
100
|
+
if (data.attachments?.length) {
|
|
101
|
+
params.attachments = await Promise.all(
|
|
102
|
+
data.attachments.map(async (att) => {
|
|
103
|
+
const filename = att.filename || att.cid || "attachment";
|
|
104
|
+
const contentType = att.contentType || "application/octet-stream";
|
|
105
|
+
if (att.path || typeof att.raw === "string") {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Attachment "${filename}" must include inline content (Buffer/string). path/raw are not supported.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
filename: String(filename),
|
|
112
|
+
contentType,
|
|
113
|
+
content: await attachmentContentToBase64(att.content),
|
|
114
|
+
...att.cid ? { contentId: String(att.cid) } : {}
|
|
115
|
+
};
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const tagValues = headers["x-groundmail-tag"] ?? [];
|
|
120
|
+
if (tagValues.length) {
|
|
121
|
+
params.tags = tagValues.flatMap((raw) => {
|
|
122
|
+
const trimmed = raw.trim();
|
|
123
|
+
if (!trimmed) return [];
|
|
124
|
+
if (trimmed.includes("=")) {
|
|
125
|
+
const [name, value] = trimmed.split("=", 2);
|
|
126
|
+
if (name.trim() && value.trim()) return [{ name: name.trim(), value: value.trim() }];
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
return [{ name: "tag", value: trimmed }];
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const forward = {};
|
|
133
|
+
for (const [key, values] of Object.entries(headers)) {
|
|
134
|
+
if (RESERVED_HEADERS.has(key)) continue;
|
|
135
|
+
if (key.startsWith("content-")) continue;
|
|
136
|
+
const value = values[values.length - 1]?.trim();
|
|
137
|
+
if (value) forward[key] = value;
|
|
138
|
+
}
|
|
139
|
+
if (Object.keys(forward).length) params.headers = forward;
|
|
140
|
+
const idem = headers["x-groundmail-idempotency-key"]?.[0]?.trim() || headers["idempotency-key"]?.[0]?.trim();
|
|
141
|
+
if (idem) params.idempotencyKey = idem;
|
|
142
|
+
const scheduled = headers["x-groundmail-scheduled-at"]?.[0]?.trim();
|
|
143
|
+
if (scheduled) params.scheduledAt = scheduled;
|
|
144
|
+
return params;
|
|
145
|
+
}
|
|
146
|
+
function createGroundmailTransport(options) {
|
|
147
|
+
const client = options.client ?? new Groundmail(options.apiKey, {
|
|
148
|
+
baseUrl: options.baseUrl ?? DEFAULT_API_BASE_URL
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
name: "Groundmail",
|
|
152
|
+
version: "0.14.0",
|
|
153
|
+
send(mail, callback) {
|
|
154
|
+
;
|
|
155
|
+
(async () => {
|
|
156
|
+
const params = await mailDataToSendParams(mail.data);
|
|
157
|
+
const result = await client.emails.send(params);
|
|
158
|
+
const envelope = mail.message.getEnvelope();
|
|
159
|
+
return {
|
|
160
|
+
envelope,
|
|
161
|
+
messageId: result.id,
|
|
162
|
+
accepted: envelope.to,
|
|
163
|
+
rejected: [],
|
|
164
|
+
pending: [],
|
|
165
|
+
response: `${result.status} ${result.id}`
|
|
166
|
+
};
|
|
167
|
+
})().then(
|
|
168
|
+
(info) => {
|
|
169
|
+
callback(null, info);
|
|
170
|
+
},
|
|
171
|
+
(err) => {
|
|
172
|
+
const error = err instanceof GroundmailError ? err : err instanceof Error ? err : new Error(String(err));
|
|
173
|
+
callback(error, void 0);
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
var groundmailTransport = createGroundmailTransport;
|
|
180
|
+
export {
|
|
181
|
+
HEADER_IDEMPOTENCY,
|
|
182
|
+
HEADER_SCHEDULED_AT,
|
|
183
|
+
HEADER_TAG,
|
|
184
|
+
createGroundmailTransport,
|
|
185
|
+
groundmailTransport,
|
|
186
|
+
mailDataToSendParams
|
|
187
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groundmail-nodemailer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.14.2",
|
|
4
|
+
"description": "Nodemailer transport for Groundmail transactional email (HTTP API, not SMTP)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"src",
|
|
19
|
+
"!src/**/*.test.ts"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Groundmail/groundmail-nodemailer.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://docs.groundmail.dev/frameworks/node",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/Groundmail/groundmail-nodemailer/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"nodemailer",
|
|
31
|
+
"nodemailer-transport",
|
|
32
|
+
"email",
|
|
33
|
+
"transactional",
|
|
34
|
+
"transport",
|
|
35
|
+
"groundmail",
|
|
36
|
+
"smtp"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"nodemailer": ">=6.0.0",
|
|
46
|
+
"groundmail": ">=0.13.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"groundmail": "0.14.2"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/nodemailer": "^8.0.1",
|
|
53
|
+
"nodemailer": "^9.0.3",
|
|
54
|
+
"tsup": "^8.5.0",
|
|
55
|
+
"typescript": "^5",
|
|
56
|
+
"vitest": "^3.2.4"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsup src/index.ts --format esm --dts --external groundmail --external nodemailer --clean",
|
|
60
|
+
"test": "vitest run",
|
|
61
|
+
"prepublishOnly": "npm run build"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nodemailer transport for Groundmail.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth for the transport lives in this package (public GitHub repo
|
|
5
|
+
* Groundmail/groundmail-nodemailer). The core `groundmail` SDK re-exports
|
|
6
|
+
* these symbols as `groundmail/nodemailer` for backward compatibility.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
createGroundmailTransport,
|
|
10
|
+
groundmailTransport,
|
|
11
|
+
mailDataToSendParams,
|
|
12
|
+
HEADER_IDEMPOTENCY,
|
|
13
|
+
HEADER_SCHEDULED_AT,
|
|
14
|
+
HEADER_TAG,
|
|
15
|
+
type GroundmailTransportOptions,
|
|
16
|
+
} from './transport'
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { Transport, TransportOptions } from 'nodemailer'
|
|
2
|
+
import type MailMessage from 'nodemailer/lib/mailer/mail-message.js'
|
|
3
|
+
import type { Address as NodemailerAddress } from 'nodemailer/lib/mailer/index.js'
|
|
4
|
+
import {
|
|
5
|
+
Groundmail,
|
|
6
|
+
DEFAULT_API_BASE_URL,
|
|
7
|
+
GroundmailError,
|
|
8
|
+
type SendEmailParams,
|
|
9
|
+
} from 'groundmail'
|
|
10
|
+
|
|
11
|
+
export const HEADER_IDEMPOTENCY = 'X-Groundmail-Idempotency-Key'
|
|
12
|
+
export const HEADER_SCHEDULED_AT = 'X-Groundmail-Scheduled-At'
|
|
13
|
+
export const HEADER_TAG = 'X-Groundmail-Tag'
|
|
14
|
+
|
|
15
|
+
const RESERVED_HEADERS = new Set([
|
|
16
|
+
'from',
|
|
17
|
+
'to',
|
|
18
|
+
'cc',
|
|
19
|
+
'bcc',
|
|
20
|
+
'reply-to',
|
|
21
|
+
'subject',
|
|
22
|
+
'content-type',
|
|
23
|
+
'mime-version',
|
|
24
|
+
'date',
|
|
25
|
+
'message-id',
|
|
26
|
+
'x-groundmail-idempotency-key',
|
|
27
|
+
'x-groundmail-scheduled-at',
|
|
28
|
+
'x-groundmail-tag',
|
|
29
|
+
'idempotency-key',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
export type GroundmailTransportOptions = {
|
|
33
|
+
apiKey: string
|
|
34
|
+
baseUrl?: string
|
|
35
|
+
/** Prebuilt client (tests / advanced wiring). */
|
|
36
|
+
client?: Groundmail
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type AddressLike = string | NodemailerAddress | Array<string | NodemailerAddress> | undefined
|
|
40
|
+
|
|
41
|
+
function formatOne(addr: string | NodemailerAddress): string {
|
|
42
|
+
if (typeof addr === 'string') return addr.trim()
|
|
43
|
+
const email = addr.address?.trim()
|
|
44
|
+
if (!email) return ''
|
|
45
|
+
const name = addr.name?.trim()
|
|
46
|
+
return name ? `${name} <${email}>` : email
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatAddresses(value: AddressLike): string[] {
|
|
50
|
+
if (value == null) return []
|
|
51
|
+
const list = Array.isArray(value) ? value : [value]
|
|
52
|
+
return list.map(formatOne).filter(Boolean)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function headerMap(headers: MailMessage['data']['headers']): Record<string, string[]> {
|
|
56
|
+
const out: Record<string, string[]> = {}
|
|
57
|
+
if (!headers) return out
|
|
58
|
+
|
|
59
|
+
if (Array.isArray(headers)) {
|
|
60
|
+
for (const entry of headers) {
|
|
61
|
+
if (!entry || typeof entry !== 'object') continue
|
|
62
|
+
const key = String((entry as { key?: string }).key || '').toLowerCase()
|
|
63
|
+
const value = String((entry as { value?: string }).value ?? '')
|
|
64
|
+
if (!key) continue
|
|
65
|
+
;(out[key] ??= []).push(value)
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const [key, raw] of Object.entries(headers)) {
|
|
71
|
+
const k = key.toLowerCase()
|
|
72
|
+
if (Array.isArray(raw)) {
|
|
73
|
+
out[k] = raw.map(String)
|
|
74
|
+
} else if (raw != null) {
|
|
75
|
+
out[k] = [String(raw)]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function attachmentContentToBase64(content: unknown): Promise<string> {
|
|
82
|
+
if (content == null) {
|
|
83
|
+
throw new Error('Attachment is missing content')
|
|
84
|
+
}
|
|
85
|
+
if (typeof content === 'string') {
|
|
86
|
+
return Buffer.from(content).toString('base64')
|
|
87
|
+
}
|
|
88
|
+
if (Buffer.isBuffer(content)) {
|
|
89
|
+
return content.toString('base64')
|
|
90
|
+
}
|
|
91
|
+
if (content instanceof Uint8Array) {
|
|
92
|
+
return Buffer.from(content).toString('base64')
|
|
93
|
+
}
|
|
94
|
+
throw new Error('Unsupported attachment content type')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function mailDataToSendParams(data: MailMessage['data']): Promise<SendEmailParams> {
|
|
98
|
+
const fromList = formatAddresses(data.from as AddressLike)
|
|
99
|
+
if (!fromList.length) throw new Error('Email is missing a From address.')
|
|
100
|
+
const to = formatAddresses(data.to as AddressLike)
|
|
101
|
+
if (!to.length) throw new Error('Email is missing a To recipient.')
|
|
102
|
+
|
|
103
|
+
const html = typeof data.html === 'string' ? data.html : undefined
|
|
104
|
+
const text = typeof data.text === 'string' ? data.text : undefined
|
|
105
|
+
if (!html && !text) {
|
|
106
|
+
throw new Error('Email must include html or text content.')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const headers = headerMap(data.headers)
|
|
110
|
+
const params: SendEmailParams = {
|
|
111
|
+
from: fromList[0],
|
|
112
|
+
to: to.length === 1 ? to[0] : to,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof data.subject === 'string' && data.subject) params.subject = data.subject
|
|
116
|
+
if (html) params.html = html
|
|
117
|
+
if (text) params.text = text
|
|
118
|
+
|
|
119
|
+
const reply = formatAddresses(data.replyTo as AddressLike)
|
|
120
|
+
if (reply.length) params.replyTo = reply.length === 1 ? reply[0] : reply
|
|
121
|
+
|
|
122
|
+
const cc = formatAddresses(data.cc as AddressLike)
|
|
123
|
+
if (cc.length) params.cc = cc
|
|
124
|
+
const bcc = formatAddresses(data.bcc as AddressLike)
|
|
125
|
+
if (bcc.length) params.bcc = bcc
|
|
126
|
+
|
|
127
|
+
if (data.attachments?.length) {
|
|
128
|
+
params.attachments = await Promise.all(
|
|
129
|
+
data.attachments.map(async (att) => {
|
|
130
|
+
const filename = att.filename || att.cid || 'attachment'
|
|
131
|
+
const contentType = att.contentType || 'application/octet-stream'
|
|
132
|
+
if (att.path || typeof att.raw === 'string') {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Attachment "${filename}" must include inline content (Buffer/string). path/raw are not supported.`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
filename: String(filename),
|
|
139
|
+
contentType,
|
|
140
|
+
content: await attachmentContentToBase64(att.content),
|
|
141
|
+
...(att.cid ? { contentId: String(att.cid) } : {}),
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tagValues = headers['x-groundmail-tag'] ?? []
|
|
148
|
+
if (tagValues.length) {
|
|
149
|
+
params.tags = tagValues.flatMap((raw) => {
|
|
150
|
+
const trimmed = raw.trim()
|
|
151
|
+
if (!trimmed) return []
|
|
152
|
+
if (trimmed.includes('=')) {
|
|
153
|
+
const [name, value] = trimmed.split('=', 2)
|
|
154
|
+
if (name.trim() && value.trim()) return [{ name: name.trim(), value: value.trim() }]
|
|
155
|
+
return []
|
|
156
|
+
}
|
|
157
|
+
return [{ name: 'tag', value: trimmed }]
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const forward: Record<string, string> = {}
|
|
162
|
+
for (const [key, values] of Object.entries(headers)) {
|
|
163
|
+
if (RESERVED_HEADERS.has(key)) continue
|
|
164
|
+
if (key.startsWith('content-')) continue
|
|
165
|
+
const value = values[values.length - 1]?.trim()
|
|
166
|
+
if (value) forward[key] = value
|
|
167
|
+
}
|
|
168
|
+
if (Object.keys(forward).length) params.headers = forward
|
|
169
|
+
|
|
170
|
+
const idem =
|
|
171
|
+
headers['x-groundmail-idempotency-key']?.[0]?.trim() ||
|
|
172
|
+
headers['idempotency-key']?.[0]?.trim()
|
|
173
|
+
if (idem) params.idempotencyKey = idem
|
|
174
|
+
|
|
175
|
+
const scheduled = headers['x-groundmail-scheduled-at']?.[0]?.trim()
|
|
176
|
+
if (scheduled) params.scheduledAt = scheduled
|
|
177
|
+
|
|
178
|
+
return params
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Nodemailer transport that delivers via Groundmail HTTP API. */
|
|
182
|
+
export function createGroundmailTransport(
|
|
183
|
+
options: GroundmailTransportOptions,
|
|
184
|
+
): Transport & TransportOptions {
|
|
185
|
+
const client =
|
|
186
|
+
options.client ??
|
|
187
|
+
new Groundmail(options.apiKey, {
|
|
188
|
+
baseUrl: options.baseUrl ?? DEFAULT_API_BASE_URL,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
name: 'Groundmail',
|
|
193
|
+
version: '0.14.0',
|
|
194
|
+
send(mail, callback) {
|
|
195
|
+
// Settle via .then so every rejection reaches the Nodemailer callback
|
|
196
|
+
// (avoids a floating promise from void async IIFE).
|
|
197
|
+
;(async () => {
|
|
198
|
+
const params = await mailDataToSendParams(mail.data)
|
|
199
|
+
const result = await client.emails.send(params)
|
|
200
|
+
const envelope = mail.message.getEnvelope()
|
|
201
|
+
return {
|
|
202
|
+
envelope,
|
|
203
|
+
messageId: result.id,
|
|
204
|
+
accepted: envelope.to,
|
|
205
|
+
rejected: [] as string[],
|
|
206
|
+
pending: [] as string[],
|
|
207
|
+
response: `${result.status} ${result.id}`,
|
|
208
|
+
}
|
|
209
|
+
})().then(
|
|
210
|
+
(info) => {
|
|
211
|
+
callback(null, info)
|
|
212
|
+
},
|
|
213
|
+
(err: unknown) => {
|
|
214
|
+
const error =
|
|
215
|
+
err instanceof GroundmailError
|
|
216
|
+
? err
|
|
217
|
+
: err instanceof Error
|
|
218
|
+
? err
|
|
219
|
+
: new Error(String(err))
|
|
220
|
+
callback(error, undefined as never)
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Alias matching common `createTransport(transport(...))` naming. */
|
|
228
|
+
export const groundmailTransport = createGroundmailTransport
|