@kodar/send 0.1.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.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Error thrown for every non-2xx API response (and terminal network
3
+ * failures). `name` carries the API's machine-readable error name, e.g.
4
+ * "domain_not_verified", "rate_limit_exceeded", "invalid_api_key".
5
+ */
6
+ export declare class SendError extends Error {
7
+ readonly statusCode: number;
8
+ constructor(statusCode: number, name: string, message: string);
9
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Error thrown for every non-2xx API response (and terminal network
3
+ * failures). `name` carries the API's machine-readable error name, e.g.
4
+ * "domain_not_verified", "rate_limit_exceeded", "invalid_api_key".
5
+ */
6
+ export class SendError extends Error {
7
+ statusCode;
8
+ constructor(statusCode, name, message) {
9
+ super(message);
10
+ this.name = name;
11
+ this.statusCode = statusCode;
12
+ }
13
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export interface HttpClientOptions {
2
+ apiKey: string;
3
+ baseUrl: string;
4
+ fetchFn: typeof fetch;
5
+ /** Extra attempts after the first (default 2). */
6
+ maxRetries: number;
7
+ timeoutMs: number;
8
+ }
9
+ export interface RequestParams {
10
+ query?: Record<string, string | number | undefined>;
11
+ body?: unknown;
12
+ idempotencyKey?: string;
13
+ }
14
+ export declare class HttpClient {
15
+ private readonly opts;
16
+ constructor(opts: HttpClientOptions);
17
+ request<T>(method: string, path: string, params?: RequestParams): Promise<T>;
18
+ }
package/dist/http.js ADDED
@@ -0,0 +1,68 @@
1
+ import { SendError } from "./errors.js";
2
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3
+ export class HttpClient {
4
+ opts;
5
+ constructor(opts) {
6
+ this.opts = opts;
7
+ }
8
+ async request(method, path, params = {}) {
9
+ const url = new URL(this.opts.baseUrl.replace(/\/$/, "") + path);
10
+ for (const [key, value] of Object.entries(params.query ?? {})) {
11
+ if (value !== undefined)
12
+ url.searchParams.set(key, String(value));
13
+ }
14
+ const headers = {
15
+ authorization: `Bearer ${this.opts.apiKey}`,
16
+ "user-agent": "kodar-send-node/0.1.0",
17
+ };
18
+ if (params.body !== undefined)
19
+ headers["content-type"] = "application/json";
20
+ if (params.idempotencyKey)
21
+ headers["idempotency-key"] = params.idempotencyKey;
22
+ // Retry policy: 429 is always safe (the request was rejected before any
23
+ // processing). Network errors and 5xx retry only when the request is
24
+ // idempotent — reads, or writes carrying an Idempotency-Key.
25
+ const retriableWrite = method === "GET" || params.idempotencyKey !== undefined;
26
+ for (let attempt = 0;; attempt++) {
27
+ let res;
28
+ try {
29
+ res = await this.opts.fetchFn(url, {
30
+ method,
31
+ headers,
32
+ body: params.body !== undefined ? JSON.stringify(params.body) : undefined,
33
+ signal: AbortSignal.timeout(this.opts.timeoutMs),
34
+ });
35
+ }
36
+ catch (err) {
37
+ if (attempt < this.opts.maxRetries && retriableWrite) {
38
+ await sleep(250 * 2 ** attempt);
39
+ continue;
40
+ }
41
+ throw new SendError(0, "network_error", `Request to ${url.pathname} failed: ${err instanceof Error ? err.message : String(err)}`);
42
+ }
43
+ if (res.status === 429 && attempt < this.opts.maxRetries) {
44
+ const retryAfterSec = Math.min(Number(res.headers.get("retry-after")) || 1, 30);
45
+ await res.arrayBuffer().catch(() => undefined);
46
+ await sleep(retryAfterSec * 1000);
47
+ continue;
48
+ }
49
+ if (res.status >= 500 && attempt < this.opts.maxRetries && retriableWrite) {
50
+ await res.arrayBuffer().catch(() => undefined);
51
+ await sleep(250 * 2 ** attempt);
52
+ continue;
53
+ }
54
+ const text = await res.text();
55
+ let json;
56
+ try {
57
+ json = text ? JSON.parse(text) : {};
58
+ }
59
+ catch {
60
+ throw new SendError(res.status, "invalid_response", `Non-JSON response (HTTP ${res.status})`);
61
+ }
62
+ if (!res.ok) {
63
+ throw new SendError(res.status, typeof json.name === "string" ? json.name : "api_error", typeof json.message === "string" ? json.message : `HTTP ${res.status}`);
64
+ }
65
+ return json;
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,32 @@
1
+ import { ApiKeys, Domains, Emails, Suppressions, Webhooks } from "./resources.js";
2
+ export interface SendOptions {
3
+ /** API origin; defaults to the hosted platform. */
4
+ baseUrl?: string;
5
+ /** Injectable fetch (tests, custom agents). */
6
+ fetch?: typeof fetch;
7
+ /** Extra attempts after the first, for 429/transient failures. Default 2. */
8
+ maxRetries?: number;
9
+ /** Per-request timeout. Default 30 000 ms. */
10
+ timeoutMs?: number;
11
+ }
12
+ /**
13
+ * Send by Kodar API client.
14
+ *
15
+ * const send = new Send("km_live_...");
16
+ * const { id } = await send.emails.create({
17
+ * from: "billing@acme.eu",
18
+ * to: "mari@client.ee",
19
+ * subject: "Invoice #4471",
20
+ * html: invoice,
21
+ * });
22
+ */
23
+ export declare class Send {
24
+ readonly emails: Emails;
25
+ readonly domains: Domains;
26
+ readonly suppressions: Suppressions;
27
+ readonly webhooks: Webhooks;
28
+ readonly apiKeys: ApiKeys;
29
+ constructor(apiKey: string, options?: SendOptions);
30
+ }
31
+ export { SendError } from "./errors.js";
32
+ export type * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import { HttpClient } from "./http.js";
2
+ import { ApiKeys, Domains, Emails, Suppressions, Webhooks } from "./resources.js";
3
+ /**
4
+ * Send by Kodar API client.
5
+ *
6
+ * const send = new Send("km_live_...");
7
+ * const { id } = await send.emails.create({
8
+ * from: "billing@acme.eu",
9
+ * to: "mari@client.ee",
10
+ * subject: "Invoice #4471",
11
+ * html: invoice,
12
+ * });
13
+ */
14
+ export class Send {
15
+ emails;
16
+ domains;
17
+ suppressions;
18
+ webhooks;
19
+ apiKeys;
20
+ constructor(apiKey, options = {}) {
21
+ if (!apiKey || !apiKey.startsWith("km_")) {
22
+ throw new Error('Send: expected an API key starting with "km_"');
23
+ }
24
+ const http = new HttpClient({
25
+ apiKey,
26
+ baseUrl: options.baseUrl ?? "https://send.kodar.io",
27
+ fetchFn: options.fetch ?? fetch,
28
+ maxRetries: options.maxRetries ?? 2,
29
+ timeoutMs: options.timeoutMs ?? 30_000,
30
+ });
31
+ this.emails = new Emails(http);
32
+ this.domains = new Domains(http);
33
+ this.suppressions = new Suppressions(http);
34
+ this.webhooks = new Webhooks(http);
35
+ this.apiKeys = new ApiKeys(http);
36
+ }
37
+ }
38
+ export { SendError } from "./errors.js";
@@ -0,0 +1,97 @@
1
+ import type { HttpClient } from "./http.js";
2
+ import type { ApiKeyObject, ApiKeyWithSecret, CreateEmailInput, DomainObject, DomainVerifyResult, EmailEventObject, EmailObject, List, ListEmailsParams, RequestOptions, SendEmailResponse, SuppressionObject, WebhookEventType, WebhookObject, WebhookWithSecret } from "./types.js";
3
+ export declare class Emails {
4
+ private readonly http;
5
+ constructor(http: HttpClient);
6
+ /** POST /emails — returns as soon as the email is durably queued. */
7
+ create(input: CreateEmailInput, opts?: RequestOptions): Promise<SendEmailResponse>;
8
+ /** POST /emails/batch — up to 100 emails, all-or-nothing, no attachments. */
9
+ batch(inputs: CreateEmailInput[], opts?: RequestOptions): Promise<{
10
+ data: SendEmailResponse[];
11
+ }>;
12
+ get(id: string): Promise<EmailObject>;
13
+ list(params?: ListEmailsParams): Promise<List<EmailObject>>;
14
+ events(id: string): Promise<List<EmailEventObject>>;
15
+ cancel(id: string): Promise<EmailObject>;
16
+ }
17
+ export declare class Domains {
18
+ private readonly http;
19
+ constructor(http: HttpClient);
20
+ create(input: {
21
+ name: string;
22
+ }): Promise<DomainObject>;
23
+ list(): Promise<List<DomainObject>>;
24
+ get(id: string): Promise<DomainObject>;
25
+ /** Re-check DNS + provider verification now. */
26
+ verify(id: string): Promise<DomainVerifyResult>;
27
+ delete(id: string): Promise<{
28
+ object: "domain";
29
+ id: string;
30
+ deleted: boolean;
31
+ }>;
32
+ }
33
+ export declare class Suppressions {
34
+ private readonly http;
35
+ constructor(http: HttpClient);
36
+ list(params?: {
37
+ limit?: number;
38
+ cursor?: string;
39
+ }): Promise<List<SuppressionObject>>;
40
+ create(input: {
41
+ email: string;
42
+ }): Promise<SuppressionObject>;
43
+ /** Resolves when suppressed; throws SendError(404) when not. */
44
+ check(email: string): Promise<{
45
+ object: "suppression";
46
+ email: string;
47
+ suppressed: true;
48
+ }>;
49
+ delete(email: string): Promise<{
50
+ object: "suppression";
51
+ email: string;
52
+ deleted: boolean;
53
+ }>;
54
+ }
55
+ export declare class ApiKeys {
56
+ private readonly http;
57
+ constructor(http: HttpClient);
58
+ /** The returned secret (km_live_...) is shown exactly once — store it. */
59
+ create(input: {
60
+ name: string;
61
+ }): Promise<ApiKeyWithSecret>;
62
+ list(): Promise<List<ApiKeyObject>>;
63
+ /** Soft revoke — the key stops authenticating immediately. Idempotent. */
64
+ revoke(id: string): Promise<{
65
+ object: "api_key";
66
+ id: string;
67
+ revoked: boolean;
68
+ }>;
69
+ }
70
+ export declare class Webhooks {
71
+ private readonly http;
72
+ constructor(http: HttpClient);
73
+ /** The returned secret (whsec_...) is shown exactly once — store it. */
74
+ create(input: {
75
+ url: string;
76
+ events: WebhookEventType[];
77
+ }): Promise<WebhookWithSecret>;
78
+ list(): Promise<List<WebhookObject>>;
79
+ get(id: string): Promise<WebhookObject>;
80
+ update(id: string, input: {
81
+ url?: string;
82
+ events?: WebhookEventType[];
83
+ status?: "enabled" | "disabled";
84
+ }): Promise<WebhookObject>;
85
+ delete(id: string): Promise<{
86
+ object: "webhook";
87
+ id: string;
88
+ deleted: boolean;
89
+ }>;
90
+ /** Push a synthetic event through the real queue + dispatcher. */
91
+ test(id: string): Promise<{
92
+ object: "webhook";
93
+ id: string;
94
+ test_enqueued: boolean;
95
+ delivery_id: string;
96
+ }>;
97
+ }
@@ -0,0 +1,156 @@
1
+ function toBase64(data) {
2
+ if (typeof Buffer !== "undefined")
3
+ return Buffer.from(data).toString("base64");
4
+ let binary = "";
5
+ for (const byte of data)
6
+ binary += String.fromCharCode(byte);
7
+ return btoa(binary);
8
+ }
9
+ function toIso(value) {
10
+ if (value === undefined)
11
+ return undefined;
12
+ return value instanceof Date ? value.toISOString() : value;
13
+ }
14
+ function toWirePayload(input) {
15
+ return {
16
+ from: input.from,
17
+ to: input.to,
18
+ cc: input.cc,
19
+ bcc: input.bcc,
20
+ reply_to: input.replyTo,
21
+ subject: input.subject,
22
+ html: input.html,
23
+ text: input.text,
24
+ headers: input.headers,
25
+ attachments: input.attachments?.map((a) => ({
26
+ filename: a.filename,
27
+ content: typeof a.content === "string" ? a.content : toBase64(a.content),
28
+ content_type: a.contentType,
29
+ })),
30
+ };
31
+ }
32
+ export class Emails {
33
+ http;
34
+ constructor(http) {
35
+ this.http = http;
36
+ }
37
+ /** POST /emails — returns as soon as the email is durably queued. */
38
+ create(input, opts = {}) {
39
+ return this.http.request("POST", "/emails", {
40
+ body: toWirePayload(input),
41
+ idempotencyKey: opts.idempotencyKey,
42
+ });
43
+ }
44
+ /** POST /emails/batch — up to 100 emails, all-or-nothing, no attachments. */
45
+ batch(inputs, opts = {}) {
46
+ return this.http.request("POST", "/emails/batch", {
47
+ body: inputs.map(toWirePayload),
48
+ idempotencyKey: opts.idempotencyKey,
49
+ });
50
+ }
51
+ get(id) {
52
+ return this.http.request("GET", `/emails/${encodeURIComponent(id)}`);
53
+ }
54
+ list(params = {}) {
55
+ return this.http.request("GET", "/emails", {
56
+ query: {
57
+ status: params.status,
58
+ to: params.to,
59
+ created_after: toIso(params.createdAfter),
60
+ created_before: toIso(params.createdBefore),
61
+ limit: params.limit,
62
+ cursor: params.cursor,
63
+ },
64
+ });
65
+ }
66
+ events(id) {
67
+ return this.http.request("GET", `/emails/${encodeURIComponent(id)}/events`);
68
+ }
69
+ cancel(id) {
70
+ return this.http.request("POST", `/emails/${encodeURIComponent(id)}/cancel`);
71
+ }
72
+ }
73
+ export class Domains {
74
+ http;
75
+ constructor(http) {
76
+ this.http = http;
77
+ }
78
+ create(input) {
79
+ return this.http.request("POST", "/domains", { body: input });
80
+ }
81
+ list() {
82
+ return this.http.request("GET", "/domains");
83
+ }
84
+ get(id) {
85
+ return this.http.request("GET", `/domains/${encodeURIComponent(id)}`);
86
+ }
87
+ /** Re-check DNS + provider verification now. */
88
+ verify(id) {
89
+ return this.http.request("POST", `/domains/${encodeURIComponent(id)}/verify`);
90
+ }
91
+ delete(id) {
92
+ return this.http.request("DELETE", `/domains/${encodeURIComponent(id)}`);
93
+ }
94
+ }
95
+ export class Suppressions {
96
+ http;
97
+ constructor(http) {
98
+ this.http = http;
99
+ }
100
+ list(params = {}) {
101
+ return this.http.request("GET", "/suppressions", { query: params });
102
+ }
103
+ create(input) {
104
+ return this.http.request("POST", "/suppressions", { body: input });
105
+ }
106
+ /** Resolves when suppressed; throws SendError(404) when not. */
107
+ check(email) {
108
+ return this.http.request("GET", `/suppressions/${encodeURIComponent(email)}`);
109
+ }
110
+ delete(email) {
111
+ return this.http.request("DELETE", `/suppressions/${encodeURIComponent(email)}`);
112
+ }
113
+ }
114
+ export class ApiKeys {
115
+ http;
116
+ constructor(http) {
117
+ this.http = http;
118
+ }
119
+ /** The returned secret (km_live_...) is shown exactly once — store it. */
120
+ create(input) {
121
+ return this.http.request("POST", "/api-keys", { body: input });
122
+ }
123
+ list() {
124
+ return this.http.request("GET", "/api-keys");
125
+ }
126
+ /** Soft revoke — the key stops authenticating immediately. Idempotent. */
127
+ revoke(id) {
128
+ return this.http.request("DELETE", `/api-keys/${encodeURIComponent(id)}`);
129
+ }
130
+ }
131
+ export class Webhooks {
132
+ http;
133
+ constructor(http) {
134
+ this.http = http;
135
+ }
136
+ /** The returned secret (whsec_...) is shown exactly once — store it. */
137
+ create(input) {
138
+ return this.http.request("POST", "/webhooks", { body: input });
139
+ }
140
+ list() {
141
+ return this.http.request("GET", "/webhooks");
142
+ }
143
+ get(id) {
144
+ return this.http.request("GET", `/webhooks/${encodeURIComponent(id)}`);
145
+ }
146
+ update(id, input) {
147
+ return this.http.request("PATCH", `/webhooks/${encodeURIComponent(id)}`, { body: input });
148
+ }
149
+ delete(id) {
150
+ return this.http.request("DELETE", `/webhooks/${encodeURIComponent(id)}`);
151
+ }
152
+ /** Push a synthetic event through the real queue + dispatcher. */
153
+ test(id) {
154
+ return this.http.request("POST", `/webhooks/${encodeURIComponent(id)}/test`);
155
+ }
156
+ }
@@ -0,0 +1,142 @@
1
+ export type EmailStatus = "queued" | "sending" | "sent" | "delivered" | "bounced" | "complained" | "failed" | "canceled";
2
+ export interface EmailObject {
3
+ object: "email";
4
+ id: string;
5
+ from: string;
6
+ to: string[];
7
+ cc: string[];
8
+ bcc: string[];
9
+ reply_to: string[];
10
+ subject: string;
11
+ html: string | null;
12
+ text: string | null;
13
+ headers: Record<string, string>;
14
+ attachments: {
15
+ filename: string;
16
+ content_type: string;
17
+ size: number;
18
+ }[];
19
+ status: EmailStatus;
20
+ provider_message_id: string | null;
21
+ error: {
22
+ name: string;
23
+ message: string;
24
+ permanent?: boolean;
25
+ } | null;
26
+ attempt: number;
27
+ max_attempts: number;
28
+ created_at: string;
29
+ updated_at: string;
30
+ }
31
+ export interface EmailEventObject {
32
+ id: string;
33
+ type: string;
34
+ payload: Record<string, unknown> | null;
35
+ occurred_at: string;
36
+ created_at: string;
37
+ }
38
+ export interface DnsRecord {
39
+ purpose: string;
40
+ type: string;
41
+ host: string;
42
+ value: string;
43
+ }
44
+ export interface DomainObject {
45
+ object: "domain";
46
+ id: string;
47
+ name: string;
48
+ status: "pending" | "verified" | "failed";
49
+ dkim_selector: string;
50
+ mail_from_subdomain: string;
51
+ dns_records: DnsRecord[];
52
+ created_at: string;
53
+ verified_at: string | null;
54
+ last_checked_at: string | null;
55
+ }
56
+ export interface DomainVerifyResult extends DomainObject {
57
+ checks: {
58
+ dkim_dns: boolean;
59
+ backend_dkim: boolean;
60
+ backend_mail_from: boolean;
61
+ };
62
+ }
63
+ export interface SuppressionObject {
64
+ object: "suppression";
65
+ email: string;
66
+ reason: "hard_bounce" | "complaint" | "manual";
67
+ source_email_id?: string | null;
68
+ created_at?: string;
69
+ }
70
+ export type WebhookEventType = "email.sent" | "email.delivered" | "email.bounced" | "email.complained" | "email.delivery_delayed" | "email.failed";
71
+ export interface WebhookObject {
72
+ object: "webhook";
73
+ id: string;
74
+ url: string;
75
+ events: string[];
76
+ status: "enabled" | "disabled";
77
+ created_at: string;
78
+ }
79
+ /** Returned by webhooks.create only — the secret is shown exactly once. */
80
+ export interface WebhookWithSecret extends WebhookObject {
81
+ secret: string;
82
+ }
83
+ export interface ApiKeyObject {
84
+ object: "api_key";
85
+ id: string;
86
+ name: string;
87
+ key_prefix: string;
88
+ created_at: string;
89
+ last_used_at: string | null;
90
+ revoked_at: string | null;
91
+ }
92
+ /** Returned by apiKeys.create only — the secret is shown exactly once. */
93
+ export interface ApiKeyWithSecret {
94
+ object: "api_key";
95
+ id: string;
96
+ name: string;
97
+ key_prefix: string;
98
+ created_at: string;
99
+ secret: string;
100
+ }
101
+ export interface List<T> {
102
+ object: "list";
103
+ data: T[];
104
+ next_cursor?: string | null;
105
+ }
106
+ export interface EmailAttachmentInput {
107
+ filename: string;
108
+ /** base64 string or raw bytes. */
109
+ content: string | Uint8Array;
110
+ contentType?: string;
111
+ }
112
+ export interface CreateEmailInput {
113
+ from: string;
114
+ to: string | string[];
115
+ cc?: string | string[];
116
+ bcc?: string | string[];
117
+ replyTo?: string | string[];
118
+ subject: string;
119
+ html?: string;
120
+ text?: string;
121
+ headers?: Record<string, string>;
122
+ /** Not supported on batch sends. */
123
+ attachments?: EmailAttachmentInput[];
124
+ }
125
+ export interface SendEmailResponse {
126
+ id: string;
127
+ /** Recipients dropped because they are on the suppression list. */
128
+ suppressed_recipients?: string[];
129
+ }
130
+ export interface ListEmailsParams {
131
+ status?: EmailStatus;
132
+ /** Recipient filter (bare address). */
133
+ to?: string;
134
+ createdAfter?: Date | string;
135
+ createdBefore?: Date | string;
136
+ limit?: number;
137
+ cursor?: string;
138
+ }
139
+ export interface RequestOptions {
140
+ /** Sent as the Idempotency-Key header (POST /emails and /emails/batch). */
141
+ idempotencyKey?: string;
142
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // Wire types, exactly as the API returns them (snake_case).
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@kodar/send",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript SDK for Send by Kodar — the transactional email API. Zero runtime dependencies; Node 18+ (global fetch).",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "prepublishOnly": "npm run build",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest"
26
+ },
27
+ "devDependencies": {
28
+ "@hono/node-server": "^1.13.0",
29
+ "@kodar/core": "*",
30
+ "@kodar/db": "*",
31
+ "@kodar/delivery": "*",
32
+ "@kodar/mail-api": "*",
33
+ "@types/node": "^22.0.0",
34
+ "typescript": "^5.6.0",
35
+ "vitest": "^3.0.0"
36
+ }
37
+ }