@easybits.cloud/mailmask 0.4.12 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -0
- package/dist/client.d.ts +18 -1
- package/dist/index.cjs +860 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +808 -5
- package/dist/partner-contract.d.ts +695 -0
- package/dist/partner.d.ts +371 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,166 @@ import { verifyWebhookSignature } from "@easybits.cloud/mailmask";
|
|
|
81
81
|
const ok = await verifyWebhookSignature(secret, { signature: req.headers["x-mailmask-signature"], timestamp: req.headers["x-mailmask-timestamp"] }, rawBody);
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
## Partners
|
|
85
|
+
|
|
86
|
+
For apps that send email **on behalf of their own customers** (each customer is a *tenant* with its
|
|
87
|
+
own domain, quota and reputation). Uses a partner credential (`mmk_…` + `mms_…` secret), not an `mk_`
|
|
88
|
+
key. Every request is signed with HMAC-SHA256 automatically; `send`/`batch` get an `idempotencyKey`
|
|
89
|
+
when you don't pass one (the same key on every retry).
|
|
90
|
+
|
|
91
|
+
Transport (0.6):
|
|
92
|
+
|
|
93
|
+
- **Retries** on rate-limit 429, 5xx, network errors and timeouts — exponential backoff with jitter
|
|
94
|
+
(`retryBaseMs` 500 → 250–500 ms, 500–1000 ms, …), honoring `Retry-After`. Only safe requests are
|
|
95
|
+
retried: GET/PUT/DELETE, `tenants.pause`/`resume`, `domains.add` and `send`/`batch` (they always
|
|
96
|
+
carry an idempotency key). `webhook.rotateSecret` and `webhook.test` are never retried. If
|
|
97
|
+
`Retry-After` asks for more than `retryMaxDelayMs` (default 30 s) the error is thrown with
|
|
98
|
+
`retryAfterMs` so you decide. `maxRetries` default 3, max 10; `onRetry` hook for your logs.
|
|
99
|
+
- **Timeout** per attempt with `AbortController` (`timeoutMs`, default 30 s) → `TimeoutError`.
|
|
100
|
+
- **Secrets stay out of logs**: the secret is not a property of the client (`console.log(mm)` can't
|
|
101
|
+
print it) and no error message carries it.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const mm = new MailMaskPartner({
|
|
105
|
+
keyId: process.env.MAILMASK_KEY_ID!, secret: process.env.MAILMASK_SECRET!,
|
|
106
|
+
timeoutMs: 10_000, maxRetries: 5,
|
|
107
|
+
onRetry: ({ attempt, delayMs, error }) => console.warn(`mailmask retry ${attempt} in ${delayMs} ms`, error.kind, error.requestId),
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Example: Formmy sending a newsletter from its customer's domain.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import {
|
|
115
|
+
MailMaskPartner, PausedError, QuotaError, RecipientSuppressedError, ReputationHoldError, ValidationError, verifyWebhook,
|
|
116
|
+
} from "@easybits.cloud/mailmask";
|
|
117
|
+
|
|
118
|
+
const mm = new MailMaskPartner({ keyId: process.env.MAILMASK_KEY_ID!, secret: process.env.MAILMASK_SECRET! });
|
|
119
|
+
|
|
120
|
+
// 1. One tenant per Formmy customer (idempotent: call it on every sign-up/update)
|
|
121
|
+
await mm.tenants.put("formmy-user-42", { name: "Panadería La Espiga" });
|
|
122
|
+
|
|
123
|
+
// 2. Their domain: show these DNS records to the customer, then poll the status
|
|
124
|
+
const { records } = await mm.domains.add("formmy-user-42", "laespiga.mx");
|
|
125
|
+
// records: [{ type: "TXT", name: "_amazonses", value: "…", level: "requerido" }, …]
|
|
126
|
+
const { state } = await mm.domains.status("formmy-user-42", "laespiga.mx"); // "pending" | "verified" | "failed"
|
|
127
|
+
|
|
128
|
+
// 3. Send (pass your own idempotencyKey to make retries across processes safe).
|
|
129
|
+
// `stream`: "transactional" (default for send: receipts, reminders; no unsubscribe link) or
|
|
130
|
+
// "broadcast" (newsletters: one-click List-Unsubscribe; an unsubscribe only silences broadcast).
|
|
131
|
+
try {
|
|
132
|
+
await mm.send("formmy-user-42", {
|
|
133
|
+
from: "La Espiga <hola@laespiga.mx>",
|
|
134
|
+
to: "reader@example.com",
|
|
135
|
+
subject: "Pan de muerto ya disponible",
|
|
136
|
+
html: "<p>Desde hoy…</p>",
|
|
137
|
+
tags: { campaign: "oct-26" },
|
|
138
|
+
stream: "broadcast",
|
|
139
|
+
idempotencyKey: "campaign-oct-26:reader@example.com",
|
|
140
|
+
});
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (e instanceof RecipientSuppressedError) { /* e.reason, e.stream, e.recipient: mark the reader, don't retry */ }
|
|
143
|
+
else if (e instanceof PausedError) { /* tenant paused (bounces/complaints): stop until resume */ }
|
|
144
|
+
else if (e instanceof QuotaError) { /* e.limit, e.resetsAt */ }
|
|
145
|
+
else if (e instanceof ValidationError) { /* e.field */ }
|
|
146
|
+
else throw e;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Up to 10,000 recipients in the background (stream defaults to "broadcast"; suppressed ones are
|
|
150
|
+
// skipped). Recipients are plain addresses or { to, vars }: {{name}} in subject/html/text is replaced
|
|
151
|
+
// per recipient (escaped inside html). Broadcasts get {{unsubscribe_url}} (or a footer if unused).
|
|
152
|
+
const job = await mm.batch("formmy-user-42", {
|
|
153
|
+
from: "hola@laespiga.mx", subject: "Hola {{nombre}}", html: "<p>{{nombre}}, tu cupón: {{cupon}}</p>",
|
|
154
|
+
recipients: ["ana@example.com", { to: "bea@example.com", vars: { nombre: "Bea", cupon: "OCT10" } }],
|
|
155
|
+
});
|
|
156
|
+
const st = await mm.batchStatus("formmy-user-42", job.batchId);
|
|
157
|
+
// st.sent / failed / skippedSuppressed, then (for up to 7 days) delivered / bounced / complained / unsubscribed
|
|
158
|
+
|
|
159
|
+
// Test mode: validates everything (signature, domain, suppression, quota) without sending or billing
|
|
160
|
+
await mm.send("formmy-user-42", { from: "hola@laespiga.mx", to: "x@example.com", subject: "s", text: "t" }, { test: true });
|
|
161
|
+
|
|
162
|
+
// Preview exactly what one recipient gets — same function the server uses, no network
|
|
163
|
+
import { previewMessage } from "@easybits.cloud/mailmask";
|
|
164
|
+
const p = previewMessage({ subject: "Hola {{nombre}}", html: "<p>{{nombre}}</p>", stream: "broadcast", vars: { nombre: "Ana" } });
|
|
165
|
+
// p.subject, p.html, p.unsubscribeFooter, p.missingVars, p.unusedVars
|
|
166
|
+
|
|
167
|
+
// Monthly usage (active tenants = at least one send this month)
|
|
168
|
+
await mm.usage({ month: "2026-10" });
|
|
169
|
+
|
|
170
|
+
// 4. Webhook: the whsec_ secret comes back only the FIRST time (later `set` calls return null:
|
|
171
|
+
// changing the URL doesn't rotate it)
|
|
172
|
+
const { secret } = await mm.webhook.set({ url: "https://formmy.app/api/mailmask/events" });
|
|
173
|
+
await mm.webhook.test(); // sends a `ping`
|
|
174
|
+
|
|
175
|
+
// Rotate without losing events: during the grace period (default 24 h) every delivery carries both
|
|
176
|
+
// signatures; pass both secrets to verifyWebhook until you've deployed the new one.
|
|
177
|
+
const { secret: next, previousValidUntil } = await mm.webhook.rotateSecret({ graceMinutes: 60 });
|
|
178
|
+
|
|
179
|
+
// Pausing: tenants.resume only lifts a pause YOU set (pausedBy "partner"). A reputation pause
|
|
180
|
+
// (pausedBy "mailmask" | "ses") throws ReputationHoldError: MailMask lifts it after review.
|
|
181
|
+
try { await mm.tenants.resume("formmy-user-42"); } catch (e) { if (!(e instanceof ReputationHoldError)) throw e; }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Webhook handler — the one-liner (Workers, Deno, Bun, Next.js route handlers, Hono…): verifies the
|
|
185
|
+
signature on the raw body, answers 401 / 200, and 500 if your `onEvent` throws (MailMask retries):
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { createWebhookHandler } from "@easybits.cloud/mailmask";
|
|
189
|
+
export const POST = createWebhookHandler({
|
|
190
|
+
secret: [process.env.MAILMASK_WEBHOOK_SECRET!, process.env.MAILMASK_WEBHOOK_SECRET_OLD ?? ""],
|
|
191
|
+
onEvent: async (event) => { /* dedupe by event.id */ },
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Or by hand (Remix/React Router, anything with a `Request`):
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
export async function action({ request }: { request: Request }) {
|
|
199
|
+
const rawBody = await request.text(); // the RAW body, before JSON.parse
|
|
200
|
+
const event = await verifyWebhook({ rawBody, headers: request.headers, secret: [process.env.MAILMASK_WEBHOOK_SECRET!, process.env.MAILMASK_WEBHOOK_SECRET_OLD ?? ""] });
|
|
201
|
+
if (!event) return new Response("bad signature", { status: 401 });
|
|
202
|
+
// event.id is the delivery id: use it to drop duplicates
|
|
203
|
+
switch (event.event) {
|
|
204
|
+
case "email.bounced":
|
|
205
|
+
case "contact.unsubscribed":
|
|
206
|
+
// mark the reader as unsubscribed for event.externalId
|
|
207
|
+
break;
|
|
208
|
+
case "tenant.paused":
|
|
209
|
+
// notify the customer
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
return new Response("ok");
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Errors: all extend `MailMaskError` (`status`, `code`, `requestId`, `retryable`) and `PartnerApiError`
|
|
217
|
+
(`body`, `retryAfterMs` and a `kind` discriminant for a plain `switch`):
|
|
218
|
+
|
|
219
|
+
| Class | `status` | `kind` | Retried by the SDK |
|
|
220
|
+
|---|---|---|---|
|
|
221
|
+
| `AuthError` | 401 | `auth` | no — `clockSkewMs` + a hint in the message if your clock is off |
|
|
222
|
+
| `ForbiddenError` | 403 | `forbidden` | no |
|
|
223
|
+
| `ReputationHoldError` (extends `ForbiddenError`) | 403 | `reputation_hold` | no |
|
|
224
|
+
| `NotFoundError` | 404 | `not_found` | no |
|
|
225
|
+
| `ConflictError` | 409 | `conflict` | no |
|
|
226
|
+
| `ValidationError` (`field`) | 422 | `validation` | no |
|
|
227
|
+
| `RecipientSuppressedError` (`reason`, `stream`, `recipient`) | 422 | `suppressed` | no |
|
|
228
|
+
| `PausedError` | 423 | `paused` | no |
|
|
229
|
+
| `QuotaError` (`limit`, `resetsAt`) | 429 `quota_exceeded` | `quota` | no |
|
|
230
|
+
| `RateLimitError` (extends `QuotaError`) | 429 `rate_limited` | `rate_limit` | yes |
|
|
231
|
+
| `PartnerApiError` | 5xx | `server` | yes (safe requests) |
|
|
232
|
+
| `NetworkError` / `TimeoutError` | 0 | `network` / `timeout` | yes (safe requests) |
|
|
233
|
+
|
|
234
|
+
`requestId` comes from `X-Request-Id` / `Fly-Request-Id`: quote it when asking for support.
|
|
235
|
+
|
|
236
|
+
Admin routes (your `mk_` owner key, not the partner credential): `MailMaskPartnerAdmin` —
|
|
237
|
+
`listPartners`, `listCredentials`, `createCredential`, `rotateCredential`, `revokeCredential`,
|
|
238
|
+
`getFallbackZone` and, for MailMask staff only, `createPartner`, `setReputationPolicy`, `resumeTenant`,
|
|
239
|
+
`setFallbackZone` (registers the partner's fallback zone, e.g. `marketing.denik.me`, and returns the
|
|
240
|
+
4 NS to delegate; then `domains.add(id, "<label>.marketing.denik.me")` writes its DNS and verifies on
|
|
241
|
+
its own). Issuing and rotating
|
|
242
|
+
are never retried (a lost response would hide a secret nobody saw).
|
|
243
|
+
|
|
84
244
|
## TypeScript
|
|
85
245
|
|
|
86
246
|
Los tipos publicados se pueden consumir desde **TypeScript 4.9 en adelante**, también con
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import type { MailMaskConfig, Domain, DomainCreated, DomainVerification, Alias, AliasCreated, MailboxCreated, CreateAliasInput, UpdateAliasInput, Rule, CreateRuleInput, UpdateRuleInput, EmailLog, SendEmailInput, BulkSendInput, BulkJob, BulkJobCreated, SmtpCredential, SmtpCredentialCreated, ApiKey, SendOptions, UploadAttachmentInput, UploadedAttachment, Suppression, Webhook, WebhookCreated, CreateWebhookInput, UpdateWebhookInput, WebhookDelivery, DnsRecordType, DnsListResponse, DnsZoneCreated, DnsDelegation, DnsImportResult, DnsAudit, DnsLookup, DnsLookupType, HttpsCheck, RegistrationInfo, DnsChangeResult, DnsPreset, DnsPresetPreview, DnsSetup, WebsiteStatus, PublishEasyBitsSiteInput, EasyBitsPublishResult, EasyBitsSiteStatus, EasyBitsUnpublishResult, BillingStatus, AddonsResponse, CheckoutLink, DomainSearchResult, TldPrice, DomainRegistrationCreated, DomainRegistration, TransferCheck, TransferDnsInventory, RenewalLink, DomainMember, DomainInvite, CannedReply, AccountProfile, AccountMe, AccountExport, InboxPage, InboxListOptions, InboxConversation, InboxConversationDetail, InboxComposeInput, InboxReplyInput, InboxUpdateInput, InboxNote, OrdersPage, ReferralStats, UploadedEmailImage } from "./types.js";
|
|
2
|
+
/** Opciones extra de `MailMaskError`; todas opcionales para no romper `new MailMaskError(status, msg)`. */
|
|
3
|
+
export interface MailMaskErrorOptions {
|
|
4
|
+
/** Código estable de la API (`not_found`, `rate_limited`…), si la respuesta lo trae. */
|
|
5
|
+
code?: string;
|
|
6
|
+
/** Id de la petición (`X-Request-Id` o `Fly-Request-Id`): cítalo al pedir soporte. */
|
|
7
|
+
requestId?: string;
|
|
8
|
+
/** Si vale la pena reintentar la MISMA petición. Default: 0 (red), 429 o 5xx. */
|
|
9
|
+
retryable?: boolean;
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Error de la API. `status` 0 = no hubo respuesta (red o tiempo agotado). Nunca lleva la llave ni
|
|
14
|
+
* el secreto: el mensaje sale del servidor o del runtime, y no se guardan los headers enviados.
|
|
15
|
+
*/
|
|
2
16
|
declare class MailMaskError extends Error {
|
|
3
17
|
status: number;
|
|
4
|
-
|
|
18
|
+
code: string | undefined;
|
|
19
|
+
requestId: string | undefined;
|
|
20
|
+
retryable: boolean;
|
|
21
|
+
constructor(status: number, message: string, opts?: MailMaskErrorOptions);
|
|
5
22
|
}
|
|
6
23
|
export declare class MailMask {
|
|
7
24
|
private baseUrl;
|