@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 +21 -0
- package/README.md +181 -0
- package/dist/index.d.ts +310 -0
- package/dist/index.js +476 -0
- package/package.json +60 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|