mailumi 1.1.0 → 1.2.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
@@ -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`.
@@ -146,9 +187,9 @@ export async function POST(request: Request) {
146
187
 
147
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`.
148
189
 
149
- ## Audiences and contacts
190
+ ## Audiences, contacts and campaigns
150
191
 
151
- 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`.
152
193
 
153
194
  ```ts
154
195
  const { data: audience } = await mailumi.audiences.create({ name: 'Newsletter' });
@@ -162,6 +203,25 @@ const { data } = await mailumi.contacts.list({ audienceId: audience.id, search:
162
203
 
163
204
  Unsubscribing applies to the address in every audience of the workspace, and adding or importing a contact never resubscribes it.
164
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
+
165
225
  ## Errors
166
226
 
167
227
  ```ts
@@ -179,7 +239,7 @@ if (error) {
179
239
  | `unauthorized` | The API key is missing, wrong, revoked or expired. |
180
240
  | `forbidden` | The key lacks the permission, or is limited to another domain. |
181
241
  | `plan_limit_reached` | The monthly or daily email allowance is used up. |
182
- | `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. |
183
243
  | `conflict` | The `Idempotency-Key` was used for a different request. |
184
244
  | `rate_limited` | Too many requests; retried automatically. |
185
245
  | `internal_error`, `service_unavailable` | A temporary problem; retried automatically. |
@@ -208,15 +268,23 @@ npx mailumi emails list
208
268
  npx mailumi emails get <id>
209
269
  npx mailumi emails cancel <id>
210
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
211
276
  npx mailumi audiences list
212
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
213
281
  ```
214
282
 
215
283
  Add `--json` for machine-readable output and `--help` for every command and option. The CLI exits with code 1 on errors.
216
284
 
217
285
  ## AI coding assistants
218
286
 
219
- 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).
220
288
 
221
289
  ## Links
222
290
 
@@ -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.1.0";
5
+ export declare const VERSION = "1.2.1";
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,15 @@ 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[];
121
+ /** Where a missing record was found instead: its name with the domain added twice, because the DNS provider adds the domain itself. Enter only the part before the domain. */
122
+ foundAt?: string;
119
123
  }
120
124
  export interface Domain {
121
125
  id: string;
122
126
  name: string;
127
+ region?: string;
123
128
  ownershipVerified?: boolean;
124
129
  sendingEnabled?: boolean;
125
130
  receivingEnabled?: boolean;
@@ -131,6 +136,127 @@ export interface Domain {
131
136
  createdAt?: number;
132
137
  records: DnsRecord[];
133
138
  }
139
+ export interface DomainVerification {
140
+ domain: Domain;
141
+ /** `true` once the domain can send. */
142
+ ready: boolean;
143
+ /** What is still missing, or that the domain is ready. */
144
+ message: string;
145
+ /** How many required records have a different value at the DNS provider. */
146
+ wrong?: number;
147
+ /** How many records were found at a doubled name; see `foundAt` on each record. */
148
+ misplaced?: number;
149
+ }
150
+ export interface DomainSettings {
151
+ id: string;
152
+ /** Receive email for the domain and send it to your email.received webhooks. */
153
+ receivingEnabled?: boolean;
154
+ /** Must be `true` to turn receiving on: the MX record replaces any mailbox provider the domain uses now. */
155
+ mxConsent?: boolean;
156
+ /** Pro and Scale plans. */
157
+ openTracking?: boolean;
158
+ /** Pro and Scale plans. */
159
+ clickTracking?: boolean;
160
+ }
161
+ export type WebhookSubscription = Exclude<WebhookEventType, 'webhook.test'>;
162
+ export interface NewWebhook {
163
+ name: string;
164
+ /** A public HTTPS URL on port 443. */
165
+ url: string;
166
+ /** Defaults to `['email.received']`. */
167
+ events?: WebhookSubscription[];
168
+ /** Only send events for this domain. Leave it out for every domain. */
169
+ domainId?: string;
170
+ /** For email.received: only mail to this address before the @, for example "support". */
171
+ recipient?: string;
172
+ }
173
+ export interface Webhook {
174
+ object: 'webhook';
175
+ id: string;
176
+ name: string;
177
+ url: string;
178
+ events: WebhookSubscription[];
179
+ domain_id: string | null;
180
+ recipient: string | null;
181
+ enabled: boolean;
182
+ created_at: string;
183
+ }
184
+ export interface WebhookDelivery {
185
+ /** Also the webhook-id header of the request. */
186
+ id: string;
187
+ object: 'webhook_delivery';
188
+ webhook_id: string;
189
+ event: WebhookEventType;
190
+ email_id: string | null;
191
+ status: 'pending' | 'delivered' | 'failed' | 'cancelled';
192
+ attempts: number;
193
+ response_code: number | null;
194
+ last_error: string | null;
195
+ next_attempt_at: string | null;
196
+ created_at: string;
197
+ delivered_at: string | null;
198
+ }
199
+ export interface NewTemplate {
200
+ name: string;
201
+ /** Placeholders such as `{{first_name}}` work in the subject and HTML. */
202
+ subject: string;
203
+ html: string;
204
+ /** Default values for placeholders an email does not pass. */
205
+ variables?: Record<string, string>;
206
+ }
207
+ export interface Template {
208
+ object: 'template';
209
+ id: string;
210
+ name: string;
211
+ subject: string;
212
+ html: string;
213
+ variables: Record<string, string>;
214
+ created_at: string;
215
+ updated_at: string;
216
+ }
217
+ export interface NewCampaign {
218
+ name: string;
219
+ /** `{{first_name}}`, `{{last_name}}` and `{{email}}` are filled in per contact. */
220
+ subject: string;
221
+ /** Plain text. Blank lines start paragraphs and links become clickable. Mailumi adds your postal address and an unsubscribe link. */
222
+ text: string;
223
+ /** A sender on a verified domain. Needed before sending. */
224
+ from?: string;
225
+ /** Needed before sending. */
226
+ audienceId?: string | null;
227
+ }
228
+ export type CampaignStats = Record<'total' | 'pending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed' | 'opened' | 'clicked' | 'unsubscribed', number>;
229
+ export interface Campaign {
230
+ object: 'campaign';
231
+ id: string;
232
+ name: string;
233
+ subject: string;
234
+ text: string;
235
+ from: string | null;
236
+ audience_id: string | null;
237
+ status: 'draft' | 'sending' | 'sent' | 'failed';
238
+ /** How many contacts it was sent to. */
239
+ recipients: number;
240
+ error: string | null;
241
+ sent_at: string | null;
242
+ created_at: string;
243
+ updated_at: string;
244
+ /** Drafts, from `campaigns.get`: how many contacts it would go to now, and what to fix before sending. */
245
+ readiness?: {
246
+ recipients: number;
247
+ missing: string[];
248
+ };
249
+ /** Campaigns that were sent. */
250
+ stats?: CampaignStats;
251
+ }
252
+ export interface Suppression {
253
+ object: 'suppression';
254
+ address: string;
255
+ reason: 'bounce' | 'complaint' | 'manual';
256
+ detail: string | null;
257
+ email_id: string | null;
258
+ created_at: string;
259
+ }
134
260
  export interface Audience {
135
261
  object: 'audience';
136
262
  id: string;
@@ -347,6 +473,54 @@ export declare class Mailumi {
347
473
  list: () => Promise<Result<{
348
474
  data: Domain[];
349
475
  }>>;
476
+ /** Adds a domain and returns the DNS records to publish. Needs a key with access to all domains. */
477
+ create: ({ name }: {
478
+ name: string;
479
+ }) => Promise<Result<Domain>>;
480
+ get: (id: string) => Promise<Result<Domain>>;
481
+ /** Checks the DNS records now. Mailumi also checks new domains every 5 minutes and emails you once one is verified. */
482
+ verify: (id: string) => Promise<Result<DomainVerification>>;
483
+ update: ({ id, receivingEnabled, mxConsent, openTracking, clickTracking, ...rest }: DomainSettings & Record<string, any>) => Promise<Result<Domain>>;
484
+ /** Removes the domain. Emails can no longer be sent from it. */
485
+ remove: (id: string) => Promise<Result<Deleted>>;
486
+ };
487
+ readonly webhooks: {
488
+ /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
489
+ verify: typeof verifyWebhook;
490
+ list: () => Promise<Result<{
491
+ data: Webhook[];
492
+ }>>;
493
+ /** Creates a webhook. `secret` is only returned here: store it as MAILUMI_WEBHOOK_SECRET. */
494
+ create: (webhook: NewWebhook) => Promise<Result<Webhook & {
495
+ secret: string;
496
+ }>>;
497
+ get: (id: string) => Promise<Result<Webhook>>;
498
+ /** Stops sending events to the webhook. */
499
+ disable: (id: string) => Promise<Result<Webhook>>;
500
+ /** Sends a signed `webhook.test` event and returns the delivery. */
501
+ test: (id: string) => Promise<Result<WebhookDelivery>>;
502
+ /** The 100 most recent deliveries, newest first. */
503
+ deliveries: (id: string) => Promise<Result<{
504
+ data: WebhookDelivery[];
505
+ }>>;
506
+ /** Delivers an event again now. */
507
+ retry: ({ id, deliveryId }: {
508
+ id: string;
509
+ deliveryId: string;
510
+ }) => Promise<Result<WebhookDelivery>>;
511
+ };
512
+ readonly templates: {
513
+ list: () => Promise<Result<{
514
+ data: Template[];
515
+ }>>;
516
+ /** Saves a template to send with `templateId`. */
517
+ create: (template: NewTemplate) => Promise<Result<Template>>;
518
+ get: (id: string) => Promise<Result<Template>>;
519
+ /** Changes only the fields you pass. */
520
+ update: ({ id, ...template }: Partial<NewTemplate> & {
521
+ id: string;
522
+ }) => Promise<Result<Template>>;
523
+ remove: (id: string) => Promise<Result<Deleted>>;
350
524
  };
351
525
  readonly audiences: {
352
526
  list: () => Promise<Result<{
@@ -391,9 +565,38 @@ export declare class Mailumi {
391
565
  }) => Promise<Result<Contact>>;
392
566
  remove: (ref: ContactRef) => Promise<Result<Deleted>>;
393
567
  };
394
- /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
395
- readonly webhooks: {
396
- verify: typeof verifyWebhook;
568
+ readonly campaigns: {
569
+ /** Lists campaigns, newest first, with delivery statistics for those that were sent. */
570
+ list: () => Promise<Result<{
571
+ data: Campaign[];
572
+ }>>;
573
+ /** Creates a draft. */
574
+ create: (campaign: NewCampaign) => Promise<Result<Campaign>>;
575
+ /** Drafts include `readiness`; sent campaigns include `stats`. */
576
+ get: (id: string) => Promise<Result<Campaign>>;
577
+ /** Changes only the fields you pass. Only drafts can be changed. */
578
+ update: ({ id, ...campaign }: Partial<NewCampaign> & {
579
+ id: string;
580
+ }) => Promise<Result<Campaign>>;
581
+ /** Sends a draft to every subscribed contact in its audience now. */
582
+ send: (id: string) => Promise<Result<Campaign>>;
583
+ /** Copies a campaign into a new draft. */
584
+ duplicate: (id: string) => Promise<Result<Campaign>>;
585
+ remove: (id: string) => Promise<Result<Deleted>>;
586
+ };
587
+ readonly suppressions: {
588
+ /** Addresses that bounced, complained or were added by hand. Mailumi does not send to them. */
589
+ list: (q?: {
590
+ search?: string;
591
+ }) => Promise<Result<{
592
+ data: Suppression[];
593
+ }>>;
594
+ /** Allows sending to the address again. */
595
+ remove: (address: string) => Promise<Result<{
596
+ object: "suppression";
597
+ address: string;
598
+ deleted: true;
599
+ }>>;
397
600
  };
398
601
  private readonly key;
399
602
  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.1.0';
10
+ exports.VERSION = '1.2.1';
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')
@@ -81,10 +81,11 @@ function toApiEmail(o) {
81
81
  }
82
82
  const toApiContact = (c) => ({ email: c.email, first_name: c.firstName ?? c.first_name, last_name: c.lastName ?? c.last_name, unsubscribed: c.unsubscribed });
83
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}`;
84
87
  class Mailumi {
85
88
  constructor(apiKey, options = {}) {
86
- /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
87
- this.webhooks = { verify: verifyWebhook };
88
89
  const env = globalThis.process?.env ?? {};
89
90
  this.key = apiKey ?? env.MAILUMI_API_KEY ?? '';
90
91
  this.baseUrl = (options.baseUrl ?? env.MAILUMI_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
@@ -112,6 +113,62 @@ class Mailumi {
112
113
  this.domains = {
113
114
  /** Lists your domains, their DNS records and whether each is verified. */
114
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)),
115
172
  };
116
173
  this.audiences = {
117
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,29 @@ 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 zone = r.foundAt?.slice(r.name.length + 1);
133
+ const state = r.verified ? 'found' : zone ? `wrong name, found at ${r.foundAt}: enter only ${r.name === zone ? '@' : r.name.slice(0, -zone.length - 1)}` : r.found?.length ? `wrong value, found ${r.found.join(', ')}` : 'missing';
134
+ return `[${state}] ${r.type} ${r.name}${r.required ? '' : ' (recommended)'}\n ${r.priority !== undefined ? `${r.priority} ` : ''}${r.value}`;
135
+ }).join('\n');
136
+ const domainView = (d) => `${d.name} (${d.id}): ${d.sendingStatus === 'verified' ? 'verified, ready to send' : d.sendingStatus}\n\n${recordList(d)}`;
137
+ const KEEPS_CHECKING = 'Mailumi keeps checking every 5 minutes and emails you once the domain is verified.';
90
138
  const table = (rows, columns) => {
91
139
  if (!rows.length)
92
140
  return '(none)';
@@ -134,6 +182,100 @@ async function run(argv) {
134
182
  result = await client.domains.list();
135
183
  print = l => table(l.data, ['name', 'sendingStatus', 'receivingStatus', 'id']);
136
184
  break;
185
+ case 'domains add':
186
+ need(1, 'domains add <name>');
187
+ result = await client.domains.create({ name: rest[0] });
188
+ 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`;
189
+ break;
190
+ case 'domains get':
191
+ need(1, 'domains get <id>');
192
+ result = await client.domains.get(rest[0]);
193
+ print = domainView;
194
+ break;
195
+ case 'domains verify': {
196
+ need(1, 'domains verify <id> [--wait]');
197
+ const until = Date.now() + 30 * 60000;
198
+ for (;;) {
199
+ result = await client.domains.verify(rest[0]);
200
+ if (!flags.wait || result.error || result.data.ready || result.data.domain.sendingStatus === 'failed' || Date.now() > until)
201
+ break;
202
+ if (!json)
203
+ console.error(`${result.data.message} Checking again in 15 seconds…`);
204
+ await new Promise(r => setTimeout(r, 15000));
205
+ }
206
+ print = v => `${v.message}\n\n${domainView(v.domain)}${v.ready ? '' : `\n\n${KEEPS_CHECKING}`}`;
207
+ break;
208
+ }
209
+ case 'domains update':
210
+ need(1, 'domains update <id> [--receiving on|off] [--mx-consent] [--open-tracking on|off] [--click-tracking on|off]');
211
+ 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') });
212
+ print = d => `Updated ${d.name}: receiving ${d.receivingEnabled ? 'on' : 'off'}, open tracking ${d.openTracking ? 'on' : 'off'}, click tracking ${d.clickTracking ? 'on' : 'off'}.`;
213
+ break;
214
+ case 'domains remove':
215
+ need(1, 'domains remove <id>');
216
+ result = await client.domains.remove(rest[0]);
217
+ print = d => `Removed domain ${d.id}.`;
218
+ break;
219
+ case 'webhooks list':
220
+ result = await client.webhooks.list();
221
+ print = l => table(l.data, ['id', 'name', 'url', 'events', 'enabled']);
222
+ break;
223
+ case 'webhooks create': {
224
+ need(1, 'webhooks create <url> --name <name>');
225
+ const name = one(flags, 'name');
226
+ if (!name)
227
+ throw new Error('webhooks create needs --name.');
228
+ result = await client.webhooks.create({ url: rest[0], name, events: many(flags, 'events'), domainId: one(flags, 'domain'), recipient: one(flags, 'recipient') });
229
+ 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}`;
230
+ break;
231
+ }
232
+ case 'webhooks test':
233
+ need(1, 'webhooks test <id>');
234
+ result = await client.webhooks.test(rest[0]);
235
+ 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}.` : ''}`;
236
+ break;
237
+ case 'webhooks deliveries':
238
+ need(1, 'webhooks deliveries <id>');
239
+ result = await client.webhooks.deliveries(rest[0]);
240
+ print = l => table(l.data, ['id', 'event', 'status', 'attempts', 'response_code', 'created_at']);
241
+ break;
242
+ case 'webhooks retry':
243
+ need(2, 'webhooks retry <id> <delivery_id>');
244
+ result = await client.webhooks.retry({ id: rest[0], deliveryId: rest[1] });
245
+ print = d => `Delivery ${d.id} is ${d.status}${d.response_code ? ` (HTTP ${d.response_code})` : ''}.`;
246
+ break;
247
+ case 'webhooks disable':
248
+ need(1, 'webhooks disable <id>');
249
+ result = await client.webhooks.disable(rest[0]);
250
+ print = w => `Disabled webhook ${w.name} (${w.id}).`;
251
+ break;
252
+ case 'templates list':
253
+ result = await client.templates.list();
254
+ print = l => table(l.data, ['id', 'name', 'subject', 'updated_at']);
255
+ break;
256
+ case 'templates get':
257
+ need(1, 'templates get <id>');
258
+ result = await client.templates.get(rest[0]);
259
+ 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');
260
+ break;
261
+ case 'templates create': {
262
+ const t = templateOptions(flags);
263
+ if (!t.name || !t.subject || !t.html)
264
+ throw new Error('templates create needs --name, --subject and --html or --html-file.');
265
+ result = await client.templates.create({ ...t, name: t.name, subject: t.subject, html: t.html });
266
+ print = t => `Created template ${t.name} (${t.id}). Send it with: mailumi send --template ${t.id} --from <address> --to <address>`;
267
+ break;
268
+ }
269
+ case 'templates update':
270
+ need(1, 'templates update <id>');
271
+ result = await client.templates.update({ id: rest[0], ...templateOptions(flags) });
272
+ print = t => `Updated template ${t.name} (${t.id}).`;
273
+ break;
274
+ case 'templates remove':
275
+ need(1, 'templates remove <id>');
276
+ result = await client.templates.remove(rest[0]);
277
+ print = t => `Deleted template ${t.id}.`;
278
+ break;
137
279
  case 'audiences list':
138
280
  result = await client.audiences.list();
139
281
  print = l => table(l.data, ['id', 'name', 'contacts', 'subscribed']);
@@ -158,6 +300,54 @@ async function run(argv) {
158
300
  result = await client.contacts.update({ audienceId: rest[0], email: rest[1], unsubscribed: true });
159
301
  print = c => `Unsubscribed ${c.email} from every audience.`;
160
302
  break;
303
+ case 'campaigns list':
304
+ result = await client.campaigns.list();
305
+ print = l => table(l.data.map((c) => ({ ...c, delivered: c.stats?.delivered ?? '' })), ['id', 'name', 'status', 'recipients', 'delivered', 'updated_at']);
306
+ break;
307
+ case 'campaigns get':
308
+ need(1, 'campaigns get <id>');
309
+ result = await client.campaigns.get(rest[0]);
310
+ print = c => [`${c.name} (${c.id}): ${c.status}`, `From: ${c.from ?? 'not set'}`, `Audience: ${c.audience_id ?? 'not set'}`, `Subject: ${c.subject}`,
311
+ ...(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}`] : []),
312
+ ...(c.stats ? ['', Object.entries(c.stats).map(([k, v]) => `${k} ${v}`).join(', ')] : []), '', c.text].join('\n');
313
+ break;
314
+ case 'campaigns create': {
315
+ const c = campaignOptions(flags);
316
+ if (!c.name || !c.subject || !c.text)
317
+ throw new Error('campaigns create needs --name, --subject and --text or --text-file.');
318
+ result = await client.campaigns.create({ ...c, name: c.name, subject: c.subject, text: c.text });
319
+ print = c => `Created draft ${c.name} (${c.id}). Check it with: mailumi campaigns get ${c.id}`;
320
+ break;
321
+ }
322
+ case 'campaigns update':
323
+ need(1, 'campaigns update <id>');
324
+ result = await client.campaigns.update({ id: rest[0], ...campaignOptions(flags) });
325
+ print = c => `Updated draft ${c.name} (${c.id}).`;
326
+ break;
327
+ case 'campaigns send':
328
+ need(1, 'campaigns send <id>');
329
+ result = await client.campaigns.send(rest[0]);
330
+ print = c => `Sending ${c.name} to ${c.recipients} contacts. Follow it with: mailumi campaigns get ${c.id}`;
331
+ break;
332
+ case 'campaigns duplicate':
333
+ need(1, 'campaigns duplicate <id>');
334
+ result = await client.campaigns.duplicate(rest[0]);
335
+ print = c => `Created draft ${c.name} (${c.id}).`;
336
+ break;
337
+ case 'campaigns remove':
338
+ need(1, 'campaigns remove <id>');
339
+ result = await client.campaigns.remove(rest[0]);
340
+ print = c => `Deleted campaign ${c.id}.`;
341
+ break;
342
+ case 'suppressions list':
343
+ result = await client.suppressions.list({ search: one(flags, 'search') });
344
+ print = l => table(l.data, ['address', 'reason', 'detail', 'created_at']);
345
+ break;
346
+ case 'suppressions remove':
347
+ need(1, 'suppressions remove <address>');
348
+ result = await client.suppressions.remove(rest[0]);
349
+ print = s => `Mailumi will send to ${s.address} again.`;
350
+ break;
161
351
  default:
162
352
  throw new Error(`Unknown command "${args.join(' ')}". Run "mailumi --help".`);
163
353
  }
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.1.0";
5
+ export declare const VERSION = "1.2.1";
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,15 @@ 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[];
121
+ /** Where a missing record was found instead: its name with the domain added twice, because the DNS provider adds the domain itself. Enter only the part before the domain. */
122
+ foundAt?: string;
119
123
  }
120
124
  export interface Domain {
121
125
  id: string;
122
126
  name: string;
127
+ region?: string;
123
128
  ownershipVerified?: boolean;
124
129
  sendingEnabled?: boolean;
125
130
  receivingEnabled?: boolean;
@@ -131,6 +136,127 @@ export interface Domain {
131
136
  createdAt?: number;
132
137
  records: DnsRecord[];
133
138
  }
139
+ export interface DomainVerification {
140
+ domain: Domain;
141
+ /** `true` once the domain can send. */
142
+ ready: boolean;
143
+ /** What is still missing, or that the domain is ready. */
144
+ message: string;
145
+ /** How many required records have a different value at the DNS provider. */
146
+ wrong?: number;
147
+ /** How many records were found at a doubled name; see `foundAt` on each record. */
148
+ misplaced?: number;
149
+ }
150
+ export interface DomainSettings {
151
+ id: string;
152
+ /** Receive email for the domain and send it to your email.received webhooks. */
153
+ receivingEnabled?: boolean;
154
+ /** Must be `true` to turn receiving on: the MX record replaces any mailbox provider the domain uses now. */
155
+ mxConsent?: boolean;
156
+ /** Pro and Scale plans. */
157
+ openTracking?: boolean;
158
+ /** Pro and Scale plans. */
159
+ clickTracking?: boolean;
160
+ }
161
+ export type WebhookSubscription = Exclude<WebhookEventType, 'webhook.test'>;
162
+ export interface NewWebhook {
163
+ name: string;
164
+ /** A public HTTPS URL on port 443. */
165
+ url: string;
166
+ /** Defaults to `['email.received']`. */
167
+ events?: WebhookSubscription[];
168
+ /** Only send events for this domain. Leave it out for every domain. */
169
+ domainId?: string;
170
+ /** For email.received: only mail to this address before the @, for example "support". */
171
+ recipient?: string;
172
+ }
173
+ export interface Webhook {
174
+ object: 'webhook';
175
+ id: string;
176
+ name: string;
177
+ url: string;
178
+ events: WebhookSubscription[];
179
+ domain_id: string | null;
180
+ recipient: string | null;
181
+ enabled: boolean;
182
+ created_at: string;
183
+ }
184
+ export interface WebhookDelivery {
185
+ /** Also the webhook-id header of the request. */
186
+ id: string;
187
+ object: 'webhook_delivery';
188
+ webhook_id: string;
189
+ event: WebhookEventType;
190
+ email_id: string | null;
191
+ status: 'pending' | 'delivered' | 'failed' | 'cancelled';
192
+ attempts: number;
193
+ response_code: number | null;
194
+ last_error: string | null;
195
+ next_attempt_at: string | null;
196
+ created_at: string;
197
+ delivered_at: string | null;
198
+ }
199
+ export interface NewTemplate {
200
+ name: string;
201
+ /** Placeholders such as `{{first_name}}` work in the subject and HTML. */
202
+ subject: string;
203
+ html: string;
204
+ /** Default values for placeholders an email does not pass. */
205
+ variables?: Record<string, string>;
206
+ }
207
+ export interface Template {
208
+ object: 'template';
209
+ id: string;
210
+ name: string;
211
+ subject: string;
212
+ html: string;
213
+ variables: Record<string, string>;
214
+ created_at: string;
215
+ updated_at: string;
216
+ }
217
+ export interface NewCampaign {
218
+ name: string;
219
+ /** `{{first_name}}`, `{{last_name}}` and `{{email}}` are filled in per contact. */
220
+ subject: string;
221
+ /** Plain text. Blank lines start paragraphs and links become clickable. Mailumi adds your postal address and an unsubscribe link. */
222
+ text: string;
223
+ /** A sender on a verified domain. Needed before sending. */
224
+ from?: string;
225
+ /** Needed before sending. */
226
+ audienceId?: string | null;
227
+ }
228
+ export type CampaignStats = Record<'total' | 'pending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed' | 'opened' | 'clicked' | 'unsubscribed', number>;
229
+ export interface Campaign {
230
+ object: 'campaign';
231
+ id: string;
232
+ name: string;
233
+ subject: string;
234
+ text: string;
235
+ from: string | null;
236
+ audience_id: string | null;
237
+ status: 'draft' | 'sending' | 'sent' | 'failed';
238
+ /** How many contacts it was sent to. */
239
+ recipients: number;
240
+ error: string | null;
241
+ sent_at: string | null;
242
+ created_at: string;
243
+ updated_at: string;
244
+ /** Drafts, from `campaigns.get`: how many contacts it would go to now, and what to fix before sending. */
245
+ readiness?: {
246
+ recipients: number;
247
+ missing: string[];
248
+ };
249
+ /** Campaigns that were sent. */
250
+ stats?: CampaignStats;
251
+ }
252
+ export interface Suppression {
253
+ object: 'suppression';
254
+ address: string;
255
+ reason: 'bounce' | 'complaint' | 'manual';
256
+ detail: string | null;
257
+ email_id: string | null;
258
+ created_at: string;
259
+ }
134
260
  export interface Audience {
135
261
  object: 'audience';
136
262
  id: string;
@@ -347,6 +473,54 @@ export declare class Mailumi {
347
473
  list: () => Promise<Result<{
348
474
  data: Domain[];
349
475
  }>>;
476
+ /** Adds a domain and returns the DNS records to publish. Needs a key with access to all domains. */
477
+ create: ({ name }: {
478
+ name: string;
479
+ }) => Promise<Result<Domain>>;
480
+ get: (id: string) => Promise<Result<Domain>>;
481
+ /** Checks the DNS records now. Mailumi also checks new domains every 5 minutes and emails you once one is verified. */
482
+ verify: (id: string) => Promise<Result<DomainVerification>>;
483
+ update: ({ id, receivingEnabled, mxConsent, openTracking, clickTracking, ...rest }: DomainSettings & Record<string, any>) => Promise<Result<Domain>>;
484
+ /** Removes the domain. Emails can no longer be sent from it. */
485
+ remove: (id: string) => Promise<Result<Deleted>>;
486
+ };
487
+ readonly webhooks: {
488
+ /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
489
+ verify: typeof verifyWebhook;
490
+ list: () => Promise<Result<{
491
+ data: Webhook[];
492
+ }>>;
493
+ /** Creates a webhook. `secret` is only returned here: store it as MAILUMI_WEBHOOK_SECRET. */
494
+ create: (webhook: NewWebhook) => Promise<Result<Webhook & {
495
+ secret: string;
496
+ }>>;
497
+ get: (id: string) => Promise<Result<Webhook>>;
498
+ /** Stops sending events to the webhook. */
499
+ disable: (id: string) => Promise<Result<Webhook>>;
500
+ /** Sends a signed `webhook.test` event and returns the delivery. */
501
+ test: (id: string) => Promise<Result<WebhookDelivery>>;
502
+ /** The 100 most recent deliveries, newest first. */
503
+ deliveries: (id: string) => Promise<Result<{
504
+ data: WebhookDelivery[];
505
+ }>>;
506
+ /** Delivers an event again now. */
507
+ retry: ({ id, deliveryId }: {
508
+ id: string;
509
+ deliveryId: string;
510
+ }) => Promise<Result<WebhookDelivery>>;
511
+ };
512
+ readonly templates: {
513
+ list: () => Promise<Result<{
514
+ data: Template[];
515
+ }>>;
516
+ /** Saves a template to send with `templateId`. */
517
+ create: (template: NewTemplate) => Promise<Result<Template>>;
518
+ get: (id: string) => Promise<Result<Template>>;
519
+ /** Changes only the fields you pass. */
520
+ update: ({ id, ...template }: Partial<NewTemplate> & {
521
+ id: string;
522
+ }) => Promise<Result<Template>>;
523
+ remove: (id: string) => Promise<Result<Deleted>>;
350
524
  };
351
525
  readonly audiences: {
352
526
  list: () => Promise<Result<{
@@ -391,9 +565,38 @@ export declare class Mailumi {
391
565
  }) => Promise<Result<Contact>>;
392
566
  remove: (ref: ContactRef) => Promise<Result<Deleted>>;
393
567
  };
394
- /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
395
- readonly webhooks: {
396
- verify: typeof verifyWebhook;
568
+ readonly campaigns: {
569
+ /** Lists campaigns, newest first, with delivery statistics for those that were sent. */
570
+ list: () => Promise<Result<{
571
+ data: Campaign[];
572
+ }>>;
573
+ /** Creates a draft. */
574
+ create: (campaign: NewCampaign) => Promise<Result<Campaign>>;
575
+ /** Drafts include `readiness`; sent campaigns include `stats`. */
576
+ get: (id: string) => Promise<Result<Campaign>>;
577
+ /** Changes only the fields you pass. Only drafts can be changed. */
578
+ update: ({ id, ...campaign }: Partial<NewCampaign> & {
579
+ id: string;
580
+ }) => Promise<Result<Campaign>>;
581
+ /** Sends a draft to every subscribed contact in its audience now. */
582
+ send: (id: string) => Promise<Result<Campaign>>;
583
+ /** Copies a campaign into a new draft. */
584
+ duplicate: (id: string) => Promise<Result<Campaign>>;
585
+ remove: (id: string) => Promise<Result<Deleted>>;
586
+ };
587
+ readonly suppressions: {
588
+ /** Addresses that bounced, complained or were added by hand. Mailumi does not send to them. */
589
+ list: (q?: {
590
+ search?: string;
591
+ }) => Promise<Result<{
592
+ data: Suppression[];
593
+ }>>;
594
+ /** Allows sending to the address again. */
595
+ remove: (address: string) => Promise<Result<{
596
+ object: "suppression";
597
+ address: string;
598
+ deleted: true;
599
+ }>>;
397
600
  };
398
601
  private readonly key;
399
602
  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.1.0';
5
+ export const VERSION = '1.2.1';
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')
@@ -76,10 +76,11 @@ export function toApiEmail(o) {
76
76
  }
77
77
  const toApiContact = (c) => ({ email: c.email, first_name: c.firstName ?? c.first_name, last_name: c.lastName ?? c.last_name, unsubscribed: c.unsubscribed });
78
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}`;
79
82
  export class Mailumi {
80
83
  constructor(apiKey, options = {}) {
81
- /** Verifies a webhook signature. Needs no API key; `verifyWebhook` is also exported on its own. */
82
- this.webhooks = { verify: verifyWebhook };
83
84
  const env = globalThis.process?.env ?? {};
84
85
  this.key = apiKey ?? env.MAILUMI_API_KEY ?? '';
85
86
  this.baseUrl = (options.baseUrl ?? env.MAILUMI_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
@@ -107,6 +108,62 @@ export class Mailumi {
107
108
  this.domains = {
108
109
  /** Lists your domains, their DNS records and whether each is verified. */
109
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)),
110
167
  };
111
168
  this.audiences = {
112
169
  list: () => this.request('GET', '/audiences'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailumi",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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",