@cmdsend/nodemailer 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-08-08
9
+
10
+ ### Added
11
+
12
+ - Initial release: Nodemailer transport for cmdsend.com, published as `@cmdsend/nodemailer`.
13
+ - Structured JSON send via `POST /v1/emails/send` (from, to, cc, bcc, subject, html, text, reply_to).
14
+ - Attachments (including inline CID images) and custom headers, mapped from Nodemailer's normalized mail data.
15
+ - Retry with exponential backoff + jitter on 429/5xx responses, respecting `Retry-After`; no retry on 4xx auth/validation errors.
16
+ - `Idempotency-Key` header derived from the message's `Message-ID` by default, to reduce double-send risk on retried calls.
17
+ - `verify()` cheap auth check.
18
+ - Zero runtime dependencies; `nodemailer` as a peer dependency; dual ESM/CJS build with bundled `.d.ts`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sandeep Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # @cmdsend/nodemailer
2
+
3
+ Nodemailer transport for [cmdsend.com](https://cmdsend.com), a transactional email API built on Amazon SES.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @cmdsend/nodemailer nodemailer
9
+ ```
10
+
11
+ ## Send an email
12
+
13
+ ```js
14
+ import nodemailer from "nodemailer";
15
+ import { cmdsendTransport } from "@cmdsend/nodemailer";
16
+
17
+ const transporter = nodemailer.createTransport(
18
+ cmdsendTransport({ apiKey: process.env.CMDSEND_API_KEY }),
19
+ );
20
+
21
+ await transporter.sendMail({
22
+ from: "you@yourdomain.com",
23
+ to: "user@example.com",
24
+ subject: "Hello from cmdsend",
25
+ html: "<p>It works.</p>",
26
+ });
27
+ ```
28
+
29
+ CommonJS: `const { cmdsendTransport } = require("@cmdsend/nodemailer");` — everything else is identical.
30
+
31
+ ## Runtime support
32
+
33
+ **This is a Nodemailer transport, so it runs wherever Nodemailer runs: Node.js.** It does not work on Cloudflare Workers, Vercel Edge Functions, or Deno Deploy — and that's not a limitation of this package specifically, it's Nodemailer itself.
34
+
35
+ Nodemailer's main entry point unconditionally `require()`s its SMTP transport, which loads Node's `net` and `tls` modules at import time (see [`smtp-connection/index.js`](https://github.com/nodemailer/nodemailer/blob/master/lib/smtp-connection/index.js)). Edge runtimes don't implement `node:net` — there's no raw TCP socket API to give you — so `import nodemailer from "nodemailer"` throws before your code even runs. This is a known, long-standing constraint tracked upstream: [nodemailer/nodemailer#1621](https://github.com/nodemailer/nodemailer/issues/1621) and [#1623](https://github.com/nodemailer/nodemailer/issues/1623). It is not something a transport package layered on top of Nodemailer — this one included — can work around.
36
+
37
+ So: use `@cmdsend/nodemailer` anywhere you're already running Node.js — Express, Fastify, NestJS, Next.js API routes / route handlers / server actions on the **Node.js runtime**, AWS Lambda, containers, or any existing app that already uses Nodemailer.
38
+
39
+ **On an edge runtime, skip Nodemailer entirely** — you don't need a mail library there, just call cmdsend's HTTP API with `fetch` directly:
40
+
41
+ ```js
42
+ // Cloudflare Workers / Vercel Edge / Deno Deploy — no nodemailer, no this package
43
+ export default {
44
+ async fetch(request, env) {
45
+ const res = await fetch("https://api.cmdsend.com/v1/emails/send", {
46
+ method: "POST",
47
+ headers: {
48
+ Authorization: `Bearer ${env.CMDSEND_API_KEY}`,
49
+ "Content-Type": "application/json",
50
+ },
51
+ body: JSON.stringify({
52
+ from: "you@yourdomain.com",
53
+ to: "user@example.com",
54
+ subject: "Hello from the edge",
55
+ html: "<p>It works.</p>",
56
+ }),
57
+ });
58
+ return new Response(await res.text(), { status: res.status });
59
+ },
60
+ };
61
+ ```
62
+
63
+ | Environment | Works with this package? | If not, use instead |
64
+ | --- | --- | --- |
65
+ | Node.js 18+ | ✅ | — |
66
+ | Next.js — Node.js runtime (API routes, most route handlers / server actions) | ✅ | — |
67
+ | Next.js — Edge runtime (`export const runtime = "edge"`) | ❌ | Plain `fetch` to the cmdsend API, as above |
68
+ | Cloudflare Workers | ❌ | Plain `fetch` to the cmdsend API, as above |
69
+ | Deno Deploy | ❌ | Plain `fetch` to the cmdsend API, as above |
70
+ | Bun | ✅ (Bun implements `node:net`/`node:tls`) | — |
71
+ | AWS Lambda (Node.js runtime) | ✅ | — |
72
+
73
+ ## Migrating from another transport
74
+
75
+ Only the transport setup changes — `transporter.sendMail({...})` calls stay exactly the same.
76
+
77
+ **From `nodemailer-sendgrid`:**
78
+
79
+ ```diff
80
+ import nodemailer from "nodemailer";
81
+ -import sgTransport from "nodemailer-sendgrid";
82
+ +import { cmdsendTransport } from "@cmdsend/nodemailer";
83
+
84
+ const transporter = nodemailer.createTransport(
85
+ - sgTransport({ apiKey: process.env.SENDGRID_API_KEY }),
86
+ + cmdsendTransport({ apiKey: process.env.CMDSEND_API_KEY }),
87
+ );
88
+ ```
89
+
90
+ **From `nodemailer-mailgun-transport`:**
91
+
92
+ ```diff
93
+ import nodemailer from "nodemailer";
94
+ -import mg from "nodemailer-mailgun-transport";
95
+ +import { cmdsendTransport } from "@cmdsend/nodemailer";
96
+
97
+ const transporter = nodemailer.createTransport(
98
+ - mg({ auth: { api_key: process.env.MAILGUN_API_KEY, domain: process.env.MAILGUN_DOMAIN } }),
99
+ + cmdsendTransport({ apiKey: process.env.CMDSEND_API_KEY }),
100
+ );
101
+ ```
102
+
103
+ **From plain SMTP:**
104
+
105
+ ```diff
106
+ import nodemailer from "nodemailer";
107
+ +import { cmdsendTransport } from "@cmdsend/nodemailer";
108
+
109
+ const transporter = nodemailer.createTransport(
110
+ - {
111
+ - host: "smtp.example.com",
112
+ - port: 587,
113
+ - auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
114
+ - },
115
+ + cmdsendTransport({ apiKey: process.env.CMDSEND_API_KEY }),
116
+ );
117
+ ```
118
+
119
+ ## Options
120
+
121
+ ```ts
122
+ cmdsendTransport({
123
+ apiKey: "cmd_...",
124
+ // baseUrl, maxRetries, timeout, fetch, headers, idempotencyKey are all optional
125
+ });
126
+ ```
127
+
128
+ | Option | Type | Default | Description |
129
+ | --- | --- | --- | --- |
130
+ | `apiKey` | `string` | `process.env.CMDSEND_API_KEY` | cmdsend API key. Required, either as an option or via the environment variable. |
131
+ | `baseUrl` | `string` | `"https://api.cmdsend.com/v1"` | Override the API base URL (e.g. for a proxy). |
132
+ | `maxRetries` | `number` | `3` | Retry attempts for `429`/`5xx` responses and transport-level failures (timeouts, DNS, connection resets). |
133
+ | `timeout` | `number` | `30000` | Per-request timeout in milliseconds. |
134
+ | `fetch` | `typeof fetch` | global `fetch` | Inject a custom `fetch` implementation — used for testing, or to route through a custom agent. |
135
+ | `headers` | `Record<string, string>` | `{}` | Extra headers sent with every request. |
136
+ | `idempotencyKey` | `boolean \| (mail) => string \| undefined` | `true` | See [Idempotency](#idempotency) below. |
137
+
138
+ Retries use exponential backoff (1s, 2s, 4s, ... capped at 16s) with up to 50% jitter, and respect a `Retry-After` header when the API sends one. `401`/`403`/`400` and other `4xx` responses are never retried.
139
+
140
+ ### Idempotency
141
+
142
+ By default, every send request includes an `Idempotency-Key` header derived from the message's `Message-ID` (set explicitly via `mail.messageId`, or generated by Nodemailer). If your app calls `sendMail` again for the same message — e.g. a serverless function retried after a timeout — the key stays the same across attempts, rather than minting a new one per HTTP call.
143
+
144
+ **This is sent defensively.** cmdsend's public API reference does not currently document idempotency key support, so this cannot be presented as a confirmed guarantee against duplicate sends — verify with cmdsend whether `Idempotency-Key` is honored server-side before relying on it in a way where a duplicate email would be a real problem. Disable it with `idempotencyKey: false`, or supply your own: `idempotencyKey: (mail) => mail.data.messageId`.
145
+
146
+ ## Troubleshooting
147
+
148
+ **`cmdsend API key is required...`**
149
+ No `apiKey` option and no `CMDSEND_API_KEY` environment variable. Set one of the two.
150
+
151
+ **`401` / "Invalid API key" / "Unauthorized"**
152
+ The key is missing, malformed, or was revoked. Check **Settings → API Keys** in the cmdsend dashboard.
153
+
154
+ **`403` / "Forbidden"**
155
+ Usually an unverified sending domain — the `from` address's domain hasn't completed SES/DNS verification in your cmdsend account. Verify it in the dashboard before sending from it.
156
+
157
+ **`429` / "QuotaExceeded" / rate limited**
158
+ Retried automatically (see [Options](#options)). If it still fails after retries, you're sustained over your plan's send rate — the error persists until you're under the limit again or your plan is upgraded.
159
+
160
+ **`cmdsend request timed out after <N>ms`**
161
+ The request didn't complete within `timeout`. Raise the `timeout` option, or check outbound network access from wherever this is running (e.g. a locked-down VPC or container).
162
+
163
+ **Attachment rejected / request too large**
164
+ cmdsend has not published a specific attachment size limit as of this writing. This transport doesn't enforce a client-side cap — if the API rejects a payload as too large, the error message in the thrown `CmdsendError` is the source of truth; treat it as such rather than a fixed number documented here.
165
+
166
+ **Getting a `CmdsendError` instead of a generic `Error`**
167
+ That's intentional — every failure from this transport (network error, timeout, non-2xx response) is normalized into a `CmdsendError` with `.statusCode` and `.code` set when available, instead of a raw `fetch`/`AbortError`.
168
+
169
+ ## A note on API coverage
170
+
171
+ This transport was built against cmdsend's structured JSON send endpoint (`POST /v1/emails/send`) and the existing `cmdsend` npm SDK, which is the closest thing to a contract available at the time of writing. A few things it supports are **not** confirmed against cmdsend's public API reference and should be verified before you depend on them in production:
172
+
173
+ - **Attachments and inline CID images** are sent as an `attachments` array (`filename`, base64 `content`, `content_type`, `content_id`). This field isn't part of the documented request schema.
174
+ - **Custom headers** (`mail.headers`) are sent as a `headers` object on the request body, also undocumented.
175
+ - **`Idempotency-Key`** — see [Idempotency](#idempotency) above.
176
+ - There is no known raw-MIME send endpoint, so this transport always sends structured JSON rather than a raw `message/rfc822` body.
177
+ - `verify()` has no dedicated auth-check endpoint to call, so it does a `GET` on a placeholder email id and treats `401`/`403` as failure, anything else as success.
178
+
179
+ A send that only uses documented fields (`from`/`to`/`cc`/`bcc`/`subject`/`html`/`text`/`reply_to`) isn't affected by any of this. But if you rely on attachments, custom headers, or idempotency: we don't know whether cmdsend's request validation ignores unrecognized fields or rejects the whole request, so test against your own account before depending on them in production.
180
+
181
+ ## Learn more
182
+
183
+ cmdsend API docs: https://cmdsend.com/docs
package/dist/index.cjs ADDED
@@ -0,0 +1,312 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ CmdsendError: () => CmdsendError,
24
+ CmdsendTransport: () => CmdsendTransport,
25
+ cmdsendTransport: () => cmdsendTransport
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+
29
+ // src/errors.ts
30
+ var CmdsendError = class extends Error {
31
+ statusCode;
32
+ code;
33
+ constructor(message, options = {}) {
34
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
35
+ this.name = "CmdsendError";
36
+ this.statusCode = options.statusCode;
37
+ this.code = options.code;
38
+ }
39
+ };
40
+
41
+ // src/http.ts
42
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
43
+ function sleep(ms) {
44
+ return new Promise((resolve) => setTimeout(resolve, ms));
45
+ }
46
+ function backoffDelayMs(attempt) {
47
+ const base = Math.min(1e3 * 2 ** attempt, 16e3);
48
+ return base + Math.random() * base * 0.5;
49
+ }
50
+ function retryAfterDelayMs(retryAfter) {
51
+ if (!retryAfter) return void 0;
52
+ const seconds = Number(retryAfter);
53
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
54
+ const dateMs = Date.parse(retryAfter);
55
+ if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
56
+ return void 0;
57
+ }
58
+ async function parseErrorBody(res) {
59
+ try {
60
+ const body = await res.json();
61
+ return {
62
+ message: typeof body.message === "string" ? body.message : void 0,
63
+ code: typeof body.error === "string" ? body.error : typeof body.code === "string" ? body.code : void 0
64
+ };
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+ async function requestJson(url, init, options) {
70
+ const { fetchImpl, maxRetries, timeoutMs } = options;
71
+ let lastError;
72
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
73
+ const controller = new AbortController();
74
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
75
+ let res;
76
+ try {
77
+ res = await fetchImpl(url, { ...init, signal: controller.signal });
78
+ } catch (err) {
79
+ clearTimeout(timer);
80
+ const isAbort = err instanceof Error && err.name === "AbortError";
81
+ const error2 = new CmdsendError(
82
+ isAbort ? `cmdsend request timed out after ${timeoutMs}ms` : `cmdsend request failed: ${err.message}`,
83
+ { cause: err }
84
+ );
85
+ if (attempt < maxRetries) {
86
+ lastError = error2;
87
+ await sleep(backoffDelayMs(attempt));
88
+ continue;
89
+ }
90
+ throw error2;
91
+ }
92
+ clearTimeout(timer);
93
+ if (res.ok) {
94
+ return await res.json().catch(() => ({}));
95
+ }
96
+ const { message, code } = await parseErrorBody(res);
97
+ const error = new CmdsendError(message ?? `cmdsend request failed with status ${res.status}`, {
98
+ statusCode: res.status,
99
+ code
100
+ });
101
+ if (RETRYABLE_STATUS.has(res.status) && attempt < maxRetries) {
102
+ lastError = error;
103
+ const delay = retryAfterDelayMs(res.headers.get("retry-after")) ?? backoffDelayMs(attempt);
104
+ await sleep(delay);
105
+ continue;
106
+ }
107
+ throw error;
108
+ }
109
+ throw lastError ?? new CmdsendError("cmdsend request failed");
110
+ }
111
+
112
+ // src/payload.ts
113
+ function formatAddress(addr) {
114
+ if (!addr.name) return addr.address;
115
+ const needsQuoting = /[",;:<>()@]/.test(addr.name) || /^\s|\s$/.test(addr.name);
116
+ const name = needsQuoting ? `"${addr.name.replace(/"/g, '\\"')}"` : addr.name;
117
+ return `${name} <${addr.address}>`;
118
+ }
119
+ function formatAddressList(list) {
120
+ if (!list || list.length === 0) return void 0;
121
+ return list.map(formatAddress);
122
+ }
123
+ function encodeAttachmentContent(attachment) {
124
+ const content = attachment.content;
125
+ if (Buffer.isBuffer(content)) return content.toString("base64");
126
+ if (typeof content === "string") {
127
+ if (attachment.encoding === "base64") return content;
128
+ return Buffer.from(content, "utf8").toString("base64");
129
+ }
130
+ return "";
131
+ }
132
+ function buildSendPayload(data) {
133
+ if (!data.from) {
134
+ throw new CmdsendError('"from" address is required to send with cmdsend.');
135
+ }
136
+ const to = formatAddressList(data.to);
137
+ if (!to) {
138
+ throw new CmdsendError('At least one "to" address is required to send with cmdsend.');
139
+ }
140
+ const payload = {
141
+ from: formatAddress(data.from),
142
+ to,
143
+ subject: data.subject ?? ""
144
+ };
145
+ if (data.html) payload.html = data.html;
146
+ if (data.text) payload.text = data.text;
147
+ if (!payload.html && !payload.text) {
148
+ throw new CmdsendError('Either "html" or "text" body is required to send with cmdsend.');
149
+ }
150
+ const cc = formatAddressList(data.cc);
151
+ if (cc) payload.cc = cc;
152
+ const bcc = formatAddressList(data.bcc);
153
+ if (bcc) payload.bcc = bcc;
154
+ const replyTo = formatAddressList(data.replyTo);
155
+ if (replyTo) payload.reply_to = replyTo.join(", ");
156
+ if (data.attachments && data.attachments.length > 0) {
157
+ payload.attachments = data.attachments.map((attachment) => {
158
+ const mapped = {
159
+ filename: typeof attachment.filename === "string" ? attachment.filename : "attachment",
160
+ content: encodeAttachmentContent(attachment)
161
+ };
162
+ if (attachment.contentType) mapped.content_type = attachment.contentType;
163
+ if (attachment.cid) mapped.content_id = attachment.cid;
164
+ return mapped;
165
+ });
166
+ }
167
+ if (data.normalizedHeaders && Object.keys(data.normalizedHeaders).length > 0) {
168
+ payload.headers = data.normalizedHeaders;
169
+ }
170
+ return payload;
171
+ }
172
+
173
+ // src/transport.ts
174
+ var VERSION = "0.1.0";
175
+ var DEFAULT_BASE_URL = "https://api.cmdsend.com/v1";
176
+ var DEFAULT_TIMEOUT_MS = 3e4;
177
+ var DEFAULT_MAX_RETRIES = 3;
178
+ function resolveApiKey(options) {
179
+ const apiKey = options.apiKey ?? process.env.CMDSEND_API_KEY;
180
+ if (!apiKey) {
181
+ throw new CmdsendError(
182
+ "cmdsend API key is required. Pass { apiKey } to the transport, or set the CMDSEND_API_KEY environment variable."
183
+ );
184
+ }
185
+ return apiKey;
186
+ }
187
+ function resolveFetch(options) {
188
+ const fetchImpl = options.fetch ?? globalThis.fetch;
189
+ if (!fetchImpl) {
190
+ throw new CmdsendError(
191
+ "No fetch implementation available. Use Node.js 18+ (which has a global fetch), or pass { fetch } explicitly."
192
+ );
193
+ }
194
+ return fetchImpl;
195
+ }
196
+ function normalizeMail(mail) {
197
+ return new Promise((resolve, reject) => {
198
+ mail.normalize((err, data) => {
199
+ if (err) {
200
+ reject(err instanceof Error ? err : new CmdsendError(String(err)));
201
+ return;
202
+ }
203
+ resolve(data);
204
+ });
205
+ });
206
+ }
207
+ function resolveIdempotencyKey(option, mail, fallbackMessageId) {
208
+ if (option === false) return void 0;
209
+ if (typeof option === "function") return option(mail);
210
+ return fallbackMessageId.replace(/^<|>$/g, "") || void 0;
211
+ }
212
+ var CmdsendTransport = class {
213
+ name = "Cmdsend";
214
+ version = VERSION;
215
+ #apiKey;
216
+ #baseUrl;
217
+ #maxRetries;
218
+ #timeout;
219
+ #fetch;
220
+ #headers;
221
+ #idempotencyKey;
222
+ constructor(options = {}) {
223
+ this.#apiKey = resolveApiKey(options);
224
+ this.#fetch = resolveFetch(options);
225
+ this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
226
+ this.#maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
227
+ this.#timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
228
+ this.#headers = options.headers ?? {};
229
+ this.#idempotencyKey = options.idempotencyKey;
230
+ }
231
+ send(mail, callback) {
232
+ this.#send(mail).then(
233
+ (info) => callback(null, info),
234
+ (err) => callback(err instanceof Error ? err : new CmdsendError(String(err)), void 0)
235
+ );
236
+ }
237
+ async #send(mail) {
238
+ const envelope = mail.message.getEnvelope();
239
+ const fallbackMessageId = mail.message.messageId();
240
+ const data = await normalizeMail(mail);
241
+ const payload = buildSendPayload(data);
242
+ const headers = {
243
+ Authorization: `Bearer ${this.#apiKey}`,
244
+ "Content-Type": "application/json",
245
+ ...this.#headers
246
+ };
247
+ const idempotencyKey = resolveIdempotencyKey(this.#idempotencyKey, mail, fallbackMessageId);
248
+ if (idempotencyKey) {
249
+ headers["Idempotency-Key"] = idempotencyKey;
250
+ }
251
+ const body = await requestJson(
252
+ `${this.#baseUrl}/emails/send`,
253
+ { method: "POST", headers, body: JSON.stringify(payload) },
254
+ { fetchImpl: this.#fetch, maxRetries: this.#maxRetries, timeoutMs: this.#timeout }
255
+ );
256
+ const messageId = body?.id ?? fallbackMessageId;
257
+ return {
258
+ messageId,
259
+ envelope,
260
+ response: `${body?.status ?? "accepted"} id=${messageId}`
261
+ };
262
+ }
263
+ verify(callback) {
264
+ const promise = this.#verify();
265
+ if (!callback) return promise;
266
+ promise.then(
267
+ (ok) => callback(null, ok),
268
+ (err) => callback(err instanceof Error ? err : new CmdsendError(String(err)), true)
269
+ );
270
+ return void 0;
271
+ }
272
+ async #verify() {
273
+ const controller = new AbortController();
274
+ const timer = setTimeout(() => controller.abort(), this.#timeout);
275
+ let res;
276
+ try {
277
+ res = await this.#fetch(`${this.#baseUrl}/emails/00000000-0000-0000-0000-000000000000`, {
278
+ method: "GET",
279
+ headers: { Authorization: `Bearer ${this.#apiKey}`, ...this.#headers },
280
+ signal: controller.signal
281
+ });
282
+ } catch (err) {
283
+ const isAbort = err instanceof Error && err.name === "AbortError";
284
+ throw new CmdsendError(
285
+ isAbort ? `cmdsend verification timed out after ${this.#timeout}ms` : `cmdsend verification failed: ${err.message}`,
286
+ { cause: err }
287
+ );
288
+ } finally {
289
+ clearTimeout(timer);
290
+ }
291
+ if (res.status === 401 || res.status === 403) {
292
+ const body = await res.json().catch(() => ({}));
293
+ throw new CmdsendError(body.message ?? "cmdsend authentication failed", {
294
+ statusCode: res.status,
295
+ code: body.error
296
+ });
297
+ }
298
+ return true;
299
+ }
300
+ };
301
+
302
+ // src/index.ts
303
+ function cmdsendTransport(options) {
304
+ return new CmdsendTransport(options);
305
+ }
306
+ // Annotate the CommonJS export names for ESM import in node:
307
+ 0 && (module.exports = {
308
+ CmdsendError,
309
+ CmdsendTransport,
310
+ cmdsendTransport
311
+ });
312
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/http.ts","../src/payload.ts","../src/transport.ts"],"sourcesContent":["import { CmdsendTransport, type CmdsendTransportOptions } from \"./transport.js\";\n\nexport { CmdsendTransport } from \"./transport.js\";\nexport type { CmdsendTransportOptions, CmdsendSentMessageInfo } from \"./transport.js\";\nexport { CmdsendError, type CmdsendErrorOptions } from \"./errors.js\";\nexport type { CmdsendSendPayload, CmdsendAttachmentPayload } from \"./payload.js\";\n\n/**\n * Creates a cmdsend transport for Nodemailer:\n *\n * ```ts\n * import nodemailer from \"nodemailer\";\n * import { cmdsendTransport } from \"nodemailer-cmdsend\";\n *\n * const transporter = nodemailer.createTransport(\n * cmdsendTransport({ apiKey: process.env.CMDSEND_API_KEY }),\n * );\n * ```\n */\nexport function cmdsendTransport(options?: CmdsendTransportOptions): CmdsendTransport {\n return new CmdsendTransport(options);\n}\n","export interface CmdsendErrorOptions {\n /** HTTP status code returned by the cmdsend API, if this error came from a response. */\n statusCode?: number;\n /** Machine-readable error code from the cmdsend API's error body (e.g. \"Unauthorized\"). */\n code?: string;\n cause?: unknown;\n}\n\n/**\n * Error type thrown by this transport. Network failures, timeouts, and\n * non-2xx API responses are all normalized into this shape instead of\n * leaking raw fetch/AbortError objects to callers.\n */\nexport class CmdsendError extends Error {\n readonly statusCode?: number;\n readonly code?: string;\n\n constructor(message: string, options: CmdsendErrorOptions = {}) {\n super(message, options.cause === undefined ? undefined : { cause: options.cause });\n this.name = \"CmdsendError\";\n this.statusCode = options.statusCode;\n this.code = options.code;\n }\n}\n","import { CmdsendError } from \"./errors.js\";\n\n// 429 and 5xx are treated as transient and safe to retry. 4xx auth/validation\n// errors (400, 401, 403, 404, 422, ...) are never retried.\nconst RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);\n\nexport type FetchLike = typeof fetch;\n\nexport interface RequestJsonOptions {\n fetchImpl: FetchLike;\n maxRetries: number;\n timeoutMs: number;\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/** Exponential backoff (base 1s, capped at 16s) with up to 50% jitter. */\nfunction backoffDelayMs(attempt: number): number {\n const base = Math.min(1000 * 2 ** attempt, 16_000);\n return base + Math.random() * base * 0.5;\n}\n\nfunction retryAfterDelayMs(retryAfter: string | null): number | undefined {\n if (!retryAfter) return undefined;\n const seconds = Number(retryAfter);\n if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);\n const dateMs = Date.parse(retryAfter);\n if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());\n return undefined;\n}\n\nasync function parseErrorBody(res: Response): Promise<{ message?: string; code?: string }> {\n try {\n const body = (await res.json()) as Record<string, unknown>;\n return {\n message: typeof body.message === \"string\" ? body.message : undefined,\n code: typeof body.error === \"string\" ? body.error : typeof body.code === \"string\" ? body.code : undefined,\n };\n } catch {\n return {};\n }\n}\n\n/**\n * POSTs/GETs JSON against the cmdsend API with retry on 429/5xx (respecting\n * Retry-After) and on transport-level failures (timeout, DNS, connection\n * reset). 4xx auth/validation errors fail immediately without retrying.\n */\nexport async function requestJson(\n url: string,\n init: { method: string; headers: Record<string, string>; body?: string },\n options: RequestJsonOptions,\n): Promise<unknown> {\n const { fetchImpl, maxRetries, timeoutMs } = options;\n let lastError: CmdsendError | undefined;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n let res: Response;\n\n try {\n res = await fetchImpl(url, { ...init, signal: controller.signal });\n } catch (err) {\n clearTimeout(timer);\n const isAbort = err instanceof Error && err.name === \"AbortError\";\n const error = new CmdsendError(\n isAbort ? `cmdsend request timed out after ${timeoutMs}ms` : `cmdsend request failed: ${(err as Error).message}`,\n { cause: err },\n );\n if (attempt < maxRetries) {\n lastError = error;\n await sleep(backoffDelayMs(attempt));\n continue;\n }\n throw error;\n }\n\n clearTimeout(timer);\n\n if (res.ok) {\n return await res.json().catch(() => ({}));\n }\n\n const { message, code } = await parseErrorBody(res);\n const error = new CmdsendError(message ?? `cmdsend request failed with status ${res.status}`, {\n statusCode: res.status,\n code,\n });\n\n if (RETRYABLE_STATUS.has(res.status) && attempt < maxRetries) {\n lastError = error;\n const delay = retryAfterDelayMs(res.headers.get(\"retry-after\")) ?? backoffDelayMs(attempt);\n await sleep(delay);\n continue;\n }\n\n throw error;\n }\n\n throw lastError ?? new CmdsendError(\"cmdsend request failed\");\n}\n","import { CmdsendError } from \"./errors.js\";\n\ninterface NormalizedAddress {\n name?: string;\n address: string;\n}\n\ninterface NormalizedAttachment {\n content?: string | Buffer;\n encoding?: string;\n filename?: string | false;\n contentType?: string;\n cid?: string;\n}\n\n/** Shape of `mail.data` after `MailMessage#normalize()` has resolved it. */\nexport interface NormalizedMail {\n from?: NormalizedAddress | null;\n to?: NormalizedAddress[] | null;\n cc?: NormalizedAddress[] | null;\n bcc?: NormalizedAddress[] | null;\n replyTo?: NormalizedAddress[] | null;\n subject?: string;\n html?: string;\n text?: string;\n attachments?: NormalizedAttachment[];\n normalizedHeaders?: Record<string, string>;\n}\n\nexport interface CmdsendAttachmentPayload {\n filename: string;\n content: string;\n content_type?: string;\n content_id?: string;\n}\n\n/**\n * Body sent to POST /emails/send. Only `from`/`to`/`subject`/`html`/`text`/\n * `cc`/`bcc`/`reply_to` are confirmed fields (mirrored from the `cmdsend`\n * npm SDK). `attachments` and `headers` are NOT part of that confirmed\n * contract — see the \"API assumptions\" section of the README.\n */\nexport interface CmdsendSendPayload {\n from: string;\n to: string[];\n subject: string;\n html?: string;\n text?: string;\n cc?: string[];\n bcc?: string[];\n reply_to?: string;\n attachments?: CmdsendAttachmentPayload[];\n headers?: Record<string, string>;\n}\n\nfunction formatAddress(addr: NormalizedAddress): string {\n if (!addr.name) return addr.address;\n const needsQuoting = /[\",;:<>()@]/.test(addr.name) || /^\\s|\\s$/.test(addr.name);\n const name = needsQuoting ? `\"${addr.name.replace(/\"/g, '\\\\\"')}\"` : addr.name;\n return `${name} <${addr.address}>`;\n}\n\nfunction formatAddressList(list: NormalizedAddress[] | null | undefined): string[] | undefined {\n if (!list || list.length === 0) return undefined;\n return list.map(formatAddress);\n}\n\nfunction encodeAttachmentContent(attachment: NormalizedAttachment): string {\n const content = attachment.content;\n if (Buffer.isBuffer(content)) return content.toString(\"base64\");\n if (typeof content === \"string\") {\n // normalize() already base64-encodes Buffer-sourced content (and sets\n // encoding: \"base64\"); a plain string here means the user passed a raw\n // utf8 string, which we still need to encode for JSON transport.\n if (attachment.encoding === \"base64\") return content;\n return Buffer.from(content, \"utf8\").toString(\"base64\");\n }\n return \"\";\n}\n\nexport function buildSendPayload(data: NormalizedMail): CmdsendSendPayload {\n if (!data.from) {\n throw new CmdsendError('\"from\" address is required to send with cmdsend.');\n }\n const to = formatAddressList(data.to);\n if (!to) {\n throw new CmdsendError('At least one \"to\" address is required to send with cmdsend.');\n }\n\n const payload: CmdsendSendPayload = {\n from: formatAddress(data.from),\n to,\n subject: data.subject ?? \"\",\n };\n\n if (data.html) payload.html = data.html;\n if (data.text) payload.text = data.text;\n if (!payload.html && !payload.text) {\n throw new CmdsendError('Either \"html\" or \"text\" body is required to send with cmdsend.');\n }\n\n const cc = formatAddressList(data.cc);\n if (cc) payload.cc = cc;\n\n const bcc = formatAddressList(data.bcc);\n if (bcc) payload.bcc = bcc;\n\n const replyTo = formatAddressList(data.replyTo);\n if (replyTo) payload.reply_to = replyTo.join(\", \");\n\n if (data.attachments && data.attachments.length > 0) {\n payload.attachments = data.attachments.map((attachment) => {\n const mapped: CmdsendAttachmentPayload = {\n filename: typeof attachment.filename === \"string\" ? attachment.filename : \"attachment\",\n content: encodeAttachmentContent(attachment),\n };\n if (attachment.contentType) mapped.content_type = attachment.contentType;\n if (attachment.cid) mapped.content_id = attachment.cid;\n return mapped;\n });\n }\n\n if (data.normalizedHeaders && Object.keys(data.normalizedHeaders).length > 0) {\n payload.headers = data.normalizedHeaders;\n }\n\n return payload;\n}\n","import type MailMessage from \"nodemailer/lib/mailer/mail-message.js\";\nimport type { Transport } from \"nodemailer\";\n\nimport { CmdsendError } from \"./errors.js\";\nimport { requestJson, type FetchLike } from \"./http.js\";\nimport { buildSendPayload, type NormalizedMail } from \"./payload.js\";\n\n// Keep in sync with package.json \"version\".\nconst VERSION = \"0.1.0\";\n\nconst DEFAULT_BASE_URL = \"https://api.cmdsend.com/v1\";\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst DEFAULT_MAX_RETRIES = 3;\n\nexport interface CmdsendSentMessageInfo {\n /** cmdsend's email id when available, otherwise the generated Message-ID. */\n messageId: string;\n envelope: { from: string | false; to: string[] };\n /** Short human-readable summary of the API response, for logging. */\n response: string;\n}\n\nexport interface CmdsendTransportOptions {\n /** cmdsend API key. Falls back to the CMDSEND_API_KEY environment variable. */\n apiKey?: string;\n /** Override the API base URL (default: \"https://api.cmdsend.com/v1\"). */\n baseUrl?: string;\n /** Max retry attempts for 429/5xx responses and transport-level failures. Default: 3. */\n maxRetries?: number;\n /** Per-request timeout in milliseconds. Default: 30000. */\n timeout?: number;\n /** Inject a custom fetch implementation (e.g. for testing). Defaults to global fetch. */\n fetch?: FetchLike;\n /** Extra headers sent with every request. */\n headers?: Record<string, string>;\n /**\n * Controls the `Idempotency-Key` header sent with each send request.\n *\n * `true` (default): derive a key from the message's Message-ID header, so\n * retried requests (e.g. a serverless function retried after a timeout)\n * don't risk a duplicate send. `false`: don't send the header. Or pass a\n * function to compute your own key per message.\n *\n * NOTE: cmdsend's public API reference does not document idempotency key\n * support. This header is sent defensively; verify with cmdsend whether\n * it's honored server-side before relying on it to prevent duplicate\n * sends in production.\n */\n idempotencyKey?: boolean | ((mail: MailMessage) => string | undefined);\n}\n\ninterface CmdsendSendResponse {\n id?: string;\n status?: string;\n}\n\nfunction resolveApiKey(options: CmdsendTransportOptions): string {\n const apiKey = options.apiKey ?? process.env.CMDSEND_API_KEY;\n if (!apiKey) {\n throw new CmdsendError(\n \"cmdsend API key is required. Pass { apiKey } to the transport, or set the CMDSEND_API_KEY environment variable.\",\n );\n }\n return apiKey;\n}\n\nfunction resolveFetch(options: CmdsendTransportOptions): FetchLike {\n const fetchImpl = options.fetch ?? globalThis.fetch;\n if (!fetchImpl) {\n throw new CmdsendError(\n \"No fetch implementation available. Use Node.js 18+ (which has a global fetch), or pass { fetch } explicitly.\",\n );\n }\n return fetchImpl;\n}\n\nfunction normalizeMail(mail: MailMessage): Promise<NormalizedMail> {\n return new Promise((resolve, reject) => {\n mail.normalize((err, data) => {\n if (err) {\n reject(err instanceof Error ? err : new CmdsendError(String(err)));\n return;\n }\n resolve(data as unknown as NormalizedMail);\n });\n });\n}\n\nfunction resolveIdempotencyKey(\n option: boolean | ((mail: MailMessage) => string | undefined) | undefined,\n mail: MailMessage,\n fallbackMessageId: string,\n): string | undefined {\n if (option === false) return undefined;\n if (typeof option === \"function\") return option(mail);\n // Default: derive from the Message-ID header (stripped of angle brackets)\n // so retrying the same message doesn't mint a new key.\n return fallbackMessageId.replace(/^<|>$/g, \"\") || undefined;\n}\n\n/**\n * Nodemailer transport for cmdsend.com. Sends via cmdsend's structured JSON\n * endpoint (POST /emails/send) — see README \"API assumptions\" for exactly\n * which fields are confirmed vs. best-effort.\n */\nexport class CmdsendTransport implements Transport<CmdsendSentMessageInfo> {\n public readonly name = \"Cmdsend\";\n public readonly version = VERSION;\n\n readonly #apiKey: string;\n readonly #baseUrl: string;\n readonly #maxRetries: number;\n readonly #timeout: number;\n readonly #fetch: FetchLike;\n readonly #headers: Record<string, string>;\n readonly #idempotencyKey: boolean | ((mail: MailMessage) => string | undefined) | undefined;\n\n constructor(options: CmdsendTransportOptions = {}) {\n this.#apiKey = resolveApiKey(options);\n this.#fetch = resolveFetch(options);\n this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.#maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;\n this.#timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;\n this.#headers = options.headers ?? {};\n this.#idempotencyKey = options.idempotencyKey;\n }\n\n send(mail: MailMessage, callback: (err: Error | null, info: CmdsendSentMessageInfo) => void): void {\n this.#send(mail).then(\n (info) => callback(null, info),\n (err: unknown) =>\n callback(err instanceof Error ? err : new CmdsendError(String(err)), undefined as unknown as CmdsendSentMessageInfo),\n );\n }\n\n async #send(mail: MailMessage): Promise<CmdsendSentMessageInfo> {\n const envelope = mail.message.getEnvelope();\n const fallbackMessageId = mail.message.messageId();\n const data = await normalizeMail(mail);\n const payload = buildSendPayload(data);\n\n const headers: Record<string, string> = {\n Authorization: `Bearer ${this.#apiKey}`,\n \"Content-Type\": \"application/json\",\n ...this.#headers,\n };\n\n const idempotencyKey = resolveIdempotencyKey(this.#idempotencyKey, mail, fallbackMessageId);\n if (idempotencyKey) {\n headers[\"Idempotency-Key\"] = idempotencyKey;\n }\n\n const body = (await requestJson(\n `${this.#baseUrl}/emails/send`,\n { method: \"POST\", headers, body: JSON.stringify(payload) },\n { fetchImpl: this.#fetch, maxRetries: this.#maxRetries, timeoutMs: this.#timeout },\n )) as CmdsendSendResponse;\n\n const messageId = body?.id ?? fallbackMessageId;\n return {\n messageId,\n envelope,\n response: `${body?.status ?? \"accepted\"} id=${messageId}`,\n };\n }\n\n /**\n * Cheap auth check. cmdsend has no dedicated \"whoami\"/health endpoint, so\n * this issues a GET against the one other confirmed route (fetch-email-\n * by-id) with a placeholder id — a request that can never send mail. A\n * 401/403 response means the key is rejected; any other status (404\n * \"not found\", 200, ...) means the key was accepted by auth middleware.\n * This ordering assumption (auth checked before the id lookup) is not\n * confirmed against cmdsend's source — see the README.\n */\n verify(): Promise<true>;\n verify(callback: (err: Error | null, success: true) => void): void;\n verify(callback?: (err: Error | null, success: true) => void): void | Promise<true> {\n const promise = this.#verify();\n if (!callback) return promise;\n promise.then(\n (ok) => callback(null, ok),\n (err: unknown) => callback(err instanceof Error ? err : new CmdsendError(String(err)), true),\n );\n return undefined;\n }\n\n async #verify(): Promise<true> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.#timeout);\n let res: Response;\n try {\n res = await this.#fetch(`${this.#baseUrl}/emails/00000000-0000-0000-0000-000000000000`, {\n method: \"GET\",\n headers: { Authorization: `Bearer ${this.#apiKey}`, ...this.#headers },\n signal: controller.signal,\n });\n } catch (err) {\n const isAbort = err instanceof Error && err.name === \"AbortError\";\n throw new CmdsendError(\n isAbort\n ? `cmdsend verification timed out after ${this.#timeout}ms`\n : `cmdsend verification failed: ${(err as Error).message}`,\n { cause: err },\n );\n } finally {\n clearTimeout(timer);\n }\n\n if (res.status === 401 || res.status === 403) {\n const body = (await res.json().catch(() => ({}))) as { message?: string; error?: string };\n throw new CmdsendError(body.message ?? \"cmdsend authentication failed\", {\n statusCode: res.status,\n code: body.error,\n });\n }\n\n return true;\n }\n}\n"],"mappings":"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","names":["error"]}
@@ -0,0 +1,125 @@
1
+ import MailMessage from 'nodemailer/lib/mailer/mail-message.js';
2
+ import { Transport } from 'nodemailer';
3
+
4
+ type FetchLike = typeof fetch;
5
+
6
+ interface CmdsendSentMessageInfo {
7
+ /** cmdsend's email id when available, otherwise the generated Message-ID. */
8
+ messageId: string;
9
+ envelope: {
10
+ from: string | false;
11
+ to: string[];
12
+ };
13
+ /** Short human-readable summary of the API response, for logging. */
14
+ response: string;
15
+ }
16
+ interface CmdsendTransportOptions {
17
+ /** cmdsend API key. Falls back to the CMDSEND_API_KEY environment variable. */
18
+ apiKey?: string;
19
+ /** Override the API base URL (default: "https://api.cmdsend.com/v1"). */
20
+ baseUrl?: string;
21
+ /** Max retry attempts for 429/5xx responses and transport-level failures. Default: 3. */
22
+ maxRetries?: number;
23
+ /** Per-request timeout in milliseconds. Default: 30000. */
24
+ timeout?: number;
25
+ /** Inject a custom fetch implementation (e.g. for testing). Defaults to global fetch. */
26
+ fetch?: FetchLike;
27
+ /** Extra headers sent with every request. */
28
+ headers?: Record<string, string>;
29
+ /**
30
+ * Controls the `Idempotency-Key` header sent with each send request.
31
+ *
32
+ * `true` (default): derive a key from the message's Message-ID header, so
33
+ * retried requests (e.g. a serverless function retried after a timeout)
34
+ * don't risk a duplicate send. `false`: don't send the header. Or pass a
35
+ * function to compute your own key per message.
36
+ *
37
+ * NOTE: cmdsend's public API reference does not document idempotency key
38
+ * support. This header is sent defensively; verify with cmdsend whether
39
+ * it's honored server-side before relying on it to prevent duplicate
40
+ * sends in production.
41
+ */
42
+ idempotencyKey?: boolean | ((mail: MailMessage) => string | undefined);
43
+ }
44
+ /**
45
+ * Nodemailer transport for cmdsend.com. Sends via cmdsend's structured JSON
46
+ * endpoint (POST /emails/send) — see README "API assumptions" for exactly
47
+ * which fields are confirmed vs. best-effort.
48
+ */
49
+ declare class CmdsendTransport implements Transport<CmdsendSentMessageInfo> {
50
+ #private;
51
+ readonly name = "Cmdsend";
52
+ readonly version = "0.1.0";
53
+ constructor(options?: CmdsendTransportOptions);
54
+ send(mail: MailMessage, callback: (err: Error | null, info: CmdsendSentMessageInfo) => void): void;
55
+ /**
56
+ * Cheap auth check. cmdsend has no dedicated "whoami"/health endpoint, so
57
+ * this issues a GET against the one other confirmed route (fetch-email-
58
+ * by-id) with a placeholder id — a request that can never send mail. A
59
+ * 401/403 response means the key is rejected; any other status (404
60
+ * "not found", 200, ...) means the key was accepted by auth middleware.
61
+ * This ordering assumption (auth checked before the id lookup) is not
62
+ * confirmed against cmdsend's source — see the README.
63
+ */
64
+ verify(): Promise<true>;
65
+ verify(callback: (err: Error | null, success: true) => void): void;
66
+ }
67
+
68
+ interface CmdsendErrorOptions {
69
+ /** HTTP status code returned by the cmdsend API, if this error came from a response. */
70
+ statusCode?: number;
71
+ /** Machine-readable error code from the cmdsend API's error body (e.g. "Unauthorized"). */
72
+ code?: string;
73
+ cause?: unknown;
74
+ }
75
+ /**
76
+ * Error type thrown by this transport. Network failures, timeouts, and
77
+ * non-2xx API responses are all normalized into this shape instead of
78
+ * leaking raw fetch/AbortError objects to callers.
79
+ */
80
+ declare class CmdsendError extends Error {
81
+ readonly statusCode?: number;
82
+ readonly code?: string;
83
+ constructor(message: string, options?: CmdsendErrorOptions);
84
+ }
85
+
86
+ interface CmdsendAttachmentPayload {
87
+ filename: string;
88
+ content: string;
89
+ content_type?: string;
90
+ content_id?: string;
91
+ }
92
+ /**
93
+ * Body sent to POST /emails/send. Only `from`/`to`/`subject`/`html`/`text`/
94
+ * `cc`/`bcc`/`reply_to` are confirmed fields (mirrored from the `cmdsend`
95
+ * npm SDK). `attachments` and `headers` are NOT part of that confirmed
96
+ * contract — see the "API assumptions" section of the README.
97
+ */
98
+ interface CmdsendSendPayload {
99
+ from: string;
100
+ to: string[];
101
+ subject: string;
102
+ html?: string;
103
+ text?: string;
104
+ cc?: string[];
105
+ bcc?: string[];
106
+ reply_to?: string;
107
+ attachments?: CmdsendAttachmentPayload[];
108
+ headers?: Record<string, string>;
109
+ }
110
+
111
+ /**
112
+ * Creates a cmdsend transport for Nodemailer:
113
+ *
114
+ * ```ts
115
+ * import nodemailer from "nodemailer";
116
+ * import { cmdsendTransport } from "nodemailer-cmdsend";
117
+ *
118
+ * const transporter = nodemailer.createTransport(
119
+ * cmdsendTransport({ apiKey: process.env.CMDSEND_API_KEY }),
120
+ * );
121
+ * ```
122
+ */
123
+ declare function cmdsendTransport(options?: CmdsendTransportOptions): CmdsendTransport;
124
+
125
+ export { type CmdsendAttachmentPayload, CmdsendError, type CmdsendErrorOptions, type CmdsendSendPayload, type CmdsendSentMessageInfo, CmdsendTransport, type CmdsendTransportOptions, cmdsendTransport };
@@ -0,0 +1,125 @@
1
+ import MailMessage from 'nodemailer/lib/mailer/mail-message.js';
2
+ import { Transport } from 'nodemailer';
3
+
4
+ type FetchLike = typeof fetch;
5
+
6
+ interface CmdsendSentMessageInfo {
7
+ /** cmdsend's email id when available, otherwise the generated Message-ID. */
8
+ messageId: string;
9
+ envelope: {
10
+ from: string | false;
11
+ to: string[];
12
+ };
13
+ /** Short human-readable summary of the API response, for logging. */
14
+ response: string;
15
+ }
16
+ interface CmdsendTransportOptions {
17
+ /** cmdsend API key. Falls back to the CMDSEND_API_KEY environment variable. */
18
+ apiKey?: string;
19
+ /** Override the API base URL (default: "https://api.cmdsend.com/v1"). */
20
+ baseUrl?: string;
21
+ /** Max retry attempts for 429/5xx responses and transport-level failures. Default: 3. */
22
+ maxRetries?: number;
23
+ /** Per-request timeout in milliseconds. Default: 30000. */
24
+ timeout?: number;
25
+ /** Inject a custom fetch implementation (e.g. for testing). Defaults to global fetch. */
26
+ fetch?: FetchLike;
27
+ /** Extra headers sent with every request. */
28
+ headers?: Record<string, string>;
29
+ /**
30
+ * Controls the `Idempotency-Key` header sent with each send request.
31
+ *
32
+ * `true` (default): derive a key from the message's Message-ID header, so
33
+ * retried requests (e.g. a serverless function retried after a timeout)
34
+ * don't risk a duplicate send. `false`: don't send the header. Or pass a
35
+ * function to compute your own key per message.
36
+ *
37
+ * NOTE: cmdsend's public API reference does not document idempotency key
38
+ * support. This header is sent defensively; verify with cmdsend whether
39
+ * it's honored server-side before relying on it to prevent duplicate
40
+ * sends in production.
41
+ */
42
+ idempotencyKey?: boolean | ((mail: MailMessage) => string | undefined);
43
+ }
44
+ /**
45
+ * Nodemailer transport for cmdsend.com. Sends via cmdsend's structured JSON
46
+ * endpoint (POST /emails/send) — see README "API assumptions" for exactly
47
+ * which fields are confirmed vs. best-effort.
48
+ */
49
+ declare class CmdsendTransport implements Transport<CmdsendSentMessageInfo> {
50
+ #private;
51
+ readonly name = "Cmdsend";
52
+ readonly version = "0.1.0";
53
+ constructor(options?: CmdsendTransportOptions);
54
+ send(mail: MailMessage, callback: (err: Error | null, info: CmdsendSentMessageInfo) => void): void;
55
+ /**
56
+ * Cheap auth check. cmdsend has no dedicated "whoami"/health endpoint, so
57
+ * this issues a GET against the one other confirmed route (fetch-email-
58
+ * by-id) with a placeholder id — a request that can never send mail. A
59
+ * 401/403 response means the key is rejected; any other status (404
60
+ * "not found", 200, ...) means the key was accepted by auth middleware.
61
+ * This ordering assumption (auth checked before the id lookup) is not
62
+ * confirmed against cmdsend's source — see the README.
63
+ */
64
+ verify(): Promise<true>;
65
+ verify(callback: (err: Error | null, success: true) => void): void;
66
+ }
67
+
68
+ interface CmdsendErrorOptions {
69
+ /** HTTP status code returned by the cmdsend API, if this error came from a response. */
70
+ statusCode?: number;
71
+ /** Machine-readable error code from the cmdsend API's error body (e.g. "Unauthorized"). */
72
+ code?: string;
73
+ cause?: unknown;
74
+ }
75
+ /**
76
+ * Error type thrown by this transport. Network failures, timeouts, and
77
+ * non-2xx API responses are all normalized into this shape instead of
78
+ * leaking raw fetch/AbortError objects to callers.
79
+ */
80
+ declare class CmdsendError extends Error {
81
+ readonly statusCode?: number;
82
+ readonly code?: string;
83
+ constructor(message: string, options?: CmdsendErrorOptions);
84
+ }
85
+
86
+ interface CmdsendAttachmentPayload {
87
+ filename: string;
88
+ content: string;
89
+ content_type?: string;
90
+ content_id?: string;
91
+ }
92
+ /**
93
+ * Body sent to POST /emails/send. Only `from`/`to`/`subject`/`html`/`text`/
94
+ * `cc`/`bcc`/`reply_to` are confirmed fields (mirrored from the `cmdsend`
95
+ * npm SDK). `attachments` and `headers` are NOT part of that confirmed
96
+ * contract — see the "API assumptions" section of the README.
97
+ */
98
+ interface CmdsendSendPayload {
99
+ from: string;
100
+ to: string[];
101
+ subject: string;
102
+ html?: string;
103
+ text?: string;
104
+ cc?: string[];
105
+ bcc?: string[];
106
+ reply_to?: string;
107
+ attachments?: CmdsendAttachmentPayload[];
108
+ headers?: Record<string, string>;
109
+ }
110
+
111
+ /**
112
+ * Creates a cmdsend transport for Nodemailer:
113
+ *
114
+ * ```ts
115
+ * import nodemailer from "nodemailer";
116
+ * import { cmdsendTransport } from "nodemailer-cmdsend";
117
+ *
118
+ * const transporter = nodemailer.createTransport(
119
+ * cmdsendTransport({ apiKey: process.env.CMDSEND_API_KEY }),
120
+ * );
121
+ * ```
122
+ */
123
+ declare function cmdsendTransport(options?: CmdsendTransportOptions): CmdsendTransport;
124
+
125
+ export { type CmdsendAttachmentPayload, CmdsendError, type CmdsendErrorOptions, type CmdsendSendPayload, type CmdsendSentMessageInfo, CmdsendTransport, type CmdsendTransportOptions, cmdsendTransport };
package/dist/index.js ADDED
@@ -0,0 +1,283 @@
1
+ // src/errors.ts
2
+ var CmdsendError = class extends Error {
3
+ statusCode;
4
+ code;
5
+ constructor(message, options = {}) {
6
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
7
+ this.name = "CmdsendError";
8
+ this.statusCode = options.statusCode;
9
+ this.code = options.code;
10
+ }
11
+ };
12
+
13
+ // src/http.ts
14
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+ function backoffDelayMs(attempt) {
19
+ const base = Math.min(1e3 * 2 ** attempt, 16e3);
20
+ return base + Math.random() * base * 0.5;
21
+ }
22
+ function retryAfterDelayMs(retryAfter) {
23
+ if (!retryAfter) return void 0;
24
+ const seconds = Number(retryAfter);
25
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
26
+ const dateMs = Date.parse(retryAfter);
27
+ if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
28
+ return void 0;
29
+ }
30
+ async function parseErrorBody(res) {
31
+ try {
32
+ const body = await res.json();
33
+ return {
34
+ message: typeof body.message === "string" ? body.message : void 0,
35
+ code: typeof body.error === "string" ? body.error : typeof body.code === "string" ? body.code : void 0
36
+ };
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+ async function requestJson(url, init, options) {
42
+ const { fetchImpl, maxRetries, timeoutMs } = options;
43
+ let lastError;
44
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
47
+ let res;
48
+ try {
49
+ res = await fetchImpl(url, { ...init, signal: controller.signal });
50
+ } catch (err) {
51
+ clearTimeout(timer);
52
+ const isAbort = err instanceof Error && err.name === "AbortError";
53
+ const error2 = new CmdsendError(
54
+ isAbort ? `cmdsend request timed out after ${timeoutMs}ms` : `cmdsend request failed: ${err.message}`,
55
+ { cause: err }
56
+ );
57
+ if (attempt < maxRetries) {
58
+ lastError = error2;
59
+ await sleep(backoffDelayMs(attempt));
60
+ continue;
61
+ }
62
+ throw error2;
63
+ }
64
+ clearTimeout(timer);
65
+ if (res.ok) {
66
+ return await res.json().catch(() => ({}));
67
+ }
68
+ const { message, code } = await parseErrorBody(res);
69
+ const error = new CmdsendError(message ?? `cmdsend request failed with status ${res.status}`, {
70
+ statusCode: res.status,
71
+ code
72
+ });
73
+ if (RETRYABLE_STATUS.has(res.status) && attempt < maxRetries) {
74
+ lastError = error;
75
+ const delay = retryAfterDelayMs(res.headers.get("retry-after")) ?? backoffDelayMs(attempt);
76
+ await sleep(delay);
77
+ continue;
78
+ }
79
+ throw error;
80
+ }
81
+ throw lastError ?? new CmdsendError("cmdsend request failed");
82
+ }
83
+
84
+ // src/payload.ts
85
+ function formatAddress(addr) {
86
+ if (!addr.name) return addr.address;
87
+ const needsQuoting = /[",;:<>()@]/.test(addr.name) || /^\s|\s$/.test(addr.name);
88
+ const name = needsQuoting ? `"${addr.name.replace(/"/g, '\\"')}"` : addr.name;
89
+ return `${name} <${addr.address}>`;
90
+ }
91
+ function formatAddressList(list) {
92
+ if (!list || list.length === 0) return void 0;
93
+ return list.map(formatAddress);
94
+ }
95
+ function encodeAttachmentContent(attachment) {
96
+ const content = attachment.content;
97
+ if (Buffer.isBuffer(content)) return content.toString("base64");
98
+ if (typeof content === "string") {
99
+ if (attachment.encoding === "base64") return content;
100
+ return Buffer.from(content, "utf8").toString("base64");
101
+ }
102
+ return "";
103
+ }
104
+ function buildSendPayload(data) {
105
+ if (!data.from) {
106
+ throw new CmdsendError('"from" address is required to send with cmdsend.');
107
+ }
108
+ const to = formatAddressList(data.to);
109
+ if (!to) {
110
+ throw new CmdsendError('At least one "to" address is required to send with cmdsend.');
111
+ }
112
+ const payload = {
113
+ from: formatAddress(data.from),
114
+ to,
115
+ subject: data.subject ?? ""
116
+ };
117
+ if (data.html) payload.html = data.html;
118
+ if (data.text) payload.text = data.text;
119
+ if (!payload.html && !payload.text) {
120
+ throw new CmdsendError('Either "html" or "text" body is required to send with cmdsend.');
121
+ }
122
+ const cc = formatAddressList(data.cc);
123
+ if (cc) payload.cc = cc;
124
+ const bcc = formatAddressList(data.bcc);
125
+ if (bcc) payload.bcc = bcc;
126
+ const replyTo = formatAddressList(data.replyTo);
127
+ if (replyTo) payload.reply_to = replyTo.join(", ");
128
+ if (data.attachments && data.attachments.length > 0) {
129
+ payload.attachments = data.attachments.map((attachment) => {
130
+ const mapped = {
131
+ filename: typeof attachment.filename === "string" ? attachment.filename : "attachment",
132
+ content: encodeAttachmentContent(attachment)
133
+ };
134
+ if (attachment.contentType) mapped.content_type = attachment.contentType;
135
+ if (attachment.cid) mapped.content_id = attachment.cid;
136
+ return mapped;
137
+ });
138
+ }
139
+ if (data.normalizedHeaders && Object.keys(data.normalizedHeaders).length > 0) {
140
+ payload.headers = data.normalizedHeaders;
141
+ }
142
+ return payload;
143
+ }
144
+
145
+ // src/transport.ts
146
+ var VERSION = "0.1.0";
147
+ var DEFAULT_BASE_URL = "https://api.cmdsend.com/v1";
148
+ var DEFAULT_TIMEOUT_MS = 3e4;
149
+ var DEFAULT_MAX_RETRIES = 3;
150
+ function resolveApiKey(options) {
151
+ const apiKey = options.apiKey ?? process.env.CMDSEND_API_KEY;
152
+ if (!apiKey) {
153
+ throw new CmdsendError(
154
+ "cmdsend API key is required. Pass { apiKey } to the transport, or set the CMDSEND_API_KEY environment variable."
155
+ );
156
+ }
157
+ return apiKey;
158
+ }
159
+ function resolveFetch(options) {
160
+ const fetchImpl = options.fetch ?? globalThis.fetch;
161
+ if (!fetchImpl) {
162
+ throw new CmdsendError(
163
+ "No fetch implementation available. Use Node.js 18+ (which has a global fetch), or pass { fetch } explicitly."
164
+ );
165
+ }
166
+ return fetchImpl;
167
+ }
168
+ function normalizeMail(mail) {
169
+ return new Promise((resolve, reject) => {
170
+ mail.normalize((err, data) => {
171
+ if (err) {
172
+ reject(err instanceof Error ? err : new CmdsendError(String(err)));
173
+ return;
174
+ }
175
+ resolve(data);
176
+ });
177
+ });
178
+ }
179
+ function resolveIdempotencyKey(option, mail, fallbackMessageId) {
180
+ if (option === false) return void 0;
181
+ if (typeof option === "function") return option(mail);
182
+ return fallbackMessageId.replace(/^<|>$/g, "") || void 0;
183
+ }
184
+ var CmdsendTransport = class {
185
+ name = "Cmdsend";
186
+ version = VERSION;
187
+ #apiKey;
188
+ #baseUrl;
189
+ #maxRetries;
190
+ #timeout;
191
+ #fetch;
192
+ #headers;
193
+ #idempotencyKey;
194
+ constructor(options = {}) {
195
+ this.#apiKey = resolveApiKey(options);
196
+ this.#fetch = resolveFetch(options);
197
+ this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
198
+ this.#maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
199
+ this.#timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
200
+ this.#headers = options.headers ?? {};
201
+ this.#idempotencyKey = options.idempotencyKey;
202
+ }
203
+ send(mail, callback) {
204
+ this.#send(mail).then(
205
+ (info) => callback(null, info),
206
+ (err) => callback(err instanceof Error ? err : new CmdsendError(String(err)), void 0)
207
+ );
208
+ }
209
+ async #send(mail) {
210
+ const envelope = mail.message.getEnvelope();
211
+ const fallbackMessageId = mail.message.messageId();
212
+ const data = await normalizeMail(mail);
213
+ const payload = buildSendPayload(data);
214
+ const headers = {
215
+ Authorization: `Bearer ${this.#apiKey}`,
216
+ "Content-Type": "application/json",
217
+ ...this.#headers
218
+ };
219
+ const idempotencyKey = resolveIdempotencyKey(this.#idempotencyKey, mail, fallbackMessageId);
220
+ if (idempotencyKey) {
221
+ headers["Idempotency-Key"] = idempotencyKey;
222
+ }
223
+ const body = await requestJson(
224
+ `${this.#baseUrl}/emails/send`,
225
+ { method: "POST", headers, body: JSON.stringify(payload) },
226
+ { fetchImpl: this.#fetch, maxRetries: this.#maxRetries, timeoutMs: this.#timeout }
227
+ );
228
+ const messageId = body?.id ?? fallbackMessageId;
229
+ return {
230
+ messageId,
231
+ envelope,
232
+ response: `${body?.status ?? "accepted"} id=${messageId}`
233
+ };
234
+ }
235
+ verify(callback) {
236
+ const promise = this.#verify();
237
+ if (!callback) return promise;
238
+ promise.then(
239
+ (ok) => callback(null, ok),
240
+ (err) => callback(err instanceof Error ? err : new CmdsendError(String(err)), true)
241
+ );
242
+ return void 0;
243
+ }
244
+ async #verify() {
245
+ const controller = new AbortController();
246
+ const timer = setTimeout(() => controller.abort(), this.#timeout);
247
+ let res;
248
+ try {
249
+ res = await this.#fetch(`${this.#baseUrl}/emails/00000000-0000-0000-0000-000000000000`, {
250
+ method: "GET",
251
+ headers: { Authorization: `Bearer ${this.#apiKey}`, ...this.#headers },
252
+ signal: controller.signal
253
+ });
254
+ } catch (err) {
255
+ const isAbort = err instanceof Error && err.name === "AbortError";
256
+ throw new CmdsendError(
257
+ isAbort ? `cmdsend verification timed out after ${this.#timeout}ms` : `cmdsend verification failed: ${err.message}`,
258
+ { cause: err }
259
+ );
260
+ } finally {
261
+ clearTimeout(timer);
262
+ }
263
+ if (res.status === 401 || res.status === 403) {
264
+ const body = await res.json().catch(() => ({}));
265
+ throw new CmdsendError(body.message ?? "cmdsend authentication failed", {
266
+ statusCode: res.status,
267
+ code: body.error
268
+ });
269
+ }
270
+ return true;
271
+ }
272
+ };
273
+
274
+ // src/index.ts
275
+ function cmdsendTransport(options) {
276
+ return new CmdsendTransport(options);
277
+ }
278
+ export {
279
+ CmdsendError,
280
+ CmdsendTransport,
281
+ cmdsendTransport
282
+ };
283
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/http.ts","../src/payload.ts","../src/transport.ts","../src/index.ts"],"sourcesContent":["export interface CmdsendErrorOptions {\n /** HTTP status code returned by the cmdsend API, if this error came from a response. */\n statusCode?: number;\n /** Machine-readable error code from the cmdsend API's error body (e.g. \"Unauthorized\"). */\n code?: string;\n cause?: unknown;\n}\n\n/**\n * Error type thrown by this transport. Network failures, timeouts, and\n * non-2xx API responses are all normalized into this shape instead of\n * leaking raw fetch/AbortError objects to callers.\n */\nexport class CmdsendError extends Error {\n readonly statusCode?: number;\n readonly code?: string;\n\n constructor(message: string, options: CmdsendErrorOptions = {}) {\n super(message, options.cause === undefined ? undefined : { cause: options.cause });\n this.name = \"CmdsendError\";\n this.statusCode = options.statusCode;\n this.code = options.code;\n }\n}\n","import { CmdsendError } from \"./errors.js\";\n\n// 429 and 5xx are treated as transient and safe to retry. 4xx auth/validation\n// errors (400, 401, 403, 404, 422, ...) are never retried.\nconst RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);\n\nexport type FetchLike = typeof fetch;\n\nexport interface RequestJsonOptions {\n fetchImpl: FetchLike;\n maxRetries: number;\n timeoutMs: number;\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/** Exponential backoff (base 1s, capped at 16s) with up to 50% jitter. */\nfunction backoffDelayMs(attempt: number): number {\n const base = Math.min(1000 * 2 ** attempt, 16_000);\n return base + Math.random() * base * 0.5;\n}\n\nfunction retryAfterDelayMs(retryAfter: string | null): number | undefined {\n if (!retryAfter) return undefined;\n const seconds = Number(retryAfter);\n if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);\n const dateMs = Date.parse(retryAfter);\n if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());\n return undefined;\n}\n\nasync function parseErrorBody(res: Response): Promise<{ message?: string; code?: string }> {\n try {\n const body = (await res.json()) as Record<string, unknown>;\n return {\n message: typeof body.message === \"string\" ? body.message : undefined,\n code: typeof body.error === \"string\" ? body.error : typeof body.code === \"string\" ? body.code : undefined,\n };\n } catch {\n return {};\n }\n}\n\n/**\n * POSTs/GETs JSON against the cmdsend API with retry on 429/5xx (respecting\n * Retry-After) and on transport-level failures (timeout, DNS, connection\n * reset). 4xx auth/validation errors fail immediately without retrying.\n */\nexport async function requestJson(\n url: string,\n init: { method: string; headers: Record<string, string>; body?: string },\n options: RequestJsonOptions,\n): Promise<unknown> {\n const { fetchImpl, maxRetries, timeoutMs } = options;\n let lastError: CmdsendError | undefined;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n let res: Response;\n\n try {\n res = await fetchImpl(url, { ...init, signal: controller.signal });\n } catch (err) {\n clearTimeout(timer);\n const isAbort = err instanceof Error && err.name === \"AbortError\";\n const error = new CmdsendError(\n isAbort ? `cmdsend request timed out after ${timeoutMs}ms` : `cmdsend request failed: ${(err as Error).message}`,\n { cause: err },\n );\n if (attempt < maxRetries) {\n lastError = error;\n await sleep(backoffDelayMs(attempt));\n continue;\n }\n throw error;\n }\n\n clearTimeout(timer);\n\n if (res.ok) {\n return await res.json().catch(() => ({}));\n }\n\n const { message, code } = await parseErrorBody(res);\n const error = new CmdsendError(message ?? `cmdsend request failed with status ${res.status}`, {\n statusCode: res.status,\n code,\n });\n\n if (RETRYABLE_STATUS.has(res.status) && attempt < maxRetries) {\n lastError = error;\n const delay = retryAfterDelayMs(res.headers.get(\"retry-after\")) ?? backoffDelayMs(attempt);\n await sleep(delay);\n continue;\n }\n\n throw error;\n }\n\n throw lastError ?? new CmdsendError(\"cmdsend request failed\");\n}\n","import { CmdsendError } from \"./errors.js\";\n\ninterface NormalizedAddress {\n name?: string;\n address: string;\n}\n\ninterface NormalizedAttachment {\n content?: string | Buffer;\n encoding?: string;\n filename?: string | false;\n contentType?: string;\n cid?: string;\n}\n\n/** Shape of `mail.data` after `MailMessage#normalize()` has resolved it. */\nexport interface NormalizedMail {\n from?: NormalizedAddress | null;\n to?: NormalizedAddress[] | null;\n cc?: NormalizedAddress[] | null;\n bcc?: NormalizedAddress[] | null;\n replyTo?: NormalizedAddress[] | null;\n subject?: string;\n html?: string;\n text?: string;\n attachments?: NormalizedAttachment[];\n normalizedHeaders?: Record<string, string>;\n}\n\nexport interface CmdsendAttachmentPayload {\n filename: string;\n content: string;\n content_type?: string;\n content_id?: string;\n}\n\n/**\n * Body sent to POST /emails/send. Only `from`/`to`/`subject`/`html`/`text`/\n * `cc`/`bcc`/`reply_to` are confirmed fields (mirrored from the `cmdsend`\n * npm SDK). `attachments` and `headers` are NOT part of that confirmed\n * contract — see the \"API assumptions\" section of the README.\n */\nexport interface CmdsendSendPayload {\n from: string;\n to: string[];\n subject: string;\n html?: string;\n text?: string;\n cc?: string[];\n bcc?: string[];\n reply_to?: string;\n attachments?: CmdsendAttachmentPayload[];\n headers?: Record<string, string>;\n}\n\nfunction formatAddress(addr: NormalizedAddress): string {\n if (!addr.name) return addr.address;\n const needsQuoting = /[\",;:<>()@]/.test(addr.name) || /^\\s|\\s$/.test(addr.name);\n const name = needsQuoting ? `\"${addr.name.replace(/\"/g, '\\\\\"')}\"` : addr.name;\n return `${name} <${addr.address}>`;\n}\n\nfunction formatAddressList(list: NormalizedAddress[] | null | undefined): string[] | undefined {\n if (!list || list.length === 0) return undefined;\n return list.map(formatAddress);\n}\n\nfunction encodeAttachmentContent(attachment: NormalizedAttachment): string {\n const content = attachment.content;\n if (Buffer.isBuffer(content)) return content.toString(\"base64\");\n if (typeof content === \"string\") {\n // normalize() already base64-encodes Buffer-sourced content (and sets\n // encoding: \"base64\"); a plain string here means the user passed a raw\n // utf8 string, which we still need to encode for JSON transport.\n if (attachment.encoding === \"base64\") return content;\n return Buffer.from(content, \"utf8\").toString(\"base64\");\n }\n return \"\";\n}\n\nexport function buildSendPayload(data: NormalizedMail): CmdsendSendPayload {\n if (!data.from) {\n throw new CmdsendError('\"from\" address is required to send with cmdsend.');\n }\n const to = formatAddressList(data.to);\n if (!to) {\n throw new CmdsendError('At least one \"to\" address is required to send with cmdsend.');\n }\n\n const payload: CmdsendSendPayload = {\n from: formatAddress(data.from),\n to,\n subject: data.subject ?? \"\",\n };\n\n if (data.html) payload.html = data.html;\n if (data.text) payload.text = data.text;\n if (!payload.html && !payload.text) {\n throw new CmdsendError('Either \"html\" or \"text\" body is required to send with cmdsend.');\n }\n\n const cc = formatAddressList(data.cc);\n if (cc) payload.cc = cc;\n\n const bcc = formatAddressList(data.bcc);\n if (bcc) payload.bcc = bcc;\n\n const replyTo = formatAddressList(data.replyTo);\n if (replyTo) payload.reply_to = replyTo.join(\", \");\n\n if (data.attachments && data.attachments.length > 0) {\n payload.attachments = data.attachments.map((attachment) => {\n const mapped: CmdsendAttachmentPayload = {\n filename: typeof attachment.filename === \"string\" ? attachment.filename : \"attachment\",\n content: encodeAttachmentContent(attachment),\n };\n if (attachment.contentType) mapped.content_type = attachment.contentType;\n if (attachment.cid) mapped.content_id = attachment.cid;\n return mapped;\n });\n }\n\n if (data.normalizedHeaders && Object.keys(data.normalizedHeaders).length > 0) {\n payload.headers = data.normalizedHeaders;\n }\n\n return payload;\n}\n","import type MailMessage from \"nodemailer/lib/mailer/mail-message.js\";\nimport type { Transport } from \"nodemailer\";\n\nimport { CmdsendError } from \"./errors.js\";\nimport { requestJson, type FetchLike } from \"./http.js\";\nimport { buildSendPayload, type NormalizedMail } from \"./payload.js\";\n\n// Keep in sync with package.json \"version\".\nconst VERSION = \"0.1.0\";\n\nconst DEFAULT_BASE_URL = \"https://api.cmdsend.com/v1\";\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst DEFAULT_MAX_RETRIES = 3;\n\nexport interface CmdsendSentMessageInfo {\n /** cmdsend's email id when available, otherwise the generated Message-ID. */\n messageId: string;\n envelope: { from: string | false; to: string[] };\n /** Short human-readable summary of the API response, for logging. */\n response: string;\n}\n\nexport interface CmdsendTransportOptions {\n /** cmdsend API key. Falls back to the CMDSEND_API_KEY environment variable. */\n apiKey?: string;\n /** Override the API base URL (default: \"https://api.cmdsend.com/v1\"). */\n baseUrl?: string;\n /** Max retry attempts for 429/5xx responses and transport-level failures. Default: 3. */\n maxRetries?: number;\n /** Per-request timeout in milliseconds. Default: 30000. */\n timeout?: number;\n /** Inject a custom fetch implementation (e.g. for testing). Defaults to global fetch. */\n fetch?: FetchLike;\n /** Extra headers sent with every request. */\n headers?: Record<string, string>;\n /**\n * Controls the `Idempotency-Key` header sent with each send request.\n *\n * `true` (default): derive a key from the message's Message-ID header, so\n * retried requests (e.g. a serverless function retried after a timeout)\n * don't risk a duplicate send. `false`: don't send the header. Or pass a\n * function to compute your own key per message.\n *\n * NOTE: cmdsend's public API reference does not document idempotency key\n * support. This header is sent defensively; verify with cmdsend whether\n * it's honored server-side before relying on it to prevent duplicate\n * sends in production.\n */\n idempotencyKey?: boolean | ((mail: MailMessage) => string | undefined);\n}\n\ninterface CmdsendSendResponse {\n id?: string;\n status?: string;\n}\n\nfunction resolveApiKey(options: CmdsendTransportOptions): string {\n const apiKey = options.apiKey ?? process.env.CMDSEND_API_KEY;\n if (!apiKey) {\n throw new CmdsendError(\n \"cmdsend API key is required. Pass { apiKey } to the transport, or set the CMDSEND_API_KEY environment variable.\",\n );\n }\n return apiKey;\n}\n\nfunction resolveFetch(options: CmdsendTransportOptions): FetchLike {\n const fetchImpl = options.fetch ?? globalThis.fetch;\n if (!fetchImpl) {\n throw new CmdsendError(\n \"No fetch implementation available. Use Node.js 18+ (which has a global fetch), or pass { fetch } explicitly.\",\n );\n }\n return fetchImpl;\n}\n\nfunction normalizeMail(mail: MailMessage): Promise<NormalizedMail> {\n return new Promise((resolve, reject) => {\n mail.normalize((err, data) => {\n if (err) {\n reject(err instanceof Error ? err : new CmdsendError(String(err)));\n return;\n }\n resolve(data as unknown as NormalizedMail);\n });\n });\n}\n\nfunction resolveIdempotencyKey(\n option: boolean | ((mail: MailMessage) => string | undefined) | undefined,\n mail: MailMessage,\n fallbackMessageId: string,\n): string | undefined {\n if (option === false) return undefined;\n if (typeof option === \"function\") return option(mail);\n // Default: derive from the Message-ID header (stripped of angle brackets)\n // so retrying the same message doesn't mint a new key.\n return fallbackMessageId.replace(/^<|>$/g, \"\") || undefined;\n}\n\n/**\n * Nodemailer transport for cmdsend.com. Sends via cmdsend's structured JSON\n * endpoint (POST /emails/send) — see README \"API assumptions\" for exactly\n * which fields are confirmed vs. best-effort.\n */\nexport class CmdsendTransport implements Transport<CmdsendSentMessageInfo> {\n public readonly name = \"Cmdsend\";\n public readonly version = VERSION;\n\n readonly #apiKey: string;\n readonly #baseUrl: string;\n readonly #maxRetries: number;\n readonly #timeout: number;\n readonly #fetch: FetchLike;\n readonly #headers: Record<string, string>;\n readonly #idempotencyKey: boolean | ((mail: MailMessage) => string | undefined) | undefined;\n\n constructor(options: CmdsendTransportOptions = {}) {\n this.#apiKey = resolveApiKey(options);\n this.#fetch = resolveFetch(options);\n this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.#maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;\n this.#timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;\n this.#headers = options.headers ?? {};\n this.#idempotencyKey = options.idempotencyKey;\n }\n\n send(mail: MailMessage, callback: (err: Error | null, info: CmdsendSentMessageInfo) => void): void {\n this.#send(mail).then(\n (info) => callback(null, info),\n (err: unknown) =>\n callback(err instanceof Error ? err : new CmdsendError(String(err)), undefined as unknown as CmdsendSentMessageInfo),\n );\n }\n\n async #send(mail: MailMessage): Promise<CmdsendSentMessageInfo> {\n const envelope = mail.message.getEnvelope();\n const fallbackMessageId = mail.message.messageId();\n const data = await normalizeMail(mail);\n const payload = buildSendPayload(data);\n\n const headers: Record<string, string> = {\n Authorization: `Bearer ${this.#apiKey}`,\n \"Content-Type\": \"application/json\",\n ...this.#headers,\n };\n\n const idempotencyKey = resolveIdempotencyKey(this.#idempotencyKey, mail, fallbackMessageId);\n if (idempotencyKey) {\n headers[\"Idempotency-Key\"] = idempotencyKey;\n }\n\n const body = (await requestJson(\n `${this.#baseUrl}/emails/send`,\n { method: \"POST\", headers, body: JSON.stringify(payload) },\n { fetchImpl: this.#fetch, maxRetries: this.#maxRetries, timeoutMs: this.#timeout },\n )) as CmdsendSendResponse;\n\n const messageId = body?.id ?? fallbackMessageId;\n return {\n messageId,\n envelope,\n response: `${body?.status ?? \"accepted\"} id=${messageId}`,\n };\n }\n\n /**\n * Cheap auth check. cmdsend has no dedicated \"whoami\"/health endpoint, so\n * this issues a GET against the one other confirmed route (fetch-email-\n * by-id) with a placeholder id — a request that can never send mail. A\n * 401/403 response means the key is rejected; any other status (404\n * \"not found\", 200, ...) means the key was accepted by auth middleware.\n * This ordering assumption (auth checked before the id lookup) is not\n * confirmed against cmdsend's source — see the README.\n */\n verify(): Promise<true>;\n verify(callback: (err: Error | null, success: true) => void): void;\n verify(callback?: (err: Error | null, success: true) => void): void | Promise<true> {\n const promise = this.#verify();\n if (!callback) return promise;\n promise.then(\n (ok) => callback(null, ok),\n (err: unknown) => callback(err instanceof Error ? err : new CmdsendError(String(err)), true),\n );\n return undefined;\n }\n\n async #verify(): Promise<true> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.#timeout);\n let res: Response;\n try {\n res = await this.#fetch(`${this.#baseUrl}/emails/00000000-0000-0000-0000-000000000000`, {\n method: \"GET\",\n headers: { Authorization: `Bearer ${this.#apiKey}`, ...this.#headers },\n signal: controller.signal,\n });\n } catch (err) {\n const isAbort = err instanceof Error && err.name === \"AbortError\";\n throw new CmdsendError(\n isAbort\n ? `cmdsend verification timed out after ${this.#timeout}ms`\n : `cmdsend verification failed: ${(err as Error).message}`,\n { cause: err },\n );\n } finally {\n clearTimeout(timer);\n }\n\n if (res.status === 401 || res.status === 403) {\n const body = (await res.json().catch(() => ({}))) as { message?: string; error?: string };\n throw new CmdsendError(body.message ?? \"cmdsend authentication failed\", {\n statusCode: res.status,\n code: body.error,\n });\n }\n\n return true;\n }\n}\n","import { CmdsendTransport, type CmdsendTransportOptions } from \"./transport.js\";\n\nexport { CmdsendTransport } from \"./transport.js\";\nexport type { CmdsendTransportOptions, CmdsendSentMessageInfo } from \"./transport.js\";\nexport { CmdsendError, type CmdsendErrorOptions } from \"./errors.js\";\nexport type { CmdsendSendPayload, CmdsendAttachmentPayload } from \"./payload.js\";\n\n/**\n * Creates a cmdsend transport for Nodemailer:\n *\n * ```ts\n * import nodemailer from \"nodemailer\";\n * import { cmdsendTransport } from \"nodemailer-cmdsend\";\n *\n * const transporter = nodemailer.createTransport(\n * cmdsendTransport({ apiKey: process.env.CMDSEND_API_KEY }),\n * );\n * ```\n */\nexport function cmdsendTransport(options?: CmdsendTransportOptions): CmdsendTransport {\n return new CmdsendTransport(options);\n}\n"],"mappings":"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","names":["error"]}
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@cmdsend/nodemailer",
3
+ "version": "0.1.0",
4
+ "description": "Nodemailer transport for cmdsend.com — transactional email API on Amazon SES",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "sideEffects": false,
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "scripts": {
27
+ "build": "tsup",
28
+ "dev": "tsup --watch",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "typecheck": "tsc --noEmit",
32
+ "prepublishOnly": "npm run build && npm test"
33
+ },
34
+ "keywords": [
35
+ "nodemailer",
36
+ "nodemailer-transport",
37
+ "transport",
38
+ "email",
39
+ "smtp",
40
+ "ses",
41
+ "aws-ses",
42
+ "transactional",
43
+ "transactional-email",
44
+ "cmdsend"
45
+ ],
46
+ "peerDependencies": {
47
+ "nodemailer": ">=6.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^26.2.0",
51
+ "@types/nodemailer": "^8.0.1",
52
+ "nodemailer": "^9.0.5",
53
+ "tsup": "^8.5.1",
54
+ "typescript": "^5.9.3",
55
+ "vitest": "^4.1.10"
56
+ },
57
+ "author": "Sandeep Singh",
58
+ "license": "MIT",
59
+ "homepage": "https://cmdsend.com",
60
+ "bugs": {
61
+ "url": "https://github.com/cmdsend/nodemailer/issues"
62
+ },
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "git+https://github.com/cmdsend/nodemailer.git"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ }
70
+ }