Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 41 additions & 39 deletions bun.lock

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
],
"scripts": {
"build": "tsdown",
"test": "bun test"
"test": "bun test",
"test:e2e": "bun test ./src/e2e.ts"
},
"devDependencies": {
"@changesets/cli": "^2.29.8",
"@types/bun": "latest",
"tsdown": "^0.20.0-beta.1",
"vitest": "^4.0.17"
"@types/bun": "1.3.9",
"tsdown": "^0.20.3",
"vitest": "^4.0.18"
},
"peerDependencies": {
"typescript": "^5"
Expand Down
199 changes: 50 additions & 149 deletions src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,206 +1,107 @@
import { describe, it, expect, beforeEach, mock } from "bun:test";
import { Lettr } from "./client";
import type { SendEmailRequest } from "./types";

const mockFetch = mock();

globalThis.fetch = mockFetch as unknown as typeof fetch;

const validRequest: SendEmailRequest = {
from: "sender@example.com",
to: ["recipient@example.com"],
subject: "Test Email",
html: "<p>Hello</p>",
};

describe("Lettr", () => {
beforeEach(() => {
mockFetch.mockReset();
});

describe("emails.send", () => {
it("returns data on successful response", async () => {
it("mounts all resource classes", () => {
const client = new Lettr("test-api-key");
expect(client.emails).toBeDefined();
expect(client.domains).toBeDefined();
expect(client.templates).toBeDefined();
expect(client.webhooks).toBeDefined();
expect(client.projects).toBeDefined();
});

describe("health", () => {
it("returns health status", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
message: "Email queued for delivery.",
message: "Health check passed.",
data: {
request_id: "abc123",
accepted: 1,
rejected: 0,
status: "ok",
timestamp: "2024-01-15T10:30:00.000Z",
},
}),
});

const client = new Lettr("test-api-key");
const result = await client.emails.send(validRequest);
const result = await client.health();

expect(result.data).toEqual({
request_id: "abc123",
accepted: 1,
rejected: 0,
message: "Email queued for delivery.",
status: "ok",
timestamp: "2024-01-15T10:30:00.000Z",
});
expect(result.error).toBeNull();
});

it("returns validation error on 422 response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 422,
json: async () => ({
message: "Validation failed.",
errors: {
from: ["The sender email address is required."],
to: ["At least one recipient is required."],
},
}),
});

const client = new Lettr("test-api-key");
const result = await client.emails.send(validRequest);

expect(result.data).toBeNull();
expect(result.error).toEqual({
type: "validation",
message: "Validation failed.",
errors: {
from: ["The sender email address is required."],
to: ["At least one recipient is required."],
},
});
});

it("returns api error on 400 response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({
message: "Failed to send email.",
errors: ["Invalid sender domain"],
}),
});

const client = new Lettr("test-api-key");
const result = await client.emails.send(validRequest);

expect(result.data).toBeNull();
expect(result.error).toEqual({
type: "api",
message: "Failed to send email.",
errors: ["Invalid sender domain"],
});
const calledUrl = mockFetch.mock.calls[0]![0] as string;
expect(calledUrl).toBe("https://app.lettr.com/api/health");
});

it("returns api error on 502 response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 502,
json: async () => ({
message: "Email transmission failed.",
errors: ["Upstream provider error"],
}),
});

const client = new Lettr("test-api-key");
const result = await client.emails.send(validRequest);

expect(result.data).toBeNull();
expect(result.error).toEqual({
type: "api",
message: "Email transmission failed.",
errors: ["Upstream provider error"],
});
});

it("returns network error when fetch fails", async () => {
it("returns network error on failure", async () => {
mockFetch.mockRejectedValueOnce(new Error("Network error"));

const client = new Lettr("test-api-key");
const result = await client.emails.send(validRequest);
const result = await client.health();

expect(result.data).toBeNull();
expect(result.error).toEqual({
type: "network",
message: "Failed to connect to Lettr API",
});
});
});

it("sends correct headers and body", async () => {
describe("authCheck", () => {
it("returns team info on valid key", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
message: "Email queued for delivery.",
data: { request_id: "abc123", accepted: 1, rejected: 0 },
message: "API key is valid.",
data: {
team_id: 123,
timestamp: "2024-01-15T10:30:00.000Z",
},
}),
});

const client = new Lettr("my-secret-key");
await client.emails.send(validRequest);
const client = new Lettr("test-api-key");
const result = await client.authCheck();

expect(mockFetch).toHaveBeenCalledWith(
"https://app.lettr.com/api/emails",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer my-secret-key",
},
body: JSON.stringify(validRequest),
}
);
expect(result.data).toEqual({
team_id: 123,
timestamp: "2024-01-15T10:30:00.000Z",
});
expect(result.error).toBeNull();

const calledUrl = mockFetch.mock.calls[0]![0] as string;
expect(calledUrl).toBe("https://app.lettr.com/api/auth/check");
});

it("handles request with all optional fields", async () => {
it("returns api error on 401", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
message: "Email queued for delivery.",
data: { request_id: "xyz789", accepted: 3, rejected: 1 },
}),
ok: false,
status: 401,
json: async () => ({ message: "API key is required." }),
});

const fullRequest: SendEmailRequest = {
from: "sender@example.com",
from_name: "Sender Name",
to: ["user1@example.com", "user2@example.com"],
cc: ["cc@example.com"],
bcc: ["bcc@example.com"],
subject: "Full Test",
html: "<h1>Hello</h1>",
text: "Hello",
reply_to: "reply@example.com",
reply_to_name: "Reply Name",
campaign_id: "test-campaign",
metadata: { user_id: "123" },
substitution_data: { name: "John" },
options: {
click_tracking: true,
open_tracking: false,
transactional: true,
},
attachments: [
{
name: "test.pdf",
type: "application/pdf",
data: "base64data",
},
],
};

const client = new Lettr("test-api-key");
const result = await client.emails.send(fullRequest);
const client = new Lettr("bad-key");
const result = await client.authCheck();

expect(result.data).toEqual({
request_id: "xyz789",
accepted: 3,
rejected: 1,
message: "Email queued for delivery.",
expect(result.data).toBeNull();
expect(result.error).toEqual({
type: "api",
message: "API key is required.",
error_code: "unauthorized",
});
expect(result.error).toBeNull();
});
});
});
27 changes: 26 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
import { HttpClient } from "./http";
import { Emails } from "./emails";
import { Domains } from "./domains";
import { Templates } from "./templates";
import { Webhooks } from "./webhooks";
import { Projects } from "./projects";
import type { HealthResponse, AuthCheckResponse, Result } from "./types";

const BASE_URL = "https://app.lettr.com/api";

export class Lettr {
public readonly emails: Emails;
public readonly domains: Domains;
public readonly templates: Templates;
public readonly webhooks: Webhooks;
public readonly projects: Projects;

private http: HttpClient;

constructor(apiKey: string) {
this.emails = new Emails(BASE_URL, apiKey);
this.http = new HttpClient(BASE_URL, apiKey);
this.emails = new Emails(this.http);
this.domains = new Domains(this.http);
this.templates = new Templates(this.http);
this.webhooks = new Webhooks(this.http);
this.projects = new Projects(this.http);
}

async health(): Promise<Result<HealthResponse>> {
return this.http.request<HealthResponse>("GET", "/health");
}

async authCheck(): Promise<Result<AuthCheckResponse>> {
return this.http.request<AuthCheckResponse>("GET", "/auth/check");
}
}
Loading