@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 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
- constructor(status: number, message: string);
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;