mailumi 1.0.0 → 1.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/README.md CHANGED
@@ -7,7 +7,8 @@ The official Node.js SDK and CLI for [Mailumi](https://mailumi.com), the email A
7
7
  - Every method resolves to `{ data, error }` and never throws for API errors
8
8
  - Automatic retries for rate limits and server errors; sends reuse one `Idempotency-Key`, so a retry never sends twice
9
9
  - Webhook signature verification built in
10
- - `npx mailumi send` from the terminal
10
+ - Set up domains, webhooks, templates, campaigns and suppressions from code, not only in the dashboard
11
+ - `npx mailumi send` and every other API call from the terminal
11
12
 
12
13
  ## Install
13
14
 
@@ -117,6 +118,46 @@ const { data: page } = await mailumi.emails.list({ direction: 'inbound' });
117
118
  const { data: bytes } = await mailumi.emails.attachment({ id, index: 0 });
118
119
  ```
119
120
 
121
+ ## Set up domains and webhooks
122
+
123
+ With an API key that has `domains:write` and `webhooks:write`, or Full access, a script, deploy pipeline or AI agent can do the whole setup:
124
+
125
+ ```ts
126
+ const { data: domain } = await mailumi.domains.create({ name: 'mail.yourdomain.com' });
127
+ for (const r of domain.records) console.log(r.type, r.name, r.value, r.required ? '' : '(recommended)');
128
+
129
+ // After publishing the records at your DNS provider:
130
+ const { data: check } = await mailumi.domains.verify(domain.id);
131
+ check.ready; // true once the domain can send
132
+ check.message; // what is still missing
133
+
134
+ const { data: hook } = await mailumi.webhooks.create({
135
+ name: 'Production',
136
+ url: 'https://yourapp.com/webhooks/mailumi',
137
+ events: ['email.delivered', 'email.bounced', 'email.complained'],
138
+ });
139
+ hook.secret; // only returned now: store it as MAILUMI_WEBHOOK_SECRET
140
+
141
+ await mailumi.webhooks.test(hook.id);
142
+ const { data: deliveries } = await mailumi.webhooks.deliveries(hook.id);
143
+ ```
144
+
145
+ Mailumi also checks new domains every 5 minutes and emails you as soon as one is verified, so there is no need to poll. If you do, wait at least 15 seconds between checks. `domains.update({ id, openTracking, clickTracking })` changes tracking; turning receiving on with `receivingEnabled: true` also needs `mxConsent: true`, because the MX record replaces any mailbox provider the domain uses now.
146
+
147
+ | Permission | Allows |
148
+ | --- | --- |
149
+ | `email:send` | Send, batch, schedule, reschedule and cancel emails |
150
+ | `email:read` | Read emails, delivery events and attachments |
151
+ | `domains:read` / `domains:write` | Read domains / add, verify, configure and remove them |
152
+ | `webhooks:write` | Create, test, retry and disable webhooks |
153
+ | `templates:write` | Create, change and delete templates |
154
+ | `contacts:write` | Manage audiences and contacts |
155
+ | `campaigns:write` | Create, send and delete campaigns |
156
+ | `suppressions:write` | List and remove suppressed addresses |
157
+ | `full_access` | Everything, including permissions added later |
158
+
159
+ API keys cannot create or revoke API keys; that stays in the dashboard. A key limited to one domain only sees and manages that domain.
160
+
120
161
  ## Webhooks
121
162
 
122
163
  Mailumi signs each webhook with the [Standard Webhooks](https://www.standardwebhooks.com/) format. `verifyWebhook` checks the signature and timestamp and returns the parsed event. It needs no API key; the secret defaults to `MAILUMI_WEBHOOK_SECRET`.
@@ -132,17 +173,23 @@ export async function POST(request: Request) {
132
173
  if (error) return new Response(error.message, { status: 401 });
133
174
 
134
175
  if (event.type === 'email.received') {
135
- // event.data has from, to, subject, text, html and attachments
176
+ const { from, reply_to, subject, text, html, message_id, spam, attachments } = event.data;
177
+ // spam is a boolean; attachments have filename, content_type, size and download_url
178
+ }
179
+ if (event.type === 'email.bounced') {
180
+ const { recipient, bounce } = event.data; // bounce.type is "Permanent" or "Transient"
136
181
  }
137
182
  return new Response('OK');
138
183
  }
139
184
  ```
140
185
 
186
+ `event.data` is typed by `event.type`: `EmailReceivedData` for `email.received`, and `EmailEventData` for events about email you sent, which is the email plus `recipient` and a detail such as `delivery`, `bounce` or `click`. Every field is described under [Receiving email](https://mailumi.com/docs#receiving) and [Webhooks](https://mailumi.com/docs#webhooks).
187
+
141
188
  `headers` can be a `Headers` object, Node's `req.headers`, or `{ id, timestamp, signature }`. Deliveries can repeat, so store `event.id` and skip ids you have seen. Events: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.failed`, `email.unsubscribed` and `email.received`.
142
189
 
143
- ## Audiences and contacts
190
+ ## Audiences, contacts and campaigns
144
191
 
145
- Add contacts from your app for campaigns. Campaigns are written and sent in the dashboard, with an unsubscribe link and one-click unsubscribe in every email. These calls need an API key with the `contacts:write` permission.
192
+ Add contacts from your app for campaigns. Every campaign email gets an unsubscribe link and one-click unsubscribe. Contacts need an API key with the `contacts:write` permission, campaigns `campaigns:write`.
146
193
 
147
194
  ```ts
148
195
  const { data: audience } = await mailumi.audiences.create({ name: 'Newsletter' });
@@ -156,6 +203,25 @@ const { data } = await mailumi.contacts.list({ audienceId: audience.id, search:
156
203
 
157
204
  Unsubscribing applies to the address in every audience of the workspace, and adding or importing a contact never resubscribes it.
158
205
 
206
+ ```ts
207
+ const { data: campaign } = await mailumi.campaigns.create({
208
+ name: 'October update',
209
+ subject: 'What is new, {{first_name}}',
210
+ text: 'Hi {{first_name}},\n\nHere is what we shipped this month: https://yourdomain.com/changelog',
211
+ from: 'Acme <news@yourdomain.com>',
212
+ audienceId: audience.id,
213
+ });
214
+
215
+ const { data: draft } = await mailumi.campaigns.get(campaign.id);
216
+ draft.readiness; // { recipients, missing }: fix everything in missing before sending
217
+
218
+ await mailumi.campaigns.send(campaign.id);
219
+ const { data: sent } = await mailumi.campaigns.get(campaign.id);
220
+ sent.stats; // delivered, bounced, opened, clicked, unsubscribed, ...
221
+ ```
222
+
223
+ Templates for transactional email work the same way: `templates.create({ name, subject, html, variables })` returns an ID to send with `templateId`. `suppressions.list()` shows addresses Mailumi no longer sends to after a bounce or complaint, and `suppressions.remove(address)` allows one again.
224
+
159
225
  ## Errors
160
226
 
161
227
  ```ts
@@ -173,7 +239,7 @@ if (error) {
173
239
  | `unauthorized` | The API key is missing, wrong, revoked or expired. |
174
240
  | `forbidden` | The key lacks the permission, or is limited to another domain. |
175
241
  | `plan_limit_reached` | The monthly or daily email allowance is used up. |
176
- | `not_found` | The email, domain, audience or contact does not exist. |
242
+ | `not_found` | The email, domain, webhook, template, audience, contact or campaign does not exist. |
177
243
  | `conflict` | The `Idempotency-Key` was used for a different request. |
178
244
  | `rate_limited` | Too many requests; retried automatically. |
179
245
  | `internal_error`, `service_unavailable` | A temporary problem; retried automatically. |
@@ -202,20 +268,28 @@ npx mailumi emails list
202
268
  npx mailumi emails get <id>
203
269
  npx mailumi emails cancel <id>
204
270
  npx mailumi domains list
271
+ npx mailumi domains add mail.yourdomain.com
272
+ npx mailumi domains verify <domain_id> --wait # re-checks every 15 seconds until verified
273
+ npx mailumi webhooks create https://yourapp.com/webhooks/mailumi --name Production --events email.delivered,email.bounced
274
+ npx mailumi webhooks test <webhook_id>
275
+ npx mailumi templates create --name Welcome --subject "Welcome, {{first_name}}" --html-file ./welcome.html
205
276
  npx mailumi audiences list
206
277
  npx mailumi contacts add <audience_id> jane@example.com --first-name Jane
278
+ npx mailumi campaigns create --name "October update" --subject "News" --text-file ./news.txt --from news@yourdomain.com --audience <audience_id>
279
+ npx mailumi campaigns send <campaign_id>
280
+ npx mailumi suppressions list
207
281
  ```
208
282
 
209
283
  Add `--json` for machine-readable output and `--help` for every command and option. The CLI exits with code 1 on errors.
210
284
 
211
285
  ## AI coding assistants
212
286
 
213
- Mailumi publishes [llms.txt](https://mailumi.com/llms.txt), [llms-full.txt](https://mailumi.com/llms-full.txt), an [OpenAPI 3.1 description](https://mailumi.com/openapi.json) and an [MCP server](https://mailumi.com/docs#mcp), so Cursor, Claude Code, Codex and VS Code can send test emails and check delivery while they build your integration. See [mailumi.com/ai](https://mailumi.com/ai).
287
+ Mailumi publishes [llms.txt](https://mailumi.com/llms.txt), [llms-full.txt](https://mailumi.com/llms-full.txt), an [OpenAPI 3.1 description](https://mailumi.com/openapi.json) and an [MCP server](https://mailumi.com/docs#mcp), so Cursor, Claude Code, Codex and VS Code can send test emails, check delivery and set up domains, webhooks, templates and campaigns while they build your integration. See [mailumi.com/ai](https://mailumi.com/ai).
214
288
 
215
289
  ## Links
216
290
 
217
291
  - [Documentation](https://mailumi.com/docs)
218
- - [Pricing](https://mailumi.com/pricing): the free plan includes 3,000 emails a month
292
+ - [Pricing](https://mailumi.com/pricing): the free plan includes 1,500 emails a month
219
293
  - [Support](https://mailumi.com/contact)
220
294
 
221
295
  ## Versioning
@@ -2,7 +2,7 @@
2
2
  * The official Mailumi SDK. Works anywhere `fetch` exists: Node.js 18+, Bun, Deno and Cloudflare Workers.
3
3
  * Every method resolves to `{ data, error }` and never throws for API errors.
4
4
  */
5
- export declare const VERSION = "1.0.0";
5
+ export declare const VERSION = "1.2.0";
6
6
  export type ErrorCode = 'invalid_request' | 'unauthorized' | 'plan_limit_reached' | 'forbidden' | 'not_found' | 'method_not_allowed' | 'conflict' | 'payload_too_large' | 'rate_limited' | 'internal_error' | 'service_unavailable' | 'network_error' | 'missing_api_key' | 'invalid_signature' | 'missing_webhook_secret';
7
7
  export interface MailumiError {
8
8
  name: 'MailumiError';
@@ -116,10 +116,13 @@ export interface DnsRecord {
116
116
  value: string;
117
117
  priority?: number;
118
118
  verified: boolean;
119
+ /** What DNS returned at this name when it does not match `value`. Only on records that are not verified. */
120
+ found?: string[];
119
121
  }
120
122
  export interface Domain {
121
123
  id: string;
122
124
  name: string;
125
+ region?: string;
123
126
  ownershipVerified?: boolean;
124
127
  sendingEnabled?: boolean;
125
128
  receivingEnabled?: boolean;
@@ -131,6 +134,125 @@ export interface Domain {
131
134
  createdAt?: number;
132
135
  records: DnsRecord[];
133
136
  }
137
+ export interface DomainVerification {
138
+ domain: Domain;
139
+ /** `true` once the domain can send. */
140
+ ready: boolean;
141
+ /** What is still missing, or that the domain is ready. */
142
+ message: string;
143
+ /** How many required records have a different value at the DNS provider. */
144
+ wrong?: number;
145
+ }
146
+ export interface DomainSettings {
147
+ id: string;
148
+ /** Receive email for the domain and send it to your email.received webhooks. */
149
+ receivingEnabled?: boolean;
150
+ /** Must be `true` to turn receiving on: the MX record replaces any mailbox provider the domain uses now. */
151
+ mxConsent?: boolean;
152
+ /** Pro and Scale plans. */
153
+ openTracking?: boolean;
154
+ /** Pro and Scale plans. */
155
+ clickTracking?: boolean;
156
+ }
157
+ export type WebhookSubscription = Exclude<WebhookEventType, 'webhook.test'>;
158
+ export interface NewWebhook {
159
+ name: string;
160
+ /** A public HTTPS URL on port 443. */
161
+ url: string;
162
+ /** Defaults to `['email.received']`. */
163
+ events?: WebhookSubscription[];
164
+ /** Only send events for this domain. Leave it out for every domain. */
165
+ domainId?: string;
166
+ /** For email.received: only mail to this address before the @, for example "support". */
167
+ recipient?: string;
168
+ }
169
+ export interface Webhook {
170
+ object: 'webhook';
171
+ id: string;
172
+ name: string;
173
+ url: string;
174
+ events: WebhookSubscription[];
175
+ domain_id: string | null;
176
+ recipient: string | null;
177
+ enabled: boolean;
178
+ created_at: string;
179
+ }
180
+ export interface WebhookDelivery {
181
+ /** Also the webhook-id header of the request. */
182
+ id: string;
183
+ object: 'webhook_delivery';
184
+ webhook_id: string;
185
+ event: WebhookEventType;
186
+ email_id: string | null;
187
+ status: 'pending' | 'delivered' | 'failed' | 'cancelled';
188
+ attempts: number;
189
+ response_code: number | null;
190
+ last_error: string | null;
191
+ next_attempt_at: string | null;
192
+ created_at: string;
193
+ delivered_at: string | null;
194
+ }
195
+ export interface NewTemplate {
196
+ name: string;
197
+ /** Placeholders such as `{{first_name}}` work in the subject and HTML. */
198
+ subject: string;
199
+ html: string;
200
+ /** Default values for placeholders an email does not pass. */
201
+ variables?: Record<string, string>;
202
+ }
203
+ export interface Template {
204
+ object: 'template';
205
+ id: string;
206
+ name: string;
207
+ subject: string;
208
+ html: string;
209
+ variables: Record<string, string>;
210
+ created_at: string;
211
+ updated_at: string;
212
+ }
213
+ export interface NewCampaign {
214
+ name: string;
215
+ /** `{{first_name}}`, `{{last_name}}` and `{{email}}` are filled in per contact. */
216
+ subject: string;
217
+ /** Plain text. Blank lines start paragraphs and links become clickable. Mailumi adds your postal address and an unsubscribe link. */
218
+ text: string;
219
+ /** A sender on a verified domain. Needed before sending. */
220
+ from?: string;
221
+ /** Needed before sending. */
222
+ audienceId?: string | null;
223
+ }
224
+ export type CampaignStats = Record<'total' | 'pending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed' | 'opened' | 'clicked' | 'unsubscribed', number>;
225
+ export interface Campaign {
226
+ object: 'campaign';
227
+ id: string;
228
+ name: string;
229
+ subject: string;
230
+ text: string;
231
+ from: string | null;
232
+ audience_id: string | null;
233
+ status: 'draft' | 'sending' | 'sent' | 'failed';
234
+ /** How many contacts it was sent to. */
235
+ recipients: number;
236
+ error: string | null;
237
+ sent_at: string | null;
238
+ created_at: string;
239
+ updated_at: string;
240
+ /** Drafts, from `campaigns.get`: how many contacts it would go to now, and what to fix before sending. */
241
+ readiness?: {
242
+ recipients: number;
243
+ missing: string[];
244
+ };
245
+ /** Campaigns that were sent. */
246
+ stats?: CampaignStats;
247
+ }
248
+ export interface Suppression {
249
+ object: 'suppression';
250
+ address: string;
251
+ reason: 'bounce' | 'complaint' | 'manual';
252
+ detail: string | null;
253
+ email_id: string | null;
254
+ created_at: string;
255
+ }
134
256
  export interface Audience {
135
257
  object: 'audience';
136
258
  id: string;
@@ -189,6 +311,105 @@ export interface WebhookEvent<T = Record<string, any>> {
189
311
  created_at: string;
190
312
  data: T;
191
313
  }
314
+ interface EmailFields {
315
+ email_id: string;
316
+ domain_id: string;
317
+ from: string;
318
+ to: string[];
319
+ subject: string;
320
+ last_event: string;
321
+ tags: Record<string, string>;
322
+ created_at: string;
323
+ }
324
+ export interface ReceivedAttachment {
325
+ id: string;
326
+ filename: string;
327
+ content_type: string;
328
+ /** Set for inline images that the HTML shows with `cid:`. */
329
+ content_id: string | null;
330
+ /** Size in bytes. */
331
+ size: number;
332
+ /** Download it server-side with `mailumi.emails.attachment({ id: email_id, index })` or a key with email:read. */
333
+ download_url: string;
334
+ }
335
+ /** `data` of an `email.received` event. */
336
+ export interface EmailReceivedData extends EmailFields {
337
+ direction: 'inbound';
338
+ status: 'received';
339
+ scheduled_at: null;
340
+ error: null;
341
+ /** `Name <address>`, or only the address when the email has no name. */
342
+ from: string;
343
+ /** The recipients at this domain, in lowercase. */
344
+ to: string[];
345
+ cc: string[];
346
+ reply_to: string[];
347
+ /** The Message-ID header with its angle brackets, for In-Reply-To and References when you reply. */
348
+ message_id: string | null;
349
+ /** Empty when the email has no plain-text part. */
350
+ text: string;
351
+ /** Empty when the email has no HTML part. It is the sender's HTML: sanitize it before showing it. */
352
+ html: string;
353
+ /** `true` when the Amazon SES spam check flagged the email. */
354
+ spam: boolean;
355
+ attachments: ReceivedAttachment[];
356
+ }
357
+ /** `data` of an event about an email you sent: the email as it is now, plus the detail for that event. */
358
+ export interface EmailEventData extends EmailFields {
359
+ direction: 'outbound';
360
+ status: string;
361
+ scheduled_at: string | null;
362
+ error: string | null;
363
+ /** The recipient the event is about. Not set on email.sent and email.failed, nor on opens and clicks of an email with more than one recipient. */
364
+ recipient?: string;
365
+ delivery?: {
366
+ smtp_response: string | null;
367
+ };
368
+ delay?: {
369
+ type: string | null;
370
+ expires_at: string | null;
371
+ message: string | null;
372
+ };
373
+ bounce?: {
374
+ type: 'Permanent' | 'Transient' | (string & {}) | null;
375
+ sub_type: string | null;
376
+ message: string | null;
377
+ };
378
+ complaint?: {
379
+ type: string | null;
380
+ };
381
+ open?: {
382
+ ip_address: string | null;
383
+ user_agent: string | null;
384
+ };
385
+ click?: {
386
+ link: string | null;
387
+ ip_address: string | null;
388
+ user_agent: string | null;
389
+ };
390
+ failed?: {
391
+ reason: string;
392
+ };
393
+ }
394
+ /** The event `verifyWebhook` returns. Check `event.type` and `event.data` has the matching fields. */
395
+ export type MailumiWebhookEvent = {
396
+ id: string;
397
+ type: 'email.received';
398
+ created_at: string;
399
+ data: EmailReceivedData;
400
+ } | {
401
+ id: string;
402
+ type: Exclude<WebhookEventType, 'email.received' | 'webhook.test'>;
403
+ created_at: string;
404
+ data: EmailEventData;
405
+ } | {
406
+ id: string;
407
+ type: 'webhook.test';
408
+ created_at: string;
409
+ data: {
410
+ message: string;
411
+ };
412
+ };
192
413
  export interface VerifyWebhookOptions {
193
414
  /** The raw request body exactly as received, before any JSON parsing. */
194
415
  payload: string | Uint8Array;
@@ -203,7 +424,8 @@ export interface VerifyWebhookOptions {
203
424
  * Checks the Standard Webhooks signature of a Mailumi webhook and returns the parsed event.
204
425
  * Store `event.id` and skip ids you have seen: a delivery can arrive more than once.
205
426
  */
206
- export declare function verifyWebhook<T = Record<string, any>>({ payload, headers, secret, tolerance }: VerifyWebhookOptions): Promise<Result<WebhookEvent<T>>>;
427
+ export declare function verifyWebhook(options: VerifyWebhookOptions): Promise<Result<MailumiWebhookEvent>>;
428
+ export declare function verifyWebhook<T>(options: VerifyWebhookOptions): Promise<Result<WebhookEvent<T>>>;
207
429
  /** Converts the SDK's camelCase options to the API's request body. snake_case keys are accepted too. */
208
430
  export declare function toApiEmail(o: SendEmailOptions & Record<string, any>): Record<string, unknown>;
209
431
  export declare class Mailumi {
@@ -247,6 +469,54 @@ export declare class Mailumi {
247
469
  list: () => Promise<Result<{
248
470
  data: Domain[];
249
471
  }>>;
472
+ /** Adds a domain and returns the DNS records to publish. Needs a key with access to all domains. */
473
+ create: ({ name }: {
474
+ name: string;
475
+ }) => Promise<Result<Domain>>;
476
+ get: (id: string) => Promise<Result<Domain>>;
477
+ /** Checks the DNS records now. Mailumi also checks new domains every 5 minutes and emails you once one is verified. */
478
+ verify: (id: string) => Promise<Result<DomainVerification>>;
479
+ update: ({ id, receivingEnabled, mxConsent, openTracking, clickTracking, ...rest }: DomainSettings & Record<string, any>) => Promise<Result<Domain>>;
480
+ /** Removes the domain. Emails can no longer be sent from it. */
481
+ remove: (id: string) => Promise<Result<Deleted>>;
482
+ };
483
+ readonly webhooks: {
484
+ /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
485
+ verify: typeof verifyWebhook;
486
+ list: () => Promise<Result<{
487
+ data: Webhook[];
488
+ }>>;
489
+ /** Creates a webhook. `secret` is only returned here: store it as MAILUMI_WEBHOOK_SECRET. */
490
+ create: (webhook: NewWebhook) => Promise<Result<Webhook & {
491
+ secret: string;
492
+ }>>;
493
+ get: (id: string) => Promise<Result<Webhook>>;
494
+ /** Stops sending events to the webhook. */
495
+ disable: (id: string) => Promise<Result<Webhook>>;
496
+ /** Sends a signed `webhook.test` event and returns the delivery. */
497
+ test: (id: string) => Promise<Result<WebhookDelivery>>;
498
+ /** The 100 most recent deliveries, newest first. */
499
+ deliveries: (id: string) => Promise<Result<{
500
+ data: WebhookDelivery[];
501
+ }>>;
502
+ /** Delivers an event again now. */
503
+ retry: ({ id, deliveryId }: {
504
+ id: string;
505
+ deliveryId: string;
506
+ }) => Promise<Result<WebhookDelivery>>;
507
+ };
508
+ readonly templates: {
509
+ list: () => Promise<Result<{
510
+ data: Template[];
511
+ }>>;
512
+ /** Saves a template to send with `templateId`. */
513
+ create: (template: NewTemplate) => Promise<Result<Template>>;
514
+ get: (id: string) => Promise<Result<Template>>;
515
+ /** Changes only the fields you pass. */
516
+ update: ({ id, ...template }: Partial<NewTemplate> & {
517
+ id: string;
518
+ }) => Promise<Result<Template>>;
519
+ remove: (id: string) => Promise<Result<Deleted>>;
250
520
  };
251
521
  readonly audiences: {
252
522
  list: () => Promise<Result<{
@@ -291,9 +561,38 @@ export declare class Mailumi {
291
561
  }) => Promise<Result<Contact>>;
292
562
  remove: (ref: ContactRef) => Promise<Result<Deleted>>;
293
563
  };
294
- /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
295
- readonly webhooks: {
296
- verify: typeof verifyWebhook;
564
+ readonly campaigns: {
565
+ /** Lists campaigns, newest first, with delivery statistics for those that were sent. */
566
+ list: () => Promise<Result<{
567
+ data: Campaign[];
568
+ }>>;
569
+ /** Creates a draft. */
570
+ create: (campaign: NewCampaign) => Promise<Result<Campaign>>;
571
+ /** Drafts include `readiness`; sent campaigns include `stats`. */
572
+ get: (id: string) => Promise<Result<Campaign>>;
573
+ /** Changes only the fields you pass. Only drafts can be changed. */
574
+ update: ({ id, ...campaign }: Partial<NewCampaign> & {
575
+ id: string;
576
+ }) => Promise<Result<Campaign>>;
577
+ /** Sends a draft to every subscribed contact in its audience now. */
578
+ send: (id: string) => Promise<Result<Campaign>>;
579
+ /** Copies a campaign into a new draft. */
580
+ duplicate: (id: string) => Promise<Result<Campaign>>;
581
+ remove: (id: string) => Promise<Result<Deleted>>;
582
+ };
583
+ readonly suppressions: {
584
+ /** Addresses that bounced, complained or were added by hand. Mailumi does not send to them. */
585
+ list: (q?: {
586
+ search?: string;
587
+ }) => Promise<Result<{
588
+ data: Suppression[];
589
+ }>>;
590
+ /** Allows sending to the address again. */
591
+ remove: (address: string) => Promise<Result<{
592
+ object: "suppression";
593
+ address: string;
594
+ deleted: true;
595
+ }>>;
297
596
  };
298
597
  private readonly key;
299
598
  private readonly baseUrl;
package/dist/cjs/index.js CHANGED
@@ -7,7 +7,7 @@ exports.toApiEmail = toApiEmail;
7
7
  * The official Mailumi SDK. Works anywhere `fetch` exists: Node.js 18+, Bun, Deno and Cloudflare Workers.
8
8
  * Every method resolves to `{ data, error }` and never throws for API errors.
9
9
  */
10
- exports.VERSION = '1.0.0';
10
+ exports.VERSION = '1.2.0';
11
11
  const DEFAULT_BASE_URL = 'https://mailumi.com/api/v1';
12
12
  function header(h, name, short) {
13
13
  if (typeof h.get === 'function')
@@ -29,10 +29,6 @@ function sameText(a, b) {
29
29
  diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
30
30
  return diff === 0;
31
31
  }
32
- /**
33
- * Checks the Standard Webhooks signature of a Mailumi webhook and returns the parsed event.
34
- * Store `event.id` and skip ids you have seen: a delivery can arrive more than once.
35
- */
36
32
  async function verifyWebhook({ payload, headers, secret, tolerance = 300 }) {
37
33
  const key = secret ?? globalThis.process?.env?.MAILUMI_WEBHOOK_SECRET;
38
34
  if (!key)
@@ -85,10 +81,11 @@ function toApiEmail(o) {
85
81
  }
86
82
  const toApiContact = (c) => ({ email: c.email, first_name: c.firstName ?? c.first_name, last_name: c.lastName ?? c.last_name, unsubscribed: c.unsubscribed });
87
83
  const contactPath = (r) => `/audiences/${encodeURIComponent(r.audienceId)}/contacts/${encodeURIComponent(r.id ?? r.email ?? '')}`;
84
+ const toApiWebhook = (w) => ({ name: w.name, url: w.url, events: w.events, domain_id: w.domainId ?? w.domain_id, recipient: w.recipient });
85
+ const toApiCampaign = (c) => ({ name: c.name, subject: c.subject, text: c.text, from: c.from, audience_id: c.audienceId !== undefined ? c.audienceId : c.audience_id });
86
+ const pathTo = (base, id, rest = '') => `${base}/${encodeURIComponent(id)}${rest}`;
88
87
  class Mailumi {
89
88
  constructor(apiKey, options = {}) {
90
- /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
91
- this.webhooks = { verify: verifyWebhook };
92
89
  const env = globalThis.process?.env ?? {};
93
90
  this.key = apiKey ?? env.MAILUMI_API_KEY ?? '';
94
91
  this.baseUrl = (options.baseUrl ?? env.MAILUMI_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
@@ -116,6 +113,62 @@ class Mailumi {
116
113
  this.domains = {
117
114
  /** Lists your domains, their DNS records and whether each is verified. */
118
115
  list: () => this.request('GET', '/domains'),
116
+ /** Adds a domain and returns the DNS records to publish. Needs a key with access to all domains. */
117
+ create: ({ name }) => this.request('POST', '/domains', { name }),
118
+ get: (id) => this.request('GET', pathTo('/domains', id)),
119
+ /** Checks the DNS records now. Mailumi also checks new domains every 5 minutes and emails you once one is verified. */
120
+ verify: (id) => this.request('POST', pathTo('/domains', id, '/verify')),
121
+ update: ({ id, receivingEnabled, mxConsent, openTracking, clickTracking, ...rest }) => this.request('PATCH', pathTo('/domains', id), {
122
+ receiving_enabled: receivingEnabled ?? rest.receiving_enabled, mx_consent: mxConsent ?? rest.mx_consent, open_tracking: openTracking ?? rest.open_tracking, click_tracking: clickTracking ?? rest.click_tracking,
123
+ }),
124
+ /** Removes the domain. Emails can no longer be sent from it. */
125
+ remove: (id) => this.request('DELETE', pathTo('/domains', id)),
126
+ };
127
+ this.webhooks = {
128
+ /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
129
+ verify: verifyWebhook,
130
+ list: () => this.request('GET', '/webhooks'),
131
+ /** Creates a webhook. `secret` is only returned here: store it as MAILUMI_WEBHOOK_SECRET. */
132
+ create: (webhook) => this.request('POST', '/webhooks', toApiWebhook(webhook)),
133
+ get: (id) => this.request('GET', pathTo('/webhooks', id)),
134
+ /** Stops sending events to the webhook. */
135
+ disable: (id) => this.request('DELETE', pathTo('/webhooks', id)),
136
+ /** Sends a signed `webhook.test` event and returns the delivery. */
137
+ test: (id) => this.request('POST', pathTo('/webhooks', id, '/test')),
138
+ /** The 100 most recent deliveries, newest first. */
139
+ deliveries: (id) => this.request('GET', pathTo('/webhooks', id, '/deliveries')),
140
+ /** Delivers an event again now. */
141
+ retry: ({ id, deliveryId }) => this.request('POST', pathTo('/webhooks', id, `/deliveries/${encodeURIComponent(deliveryId)}/retry`)),
142
+ };
143
+ this.templates = {
144
+ list: () => this.request('GET', '/templates'),
145
+ /** Saves a template to send with `templateId`. */
146
+ create: (template) => this.request('POST', '/templates', template),
147
+ get: (id) => this.request('GET', pathTo('/templates', id)),
148
+ /** Changes only the fields you pass. */
149
+ update: ({ id, ...template }) => this.request('PATCH', pathTo('/templates', id), template),
150
+ remove: (id) => this.request('DELETE', pathTo('/templates', id)),
151
+ };
152
+ this.campaigns = {
153
+ /** Lists campaigns, newest first, with delivery statistics for those that were sent. */
154
+ list: () => this.request('GET', '/campaigns'),
155
+ /** Creates a draft. */
156
+ create: (campaign) => this.request('POST', '/campaigns', toApiCampaign(campaign)),
157
+ /** Drafts include `readiness`; sent campaigns include `stats`. */
158
+ get: (id) => this.request('GET', pathTo('/campaigns', id)),
159
+ /** Changes only the fields you pass. Only drafts can be changed. */
160
+ update: ({ id, ...campaign }) => this.request('PATCH', pathTo('/campaigns', id), toApiCampaign(campaign)),
161
+ /** Sends a draft to every subscribed contact in its audience now. */
162
+ send: (id) => this.request('POST', pathTo('/campaigns', id, '/send')),
163
+ /** Copies a campaign into a new draft. */
164
+ duplicate: (id) => this.request('POST', pathTo('/campaigns', id, '/duplicate')),
165
+ remove: (id) => this.request('DELETE', pathTo('/campaigns', id)),
166
+ };
167
+ this.suppressions = {
168
+ /** Addresses that bounced, complained or were added by hand. Mailumi does not send to them. */
169
+ list: (q = {}) => this.request('GET', '/suppressions' + query(q)),
170
+ /** Allows sending to the address again. */
171
+ remove: (address) => this.request('DELETE', pathTo('/suppressions', address)),
119
172
  };
120
173
  this.audiences = {
121
174
  list: () => this.request('GET', '/audiences'),
package/dist/cli.js CHANGED
@@ -13,11 +13,36 @@ Commands
13
13
  emails cancel <id> Cancel a scheduled email
14
14
  emails reschedule <id> <at> Change the send time: an ISO 8601 date or "in 2 hours"
15
15
  domains list List domains and their verification status
16
+ domains add <name> Add a domain and show the DNS records to publish
17
+ domains get <id> Show a domain's DNS records and which were found
18
+ domains verify <id> Check the DNS records now (--wait: re-check every 15 seconds until verified)
19
+ domains update <id> --receiving on|off [--mx-consent], --open-tracking on|off, --click-tracking on|off
20
+ domains remove <id> Remove a domain
21
+ webhooks list List webhooks
22
+ webhooks create <url> --name <name> [--events email.delivered,email.bounced] [--domain <id>] [--recipient <name>]
23
+ webhooks test <id> Send a signed test event
24
+ webhooks deliveries <id> Show the latest deliveries
25
+ webhooks retry <id> <delivery_id>
26
+ webhooks disable <id> Stop sending events to a webhook
27
+ templates list List templates
28
+ templates get <id> Show a template
29
+ templates create --name <name> --subject <text> --html <html> | --html-file <path> [--var name=default]
30
+ templates update <id> Change any of --name, --subject, --html, --html-file, --var
31
+ templates remove <id> Delete a template
16
32
  audiences list List audiences
17
33
  audiences create <name> Create an audience
18
34
  contacts list <audience> List contacts (--search <text>, --after <cursor>)
19
35
  contacts add <audience> <email> [--first-name <name>] [--last-name <name>]
20
36
  contacts unsubscribe <audience> <email>
37
+ campaigns list List campaigns with their results
38
+ campaigns get <id> Show a campaign, and what is missing before a draft can be sent
39
+ campaigns create --name <name> --subject <text> --text <text> | --text-file <path> [--from <address>] [--audience <id>]
40
+ campaigns update <id> Change any of --name, --subject, --text, --text-file, --from, --audience
41
+ campaigns send <id> Send a draft to its audience now
42
+ campaigns duplicate <id> Copy a campaign into a new draft
43
+ campaigns remove <id> Delete a campaign
44
+ suppressions list List suppressed addresses (--search <text>)
45
+ suppressions remove <address> Allow sending to an address again
21
46
 
22
47
  Send options
23
48
  --from <address> Sender on a verified domain, e.g. "Acme <hello@yourdomain.com>"
@@ -37,7 +62,7 @@ Global options
37
62
  --help, --version
38
63
 
39
64
  Docs: https://mailumi.com/docs`;
40
- const BOOLEAN = new Set(['json', 'help', 'version', 'inbound', 'outbound']);
65
+ const BOOLEAN = new Set(['json', 'help', 'version', 'inbound', 'outbound', 'wait', 'mx-consent']);
41
66
  function parse(argv) {
42
67
  const args = [], flags = {};
43
68
  for (let i = 0; i < argv.length; i++) {
@@ -87,6 +112,28 @@ function sendOptions(f) {
87
112
  variables: pairs(f, 'var') && Object.fromEntries(pairs(f, 'var')), tags: pairs(f, 'tag')?.map(([name, value]) => ({ name, value })),
88
113
  };
89
114
  }
115
+ function toggle(f, name) {
116
+ const v = one(f, name);
117
+ if (v === undefined)
118
+ return undefined;
119
+ if (/^(on|true|yes)$/i.test(v))
120
+ return true;
121
+ if (/^(off|false|no)$/i.test(v))
122
+ return false;
123
+ throw new Error(`--${name} expects on or off, got "${v}".`);
124
+ }
125
+ const fileOr = (f, name) => one(f, name) ?? (one(f, `${name}-file`) ? readFileSync(one(f, `${name}-file`), 'utf8') : undefined);
126
+ function templateOptions(f) {
127
+ const variables = pairs(f, 'var');
128
+ return { name: one(f, 'name'), subject: one(f, 'subject'), html: fileOr(f, 'html'), variables: variables && Object.fromEntries(variables) };
129
+ }
130
+ const campaignOptions = (f) => ({ name: one(f, 'name'), subject: one(f, 'subject'), text: fileOr(f, 'text'), from: one(f, 'from'), audienceId: one(f, 'audience') });
131
+ const recordList = (d) => d.records.map(r => {
132
+ const state = r.verified ? 'found' : r.found?.length ? `wrong value, found ${r.found.join(', ')}` : 'missing';
133
+ return `[${state}] ${r.type} ${r.name}${r.required ? '' : ' (recommended)'}\n ${r.priority !== undefined ? `${r.priority} ` : ''}${r.value}`;
134
+ }).join('\n');
135
+ const domainView = (d) => `${d.name} (${d.id}): ${d.sendingStatus === 'verified' ? 'verified, ready to send' : d.sendingStatus}\n\n${recordList(d)}`;
136
+ const KEEPS_CHECKING = 'Mailumi keeps checking every 5 minutes and emails you once the domain is verified.';
90
137
  const table = (rows, columns) => {
91
138
  if (!rows.length)
92
139
  return '(none)';
@@ -134,6 +181,100 @@ async function run(argv) {
134
181
  result = await client.domains.list();
135
182
  print = l => table(l.data, ['name', 'sendingStatus', 'receivingStatus', 'id']);
136
183
  break;
184
+ case 'domains add':
185
+ need(1, 'domains add <name>');
186
+ result = await client.domains.create({ name: rest[0] });
187
+ print = d => `Added ${d.name} (${d.id}). Publish these DNS records at your DNS provider:\n\n${recordList(d)}\n\nThen run: mailumi domains verify ${d.id} --wait`;
188
+ break;
189
+ case 'domains get':
190
+ need(1, 'domains get <id>');
191
+ result = await client.domains.get(rest[0]);
192
+ print = domainView;
193
+ break;
194
+ case 'domains verify': {
195
+ need(1, 'domains verify <id> [--wait]');
196
+ const until = Date.now() + 30 * 60000;
197
+ for (;;) {
198
+ result = await client.domains.verify(rest[0]);
199
+ if (!flags.wait || result.error || result.data.ready || result.data.domain.sendingStatus === 'failed' || Date.now() > until)
200
+ break;
201
+ if (!json)
202
+ console.error(`${result.data.message} Checking again in 15 seconds…`);
203
+ await new Promise(r => setTimeout(r, 15000));
204
+ }
205
+ print = v => `${v.message}\n\n${domainView(v.domain)}${v.ready ? '' : `\n\n${KEEPS_CHECKING}`}`;
206
+ break;
207
+ }
208
+ case 'domains update':
209
+ need(1, 'domains update <id> [--receiving on|off] [--mx-consent] [--open-tracking on|off] [--click-tracking on|off]');
210
+ result = await client.domains.update({ id: rest[0], receivingEnabled: toggle(flags, 'receiving'), mxConsent: flags['mx-consent'] ? true : undefined, openTracking: toggle(flags, 'open-tracking'), clickTracking: toggle(flags, 'click-tracking') });
211
+ print = d => `Updated ${d.name}: receiving ${d.receivingEnabled ? 'on' : 'off'}, open tracking ${d.openTracking ? 'on' : 'off'}, click tracking ${d.clickTracking ? 'on' : 'off'}.`;
212
+ break;
213
+ case 'domains remove':
214
+ need(1, 'domains remove <id>');
215
+ result = await client.domains.remove(rest[0]);
216
+ print = d => `Removed domain ${d.id}.`;
217
+ break;
218
+ case 'webhooks list':
219
+ result = await client.webhooks.list();
220
+ print = l => table(l.data, ['id', 'name', 'url', 'events', 'enabled']);
221
+ break;
222
+ case 'webhooks create': {
223
+ need(1, 'webhooks create <url> --name <name>');
224
+ const name = one(flags, 'name');
225
+ if (!name)
226
+ throw new Error('webhooks create needs --name.');
227
+ result = await client.webhooks.create({ url: rest[0], name, events: many(flags, 'events'), domainId: one(flags, 'domain'), recipient: one(flags, 'recipient') });
228
+ print = w => `Created webhook ${w.name} (${w.id}) for ${w.events.join(', ')}.\n\nSigning secret, shown only now. Store it as MAILUMI_WEBHOOK_SECRET:\n${w.secret}\n\nSend a test event with: mailumi webhooks test ${w.id}`;
229
+ break;
230
+ }
231
+ case 'webhooks test':
232
+ need(1, 'webhooks test <id>');
233
+ result = await client.webhooks.test(rest[0]);
234
+ print = d => d.status === 'delivered' ? `Delivered test event ${d.id} (HTTP ${d.response_code}).` : `The test event ${d.id} is ${d.status}${d.response_code ? ` (HTTP ${d.response_code})` : ''}${d.last_error ? `: ${d.last_error}` : ''}.${d.next_attempt_at ? ` Next attempt at ${d.next_attempt_at}.` : ''}`;
235
+ break;
236
+ case 'webhooks deliveries':
237
+ need(1, 'webhooks deliveries <id>');
238
+ result = await client.webhooks.deliveries(rest[0]);
239
+ print = l => table(l.data, ['id', 'event', 'status', 'attempts', 'response_code', 'created_at']);
240
+ break;
241
+ case 'webhooks retry':
242
+ need(2, 'webhooks retry <id> <delivery_id>');
243
+ result = await client.webhooks.retry({ id: rest[0], deliveryId: rest[1] });
244
+ print = d => `Delivery ${d.id} is ${d.status}${d.response_code ? ` (HTTP ${d.response_code})` : ''}.`;
245
+ break;
246
+ case 'webhooks disable':
247
+ need(1, 'webhooks disable <id>');
248
+ result = await client.webhooks.disable(rest[0]);
249
+ print = w => `Disabled webhook ${w.name} (${w.id}).`;
250
+ break;
251
+ case 'templates list':
252
+ result = await client.templates.list();
253
+ print = l => table(l.data, ['id', 'name', 'subject', 'updated_at']);
254
+ break;
255
+ case 'templates get':
256
+ need(1, 'templates get <id>');
257
+ result = await client.templates.get(rest[0]);
258
+ print = t => [`${t.name} (${t.id})`, `Subject: ${t.subject}`, `Variables: ${Object.entries(t.variables).map(([k, v]) => v ? `${k}=${v}` : k).join(', ') || 'none'}`, '', t.html].join('\n');
259
+ break;
260
+ case 'templates create': {
261
+ const t = templateOptions(flags);
262
+ if (!t.name || !t.subject || !t.html)
263
+ throw new Error('templates create needs --name, --subject and --html or --html-file.');
264
+ result = await client.templates.create({ ...t, name: t.name, subject: t.subject, html: t.html });
265
+ print = t => `Created template ${t.name} (${t.id}). Send it with: mailumi send --template ${t.id} --from <address> --to <address>`;
266
+ break;
267
+ }
268
+ case 'templates update':
269
+ need(1, 'templates update <id>');
270
+ result = await client.templates.update({ id: rest[0], ...templateOptions(flags) });
271
+ print = t => `Updated template ${t.name} (${t.id}).`;
272
+ break;
273
+ case 'templates remove':
274
+ need(1, 'templates remove <id>');
275
+ result = await client.templates.remove(rest[0]);
276
+ print = t => `Deleted template ${t.id}.`;
277
+ break;
137
278
  case 'audiences list':
138
279
  result = await client.audiences.list();
139
280
  print = l => table(l.data, ['id', 'name', 'contacts', 'subscribed']);
@@ -158,6 +299,54 @@ async function run(argv) {
158
299
  result = await client.contacts.update({ audienceId: rest[0], email: rest[1], unsubscribed: true });
159
300
  print = c => `Unsubscribed ${c.email} from every audience.`;
160
301
  break;
302
+ case 'campaigns list':
303
+ result = await client.campaigns.list();
304
+ print = l => table(l.data.map((c) => ({ ...c, delivered: c.stats?.delivered ?? '' })), ['id', 'name', 'status', 'recipients', 'delivered', 'updated_at']);
305
+ break;
306
+ case 'campaigns get':
307
+ need(1, 'campaigns get <id>');
308
+ result = await client.campaigns.get(rest[0]);
309
+ print = c => [`${c.name} (${c.id}): ${c.status}`, `From: ${c.from ?? 'not set'}`, `Audience: ${c.audience_id ?? 'not set'}`, `Subject: ${c.subject}`,
310
+ ...(c.readiness ? ['', c.readiness.missing.length ? `Before sending: ${c.readiness.missing.join(' ')}` : `Ready to send to ${c.readiness.recipients} contacts: mailumi campaigns send ${c.id}`] : []),
311
+ ...(c.stats ? ['', Object.entries(c.stats).map(([k, v]) => `${k} ${v}`).join(', ')] : []), '', c.text].join('\n');
312
+ break;
313
+ case 'campaigns create': {
314
+ const c = campaignOptions(flags);
315
+ if (!c.name || !c.subject || !c.text)
316
+ throw new Error('campaigns create needs --name, --subject and --text or --text-file.');
317
+ result = await client.campaigns.create({ ...c, name: c.name, subject: c.subject, text: c.text });
318
+ print = c => `Created draft ${c.name} (${c.id}). Check it with: mailumi campaigns get ${c.id}`;
319
+ break;
320
+ }
321
+ case 'campaigns update':
322
+ need(1, 'campaigns update <id>');
323
+ result = await client.campaigns.update({ id: rest[0], ...campaignOptions(flags) });
324
+ print = c => `Updated draft ${c.name} (${c.id}).`;
325
+ break;
326
+ case 'campaigns send':
327
+ need(1, 'campaigns send <id>');
328
+ result = await client.campaigns.send(rest[0]);
329
+ print = c => `Sending ${c.name} to ${c.recipients} contacts. Follow it with: mailumi campaigns get ${c.id}`;
330
+ break;
331
+ case 'campaigns duplicate':
332
+ need(1, 'campaigns duplicate <id>');
333
+ result = await client.campaigns.duplicate(rest[0]);
334
+ print = c => `Created draft ${c.name} (${c.id}).`;
335
+ break;
336
+ case 'campaigns remove':
337
+ need(1, 'campaigns remove <id>');
338
+ result = await client.campaigns.remove(rest[0]);
339
+ print = c => `Deleted campaign ${c.id}.`;
340
+ break;
341
+ case 'suppressions list':
342
+ result = await client.suppressions.list({ search: one(flags, 'search') });
343
+ print = l => table(l.data, ['address', 'reason', 'detail', 'created_at']);
344
+ break;
345
+ case 'suppressions remove':
346
+ need(1, 'suppressions remove <address>');
347
+ result = await client.suppressions.remove(rest[0]);
348
+ print = s => `Mailumi will send to ${s.address} again.`;
349
+ break;
161
350
  default:
162
351
  throw new Error(`Unknown command "${args.join(' ')}". Run "mailumi --help".`);
163
352
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * The official Mailumi SDK. Works anywhere `fetch` exists: Node.js 18+, Bun, Deno and Cloudflare Workers.
3
3
  * Every method resolves to `{ data, error }` and never throws for API errors.
4
4
  */
5
- export declare const VERSION = "1.0.0";
5
+ export declare const VERSION = "1.2.0";
6
6
  export type ErrorCode = 'invalid_request' | 'unauthorized' | 'plan_limit_reached' | 'forbidden' | 'not_found' | 'method_not_allowed' | 'conflict' | 'payload_too_large' | 'rate_limited' | 'internal_error' | 'service_unavailable' | 'network_error' | 'missing_api_key' | 'invalid_signature' | 'missing_webhook_secret';
7
7
  export interface MailumiError {
8
8
  name: 'MailumiError';
@@ -116,10 +116,13 @@ export interface DnsRecord {
116
116
  value: string;
117
117
  priority?: number;
118
118
  verified: boolean;
119
+ /** What DNS returned at this name when it does not match `value`. Only on records that are not verified. */
120
+ found?: string[];
119
121
  }
120
122
  export interface Domain {
121
123
  id: string;
122
124
  name: string;
125
+ region?: string;
123
126
  ownershipVerified?: boolean;
124
127
  sendingEnabled?: boolean;
125
128
  receivingEnabled?: boolean;
@@ -131,6 +134,125 @@ export interface Domain {
131
134
  createdAt?: number;
132
135
  records: DnsRecord[];
133
136
  }
137
+ export interface DomainVerification {
138
+ domain: Domain;
139
+ /** `true` once the domain can send. */
140
+ ready: boolean;
141
+ /** What is still missing, or that the domain is ready. */
142
+ message: string;
143
+ /** How many required records have a different value at the DNS provider. */
144
+ wrong?: number;
145
+ }
146
+ export interface DomainSettings {
147
+ id: string;
148
+ /** Receive email for the domain and send it to your email.received webhooks. */
149
+ receivingEnabled?: boolean;
150
+ /** Must be `true` to turn receiving on: the MX record replaces any mailbox provider the domain uses now. */
151
+ mxConsent?: boolean;
152
+ /** Pro and Scale plans. */
153
+ openTracking?: boolean;
154
+ /** Pro and Scale plans. */
155
+ clickTracking?: boolean;
156
+ }
157
+ export type WebhookSubscription = Exclude<WebhookEventType, 'webhook.test'>;
158
+ export interface NewWebhook {
159
+ name: string;
160
+ /** A public HTTPS URL on port 443. */
161
+ url: string;
162
+ /** Defaults to `['email.received']`. */
163
+ events?: WebhookSubscription[];
164
+ /** Only send events for this domain. Leave it out for every domain. */
165
+ domainId?: string;
166
+ /** For email.received: only mail to this address before the @, for example "support". */
167
+ recipient?: string;
168
+ }
169
+ export interface Webhook {
170
+ object: 'webhook';
171
+ id: string;
172
+ name: string;
173
+ url: string;
174
+ events: WebhookSubscription[];
175
+ domain_id: string | null;
176
+ recipient: string | null;
177
+ enabled: boolean;
178
+ created_at: string;
179
+ }
180
+ export interface WebhookDelivery {
181
+ /** Also the webhook-id header of the request. */
182
+ id: string;
183
+ object: 'webhook_delivery';
184
+ webhook_id: string;
185
+ event: WebhookEventType;
186
+ email_id: string | null;
187
+ status: 'pending' | 'delivered' | 'failed' | 'cancelled';
188
+ attempts: number;
189
+ response_code: number | null;
190
+ last_error: string | null;
191
+ next_attempt_at: string | null;
192
+ created_at: string;
193
+ delivered_at: string | null;
194
+ }
195
+ export interface NewTemplate {
196
+ name: string;
197
+ /** Placeholders such as `{{first_name}}` work in the subject and HTML. */
198
+ subject: string;
199
+ html: string;
200
+ /** Default values for placeholders an email does not pass. */
201
+ variables?: Record<string, string>;
202
+ }
203
+ export interface Template {
204
+ object: 'template';
205
+ id: string;
206
+ name: string;
207
+ subject: string;
208
+ html: string;
209
+ variables: Record<string, string>;
210
+ created_at: string;
211
+ updated_at: string;
212
+ }
213
+ export interface NewCampaign {
214
+ name: string;
215
+ /** `{{first_name}}`, `{{last_name}}` and `{{email}}` are filled in per contact. */
216
+ subject: string;
217
+ /** Plain text. Blank lines start paragraphs and links become clickable. Mailumi adds your postal address and an unsubscribe link. */
218
+ text: string;
219
+ /** A sender on a verified domain. Needed before sending. */
220
+ from?: string;
221
+ /** Needed before sending. */
222
+ audienceId?: string | null;
223
+ }
224
+ export type CampaignStats = Record<'total' | 'pending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed' | 'opened' | 'clicked' | 'unsubscribed', number>;
225
+ export interface Campaign {
226
+ object: 'campaign';
227
+ id: string;
228
+ name: string;
229
+ subject: string;
230
+ text: string;
231
+ from: string | null;
232
+ audience_id: string | null;
233
+ status: 'draft' | 'sending' | 'sent' | 'failed';
234
+ /** How many contacts it was sent to. */
235
+ recipients: number;
236
+ error: string | null;
237
+ sent_at: string | null;
238
+ created_at: string;
239
+ updated_at: string;
240
+ /** Drafts, from `campaigns.get`: how many contacts it would go to now, and what to fix before sending. */
241
+ readiness?: {
242
+ recipients: number;
243
+ missing: string[];
244
+ };
245
+ /** Campaigns that were sent. */
246
+ stats?: CampaignStats;
247
+ }
248
+ export interface Suppression {
249
+ object: 'suppression';
250
+ address: string;
251
+ reason: 'bounce' | 'complaint' | 'manual';
252
+ detail: string | null;
253
+ email_id: string | null;
254
+ created_at: string;
255
+ }
134
256
  export interface Audience {
135
257
  object: 'audience';
136
258
  id: string;
@@ -189,6 +311,105 @@ export interface WebhookEvent<T = Record<string, any>> {
189
311
  created_at: string;
190
312
  data: T;
191
313
  }
314
+ interface EmailFields {
315
+ email_id: string;
316
+ domain_id: string;
317
+ from: string;
318
+ to: string[];
319
+ subject: string;
320
+ last_event: string;
321
+ tags: Record<string, string>;
322
+ created_at: string;
323
+ }
324
+ export interface ReceivedAttachment {
325
+ id: string;
326
+ filename: string;
327
+ content_type: string;
328
+ /** Set for inline images that the HTML shows with `cid:`. */
329
+ content_id: string | null;
330
+ /** Size in bytes. */
331
+ size: number;
332
+ /** Download it server-side with `mailumi.emails.attachment({ id: email_id, index })` or a key with email:read. */
333
+ download_url: string;
334
+ }
335
+ /** `data` of an `email.received` event. */
336
+ export interface EmailReceivedData extends EmailFields {
337
+ direction: 'inbound';
338
+ status: 'received';
339
+ scheduled_at: null;
340
+ error: null;
341
+ /** `Name <address>`, or only the address when the email has no name. */
342
+ from: string;
343
+ /** The recipients at this domain, in lowercase. */
344
+ to: string[];
345
+ cc: string[];
346
+ reply_to: string[];
347
+ /** The Message-ID header with its angle brackets, for In-Reply-To and References when you reply. */
348
+ message_id: string | null;
349
+ /** Empty when the email has no plain-text part. */
350
+ text: string;
351
+ /** Empty when the email has no HTML part. It is the sender's HTML: sanitize it before showing it. */
352
+ html: string;
353
+ /** `true` when the Amazon SES spam check flagged the email. */
354
+ spam: boolean;
355
+ attachments: ReceivedAttachment[];
356
+ }
357
+ /** `data` of an event about an email you sent: the email as it is now, plus the detail for that event. */
358
+ export interface EmailEventData extends EmailFields {
359
+ direction: 'outbound';
360
+ status: string;
361
+ scheduled_at: string | null;
362
+ error: string | null;
363
+ /** The recipient the event is about. Not set on email.sent and email.failed, nor on opens and clicks of an email with more than one recipient. */
364
+ recipient?: string;
365
+ delivery?: {
366
+ smtp_response: string | null;
367
+ };
368
+ delay?: {
369
+ type: string | null;
370
+ expires_at: string | null;
371
+ message: string | null;
372
+ };
373
+ bounce?: {
374
+ type: 'Permanent' | 'Transient' | (string & {}) | null;
375
+ sub_type: string | null;
376
+ message: string | null;
377
+ };
378
+ complaint?: {
379
+ type: string | null;
380
+ };
381
+ open?: {
382
+ ip_address: string | null;
383
+ user_agent: string | null;
384
+ };
385
+ click?: {
386
+ link: string | null;
387
+ ip_address: string | null;
388
+ user_agent: string | null;
389
+ };
390
+ failed?: {
391
+ reason: string;
392
+ };
393
+ }
394
+ /** The event `verifyWebhook` returns. Check `event.type` and `event.data` has the matching fields. */
395
+ export type MailumiWebhookEvent = {
396
+ id: string;
397
+ type: 'email.received';
398
+ created_at: string;
399
+ data: EmailReceivedData;
400
+ } | {
401
+ id: string;
402
+ type: Exclude<WebhookEventType, 'email.received' | 'webhook.test'>;
403
+ created_at: string;
404
+ data: EmailEventData;
405
+ } | {
406
+ id: string;
407
+ type: 'webhook.test';
408
+ created_at: string;
409
+ data: {
410
+ message: string;
411
+ };
412
+ };
192
413
  export interface VerifyWebhookOptions {
193
414
  /** The raw request body exactly as received, before any JSON parsing. */
194
415
  payload: string | Uint8Array;
@@ -203,7 +424,8 @@ export interface VerifyWebhookOptions {
203
424
  * Checks the Standard Webhooks signature of a Mailumi webhook and returns the parsed event.
204
425
  * Store `event.id` and skip ids you have seen: a delivery can arrive more than once.
205
426
  */
206
- export declare function verifyWebhook<T = Record<string, any>>({ payload, headers, secret, tolerance }: VerifyWebhookOptions): Promise<Result<WebhookEvent<T>>>;
427
+ export declare function verifyWebhook(options: VerifyWebhookOptions): Promise<Result<MailumiWebhookEvent>>;
428
+ export declare function verifyWebhook<T>(options: VerifyWebhookOptions): Promise<Result<WebhookEvent<T>>>;
207
429
  /** Converts the SDK's camelCase options to the API's request body. snake_case keys are accepted too. */
208
430
  export declare function toApiEmail(o: SendEmailOptions & Record<string, any>): Record<string, unknown>;
209
431
  export declare class Mailumi {
@@ -247,6 +469,54 @@ export declare class Mailumi {
247
469
  list: () => Promise<Result<{
248
470
  data: Domain[];
249
471
  }>>;
472
+ /** Adds a domain and returns the DNS records to publish. Needs a key with access to all domains. */
473
+ create: ({ name }: {
474
+ name: string;
475
+ }) => Promise<Result<Domain>>;
476
+ get: (id: string) => Promise<Result<Domain>>;
477
+ /** Checks the DNS records now. Mailumi also checks new domains every 5 minutes and emails you once one is verified. */
478
+ verify: (id: string) => Promise<Result<DomainVerification>>;
479
+ update: ({ id, receivingEnabled, mxConsent, openTracking, clickTracking, ...rest }: DomainSettings & Record<string, any>) => Promise<Result<Domain>>;
480
+ /** Removes the domain. Emails can no longer be sent from it. */
481
+ remove: (id: string) => Promise<Result<Deleted>>;
482
+ };
483
+ readonly webhooks: {
484
+ /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
485
+ verify: typeof verifyWebhook;
486
+ list: () => Promise<Result<{
487
+ data: Webhook[];
488
+ }>>;
489
+ /** Creates a webhook. `secret` is only returned here: store it as MAILUMI_WEBHOOK_SECRET. */
490
+ create: (webhook: NewWebhook) => Promise<Result<Webhook & {
491
+ secret: string;
492
+ }>>;
493
+ get: (id: string) => Promise<Result<Webhook>>;
494
+ /** Stops sending events to the webhook. */
495
+ disable: (id: string) => Promise<Result<Webhook>>;
496
+ /** Sends a signed `webhook.test` event and returns the delivery. */
497
+ test: (id: string) => Promise<Result<WebhookDelivery>>;
498
+ /** The 100 most recent deliveries, newest first. */
499
+ deliveries: (id: string) => Promise<Result<{
500
+ data: WebhookDelivery[];
501
+ }>>;
502
+ /** Delivers an event again now. */
503
+ retry: ({ id, deliveryId }: {
504
+ id: string;
505
+ deliveryId: string;
506
+ }) => Promise<Result<WebhookDelivery>>;
507
+ };
508
+ readonly templates: {
509
+ list: () => Promise<Result<{
510
+ data: Template[];
511
+ }>>;
512
+ /** Saves a template to send with `templateId`. */
513
+ create: (template: NewTemplate) => Promise<Result<Template>>;
514
+ get: (id: string) => Promise<Result<Template>>;
515
+ /** Changes only the fields you pass. */
516
+ update: ({ id, ...template }: Partial<NewTemplate> & {
517
+ id: string;
518
+ }) => Promise<Result<Template>>;
519
+ remove: (id: string) => Promise<Result<Deleted>>;
250
520
  };
251
521
  readonly audiences: {
252
522
  list: () => Promise<Result<{
@@ -291,9 +561,38 @@ export declare class Mailumi {
291
561
  }) => Promise<Result<Contact>>;
292
562
  remove: (ref: ContactRef) => Promise<Result<Deleted>>;
293
563
  };
294
- /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
295
- readonly webhooks: {
296
- verify: typeof verifyWebhook;
564
+ readonly campaigns: {
565
+ /** Lists campaigns, newest first, with delivery statistics for those that were sent. */
566
+ list: () => Promise<Result<{
567
+ data: Campaign[];
568
+ }>>;
569
+ /** Creates a draft. */
570
+ create: (campaign: NewCampaign) => Promise<Result<Campaign>>;
571
+ /** Drafts include `readiness`; sent campaigns include `stats`. */
572
+ get: (id: string) => Promise<Result<Campaign>>;
573
+ /** Changes only the fields you pass. Only drafts can be changed. */
574
+ update: ({ id, ...campaign }: Partial<NewCampaign> & {
575
+ id: string;
576
+ }) => Promise<Result<Campaign>>;
577
+ /** Sends a draft to every subscribed contact in its audience now. */
578
+ send: (id: string) => Promise<Result<Campaign>>;
579
+ /** Copies a campaign into a new draft. */
580
+ duplicate: (id: string) => Promise<Result<Campaign>>;
581
+ remove: (id: string) => Promise<Result<Deleted>>;
582
+ };
583
+ readonly suppressions: {
584
+ /** Addresses that bounced, complained or were added by hand. Mailumi does not send to them. */
585
+ list: (q?: {
586
+ search?: string;
587
+ }) => Promise<Result<{
588
+ data: Suppression[];
589
+ }>>;
590
+ /** Allows sending to the address again. */
591
+ remove: (address: string) => Promise<Result<{
592
+ object: "suppression";
593
+ address: string;
594
+ deleted: true;
595
+ }>>;
297
596
  };
298
597
  private readonly key;
299
598
  private readonly baseUrl;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * The official Mailumi SDK. Works anywhere `fetch` exists: Node.js 18+, Bun, Deno and Cloudflare Workers.
3
3
  * Every method resolves to `{ data, error }` and never throws for API errors.
4
4
  */
5
- export const VERSION = '1.0.0';
5
+ export const VERSION = '1.2.0';
6
6
  const DEFAULT_BASE_URL = 'https://mailumi.com/api/v1';
7
7
  function header(h, name, short) {
8
8
  if (typeof h.get === 'function')
@@ -24,10 +24,6 @@ function sameText(a, b) {
24
24
  diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
25
25
  return diff === 0;
26
26
  }
27
- /**
28
- * Checks the Standard Webhooks signature of a Mailumi webhook and returns the parsed event.
29
- * Store `event.id` and skip ids you have seen: a delivery can arrive more than once.
30
- */
31
27
  export async function verifyWebhook({ payload, headers, secret, tolerance = 300 }) {
32
28
  const key = secret ?? globalThis.process?.env?.MAILUMI_WEBHOOK_SECRET;
33
29
  if (!key)
@@ -80,10 +76,11 @@ export function toApiEmail(o) {
80
76
  }
81
77
  const toApiContact = (c) => ({ email: c.email, first_name: c.firstName ?? c.first_name, last_name: c.lastName ?? c.last_name, unsubscribed: c.unsubscribed });
82
78
  const contactPath = (r) => `/audiences/${encodeURIComponent(r.audienceId)}/contacts/${encodeURIComponent(r.id ?? r.email ?? '')}`;
79
+ const toApiWebhook = (w) => ({ name: w.name, url: w.url, events: w.events, domain_id: w.domainId ?? w.domain_id, recipient: w.recipient });
80
+ const toApiCampaign = (c) => ({ name: c.name, subject: c.subject, text: c.text, from: c.from, audience_id: c.audienceId !== undefined ? c.audienceId : c.audience_id });
81
+ const pathTo = (base, id, rest = '') => `${base}/${encodeURIComponent(id)}${rest}`;
83
82
  export class Mailumi {
84
83
  constructor(apiKey, options = {}) {
85
- /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
86
- this.webhooks = { verify: verifyWebhook };
87
84
  const env = globalThis.process?.env ?? {};
88
85
  this.key = apiKey ?? env.MAILUMI_API_KEY ?? '';
89
86
  this.baseUrl = (options.baseUrl ?? env.MAILUMI_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
@@ -111,6 +108,62 @@ export class Mailumi {
111
108
  this.domains = {
112
109
  /** Lists your domains, their DNS records and whether each is verified. */
113
110
  list: () => this.request('GET', '/domains'),
111
+ /** Adds a domain and returns the DNS records to publish. Needs a key with access to all domains. */
112
+ create: ({ name }) => this.request('POST', '/domains', { name }),
113
+ get: (id) => this.request('GET', pathTo('/domains', id)),
114
+ /** Checks the DNS records now. Mailumi also checks new domains every 5 minutes and emails you once one is verified. */
115
+ verify: (id) => this.request('POST', pathTo('/domains', id, '/verify')),
116
+ update: ({ id, receivingEnabled, mxConsent, openTracking, clickTracking, ...rest }) => this.request('PATCH', pathTo('/domains', id), {
117
+ receiving_enabled: receivingEnabled ?? rest.receiving_enabled, mx_consent: mxConsent ?? rest.mx_consent, open_tracking: openTracking ?? rest.open_tracking, click_tracking: clickTracking ?? rest.click_tracking,
118
+ }),
119
+ /** Removes the domain. Emails can no longer be sent from it. */
120
+ remove: (id) => this.request('DELETE', pathTo('/domains', id)),
121
+ };
122
+ this.webhooks = {
123
+ /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
124
+ verify: verifyWebhook,
125
+ list: () => this.request('GET', '/webhooks'),
126
+ /** Creates a webhook. `secret` is only returned here: store it as MAILUMI_WEBHOOK_SECRET. */
127
+ create: (webhook) => this.request('POST', '/webhooks', toApiWebhook(webhook)),
128
+ get: (id) => this.request('GET', pathTo('/webhooks', id)),
129
+ /** Stops sending events to the webhook. */
130
+ disable: (id) => this.request('DELETE', pathTo('/webhooks', id)),
131
+ /** Sends a signed `webhook.test` event and returns the delivery. */
132
+ test: (id) => this.request('POST', pathTo('/webhooks', id, '/test')),
133
+ /** The 100 most recent deliveries, newest first. */
134
+ deliveries: (id) => this.request('GET', pathTo('/webhooks', id, '/deliveries')),
135
+ /** Delivers an event again now. */
136
+ retry: ({ id, deliveryId }) => this.request('POST', pathTo('/webhooks', id, `/deliveries/${encodeURIComponent(deliveryId)}/retry`)),
137
+ };
138
+ this.templates = {
139
+ list: () => this.request('GET', '/templates'),
140
+ /** Saves a template to send with `templateId`. */
141
+ create: (template) => this.request('POST', '/templates', template),
142
+ get: (id) => this.request('GET', pathTo('/templates', id)),
143
+ /** Changes only the fields you pass. */
144
+ update: ({ id, ...template }) => this.request('PATCH', pathTo('/templates', id), template),
145
+ remove: (id) => this.request('DELETE', pathTo('/templates', id)),
146
+ };
147
+ this.campaigns = {
148
+ /** Lists campaigns, newest first, with delivery statistics for those that were sent. */
149
+ list: () => this.request('GET', '/campaigns'),
150
+ /** Creates a draft. */
151
+ create: (campaign) => this.request('POST', '/campaigns', toApiCampaign(campaign)),
152
+ /** Drafts include `readiness`; sent campaigns include `stats`. */
153
+ get: (id) => this.request('GET', pathTo('/campaigns', id)),
154
+ /** Changes only the fields you pass. Only drafts can be changed. */
155
+ update: ({ id, ...campaign }) => this.request('PATCH', pathTo('/campaigns', id), toApiCampaign(campaign)),
156
+ /** Sends a draft to every subscribed contact in its audience now. */
157
+ send: (id) => this.request('POST', pathTo('/campaigns', id, '/send')),
158
+ /** Copies a campaign into a new draft. */
159
+ duplicate: (id) => this.request('POST', pathTo('/campaigns', id, '/duplicate')),
160
+ remove: (id) => this.request('DELETE', pathTo('/campaigns', id)),
161
+ };
162
+ this.suppressions = {
163
+ /** Addresses that bounced, complained or were added by hand. Mailumi does not send to them. */
164
+ list: (q = {}) => this.request('GET', '/suppressions' + query(q)),
165
+ /** Allows sending to the address again. */
166
+ remove: (address) => this.request('DELETE', pathTo('/suppressions', address)),
114
167
  };
115
168
  this.audiences = {
116
169
  list: () => this.request('GET', '/audiences'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailumi",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Official Mailumi SDK and CLI. Send transactional email and campaigns from your own domain, receive email by webhook, with your data stored in the EU.",
5
5
  "keywords": [
6
6
  "email",