retransmit.dev 0.4.0 → 0.6.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
@@ -101,6 +101,80 @@ like `From`, `To`, `Subject`, `Date` or `Message-ID`, are rejected with a
101
101
  headers even if you pass your own. Headers come back on `emails.get`, and
102
102
  batch emails accept the same field.
103
103
 
104
+ ### Attachments
105
+
106
+ Attach up to 20 files, 30 MB in total. Pass the bytes as `content` (a
107
+ `Buffer`, `Uint8Array` or base64 string), or a public URL as `path` and
108
+ Retransmit fetches it while the request runs. Either way the file travels
109
+ inside the email.
110
+
111
+ ```ts
112
+ import { readFile } from "node:fs/promises";
113
+
114
+ await retransmit.emails.send({
115
+ from: "Acme <billing@yourdomain.com>",
116
+ to: "user@example.com",
117
+ subject: "Your invoice",
118
+ html: "<p>Your invoice is attached.</p>",
119
+ attachments: [
120
+ { filename: "invoice.pdf", content: await readFile("./invoice.pdf") },
121
+ { filename: "terms.pdf", path: "https://yourdomain.com/terms.pdf" },
122
+ ],
123
+ });
124
+ ```
125
+
126
+ To embed an image in the HTML, give it a `contentId` and reference it as
127
+ `cid:`:
128
+
129
+ ```ts
130
+ await retransmit.emails.send({
131
+ from: "Acme <hello@yourdomain.com>",
132
+ to: "user@example.com",
133
+ subject: "Welcome",
134
+ html: '<p><img src="cid:logo" alt="Acme" /> Glad to have you.</p>',
135
+ attachments: [{ filename: "logo.png", path: "https://yourdomain.com/logo.png", contentId: "logo" }],
136
+ });
137
+ ```
138
+
139
+ Executables, scripts and installers are rejected with `invalid_attachment`,
140
+ and `batch.send` does not accept attachments. Files are kept for 30 days so
141
+ you can see what went out:
142
+
143
+ ```ts
144
+ const { data } = await retransmit.emails.attachments("em_xxxxxxxxxxxx");
145
+ // data.attachments: [{ id, filename, content_type, size, download_url, expires_at, ... }]
146
+ ```
147
+
148
+ `download_url` is signed and valid for one hour; it is `null` once the file
149
+ has expired.
150
+
151
+ ### Idempotency
152
+
153
+ A send can succeed on the server and still fail on your side, through a
154
+ timeout or a dropped connection. Retrying it blindly sends the email twice.
155
+ Pass an `idempotencyKey` and retries become safe: for 24 hours the same key
156
+ with the same payload returns the original response, `id` included, and
157
+ nothing is queued again.
158
+
159
+ ```ts
160
+ await retransmit.emails.send(
161
+ {
162
+ from: "Acme <hello@yourdomain.com>",
163
+ to: "user@example.com",
164
+ subject: "Welcome to Acme",
165
+ html: "<p>Glad to have you.</p>",
166
+ },
167
+ { idempotencyKey: "welcome-user/123" },
168
+ );
169
+ ```
170
+
171
+ Use a value that identifies that exact email, such as a UUID or
172
+ `<event>/<entity-id>`. Keys are 1 to 256 characters and are shared by every
173
+ API key in your organization. The same key with a different payload returns
174
+ `invalid_idempotent_request`; a retry that overlaps the first request returns
175
+ `concurrent_idempotent_requests`, so wait a moment and try again. Batches
176
+ accept the same option with one key for the whole batch.
177
+
104
178
  ### List and filter emails
105
179
 
106
180
  `emails.list` returns your emails newest first. Every tag you pass must match.
@@ -155,15 +229,59 @@ const { data } = await retransmit.sms.get("sms_xxxxxxxxxxxx");
155
229
  console.log(data?.status); // "sent" | "delivered" | "undelivered" | ...
156
230
  ```
157
231
 
232
+ ### Sender IDs
233
+
234
+ `from` is the name shown on the handset instead of a phone number. Approval is
235
+ per country, so request it first in the dashboard under **SMS > Sender IDs**.
236
+ The request asks for the name and the countries you send to. Most countries
237
+ accept the name as it is; where the carriers require a registration, the form
238
+ adds what that filing needs (what you send, a sample message, your legal
239
+ entity). Retransmit files it for you, and there is no AWS or carrier account to
240
+ set up.
241
+
242
+ Sending with a name that is not approved for the destination country fails with
243
+ `sender_not_allowed`. Leave `from` out to use your approved sender for that
244
+ country, or the provider default when you have none.
245
+
246
+ The United States, Canada, and Mexico do not accept alphanumeric sender IDs.
247
+
248
+ ### Choosing a carrier
249
+
250
+ You do not have to work out which carrier fits a number. Leave `provider` out
251
+ and Retransmit routes by destination country and price.
252
+
253
+ Pass it to pin the send to one carrier. `sns` (AWS End User Messaging) is the
254
+ one that reaches every destination; `mtn` and `orange` only cover the countries
255
+ Retransmit has that carrier in:
256
+
257
+ ```ts
258
+ await retransmit.sms.send({
259
+ to: "+237670000000",
260
+ text: "Your verification code is 482913",
261
+ provider: "sns", // "sns" | "mtn" | "orange"
262
+ });
263
+ ```
264
+
265
+ The value is the carrier, not one of its country operations, so it keeps
266
+ working as more countries are added. A pinned send never falls back: if that
267
+ carrier cannot deliver to the destination, the request fails with `no_route`.
268
+
269
+ `get` returns both. `requested_provider` is what you asked for, `provider` is
270
+ the country operation that carried the message (`mtn_cm`, `orange_cm`,
271
+ `aws_sns`), and is null until the message is routed.
272
+
158
273
  ## Email batches
159
274
 
160
275
  Send up to 10,000 emails in one request:
161
276
 
162
277
  ```ts
163
- const { data: batch } = await retransmit.batch.send([
164
- { from: "Acme <hello@yourdomain.com>", to: "a@example.com", subject: "Hi", text: "Hello A" },
165
- { from: "Acme <hello@yourdomain.com>", to: "b@example.com", subject: "Hi", text: "Hello B" },
166
- ]);
278
+ const { data: batch } = await retransmit.batch.send(
279
+ [
280
+ { from: "Acme <hello@yourdomain.com>", to: "a@example.com", subject: "Hi", text: "Hello A" },
281
+ { from: "Acme <hello@yourdomain.com>", to: "b@example.com", subject: "Hi", text: "Hello B" },
282
+ ],
283
+ { idempotencyKey: "weekly-digest/2026-09-07" },
284
+ );
167
285
 
168
286
  const { data: progress } = await retransmit.batch.get(batch!.id);
169
287
  console.log(progress?.processed, "/", progress?.total, progress?.counts);
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  EMAIL_STATUSES: () => EMAIL_STATUSES,
25
25
  Emails: () => Emails,
26
26
  Retransmit: () => Retransmit,
27
+ SMS_PROVIDERS: () => SMS_PROVIDERS,
27
28
  SMS_STATUSES: () => SMS_STATUSES,
28
29
  Sms: () => Sms,
29
30
  WHATSAPP_STATUSES: () => WHATSAPP_STATUSES,
@@ -32,6 +33,24 @@ __export(index_exports, {
32
33
  module.exports = __toCommonJS(index_exports);
33
34
 
34
35
  // src/emails.ts
36
+ function toBase64(bytes) {
37
+ if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
38
+ let binary = "";
39
+ const CHUNK = 32768;
40
+ for (let i = 0; i < bytes.length; i += CHUNK) {
41
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
42
+ }
43
+ return btoa(binary);
44
+ }
45
+ function toWireAttachment(attachment) {
46
+ return {
47
+ filename: attachment.filename,
48
+ content: attachment.content === void 0 || typeof attachment.content === "string" ? attachment.content : toBase64(attachment.content),
49
+ path: attachment.path,
50
+ content_type: attachment.contentType,
51
+ content_id: attachment.contentId
52
+ };
53
+ }
35
54
  function toWirePayload(options) {
36
55
  return {
37
56
  from: options.from,
@@ -44,7 +63,8 @@ function toWirePayload(options) {
44
63
  text: options.text,
45
64
  marketing: options.marketing,
46
65
  tags: options.tags,
47
- headers: options.headers
66
+ headers: options.headers,
67
+ attachments: options.attachments?.map(toWireAttachment)
48
68
  };
49
69
  }
50
70
  var Emails = class {
@@ -52,9 +72,18 @@ var Emails = class {
52
72
  this.client = client;
53
73
  }
54
74
  client;
55
- /** Queues a single email. Poll `get(id)` or subscribe to webhooks for the outcome. */
56
- send(options) {
57
- return this.client.request("POST", "/v1/emails", toWirePayload(options));
75
+ /**
76
+ * Queues a single email. Poll `get(id)` or subscribe to webhooks for the
77
+ * outcome. Pass `{ idempotencyKey }` to make the call safe to retry.
78
+ */
79
+ send(options, requestOptions) {
80
+ return this.client.request(
81
+ "POST",
82
+ "/v1/emails",
83
+ toWirePayload(options),
84
+ void 0,
85
+ requestOptions
86
+ );
58
87
  }
59
88
  /** Retrieves an email with its current status and event history. */
60
89
  get(id) {
@@ -77,6 +106,21 @@ var Emails = class {
77
106
  tags() {
78
107
  return this.client.request("GET", "/v1/emails/tags");
79
108
  }
109
+ /**
110
+ * The attachments of an email, each with a signed `download_url` valid for
111
+ * one hour. Files are kept for 30 days after the send; after that the link
112
+ * is `null` and only the metadata remains.
113
+ */
114
+ attachments(emailId) {
115
+ return this.client.request("GET", `/v1/emails/${encodeURIComponent(emailId)}/attachments`);
116
+ }
117
+ /** One attachment of an email with a signed download link. */
118
+ getAttachment(emailId, attachmentId) {
119
+ return this.client.request(
120
+ "GET",
121
+ `/v1/emails/${encodeURIComponent(emailId)}/attachments/${encodeURIComponent(attachmentId)}`
122
+ );
123
+ }
80
124
  };
81
125
 
82
126
  // src/batch.ts
@@ -85,11 +129,19 @@ var Batch = class {
85
129
  this.client = client;
86
130
  }
87
131
  client;
88
- /** Queues up to 10,000 emails in one request. Track progress with `get(id)`. */
89
- send(emails) {
90
- return this.client.request("POST", "/v1/emails/batch", {
91
- emails: emails.map(toWirePayload)
92
- });
132
+ /**
133
+ * Queues up to 10,000 emails in one request. Track progress with `get(id)`.
134
+ * Pass `{ idempotencyKey }` covering the whole batch to make the call safe
135
+ * to retry.
136
+ */
137
+ send(emails, requestOptions) {
138
+ return this.client.request(
139
+ "POST",
140
+ "/v1/emails/batch",
141
+ { emails: emails.map(toWirePayload) },
142
+ void 0,
143
+ requestOptions
144
+ );
93
145
  }
94
146
  /** Batch progress: how many emails are in each status so far. */
95
147
  get(id) {
@@ -108,7 +160,8 @@ var Sms = class {
108
160
  return this.client.request("POST", "/v1/sms", {
109
161
  from: options.from,
110
162
  to: options.to,
111
- text: options.text
163
+ text: options.text,
164
+ provider: options.provider
112
165
  });
113
166
  }
114
167
  /** Retrieves an SMS with its current status and event history. */
@@ -148,7 +201,7 @@ var Whatsapp = class {
148
201
 
149
202
  // src/retransmit.ts
150
203
  var DEFAULT_BASE_URL = "https://api.retransmit.dev";
151
- var USER_AGENT = "retransmit.dev-node/0.4.0";
204
+ var USER_AGENT = "retransmit.dev-node/0.6.0";
152
205
  function readEnv(name) {
153
206
  return typeof process !== "undefined" ? process.env?.[name] : void 0;
154
207
  }
@@ -173,7 +226,7 @@ var Retransmit = class {
173
226
  );
174
227
  }
175
228
  /** Internal transport shared by the resource classes. API failures are returned, never thrown. */
176
- async request(method, path, body, query) {
229
+ async request(method, path, body, query, options = {}) {
177
230
  const params = new URLSearchParams();
178
231
  for (const [key, value] of Object.entries(query ?? {})) {
179
232
  if (value === void 0) continue;
@@ -181,15 +234,19 @@ var Retransmit = class {
181
234
  }
182
235
  const encoded = params.toString();
183
236
  const search = encoded ? `?${encoded}` : "";
237
+ const headers = {
238
+ Authorization: `Bearer ${this.apiKey}`,
239
+ "Content-Type": "application/json",
240
+ "User-Agent": USER_AGENT
241
+ };
242
+ if (options.idempotencyKey !== void 0) {
243
+ headers["Idempotency-Key"] = options.idempotencyKey;
244
+ }
184
245
  let response;
185
246
  try {
186
247
  response = await fetch(`${this.baseUrl}${path}${search}`, {
187
248
  method,
188
- headers: {
189
- Authorization: `Bearer ${this.apiKey}`,
190
- "Content-Type": "application/json",
191
- "User-Agent": USER_AGENT
192
- },
249
+ headers,
193
250
  body: body === void 0 ? void 0 : JSON.stringify(body)
194
251
  });
195
252
  } catch (cause) {
@@ -245,6 +302,7 @@ var SMS_STATUSES = [
245
302
  "rejected",
246
303
  "failed"
247
304
  ];
305
+ var SMS_PROVIDERS = ["sns", "mtn", "orange"];
248
306
  var WHATSAPP_STATUSES = ["queued", "sent", "delivered", "read", "failed"];
249
307
  // Annotate the CommonJS export names for ESM import in node:
250
308
  0 && (module.exports = {
@@ -252,6 +310,7 @@ var WHATSAPP_STATUSES = ["queued", "sent", "delivered", "read", "failed"];
252
310
  EMAIL_STATUSES,
253
311
  Emails,
254
312
  Retransmit,
313
+ SMS_PROVIDERS,
255
314
  SMS_STATUSES,
256
315
  Sms,
257
316
  WHATSAPP_STATUSES,
package/dist/index.d.cts CHANGED
@@ -20,6 +20,18 @@ interface RetransmitOptions {
20
20
  /** Override the API origin. Defaults to `https://api.retransmit.dev` (or `RETRANSMIT_BASE_URL`). */
21
21
  baseUrl?: string;
22
22
  }
23
+ /** Per-request options, passed as the second argument of `emails.send` and `batch.send`. */
24
+ interface RequestOptions {
25
+ /**
26
+ * Makes the request safe to retry. Sent as the `Idempotency-Key` header.
27
+ * For 24 hours, a retry with the same key and payload returns the original
28
+ * response instead of queuing the email again. 1 to 256 characters; a UUID
29
+ * or `<event>/<entity-id>` such as `welcome-user/123` works well. A retry
30
+ * with a different payload fails with `invalid_idempotent_request`, and one
31
+ * that overlaps the first request fails with `concurrent_idempotent_requests`.
32
+ */
33
+ idempotencyKey?: string;
34
+ }
23
35
  interface SendEmailOptions {
24
36
  /** Sender, as `address@domain.com` or `Name <address@domain.com>`. The domain must be verified on your account. */
25
37
  from: string;
@@ -58,8 +70,58 @@ interface SendEmailOptions {
58
70
  * List-Unsubscribe headers take precedence over yours.
59
71
  */
60
72
  headers?: EmailHeaders;
73
+ /**
74
+ * Files to attach, up to 20 per email and 30 MB in total. Each needs a
75
+ * `filename` and either `content` (the bytes) or `path` (a public URL
76
+ * fetched when you call `send`). Add `contentId` to embed an image inline.
77
+ * Not accepted by `batch.send`.
78
+ */
79
+ attachments?: Attachment[];
61
80
  }
62
81
  type EmailHeaders = Record<string, string>;
82
+ /** A file to attach. Provide exactly one of `content` and `path`. */
83
+ interface Attachment {
84
+ /**
85
+ * Name the recipient sees, up to 255 characters, no path separators. Its
86
+ * extension sets the content type when `contentType` is omitted.
87
+ */
88
+ filename: string;
89
+ /** The file: a `Buffer`, a `Uint8Array`, or an already base64 encoded string. */
90
+ content?: string | Uint8Array;
91
+ /**
92
+ * Public http(s) URL the file is fetched from while the request runs, with
93
+ * a 15 second budget. Private and internal hosts are refused.
94
+ */
95
+ path?: string;
96
+ /** MIME type such as `application/pdf`. Inferred from the filename when omitted. */
97
+ contentType?: string;
98
+ /**
99
+ * Embeds the file inline and sets its Content-ID. Reference it in `html`
100
+ * as `<img src="cid:the-id">`. Letters, digits, `.`, `_`, `@` and `-`, up
101
+ * to 128 characters, unique per email.
102
+ */
103
+ contentId?: string;
104
+ }
105
+ /** An attachment as it was sent, without a download link. */
106
+ interface EmailAttachment {
107
+ id: string;
108
+ filename: string;
109
+ content_type: string;
110
+ /** Size in bytes of the decoded file. */
111
+ size: number;
112
+ content_id: string | null;
113
+ /** True for images embedded via `contentId`. */
114
+ inline: boolean;
115
+ }
116
+ interface EmailAttachmentWithDownload extends EmailAttachment {
117
+ /** When the stored file is deleted, 30 days after the send. */
118
+ expires_at: string;
119
+ /** Signed link to download the file, valid for one hour. `null` once the file has expired. */
120
+ download_url: string | null;
121
+ }
122
+ interface ListEmailAttachmentsResponse {
123
+ attachments: EmailAttachmentWithDownload[];
124
+ }
63
125
  interface EmailTag {
64
126
  name: string;
65
127
  value: string;
@@ -91,6 +153,8 @@ interface GetEmailResponse {
91
153
  created_at: string;
92
154
  last_event_at: string | null;
93
155
  events: EmailEvent[];
156
+ /** Attachments given at send time, without download links. See `emails.attachments`. */
157
+ attachments: EmailAttachment[];
94
158
  }
95
159
  interface ListEmailsOptions {
96
160
  /**
@@ -136,16 +200,38 @@ interface ListEmailTagsResponse {
136
200
  }
137
201
  declare const SMS_STATUSES: readonly ["queued", "sent", "delivered", "undelivered", "expired", "rejected", "failed"];
138
202
  type SmsStatus = (typeof SMS_STATUSES)[number];
203
+ /**
204
+ * Carriers a send can be pinned to. Carrier-level, not per-country: `mtn`
205
+ * covers every MTN network Retransmit integrates with. `sns` (AWS End User
206
+ * Messaging) is first because it is the one that reaches every destination.
207
+ */
208
+ declare const SMS_PROVIDERS: readonly ["sns", "mtn", "orange"];
209
+ type SmsProvider = (typeof SMS_PROVIDERS)[number];
139
210
  interface SendSmsOptions {
140
211
  /**
141
212
  * Sender id shown on the recipient's device (up to 11 characters:
142
- * letters, digits, space, - and _). Defaults to the sender configured for
143
- * the routed provider.
213
+ * letters, digits, space, - and _).
214
+ *
215
+ * Approval is per country, so this must be a name your organization has had
216
+ * approved for the destination; request it in the dashboard under
217
+ * SMS > Sender IDs, which asks for the name and the countries, and for
218
+ * registration details only where the carriers require a filing. Sending
219
+ * with an unapproved name fails with `sender_not_allowed`. Leave it out to
220
+ * use your approved sender for that country, or the provider default when
221
+ * you have none.
144
222
  */
145
223
  from?: string;
146
224
  /** One recipient or up to 50, in international format (`+237670000000`). All must share one country. */
147
225
  to: string | string[];
148
226
  text: string;
227
+ /**
228
+ * Pins the send to one carrier. Leave it out to let Retransmit route by
229
+ * destination country and price. `sns` covers every destination; `mtn` and
230
+ * `orange` only the countries Retransmit has that carrier in. A pinned send
231
+ * never falls back to another carrier: it fails with `no_route` when that
232
+ * one cannot deliver.
233
+ */
234
+ provider?: SmsProvider;
149
235
  }
150
236
  interface SendSmsResponse {
151
237
  id: string;
@@ -167,7 +253,13 @@ interface GetSmsResponse {
167
253
  text: string;
168
254
  country: string | null;
169
255
  segments: number;
170
- /** Upstream provider that carried the message, e.g. `mtn_cm`. */
256
+ /** Carrier the send was pinned to, null when routing chose freely. */
257
+ requested_provider: SmsProvider | null;
258
+ /**
259
+ * Country operation that carried the message, e.g. `mtn_cm`, `orange_cm`
260
+ * or `aws_sns`. More specific than `requested_provider`, and null until
261
+ * the message is routed.
262
+ */
171
263
  provider: string | null;
172
264
  status: SmsStatus;
173
265
  error: string | null;
@@ -269,8 +361,12 @@ interface GetBatchResponse {
269
361
  declare class Batch {
270
362
  private readonly client;
271
363
  constructor(client: Retransmit);
272
- /** Queues up to 10,000 emails in one request. Track progress with `get(id)`. */
273
- send(emails: SendEmailOptions[]): Promise<Result<SendBatchResponse>>;
364
+ /**
365
+ * Queues up to 10,000 emails in one request. Track progress with `get(id)`.
366
+ * Pass `{ idempotencyKey }` covering the whole batch to make the call safe
367
+ * to retry.
368
+ */
369
+ send(emails: SendEmailOptions[], requestOptions?: RequestOptions): Promise<Result<SendBatchResponse>>;
274
370
  /** Batch progress: how many emails are in each status so far. */
275
371
  get(id: string): Promise<Result<GetBatchResponse>>;
276
372
  }
@@ -278,8 +374,11 @@ declare class Batch {
278
374
  declare class Emails {
279
375
  private readonly client;
280
376
  constructor(client: Retransmit);
281
- /** Queues a single email. Poll `get(id)` or subscribe to webhooks for the outcome. */
282
- send(options: SendEmailOptions): Promise<Result<SendEmailResponse>>;
377
+ /**
378
+ * Queues a single email. Poll `get(id)` or subscribe to webhooks for the
379
+ * outcome. Pass `{ idempotencyKey }` to make the call safe to retry.
380
+ */
381
+ send(options: SendEmailOptions, requestOptions?: RequestOptions): Promise<Result<SendEmailResponse>>;
283
382
  /** Retrieves an email with its current status and event history. */
284
383
  get(id: string): Promise<Result<GetEmailResponse>>;
285
384
  /**
@@ -289,6 +388,14 @@ declare class Emails {
289
388
  list(options?: ListEmailsOptions): Promise<Result<ListEmailsResponse>>;
290
389
  /** Every distinct tag on your emails, with a count of emails carrying it. */
291
390
  tags(): Promise<Result<ListEmailTagsResponse>>;
391
+ /**
392
+ * The attachments of an email, each with a signed `download_url` valid for
393
+ * one hour. Files are kept for 30 days after the send; after that the link
394
+ * is `null` and only the metadata remains.
395
+ */
396
+ attachments(emailId: string): Promise<Result<ListEmailAttachmentsResponse>>;
397
+ /** One attachment of an email with a signed download link. */
398
+ getAttachment(emailId: string, attachmentId: string): Promise<Result<EmailAttachmentWithDownload>>;
292
399
  }
293
400
 
294
401
  declare class Sms {
@@ -322,7 +429,7 @@ declare class Retransmit {
322
429
  private readonly baseUrl;
323
430
  constructor(apiKey?: string, options?: RetransmitOptions);
324
431
  /** Internal transport shared by the resource classes. API failures are returned, never thrown. */
325
- request<T>(method: "GET" | "POST", path: string, body?: unknown, query?: Record<string, string | number | string[] | undefined>): Promise<Result<T>>;
432
+ request<T>(method: "GET" | "POST", path: string, body?: unknown, query?: Record<string, string | number | string[] | undefined>, options?: RequestOptions): Promise<Result<T>>;
326
433
  }
327
434
 
328
- export { Batch, EMAIL_STATUSES, type EmailEvent, type EmailStatus, type EmailSummary, type EmailTag, type EmailTagCount, Emails, type GetBatchResponse, type GetEmailResponse, type GetSmsResponse, type GetWhatsappResponse, type ListEmailTagsResponse, type ListEmailsOptions, type ListEmailsResponse, type Result, Retransmit, type RetransmitError, type RetransmitOptions, SMS_STATUSES, type SendBatchResponse, type SendEmailOptions, type SendEmailResponse, type SendSmsOptions, type SendSmsResponse, type SendWhatsappOptions, type SendWhatsappResponse, Sms, type SmsEvent, type SmsStatus, WHATSAPP_STATUSES, Whatsapp, type WhatsappDocument, type WhatsappEvent, type WhatsappMedia, type WhatsappMessageType, type WhatsappStatus, type WhatsappTemplate };
435
+ export { type Attachment, Batch, EMAIL_STATUSES, type EmailAttachment, type EmailAttachmentWithDownload, type EmailEvent, type EmailStatus, type EmailSummary, type EmailTag, type EmailTagCount, Emails, type GetBatchResponse, type GetEmailResponse, type GetSmsResponse, type GetWhatsappResponse, type ListEmailAttachmentsResponse, type ListEmailTagsResponse, type ListEmailsOptions, type ListEmailsResponse, type RequestOptions, type Result, Retransmit, type RetransmitError, type RetransmitOptions, SMS_PROVIDERS, SMS_STATUSES, type SendBatchResponse, type SendEmailOptions, type SendEmailResponse, type SendSmsOptions, type SendSmsResponse, type SendWhatsappOptions, type SendWhatsappResponse, Sms, type SmsEvent, type SmsProvider, type SmsStatus, WHATSAPP_STATUSES, Whatsapp, type WhatsappDocument, type WhatsappEvent, type WhatsappMedia, type WhatsappMessageType, type WhatsappStatus, type WhatsappTemplate };
package/dist/index.d.ts CHANGED
@@ -20,6 +20,18 @@ interface RetransmitOptions {
20
20
  /** Override the API origin. Defaults to `https://api.retransmit.dev` (or `RETRANSMIT_BASE_URL`). */
21
21
  baseUrl?: string;
22
22
  }
23
+ /** Per-request options, passed as the second argument of `emails.send` and `batch.send`. */
24
+ interface RequestOptions {
25
+ /**
26
+ * Makes the request safe to retry. Sent as the `Idempotency-Key` header.
27
+ * For 24 hours, a retry with the same key and payload returns the original
28
+ * response instead of queuing the email again. 1 to 256 characters; a UUID
29
+ * or `<event>/<entity-id>` such as `welcome-user/123` works well. A retry
30
+ * with a different payload fails with `invalid_idempotent_request`, and one
31
+ * that overlaps the first request fails with `concurrent_idempotent_requests`.
32
+ */
33
+ idempotencyKey?: string;
34
+ }
23
35
  interface SendEmailOptions {
24
36
  /** Sender, as `address@domain.com` or `Name <address@domain.com>`. The domain must be verified on your account. */
25
37
  from: string;
@@ -58,8 +70,58 @@ interface SendEmailOptions {
58
70
  * List-Unsubscribe headers take precedence over yours.
59
71
  */
60
72
  headers?: EmailHeaders;
73
+ /**
74
+ * Files to attach, up to 20 per email and 30 MB in total. Each needs a
75
+ * `filename` and either `content` (the bytes) or `path` (a public URL
76
+ * fetched when you call `send`). Add `contentId` to embed an image inline.
77
+ * Not accepted by `batch.send`.
78
+ */
79
+ attachments?: Attachment[];
61
80
  }
62
81
  type EmailHeaders = Record<string, string>;
82
+ /** A file to attach. Provide exactly one of `content` and `path`. */
83
+ interface Attachment {
84
+ /**
85
+ * Name the recipient sees, up to 255 characters, no path separators. Its
86
+ * extension sets the content type when `contentType` is omitted.
87
+ */
88
+ filename: string;
89
+ /** The file: a `Buffer`, a `Uint8Array`, or an already base64 encoded string. */
90
+ content?: string | Uint8Array;
91
+ /**
92
+ * Public http(s) URL the file is fetched from while the request runs, with
93
+ * a 15 second budget. Private and internal hosts are refused.
94
+ */
95
+ path?: string;
96
+ /** MIME type such as `application/pdf`. Inferred from the filename when omitted. */
97
+ contentType?: string;
98
+ /**
99
+ * Embeds the file inline and sets its Content-ID. Reference it in `html`
100
+ * as `<img src="cid:the-id">`. Letters, digits, `.`, `_`, `@` and `-`, up
101
+ * to 128 characters, unique per email.
102
+ */
103
+ contentId?: string;
104
+ }
105
+ /** An attachment as it was sent, without a download link. */
106
+ interface EmailAttachment {
107
+ id: string;
108
+ filename: string;
109
+ content_type: string;
110
+ /** Size in bytes of the decoded file. */
111
+ size: number;
112
+ content_id: string | null;
113
+ /** True for images embedded via `contentId`. */
114
+ inline: boolean;
115
+ }
116
+ interface EmailAttachmentWithDownload extends EmailAttachment {
117
+ /** When the stored file is deleted, 30 days after the send. */
118
+ expires_at: string;
119
+ /** Signed link to download the file, valid for one hour. `null` once the file has expired. */
120
+ download_url: string | null;
121
+ }
122
+ interface ListEmailAttachmentsResponse {
123
+ attachments: EmailAttachmentWithDownload[];
124
+ }
63
125
  interface EmailTag {
64
126
  name: string;
65
127
  value: string;
@@ -91,6 +153,8 @@ interface GetEmailResponse {
91
153
  created_at: string;
92
154
  last_event_at: string | null;
93
155
  events: EmailEvent[];
156
+ /** Attachments given at send time, without download links. See `emails.attachments`. */
157
+ attachments: EmailAttachment[];
94
158
  }
95
159
  interface ListEmailsOptions {
96
160
  /**
@@ -136,16 +200,38 @@ interface ListEmailTagsResponse {
136
200
  }
137
201
  declare const SMS_STATUSES: readonly ["queued", "sent", "delivered", "undelivered", "expired", "rejected", "failed"];
138
202
  type SmsStatus = (typeof SMS_STATUSES)[number];
203
+ /**
204
+ * Carriers a send can be pinned to. Carrier-level, not per-country: `mtn`
205
+ * covers every MTN network Retransmit integrates with. `sns` (AWS End User
206
+ * Messaging) is first because it is the one that reaches every destination.
207
+ */
208
+ declare const SMS_PROVIDERS: readonly ["sns", "mtn", "orange"];
209
+ type SmsProvider = (typeof SMS_PROVIDERS)[number];
139
210
  interface SendSmsOptions {
140
211
  /**
141
212
  * Sender id shown on the recipient's device (up to 11 characters:
142
- * letters, digits, space, - and _). Defaults to the sender configured for
143
- * the routed provider.
213
+ * letters, digits, space, - and _).
214
+ *
215
+ * Approval is per country, so this must be a name your organization has had
216
+ * approved for the destination; request it in the dashboard under
217
+ * SMS > Sender IDs, which asks for the name and the countries, and for
218
+ * registration details only where the carriers require a filing. Sending
219
+ * with an unapproved name fails with `sender_not_allowed`. Leave it out to
220
+ * use your approved sender for that country, or the provider default when
221
+ * you have none.
144
222
  */
145
223
  from?: string;
146
224
  /** One recipient or up to 50, in international format (`+237670000000`). All must share one country. */
147
225
  to: string | string[];
148
226
  text: string;
227
+ /**
228
+ * Pins the send to one carrier. Leave it out to let Retransmit route by
229
+ * destination country and price. `sns` covers every destination; `mtn` and
230
+ * `orange` only the countries Retransmit has that carrier in. A pinned send
231
+ * never falls back to another carrier: it fails with `no_route` when that
232
+ * one cannot deliver.
233
+ */
234
+ provider?: SmsProvider;
149
235
  }
150
236
  interface SendSmsResponse {
151
237
  id: string;
@@ -167,7 +253,13 @@ interface GetSmsResponse {
167
253
  text: string;
168
254
  country: string | null;
169
255
  segments: number;
170
- /** Upstream provider that carried the message, e.g. `mtn_cm`. */
256
+ /** Carrier the send was pinned to, null when routing chose freely. */
257
+ requested_provider: SmsProvider | null;
258
+ /**
259
+ * Country operation that carried the message, e.g. `mtn_cm`, `orange_cm`
260
+ * or `aws_sns`. More specific than `requested_provider`, and null until
261
+ * the message is routed.
262
+ */
171
263
  provider: string | null;
172
264
  status: SmsStatus;
173
265
  error: string | null;
@@ -269,8 +361,12 @@ interface GetBatchResponse {
269
361
  declare class Batch {
270
362
  private readonly client;
271
363
  constructor(client: Retransmit);
272
- /** Queues up to 10,000 emails in one request. Track progress with `get(id)`. */
273
- send(emails: SendEmailOptions[]): Promise<Result<SendBatchResponse>>;
364
+ /**
365
+ * Queues up to 10,000 emails in one request. Track progress with `get(id)`.
366
+ * Pass `{ idempotencyKey }` covering the whole batch to make the call safe
367
+ * to retry.
368
+ */
369
+ send(emails: SendEmailOptions[], requestOptions?: RequestOptions): Promise<Result<SendBatchResponse>>;
274
370
  /** Batch progress: how many emails are in each status so far. */
275
371
  get(id: string): Promise<Result<GetBatchResponse>>;
276
372
  }
@@ -278,8 +374,11 @@ declare class Batch {
278
374
  declare class Emails {
279
375
  private readonly client;
280
376
  constructor(client: Retransmit);
281
- /** Queues a single email. Poll `get(id)` or subscribe to webhooks for the outcome. */
282
- send(options: SendEmailOptions): Promise<Result<SendEmailResponse>>;
377
+ /**
378
+ * Queues a single email. Poll `get(id)` or subscribe to webhooks for the
379
+ * outcome. Pass `{ idempotencyKey }` to make the call safe to retry.
380
+ */
381
+ send(options: SendEmailOptions, requestOptions?: RequestOptions): Promise<Result<SendEmailResponse>>;
283
382
  /** Retrieves an email with its current status and event history. */
284
383
  get(id: string): Promise<Result<GetEmailResponse>>;
285
384
  /**
@@ -289,6 +388,14 @@ declare class Emails {
289
388
  list(options?: ListEmailsOptions): Promise<Result<ListEmailsResponse>>;
290
389
  /** Every distinct tag on your emails, with a count of emails carrying it. */
291
390
  tags(): Promise<Result<ListEmailTagsResponse>>;
391
+ /**
392
+ * The attachments of an email, each with a signed `download_url` valid for
393
+ * one hour. Files are kept for 30 days after the send; after that the link
394
+ * is `null` and only the metadata remains.
395
+ */
396
+ attachments(emailId: string): Promise<Result<ListEmailAttachmentsResponse>>;
397
+ /** One attachment of an email with a signed download link. */
398
+ getAttachment(emailId: string, attachmentId: string): Promise<Result<EmailAttachmentWithDownload>>;
292
399
  }
293
400
 
294
401
  declare class Sms {
@@ -322,7 +429,7 @@ declare class Retransmit {
322
429
  private readonly baseUrl;
323
430
  constructor(apiKey?: string, options?: RetransmitOptions);
324
431
  /** Internal transport shared by the resource classes. API failures are returned, never thrown. */
325
- request<T>(method: "GET" | "POST", path: string, body?: unknown, query?: Record<string, string | number | string[] | undefined>): Promise<Result<T>>;
432
+ request<T>(method: "GET" | "POST", path: string, body?: unknown, query?: Record<string, string | number | string[] | undefined>, options?: RequestOptions): Promise<Result<T>>;
326
433
  }
327
434
 
328
- export { Batch, EMAIL_STATUSES, type EmailEvent, type EmailStatus, type EmailSummary, type EmailTag, type EmailTagCount, Emails, type GetBatchResponse, type GetEmailResponse, type GetSmsResponse, type GetWhatsappResponse, type ListEmailTagsResponse, type ListEmailsOptions, type ListEmailsResponse, type Result, Retransmit, type RetransmitError, type RetransmitOptions, SMS_STATUSES, type SendBatchResponse, type SendEmailOptions, type SendEmailResponse, type SendSmsOptions, type SendSmsResponse, type SendWhatsappOptions, type SendWhatsappResponse, Sms, type SmsEvent, type SmsStatus, WHATSAPP_STATUSES, Whatsapp, type WhatsappDocument, type WhatsappEvent, type WhatsappMedia, type WhatsappMessageType, type WhatsappStatus, type WhatsappTemplate };
435
+ export { type Attachment, Batch, EMAIL_STATUSES, type EmailAttachment, type EmailAttachmentWithDownload, type EmailEvent, type EmailStatus, type EmailSummary, type EmailTag, type EmailTagCount, Emails, type GetBatchResponse, type GetEmailResponse, type GetSmsResponse, type GetWhatsappResponse, type ListEmailAttachmentsResponse, type ListEmailTagsResponse, type ListEmailsOptions, type ListEmailsResponse, type RequestOptions, type Result, Retransmit, type RetransmitError, type RetransmitOptions, SMS_PROVIDERS, SMS_STATUSES, type SendBatchResponse, type SendEmailOptions, type SendEmailResponse, type SendSmsOptions, type SendSmsResponse, type SendWhatsappOptions, type SendWhatsappResponse, Sms, type SmsEvent, type SmsProvider, type SmsStatus, WHATSAPP_STATUSES, Whatsapp, type WhatsappDocument, type WhatsappEvent, type WhatsappMedia, type WhatsappMessageType, type WhatsappStatus, type WhatsappTemplate };
package/dist/index.js CHANGED
@@ -1,4 +1,22 @@
1
1
  // src/emails.ts
2
+ function toBase64(bytes) {
3
+ if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
4
+ let binary = "";
5
+ const CHUNK = 32768;
6
+ for (let i = 0; i < bytes.length; i += CHUNK) {
7
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
8
+ }
9
+ return btoa(binary);
10
+ }
11
+ function toWireAttachment(attachment) {
12
+ return {
13
+ filename: attachment.filename,
14
+ content: attachment.content === void 0 || typeof attachment.content === "string" ? attachment.content : toBase64(attachment.content),
15
+ path: attachment.path,
16
+ content_type: attachment.contentType,
17
+ content_id: attachment.contentId
18
+ };
19
+ }
2
20
  function toWirePayload(options) {
3
21
  return {
4
22
  from: options.from,
@@ -11,7 +29,8 @@ function toWirePayload(options) {
11
29
  text: options.text,
12
30
  marketing: options.marketing,
13
31
  tags: options.tags,
14
- headers: options.headers
32
+ headers: options.headers,
33
+ attachments: options.attachments?.map(toWireAttachment)
15
34
  };
16
35
  }
17
36
  var Emails = class {
@@ -19,9 +38,18 @@ var Emails = class {
19
38
  this.client = client;
20
39
  }
21
40
  client;
22
- /** Queues a single email. Poll `get(id)` or subscribe to webhooks for the outcome. */
23
- send(options) {
24
- return this.client.request("POST", "/v1/emails", toWirePayload(options));
41
+ /**
42
+ * Queues a single email. Poll `get(id)` or subscribe to webhooks for the
43
+ * outcome. Pass `{ idempotencyKey }` to make the call safe to retry.
44
+ */
45
+ send(options, requestOptions) {
46
+ return this.client.request(
47
+ "POST",
48
+ "/v1/emails",
49
+ toWirePayload(options),
50
+ void 0,
51
+ requestOptions
52
+ );
25
53
  }
26
54
  /** Retrieves an email with its current status and event history. */
27
55
  get(id) {
@@ -44,6 +72,21 @@ var Emails = class {
44
72
  tags() {
45
73
  return this.client.request("GET", "/v1/emails/tags");
46
74
  }
75
+ /**
76
+ * The attachments of an email, each with a signed `download_url` valid for
77
+ * one hour. Files are kept for 30 days after the send; after that the link
78
+ * is `null` and only the metadata remains.
79
+ */
80
+ attachments(emailId) {
81
+ return this.client.request("GET", `/v1/emails/${encodeURIComponent(emailId)}/attachments`);
82
+ }
83
+ /** One attachment of an email with a signed download link. */
84
+ getAttachment(emailId, attachmentId) {
85
+ return this.client.request(
86
+ "GET",
87
+ `/v1/emails/${encodeURIComponent(emailId)}/attachments/${encodeURIComponent(attachmentId)}`
88
+ );
89
+ }
47
90
  };
48
91
 
49
92
  // src/batch.ts
@@ -52,11 +95,19 @@ var Batch = class {
52
95
  this.client = client;
53
96
  }
54
97
  client;
55
- /** Queues up to 10,000 emails in one request. Track progress with `get(id)`. */
56
- send(emails) {
57
- return this.client.request("POST", "/v1/emails/batch", {
58
- emails: emails.map(toWirePayload)
59
- });
98
+ /**
99
+ * Queues up to 10,000 emails in one request. Track progress with `get(id)`.
100
+ * Pass `{ idempotencyKey }` covering the whole batch to make the call safe
101
+ * to retry.
102
+ */
103
+ send(emails, requestOptions) {
104
+ return this.client.request(
105
+ "POST",
106
+ "/v1/emails/batch",
107
+ { emails: emails.map(toWirePayload) },
108
+ void 0,
109
+ requestOptions
110
+ );
60
111
  }
61
112
  /** Batch progress: how many emails are in each status so far. */
62
113
  get(id) {
@@ -75,7 +126,8 @@ var Sms = class {
75
126
  return this.client.request("POST", "/v1/sms", {
76
127
  from: options.from,
77
128
  to: options.to,
78
- text: options.text
129
+ text: options.text,
130
+ provider: options.provider
79
131
  });
80
132
  }
81
133
  /** Retrieves an SMS with its current status and event history. */
@@ -115,7 +167,7 @@ var Whatsapp = class {
115
167
 
116
168
  // src/retransmit.ts
117
169
  var DEFAULT_BASE_URL = "https://api.retransmit.dev";
118
- var USER_AGENT = "retransmit.dev-node/0.4.0";
170
+ var USER_AGENT = "retransmit.dev-node/0.6.0";
119
171
  function readEnv(name) {
120
172
  return typeof process !== "undefined" ? process.env?.[name] : void 0;
121
173
  }
@@ -140,7 +192,7 @@ var Retransmit = class {
140
192
  );
141
193
  }
142
194
  /** Internal transport shared by the resource classes. API failures are returned, never thrown. */
143
- async request(method, path, body, query) {
195
+ async request(method, path, body, query, options = {}) {
144
196
  const params = new URLSearchParams();
145
197
  for (const [key, value] of Object.entries(query ?? {})) {
146
198
  if (value === void 0) continue;
@@ -148,15 +200,19 @@ var Retransmit = class {
148
200
  }
149
201
  const encoded = params.toString();
150
202
  const search = encoded ? `?${encoded}` : "";
203
+ const headers = {
204
+ Authorization: `Bearer ${this.apiKey}`,
205
+ "Content-Type": "application/json",
206
+ "User-Agent": USER_AGENT
207
+ };
208
+ if (options.idempotencyKey !== void 0) {
209
+ headers["Idempotency-Key"] = options.idempotencyKey;
210
+ }
151
211
  let response;
152
212
  try {
153
213
  response = await fetch(`${this.baseUrl}${path}${search}`, {
154
214
  method,
155
- headers: {
156
- Authorization: `Bearer ${this.apiKey}`,
157
- "Content-Type": "application/json",
158
- "User-Agent": USER_AGENT
159
- },
215
+ headers,
160
216
  body: body === void 0 ? void 0 : JSON.stringify(body)
161
217
  });
162
218
  } catch (cause) {
@@ -212,12 +268,14 @@ var SMS_STATUSES = [
212
268
  "rejected",
213
269
  "failed"
214
270
  ];
271
+ var SMS_PROVIDERS = ["sns", "mtn", "orange"];
215
272
  var WHATSAPP_STATUSES = ["queued", "sent", "delivered", "read", "failed"];
216
273
  export {
217
274
  Batch,
218
275
  EMAIL_STATUSES,
219
276
  Emails,
220
277
  Retransmit,
278
+ SMS_PROVIDERS,
221
279
  SMS_STATUSES,
222
280
  Sms,
223
281
  WHATSAPP_STATUSES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retransmit.dev",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Node.js SDK for the Retransmit email, SMS and WhatsApp APIs",
5
5
  "keywords": [
6
6
  "email",