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 CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # groundmail-nodemailer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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'
@@ -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