idempotency (#282)

This commit is contained in:
KM Koushik
2025-11-17 11:42:09 +11:00
committed by GitHub
parent eacf231173
commit cb489654b5
14 changed files with 859 additions and 1118 deletions
+31
View File
@@ -48,6 +48,37 @@ usesend.emails.send({
html: "<p>useSend is the best open source product to send emails</p>",
text: "useSend is the best open source product to send emails",
});
// Safely retry sends with an idempotency key
await usesend.emails.send(
{
to: "hello@acme.com",
from: "hello@company.com",
subject: "useSend email",
html: "<p>useSend is the best open source product to send emails</p>",
},
{ idempotencyKey: "signup-123" },
);
// Works for bulk sends too
await usesend.emails.batch(
[
{
to: "a@example.com",
from: "hello@company.com",
subject: "Welcome",
html: "<p>Hello A</p>",
},
{
to: "b@example.com",
from: "hello@company.com",
subject: "Welcome",
html: "<p>Hello B</p>",
},
],
{ idempotencyKey: "bulk-welcome-1" },
);
// Reusing the same key with a different payload returns HTTP 409.
```
## Campaigns
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "usesend-js",
"version": "1.5.6",
"version": "1.5.7",
"description": "",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -28,4 +28,4 @@
"@react-email/render": "^1.0.6",
"react": "^19.1.0"
}
}
}
+22 -6
View File
@@ -67,16 +67,23 @@ type BatchEmailResponse = {
error: ErrorResponse | null;
};
type EmailRequestOptions = {
idempotencyKey?: string;
};
export class Emails {
constructor(private readonly usesend: UseSend) {
this.usesend = usesend;
}
async send(payload: SendEmailPayload) {
return this.create(payload);
async send(payload: SendEmailPayload, options?: EmailRequestOptions) {
return this.create(payload, options);
}
async create(payload: SendEmailPayload): Promise<CreateEmailResponse> {
async create(
payload: SendEmailPayload,
options?: EmailRequestOptions,
): Promise<CreateEmailResponse> {
if (payload.react) {
payload.html = await render(payload.react as React.ReactElement);
delete payload.react;
@@ -84,7 +91,10 @@ export class Emails {
const data = await this.usesend.post<CreateEmailResponseSuccess>(
"/emails",
payload
payload,
options?.idempotencyKey
? { headers: { "Idempotency-Key": options.idempotencyKey } }
: undefined,
);
return data;
@@ -96,11 +106,17 @@ export class Emails {
* @param payload An array of email payloads. Max 100 emails.
* @returns A promise that resolves to the list of created email IDs or an error.
*/
async batch(payload: BatchEmailPayload): Promise<BatchEmailResponse> {
async batch(
payload: BatchEmailPayload,
options?: EmailRequestOptions,
): Promise<BatchEmailResponse> {
// Note: React element rendering is not supported in batch mode.
const response = await this.usesend.post<BatchEmailResponseSuccess>(
"/emails/batch",
payload
payload,
options?.idempotencyKey
? { headers: { "Idempotency-Key": options.idempotencyKey } }
: undefined,
);
return {
data: response.data ? response.data.data : null,
+61 -20
View File
@@ -12,8 +12,12 @@ function isUseSendErrorResponse(error: { error: ErrorResponse }) {
return error.error.code !== undefined;
}
type RequestOptions = {
headers?: HeadersInit;
};
export class UseSend {
private readonly headers: Headers;
private readonly baseHeaders: Headers;
// readonly domains = new Domains(this);
readonly emails = new Emails(this);
@@ -42,17 +46,36 @@ export class UseSend {
this.url = `${url}/api/v1`;
}
this.headers = new Headers({
this.baseHeaders = new Headers({
Authorization: `Bearer ${this.key}`,
"Content-Type": "application/json",
});
}
private mergeHeaders(extra?: HeadersInit) {
const headers = new Headers(this.baseHeaders);
if (!extra) {
return headers;
}
const additional = new Headers(extra);
additional.forEach((value, key) => {
headers.set(key, value);
});
return headers;
}
async fetchRequest<T>(
path: string,
options = {},
options: RequestInit = {},
): Promise<{ data: T | null; error: ErrorResponse | null }> {
const response = await fetch(`${this.url}${path}`, options);
const requestOptions: RequestInit = {
...options,
headers: this.mergeHeaders(options.headers),
};
const response = await fetch(`${this.url}${path}`, requestOptions);
const defaultError = {
code: "INTERNAL_SERVER_ERROR",
message: response.statusText,
@@ -82,52 +105,70 @@ export class UseSend {
return { data, error: null };
}
async post<T>(path: string, body: unknown) {
const requestOptions = {
async post<T>(path: string, body: unknown, options?: RequestOptions) {
const requestOptions: RequestInit = {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
};
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions);
}
async get<T>(path: string) {
const requestOptions = {
async get<T>(path: string, options?: RequestOptions) {
const requestOptions: RequestInit = {
method: "GET",
headers: this.headers,
};
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions);
}
async put<T>(path: string, body: any) {
const requestOptions = {
async put<T>(path: string, body: any, options?: RequestOptions) {
const requestOptions: RequestInit = {
method: "PUT",
headers: this.headers,
body: JSON.stringify(body),
};
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions);
}
async patch<T>(path: string, body: any) {
const requestOptions = {
async patch<T>(path: string, body: any, options?: RequestOptions) {
const requestOptions: RequestInit = {
method: "PATCH",
headers: this.headers,
body: JSON.stringify(body),
};
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions);
}
async delete<T>(path: string, body?: unknown) {
const requestOptions = {
async delete<T>(path: string, body?: unknown, options?: RequestOptions) {
const requestOptions: RequestInit = {
method: "DELETE",
headers: this.headers,
body: JSON.stringify(body),
};
if (body !== undefined) {
requestOptions.body = JSON.stringify(body);
}
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions);
}
}