@elevora-tech/mailcap 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathew Mozer
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,181 @@
1
+ # @elevora-tech/mailcap
2
+
3
+ Zero-branching email SDK. One function, identical in every environment — env
4
+ vars alone decide whether an email is captured by [Mailcap](https://github.com/mat-hiretalk/mailcap)
5
+ for local/dev/staging inspection, or delivered for real via Resend, SendGrid,
6
+ or Mailgun.
7
+
8
+ ```ts
9
+ import { sendEmail } from "@elevora-tech/mailcap";
10
+
11
+ await sendEmail({ from, to, subject, html }); // or { text } / { attachments }
12
+ ```
13
+
14
+ That line never changes between `local-dave`, staging, and production. What
15
+ changes is your environment configuration:
16
+
17
+ ```bash
18
+ # Captured — nothing is delivered
19
+ MAILCAP_API_KEY=mc_...
20
+ MAILCAP_URL=https://mailcap.yourdomain.com
21
+
22
+ # Delivered for real (production)
23
+ MAIL_PROVIDER=resend # or sendgrid | mailgun
24
+ RESEND_API_KEY=re_...
25
+ ```
26
+
27
+ ## Guarantees
28
+
29
+ - **No mode flag.** Capture activates purely from `MAILCAP_API_KEY` being
30
+ present. There is no branch in your code that could ship the wrong way.
31
+ - **Real-send guard.** Outside `NODE_ENV=production`, real provider delivery is
32
+ refused unless you set `MAILCAP_ALLOW_REAL_SEND=true`. A dev machine that
33
+ happens to have a real provider key configured still can't email real people.
34
+ This guard is checked *before* provider config is validated — so if you see
35
+ the guard error while genuinely expecting capture, the fix is almost always
36
+ `MAILCAP_API_KEY` (+ `MAILCAP_URL`), not the override.
37
+ - **Loud misconfiguration.** Missing or contradictory env vars throw
38
+ immediately, naming exactly what's missing — never a silent no-op.
39
+ - **Ingest failures are never silent.** If Mailcap capture fails, `sendEmail`
40
+ rejects; your app sees the error.
41
+
42
+ ## Providers
43
+
44
+ | `MAIL_PROVIDER` | Required env vars |
45
+ |---|---|
46
+ | `resend` | `RESEND_API_KEY` |
47
+ | `sendgrid` | `SENDGRID_API_KEY` |
48
+ | `mailgun` | `MAILGUN_API_KEY`, `MAILGUN_DOMAIN`, optional `MAILGUN_REGION=us\|eu` |
49
+
50
+ Switching providers is a config change only — no call-site changes.
51
+
52
+ ## Advanced: explicit config
53
+
54
+ `sendEmail` reads `process.env` lazily on first call. For tests or multiple
55
+ mailer instances, use `createMailer` with explicit overrides:
56
+
57
+ ```ts
58
+ import { createMailer } from "@elevora-tech/mailcap";
59
+
60
+ const mailer = createMailer({
61
+ captureApiKey: "mc_test",
62
+ captureUrl: "https://mailcap.example.test",
63
+ });
64
+
65
+ await mailer.send({ from, to, subject, html });
66
+ ```
67
+
68
+ ## Already have a provider client wired up everywhere?
69
+
70
+ `sendEmail()` is the ideal shape for new code, but rewriting a codebase that
71
+ already calls `mailgun.messages.create(...)` (or `@sendgrid/mail`'s `.send()`,
72
+ or Resend's `.emails.send()`) at many call sites is real, risky work you can
73
+ reasonably not want to do. Wrap the client instead — the call shape at every
74
+ existing site stays identical:
75
+
76
+ ```ts
77
+ import Mailgun from "mailgun.js";
78
+ import { wrapMailgunClient } from "@elevora-tech/mailcap";
79
+
80
+ const rawClient = new Mailgun(formData).client({ username: "api", key: apiKey });
81
+ export const mailgun = wrapMailgunClient(rawClient); // <- only this line changes
82
+
83
+ // every existing call site, completely unchanged:
84
+ await mailgun.messages.create(domain, {
85
+ from, to, subject,
86
+ template: "login_otp_template",
87
+ "t:variables": JSON.stringify({ otp }),
88
+ });
89
+ ```
90
+
91
+ `wrapSendGridClient` and `wrapResendClient` work the same way for
92
+ `@sendgrid/mail` and `resend`. All three apply the identical capture-vs-deliver
93
+ routing, real-send guard, and loud misconfiguration errors as `sendEmail`.
94
+
95
+ For call shapes that don't fit a wrapper, there's a lower-level manual gate:
96
+
97
+ ```ts
98
+ import { isMailcapCaptureEnabled, captureRaw } from "@elevora-tech/mailcap";
99
+
100
+ if (isMailcapCaptureEnabled()) {
101
+ await captureRaw({ from, to, subject, html });
102
+ } else {
103
+ await existingProviderCall(); // untouched
104
+ }
105
+ ```
106
+
107
+ ## Version compatibility — what happens when Resend/SendGrid/Mailgun update?
108
+
109
+ `wrapResendClient`, `wrapSendGridClient`, and `wrapMailgunClient` never import
110
+ the vendor SDK. Each takes a **structural type**
111
+ (`ResendLikeClient`/`SendGridLikeClient`/`MailgunLikeClient`) describing only
112
+ the one method call shape Mailcap needs — `client.emails.send(payload)`,
113
+ `client.send(msg)`, `client.messages.create(domain, data)`. There is no
114
+ version of `resend`, `@sendgrid/mail`, or `mailgun.js` pinned anywhere in this
115
+ package, so there's nothing in mailcap-sdk to upgrade in lockstep with those
116
+ vendors.
117
+
118
+ In practice that means:
119
+
120
+ - **Internal vendor changes are invisible to Mailcap.** New optional params,
121
+ auth changes, retry logic, bundling changes — none of it touches the
122
+ wrapper, because the wrapper never calls into vendor internals, only the one
123
+ public method it wraps.
124
+ - **The only thing that can break a wrapper** is the vendor changing the
125
+ *shape* Mailcap depends on: the method name/signature itself, or a payload
126
+ field name the translator reads (e.g. SendGrid's `personalizations`,
127
+ `templateId`, `dynamicTemplateData`; Mailgun's `template` / `t:variables`).
128
+ These are long-stable, publicly documented wire shapes, not implementation
129
+ details — vendors change them rarely and usually only in a major version.
130
+ - **If that ever does happen**, it fails loudly, not silently:
131
+ `emailMessageSchema.parse` throws on missing/malformed required fields
132
+ rather than shipping a half-populated capture. You'd see a validation error
133
+ pointing at the exact field, not a mysteriously empty inbox entry.
134
+ - **Your existing call sites are unaffected either way** — you're still
135
+ calling the vendor client the same way; the wrapper just intercepts the one
136
+ method. Upgrading the vendor package in your own app is a decision you make
137
+ independently of Mailcap.
138
+
139
+ ## Provider-side templates
140
+
141
+ Some providers render from a template stored on their side rather than an
142
+ `html` string you supply — Mailgun's `template` name + `t:variables`,
143
+ SendGrid's `templateId` + `dynamicTemplateData`. Pass it as `template`
144
+ instead of `html`/`text`:
145
+
146
+ ```ts
147
+ await sendEmail({
148
+ from, to, subject,
149
+ template: { id: "d-abc123", provider: "sendgrid", data: { name: "Dave" } },
150
+ });
151
+ ```
152
+
153
+ Mailcap has no access to the real template, so its inbox falls back to a
154
+ locally-registered preview (or a plain data table if none is registered) —
155
+ see the [service's docs](https://github.com/mat-hiretalk/mailcap) for the mock
156
+ template registry. Resend has no provider-side template API — a template-only
157
+ message on `MAIL_PROVIDER=resend` throws a clear error telling you to render
158
+ `html` first (e.g. with React Email) or switch providers for that send.
159
+
160
+ ## Idempotency
161
+
162
+ Pass `messageId` to dedupe retries — Mailcap only stores the first delivery
163
+ for a given id within an environment:
164
+
165
+ ```ts
166
+ await sendEmail({ from, to, subject, html, messageId: "signup-verify-42" });
167
+ ```
168
+
169
+ ## Maintenance note: `dist/` is committed
170
+
171
+ This package isn't published to npm yet (N7's long-term plan; no npm token
172
+ available in this environment). Consumers install it as a git dependency
173
+ (`github:Elevora-Tech/mailcap-sdk#<commit>`), which is why `dist/` is checked
174
+ into this repo instead of gitignored — a git-sourced install ships repo
175
+ content as-is, and there's no install-time build step gating it. **After any
176
+ change to `src/`, run `pnpm build` and commit the resulting `dist/` alongside
177
+ it**, or consumers pinned to that commit won't see the change.
178
+
179
+ ## License
180
+
181
+ MIT
@@ -0,0 +1,310 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Single source of truth for the email message shape (spec F10).
5
+ * The Mailcap service imports this same schema to validate /api/ingest,
6
+ * so SDK and service can never drift silently — a shape mismatch is a
7
+ * validation error, not a bug discovered in production.
8
+ */
9
+ declare const attachmentSchema: z.ZodObject<{
10
+ filename: z.ZodString;
11
+ contentType: z.ZodString;
12
+ /** Base64-encoded content. Total message size is capped by the service (F5, 10MB). */
13
+ content: z.ZodString;
14
+ }, "strip", z.ZodTypeAny, {
15
+ filename: string;
16
+ contentType: string;
17
+ content: string;
18
+ }, {
19
+ filename: string;
20
+ contentType: string;
21
+ content: string;
22
+ }>;
23
+ /**
24
+ * Provider-side template reference (F37a) — for sends where the provider
25
+ * renders a server-side template (Mailgun's `template` name, SendGrid's
26
+ * `templateId`) instead of the caller supplying html/text. Mailcap has no
27
+ * access to the real template, so it captures the reference + data and
28
+ * falls back to a mock-template preview or a data table (F37b).
29
+ */
30
+ declare const templateRefSchema: z.ZodObject<{
31
+ id: z.ZodString;
32
+ provider: z.ZodOptional<z.ZodEnum<["mailgun", "sendgrid"]>>;
33
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
34
+ }, "strip", z.ZodTypeAny, {
35
+ id: string;
36
+ provider?: "mailgun" | "sendgrid" | undefined;
37
+ data?: Record<string, unknown> | undefined;
38
+ }, {
39
+ id: string;
40
+ provider?: "mailgun" | "sendgrid" | undefined;
41
+ data?: Record<string, unknown> | undefined;
42
+ }>;
43
+ declare const emailMessageSchema: z.ZodEffects<z.ZodObject<{
44
+ from: z.ZodString;
45
+ to: z.ZodArray<z.ZodString, "many">;
46
+ cc: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
47
+ bcc: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
48
+ subject: z.ZodString;
49
+ html: z.ZodOptional<z.ZodString>;
50
+ text: z.ZodOptional<z.ZodString>;
51
+ /** Provider-side template reference — alternative to html/text (F37a). */
52
+ template: z.ZodOptional<z.ZodObject<{
53
+ id: z.ZodString;
54
+ provider: z.ZodOptional<z.ZodEnum<["mailgun", "sendgrid"]>>;
55
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
56
+ }, "strip", z.ZodTypeAny, {
57
+ id: string;
58
+ provider?: "mailgun" | "sendgrid" | undefined;
59
+ data?: Record<string, unknown> | undefined;
60
+ }, {
61
+ id: string;
62
+ provider?: "mailgun" | "sendgrid" | undefined;
63
+ data?: Record<string, unknown> | undefined;
64
+ }>>;
65
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
66
+ attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
67
+ filename: z.ZodString;
68
+ contentType: z.ZodString;
69
+ /** Base64-encoded content. Total message size is capped by the service (F5, 10MB). */
70
+ content: z.ZodString;
71
+ }, "strip", z.ZodTypeAny, {
72
+ filename: string;
73
+ contentType: string;
74
+ content: string;
75
+ }, {
76
+ filename: string;
77
+ contentType: string;
78
+ content: string;
79
+ }>, "many">>;
80
+ /** Optional client-supplied id for idempotency (F7). */
81
+ messageId: z.ZodOptional<z.ZodString>;
82
+ }, "strip", z.ZodTypeAny, {
83
+ from: string;
84
+ to: string[];
85
+ subject: string;
86
+ cc?: string[] | undefined;
87
+ bcc?: string[] | undefined;
88
+ html?: string | undefined;
89
+ text?: string | undefined;
90
+ template?: {
91
+ id: string;
92
+ provider?: "mailgun" | "sendgrid" | undefined;
93
+ data?: Record<string, unknown> | undefined;
94
+ } | undefined;
95
+ headers?: Record<string, string> | undefined;
96
+ attachments?: {
97
+ filename: string;
98
+ contentType: string;
99
+ content: string;
100
+ }[] | undefined;
101
+ messageId?: string | undefined;
102
+ }, {
103
+ from: string;
104
+ to: string[];
105
+ subject: string;
106
+ cc?: string[] | undefined;
107
+ bcc?: string[] | undefined;
108
+ html?: string | undefined;
109
+ text?: string | undefined;
110
+ template?: {
111
+ id: string;
112
+ provider?: "mailgun" | "sendgrid" | undefined;
113
+ data?: Record<string, unknown> | undefined;
114
+ } | undefined;
115
+ headers?: Record<string, string> | undefined;
116
+ attachments?: {
117
+ filename: string;
118
+ contentType: string;
119
+ content: string;
120
+ }[] | undefined;
121
+ messageId?: string | undefined;
122
+ }>, {
123
+ from: string;
124
+ to: string[];
125
+ subject: string;
126
+ cc?: string[] | undefined;
127
+ bcc?: string[] | undefined;
128
+ html?: string | undefined;
129
+ text?: string | undefined;
130
+ template?: {
131
+ id: string;
132
+ provider?: "mailgun" | "sendgrid" | undefined;
133
+ data?: Record<string, unknown> | undefined;
134
+ } | undefined;
135
+ headers?: Record<string, string> | undefined;
136
+ attachments?: {
137
+ filename: string;
138
+ contentType: string;
139
+ content: string;
140
+ }[] | undefined;
141
+ messageId?: string | undefined;
142
+ }, {
143
+ from: string;
144
+ to: string[];
145
+ subject: string;
146
+ cc?: string[] | undefined;
147
+ bcc?: string[] | undefined;
148
+ html?: string | undefined;
149
+ text?: string | undefined;
150
+ template?: {
151
+ id: string;
152
+ provider?: "mailgun" | "sendgrid" | undefined;
153
+ data?: Record<string, unknown> | undefined;
154
+ } | undefined;
155
+ headers?: Record<string, string> | undefined;
156
+ attachments?: {
157
+ filename: string;
158
+ contentType: string;
159
+ content: string;
160
+ }[] | undefined;
161
+ messageId?: string | undefined;
162
+ }>;
163
+ type EmailMessage = z.infer<typeof emailMessageSchema>;
164
+ type Attachment = z.infer<typeof attachmentSchema>;
165
+ type TemplateRef = z.infer<typeof templateRefSchema>;
166
+ declare const ingestResponseSchema: z.ZodObject<{
167
+ id: z.ZodString;
168
+ }, "strip", z.ZodTypeAny, {
169
+ id: string;
170
+ }, {
171
+ id: string;
172
+ }>;
173
+ type IngestResponse = z.infer<typeof ingestResponseSchema>;
174
+
175
+ type MailProvider = "resend" | "sendgrid" | "mailgun";
176
+ interface MailcapConfig {
177
+ /** Presence of this key routes sends to Mailcap capture instead of real delivery. */
178
+ captureApiKey?: string;
179
+ captureUrl?: string;
180
+ provider?: MailProvider;
181
+ resendApiKey?: string;
182
+ sendgridApiKey?: string;
183
+ mailgunApiKey?: string;
184
+ mailgunDomain?: string;
185
+ mailgunRegion?: "us" | "eu";
186
+ /** Defaults to process.env.NODE_ENV. Overridable for tests. */
187
+ nodeEnv?: string;
188
+ /** Escape hatch for F31's guard — real delivery outside production requires this. */
189
+ allowRealSend?: boolean;
190
+ }
191
+ interface SendResult {
192
+ id: string;
193
+ mode: "captured" | "delivered";
194
+ provider?: MailProvider;
195
+ }
196
+ interface Mailer {
197
+ send(message: EmailMessage): Promise<SendResult>;
198
+ }
199
+ /**
200
+ * Builds a Mailer from explicit config (falls back to env vars for anything
201
+ * not overridden). Prefer this in tests or when a project needs more than one
202
+ * mailer instance; most app code should use the top-level `sendEmail` instead.
203
+ */
204
+ declare function createMailer(overrides?: Partial<MailcapConfig>): Mailer;
205
+ /**
206
+ * The one function app code calls, identical in every environment (F30).
207
+ * Env vars alone decide whether this captures or delivers for real.
208
+ */
209
+ declare function sendEmail(message: EmailMessage): Promise<SendResult>;
210
+ /** Test-only escape hatch to reset the memoized default mailer between env changes. */
211
+ declare function __resetDefaultMailer(): void;
212
+
213
+ /**
214
+ * F36 — brownfield integration for codebases that already call a provider
215
+ * SDK directly at many call sites (Thriveworks' notification service calls
216
+ * `mailgun.messages.create(domain, { template, "t:variables", ... })` at
217
+ * ~15 sites with no shared wrapper). Rewriting every call site to adopt
218
+ * `sendEmail()` is real, risky work a team can reasonably refuse — these
219
+ * wrappers let the migration touch only the client construction line.
220
+ */
221
+ /** Pure boolean check — true when MAILCAP_API_KEY is set (capture is active). */
222
+ declare function isMailcapCaptureEnabled(overrides?: Partial<MailcapConfig>): boolean;
223
+ /**
224
+ * POSTs an already-shared-schema-shaped message to Mailcap ingest directly,
225
+ * without going through a provider translator. Use this to hand-write your
226
+ * own `if (isMailcapCaptureEnabled()) { ... } else { <existing call> }` gate
227
+ * around code that neither `sendEmail()` nor the wrap-mode adapters below
228
+ * fit cleanly.
229
+ */
230
+ declare function captureRaw(message: EmailMessage, overrides?: Partial<MailcapConfig>): Promise<{
231
+ id: string;
232
+ }>;
233
+ interface MailgunLikeClient {
234
+ messages: {
235
+ create: (domain: string, data: Record<string, unknown>) => Promise<unknown>;
236
+ };
237
+ }
238
+ /**
239
+ * Wraps an existing `mailgun.js` client (`new Mailgun(formData).client({...})`).
240
+ * `wrapped.messages.create(domain, data)` — identical call shape — captures
241
+ * in dev/staging, delivers for real in production, exactly like `sendEmail`.
242
+ */
243
+ declare function wrapMailgunClient(client: MailgunLikeClient, overrides?: Partial<MailcapConfig>): MailgunLikeClient;
244
+ interface SendGridLikeClient {
245
+ send: (msg: Record<string, unknown>) => Promise<unknown>;
246
+ }
247
+ /**
248
+ * Wraps an existing `@sendgrid/mail` client (`sgMail` after `.setApiKey(...)`).
249
+ * `wrapped.send(msg)` — identical call shape — captures in dev/staging,
250
+ * delivers for real in production, exactly like `sendEmail`.
251
+ */
252
+ declare function wrapSendGridClient(client: SendGridLikeClient, overrides?: Partial<MailcapConfig>): SendGridLikeClient;
253
+ interface ResendLikeClient {
254
+ emails: {
255
+ send: (payload: Record<string, unknown>) => Promise<unknown>;
256
+ };
257
+ }
258
+ /**
259
+ * Wraps an existing `resend` client (`new Resend(apiKey)`).
260
+ * `wrapped.emails.send(payload)` — identical call shape — captures in
261
+ * dev/staging, delivers for real in production, exactly like `sendEmail`.
262
+ */
263
+ declare function wrapResendClient(client: ResendLikeClient, overrides?: Partial<MailcapConfig>): ResendLikeClient;
264
+
265
+ /** Thrown when required configuration is missing or contradictory (F30). */
266
+ declare class MailcapConfigError extends Error {
267
+ constructor(message: string);
268
+ }
269
+ /**
270
+ * Thrown when real provider delivery is attempted outside production without
271
+ * an explicit override (F31) — the guard against emailing real people from a
272
+ * dev machine that happens to have a real provider key configured.
273
+ */
274
+ declare class MailcapRealSendGuardError extends Error {
275
+ constructor();
276
+ }
277
+ /** Thrown when the capture ingest call itself fails (F4) — never a silent drop. */
278
+ declare class MailcapIngestError extends Error {
279
+ readonly status: number | undefined;
280
+ readonly body: string;
281
+ constructor(status: number | undefined, body: string);
282
+ }
283
+
284
+ interface DeliveryResult {
285
+ id: string;
286
+ }
287
+ interface ProviderAdapter {
288
+ name: string;
289
+ deliver(message: EmailMessage): Promise<DeliveryResult>;
290
+ }
291
+ declare class ProviderDeliveryError extends Error {
292
+ readonly provider: string;
293
+ readonly status: number | undefined;
294
+ readonly body: string;
295
+ constructor(provider: string, status: number | undefined, body: string);
296
+ }
297
+
298
+ declare function createResendAdapter(apiKey: string): ProviderAdapter;
299
+
300
+ declare function createSendGridAdapter(apiKey: string): ProviderAdapter;
301
+
302
+ interface MailgunConfig {
303
+ apiKey: string;
304
+ domain: string;
305
+ /** Mailgun has US and EU regions with different API hosts. Defaults to US. */
306
+ region?: "us" | "eu";
307
+ }
308
+ declare function createMailgunAdapter(config: MailgunConfig): ProviderAdapter;
309
+
310
+ export { type Attachment, type DeliveryResult, type EmailMessage, type IngestResponse, type MailProvider, type MailcapConfig, MailcapConfigError, MailcapIngestError, MailcapRealSendGuardError, type Mailer, type MailgunLikeClient, type ProviderAdapter, ProviderDeliveryError, type ResendLikeClient, type SendGridLikeClient, type SendResult, type TemplateRef, __resetDefaultMailer, attachmentSchema, captureRaw, createMailer, createMailgunAdapter, createResendAdapter, createSendGridAdapter, emailMessageSchema, ingestResponseSchema, isMailcapCaptureEnabled, sendEmail, templateRefSchema, wrapMailgunClient, wrapResendClient, wrapSendGridClient };
package/dist/index.js ADDED
@@ -0,0 +1,476 @@
1
+ // src/schema.ts
2
+ import { z } from "zod";
3
+ var attachmentSchema = z.object({
4
+ filename: z.string().min(1),
5
+ contentType: z.string().min(1),
6
+ /** Base64-encoded content. Total message size is capped by the service (F5, 10MB). */
7
+ content: z.string().min(1)
8
+ });
9
+ var templateRefSchema = z.object({
10
+ id: z.string().min(1),
11
+ provider: z.enum(["mailgun", "sendgrid"]).optional(),
12
+ data: z.record(z.string(), z.unknown()).optional()
13
+ });
14
+ var emailMessageSchema = z.object({
15
+ from: z.string().email(),
16
+ to: z.array(z.string().email()).min(1),
17
+ cc: z.array(z.string().email()).optional(),
18
+ bcc: z.array(z.string().email()).optional(),
19
+ subject: z.string().min(1),
20
+ html: z.string().optional(),
21
+ text: z.string().optional(),
22
+ /** Provider-side template reference — alternative to html/text (F37a). */
23
+ template: templateRefSchema.optional(),
24
+ headers: z.record(z.string(), z.string()).optional(),
25
+ attachments: z.array(attachmentSchema).optional(),
26
+ /** Optional client-supplied id for idempotency (F7). */
27
+ messageId: z.string().optional()
28
+ }).refine((msg) => msg.html !== void 0 || msg.text !== void 0 || msg.template !== void 0, {
29
+ message: "Message must have at least one of html, text, or template."
30
+ });
31
+ var ingestResponseSchema = z.object({
32
+ id: z.string()
33
+ });
34
+
35
+ // src/errors.ts
36
+ var MailcapConfigError = class extends Error {
37
+ constructor(message) {
38
+ super(`[mailcap] ${message}`);
39
+ this.name = "MailcapConfigError";
40
+ }
41
+ };
42
+ var MailcapRealSendGuardError = class extends Error {
43
+ constructor() {
44
+ super(
45
+ "[mailcap] Refusing to send: NODE_ENV is not 'production' and no MAILCAP_API_KEY is set, so this send would otherwise go out to a REAL recipient from a dev/test machine. Most likely fix: set MAILCAP_API_KEY (+ MAILCAP_URL) to capture instead. If you actually intend to send real email from here, set MAILCAP_ALLOW_REAL_SEND=true \u2014 note this check runs before provider config is validated, so you may see a follow-up error about MAIL_PROVIDER once this guard is satisfied."
46
+ );
47
+ this.name = "MailcapRealSendGuardError";
48
+ }
49
+ };
50
+ var MailcapIngestError = class extends Error {
51
+ constructor(status, body) {
52
+ super(`[mailcap] Ingest failed (status ${status ?? "n/a"}): ${body}`);
53
+ this.status = status;
54
+ this.body = body;
55
+ this.name = "MailcapIngestError";
56
+ }
57
+ status;
58
+ body;
59
+ };
60
+
61
+ // src/adapters/types.ts
62
+ var ProviderDeliveryError = class extends Error {
63
+ constructor(provider, status, body) {
64
+ super(`[mailcap] ${provider} delivery failed (status ${status ?? "n/a"}): ${body}`);
65
+ this.provider = provider;
66
+ this.status = status;
67
+ this.body = body;
68
+ this.name = "ProviderDeliveryError";
69
+ }
70
+ provider;
71
+ status;
72
+ body;
73
+ };
74
+
75
+ // src/adapters/resend.ts
76
+ function toAttachmentPayload(message) {
77
+ if (!message.attachments?.length) return void 0;
78
+ return message.attachments.map((a) => ({
79
+ filename: a.filename,
80
+ content: a.content,
81
+ content_type: a.contentType
82
+ }));
83
+ }
84
+ function createResendAdapter(apiKey) {
85
+ return {
86
+ name: "resend",
87
+ async deliver(message) {
88
+ if (message.template && !message.html && !message.text) {
89
+ throw new MailcapConfigError(
90
+ "Resend has no provider-side template API \u2014 it has no `template` to deliver via. Render html client-side (e.g. React Email) before sending, or switch MAIL_PROVIDER to mailgun/sendgrid for this message."
91
+ );
92
+ }
93
+ const res = await fetch("https://api.resend.com/emails", {
94
+ method: "POST",
95
+ headers: {
96
+ Authorization: `Bearer ${apiKey}`,
97
+ "Content-Type": "application/json"
98
+ },
99
+ body: JSON.stringify({
100
+ from: message.from,
101
+ to: message.to,
102
+ cc: message.cc,
103
+ bcc: message.bcc,
104
+ subject: message.subject,
105
+ html: message.html,
106
+ text: message.text,
107
+ headers: message.headers,
108
+ attachments: toAttachmentPayload(message)
109
+ })
110
+ });
111
+ if (!res.ok) {
112
+ throw new ProviderDeliveryError("resend", res.status, await res.text());
113
+ }
114
+ const data = await res.json();
115
+ return { id: data.id };
116
+ }
117
+ };
118
+ }
119
+
120
+ // src/adapters/sendgrid.ts
121
+ function toAttachmentPayload2(message) {
122
+ if (!message.attachments?.length) return void 0;
123
+ return message.attachments.map((a) => ({
124
+ filename: a.filename,
125
+ content: a.content,
126
+ type: a.contentType
127
+ }));
128
+ }
129
+ function toHeadersPayload(message) {
130
+ if (!message.headers) return void 0;
131
+ return message.headers;
132
+ }
133
+ function createSendGridAdapter(apiKey) {
134
+ return {
135
+ name: "sendgrid",
136
+ async deliver(message) {
137
+ const res = await fetch("https://api.sendgrid.com/v3/mail/send", {
138
+ method: "POST",
139
+ headers: {
140
+ Authorization: `Bearer ${apiKey}`,
141
+ "Content-Type": "application/json"
142
+ },
143
+ body: JSON.stringify({
144
+ personalizations: [
145
+ {
146
+ to: message.to.map((email) => ({ email })),
147
+ cc: message.cc?.map((email) => ({ email })),
148
+ bcc: message.bcc?.map((email) => ({ email }))
149
+ }
150
+ ],
151
+ from: { email: message.from },
152
+ subject: message.subject,
153
+ content: message.text || message.html ? [
154
+ message.text ? { type: "text/plain", value: message.text } : null,
155
+ message.html ? { type: "text/html", value: message.html } : null
156
+ ].filter((c) => c !== null) : void 0,
157
+ template_id: message.template?.provider === "sendgrid" ? message.template.id : void 0,
158
+ dynamic_template_data: message.template?.provider === "sendgrid" ? message.template.data : void 0,
159
+ headers: toHeadersPayload(message),
160
+ attachments: toAttachmentPayload2(message)
161
+ })
162
+ });
163
+ if (!res.ok) {
164
+ throw new ProviderDeliveryError("sendgrid", res.status, await res.text());
165
+ }
166
+ const id = res.headers.get("x-message-id") ?? crypto.randomUUID();
167
+ return { id };
168
+ }
169
+ };
170
+ }
171
+
172
+ // src/adapters/mailgun.ts
173
+ function base64ToBlob(content, contentType) {
174
+ const binary = atob(content);
175
+ const bytes = new Uint8Array(binary.length);
176
+ for (let i = 0; i < binary.length; i++) {
177
+ bytes[i] = binary.charCodeAt(i);
178
+ }
179
+ return new Blob([bytes], { type: contentType });
180
+ }
181
+ function createMailgunAdapter(config) {
182
+ const host = config.region === "eu" ? "api.eu.mailgun.net" : "api.mailgun.net";
183
+ return {
184
+ name: "mailgun",
185
+ async deliver(message) {
186
+ const form = new FormData();
187
+ form.append("from", message.from);
188
+ for (const to of message.to) form.append("to", to);
189
+ for (const cc of message.cc ?? []) form.append("cc", cc);
190
+ for (const bcc of message.bcc ?? []) form.append("bcc", bcc);
191
+ form.append("subject", message.subject);
192
+ if (message.text) form.append("text", message.text);
193
+ if (message.html) form.append("html", message.html);
194
+ if (message.template?.provider === "mailgun") {
195
+ form.append("template", message.template.id);
196
+ if (message.template.data) {
197
+ form.append("t:variables", JSON.stringify(message.template.data));
198
+ }
199
+ }
200
+ for (const [key, value] of Object.entries(message.headers ?? {})) {
201
+ form.append(`h:${key}`, value);
202
+ }
203
+ for (const attachment of message.attachments ?? []) {
204
+ form.append(
205
+ "attachment",
206
+ base64ToBlob(attachment.content, attachment.contentType),
207
+ attachment.filename
208
+ );
209
+ }
210
+ const res = await fetch(`https://${host}/v3/${config.domain}/messages`, {
211
+ method: "POST",
212
+ headers: {
213
+ Authorization: `Basic ${btoa(`api:${config.apiKey}`)}`
214
+ },
215
+ body: form
216
+ });
217
+ if (!res.ok) {
218
+ throw new ProviderDeliveryError("mailgun", res.status, await res.text());
219
+ }
220
+ const data = await res.json();
221
+ return { id: data.id };
222
+ }
223
+ };
224
+ }
225
+
226
+ // src/mailer.ts
227
+ function readConfigFromEnv() {
228
+ const env = typeof process !== "undefined" ? process.env : {};
229
+ return {
230
+ captureApiKey: env.MAILCAP_API_KEY,
231
+ captureUrl: env.MAILCAP_URL,
232
+ provider: env.MAIL_PROVIDER,
233
+ resendApiKey: env.RESEND_API_KEY,
234
+ sendgridApiKey: env.SENDGRID_API_KEY,
235
+ mailgunApiKey: env.MAILGUN_API_KEY,
236
+ mailgunDomain: env.MAILGUN_DOMAIN,
237
+ mailgunRegion: env.MAILGUN_REGION,
238
+ nodeEnv: env.NODE_ENV,
239
+ allowRealSend: env.MAILCAP_ALLOW_REAL_SEND === "true"
240
+ };
241
+ }
242
+ function resolveAdapter(config) {
243
+ if (!config.provider) {
244
+ throw new MailcapConfigError(
245
+ "No capture key present and MAIL_PROVIDER is not set. Set MAILCAP_API_KEY to capture, or set MAIL_PROVIDER=resend|sendgrid|mailgun plus that provider's API key to deliver for real."
246
+ );
247
+ }
248
+ switch (config.provider) {
249
+ case "resend": {
250
+ if (!config.resendApiKey) {
251
+ throw new MailcapConfigError(
252
+ "MAIL_PROVIDER=resend but RESEND_API_KEY is not set."
253
+ );
254
+ }
255
+ return createResendAdapter(config.resendApiKey);
256
+ }
257
+ case "sendgrid": {
258
+ if (!config.sendgridApiKey) {
259
+ throw new MailcapConfigError(
260
+ "MAIL_PROVIDER=sendgrid but SENDGRID_API_KEY is not set."
261
+ );
262
+ }
263
+ return createSendGridAdapter(config.sendgridApiKey);
264
+ }
265
+ case "mailgun": {
266
+ if (!config.mailgunApiKey) {
267
+ throw new MailcapConfigError(
268
+ "MAIL_PROVIDER=mailgun but MAILGUN_API_KEY is not set."
269
+ );
270
+ }
271
+ if (!config.mailgunDomain) {
272
+ throw new MailcapConfigError(
273
+ "MAIL_PROVIDER=mailgun but MAILGUN_DOMAIN is not set."
274
+ );
275
+ }
276
+ return createMailgunAdapter({
277
+ apiKey: config.mailgunApiKey,
278
+ domain: config.mailgunDomain,
279
+ region: config.mailgunRegion
280
+ });
281
+ }
282
+ default:
283
+ throw new MailcapConfigError(
284
+ `MAIL_PROVIDER=${String(config.provider)} is not a recognized provider (expected resend, sendgrid, or mailgun).`
285
+ );
286
+ }
287
+ }
288
+ async function deliverToCapture(message, config) {
289
+ if (!config.captureUrl) {
290
+ throw new MailcapConfigError(
291
+ "MAILCAP_API_KEY is set but MAILCAP_URL is not \u2014 cannot reach the Mailcap service."
292
+ );
293
+ }
294
+ const res = await fetch(new URL("/api/ingest", config.captureUrl), {
295
+ method: "POST",
296
+ headers: {
297
+ Authorization: `Bearer ${config.captureApiKey}`,
298
+ "Content-Type": "application/json"
299
+ },
300
+ body: JSON.stringify(message)
301
+ });
302
+ if (!res.ok) {
303
+ throw new MailcapIngestError(res.status, await res.text());
304
+ }
305
+ const parsed = ingestResponseSchema.parse(await res.json());
306
+ return { id: parsed.id, mode: "captured" };
307
+ }
308
+ function assertRealSendAllowed(config) {
309
+ const nodeEnv = config.nodeEnv ?? "development";
310
+ if (nodeEnv !== "production" && !config.allowRealSend) {
311
+ throw new MailcapRealSendGuardError();
312
+ }
313
+ }
314
+ async function deliverToProvider(message, config) {
315
+ assertRealSendAllowed(config);
316
+ const adapter = resolveAdapter(config);
317
+ const result = await adapter.deliver(message);
318
+ return { id: result.id, mode: "delivered", provider: config.provider };
319
+ }
320
+ function createMailer(overrides = {}) {
321
+ const config = { ...readConfigFromEnv(), ...overrides };
322
+ return {
323
+ async send(message) {
324
+ const validated = emailMessageSchema.parse(message);
325
+ if (config.captureApiKey) {
326
+ return deliverToCapture(validated, config);
327
+ }
328
+ return deliverToProvider(validated, config);
329
+ }
330
+ };
331
+ }
332
+ var defaultMailer;
333
+ function sendEmail(message) {
334
+ if (!defaultMailer) {
335
+ defaultMailer = createMailer();
336
+ }
337
+ return defaultMailer.send(message);
338
+ }
339
+ function __resetDefaultMailer() {
340
+ defaultMailer = void 0;
341
+ }
342
+
343
+ // src/wrap.ts
344
+ function isMailcapCaptureEnabled(overrides = {}) {
345
+ const config = { ...readConfigFromEnv(), ...overrides };
346
+ return Boolean(config.captureApiKey);
347
+ }
348
+ async function captureRaw(message, overrides = {}) {
349
+ const config = { ...readConfigFromEnv(), ...overrides };
350
+ const validated = emailMessageSchema.parse(message);
351
+ const result = await deliverToCapture(validated, config);
352
+ return { id: result.id };
353
+ }
354
+ function normalizeRecipients(value) {
355
+ if (!value) return [];
356
+ if (typeof value === "string") return value.split(",").map((s) => s.trim()).filter(Boolean);
357
+ if (Array.isArray(value)) {
358
+ return value.map((v) => typeof v === "string" ? v : v.email);
359
+ }
360
+ return [];
361
+ }
362
+ function mailgunPayloadToMessage(data) {
363
+ const template = data.template ? {
364
+ id: String(data.template),
365
+ provider: "mailgun",
366
+ data: data["t:variables"] ? JSON.parse(String(data["t:variables"])) : void 0
367
+ } : void 0;
368
+ return emailMessageSchema.parse({
369
+ from: data.from,
370
+ to: normalizeRecipients(data.to),
371
+ cc: normalizeRecipients(data.cc),
372
+ bcc: normalizeRecipients(data.bcc),
373
+ subject: data.subject,
374
+ html: data.html,
375
+ text: data.text,
376
+ template
377
+ });
378
+ }
379
+ function wrapMailgunClient(client, overrides = {}) {
380
+ return {
381
+ messages: {
382
+ async create(domain, data) {
383
+ const config = { ...readConfigFromEnv(), ...overrides };
384
+ if (config.captureApiKey) {
385
+ const message = mailgunPayloadToMessage(data);
386
+ const result = await deliverToCapture(message, config);
387
+ return { id: result.id, message: "Queued. Thank you." };
388
+ }
389
+ assertRealSendAllowed(config);
390
+ return client.messages.create(domain, data);
391
+ }
392
+ }
393
+ };
394
+ }
395
+ function sendgridPayloadToMessage(msg) {
396
+ const personalizations = msg.personalizations;
397
+ const first = personalizations?.[0];
398
+ const template = msg.templateId ? {
399
+ id: String(msg.templateId),
400
+ provider: "sendgrid",
401
+ data: msg.dynamicTemplateData
402
+ } : void 0;
403
+ const from = typeof msg.from === "string" ? msg.from : msg.from?.email;
404
+ return emailMessageSchema.parse({
405
+ from,
406
+ to: normalizeRecipients(first?.to ?? msg.to),
407
+ cc: normalizeRecipients(first?.cc ?? msg.cc),
408
+ bcc: normalizeRecipients(first?.bcc ?? msg.bcc),
409
+ subject: msg.subject,
410
+ html: msg.html,
411
+ text: msg.text,
412
+ template
413
+ });
414
+ }
415
+ function wrapSendGridClient(client, overrides = {}) {
416
+ return {
417
+ async send(msg) {
418
+ const config = { ...readConfigFromEnv(), ...overrides };
419
+ if (config.captureApiKey) {
420
+ const message = sendgridPayloadToMessage(msg);
421
+ const result = await deliverToCapture(message, config);
422
+ return [{ statusCode: 202, headers: { "x-message-id": result.id } }, {}];
423
+ }
424
+ assertRealSendAllowed(config);
425
+ return client.send(msg);
426
+ }
427
+ };
428
+ }
429
+ function resendPayloadToMessage(payload) {
430
+ return emailMessageSchema.parse({
431
+ from: payload.from,
432
+ to: normalizeRecipients(payload.to),
433
+ cc: normalizeRecipients(payload.cc),
434
+ bcc: normalizeRecipients(payload.bcc),
435
+ subject: payload.subject,
436
+ html: payload.html,
437
+ text: payload.text
438
+ });
439
+ }
440
+ function wrapResendClient(client, overrides = {}) {
441
+ return {
442
+ emails: {
443
+ async send(payload) {
444
+ const config = { ...readConfigFromEnv(), ...overrides };
445
+ if (config.captureApiKey) {
446
+ const message = resendPayloadToMessage(payload);
447
+ const result = await deliverToCapture(message, config);
448
+ return { data: { id: result.id }, error: null };
449
+ }
450
+ assertRealSendAllowed(config);
451
+ return client.emails.send(payload);
452
+ }
453
+ }
454
+ };
455
+ }
456
+ export {
457
+ MailcapConfigError,
458
+ MailcapIngestError,
459
+ MailcapRealSendGuardError,
460
+ ProviderDeliveryError,
461
+ __resetDefaultMailer,
462
+ attachmentSchema,
463
+ captureRaw,
464
+ createMailer,
465
+ createMailgunAdapter,
466
+ createResendAdapter,
467
+ createSendGridAdapter,
468
+ emailMessageSchema,
469
+ ingestResponseSchema,
470
+ isMailcapCaptureEnabled,
471
+ sendEmail,
472
+ templateRefSchema,
473
+ wrapMailgunClient,
474
+ wrapResendClient,
475
+ wrapSendGridClient
476
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@elevora-tech/mailcap",
3
+ "version": "0.2.0",
4
+ "description": "Zero-branching email SDK: captures in dev/staging via Mailcap, delivers for real via Resend, SendGrid, or Mailgun in production.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
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
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format esm --dts --clean",
20
+ "dev": "tsup src/index.ts --format esm --dts --watch",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "typecheck": "tsc --noEmit",
24
+ "lint": "eslint ."
25
+ },
26
+ "keywords": [
27
+ "email",
28
+ "testing",
29
+ "resend",
30
+ "sendgrid",
31
+ "mailgun",
32
+ "sdk"
33
+ ],
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/Elevora-Tech/mailcap-sdk"
38
+ },
39
+ "homepage": "https://github.com/Elevora-Tech/mailcap-sdk#readme",
40
+ "bugs": "https://github.com/Elevora-Tech/mailcap-sdk/issues",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "zod": "^3.23.8"
46
+ },
47
+ "devDependencies": {
48
+ "@changesets/cli": "^2.31.1",
49
+ "@eslint/js": "^10.0.1",
50
+ "@types/node": "^22.10.2",
51
+ "eslint": "^9.17.0",
52
+ "tsup": "^8.3.5",
53
+ "typescript": "^5.7.2",
54
+ "typescript-eslint": "^8.64.0",
55
+ "vitest": "^2.1.8"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ }
60
+ }