@nanbon/fetan 0.0.0-stage → 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.
package/README.md CHANGED
@@ -1,3 +1,105 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @nanbon/fetan
2
+
3
+ Official Node.js SDK for the [Fetan](https://fetan.et) transactional email API. Zero dependencies, full TypeScript types, automatic retries.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @nanbon/fetan
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```ts
14
+ import { Mailer } from "@nanbon/fetan";
15
+
16
+ const mailer = new Mailer("em_live_xxxxxxxx", {
17
+ baseUrl: "https://mail.example.com", // your Fetan deployment
18
+ });
19
+
20
+ const { id, status } = await mailer.emails.send({
21
+ from: "hello@yourdomain.com",
22
+ to: "customer@example.com",
23
+ subject: "ሰላም from Fetan",
24
+ html: "<strong>It works!</strong>",
25
+ });
26
+ ```
27
+
28
+ ## Emails
29
+
30
+ ```ts
31
+ // Send with everything
32
+ await mailer.emails.send({
33
+ from: "billing@yourdomain.com",
34
+ to: ["a@example.com", "b@example.com"],
35
+ cc: "manager@example.com",
36
+ reply_to: "support@yourdomain.com",
37
+ subject: "Your invoice",
38
+ html: "<p>…</p>",
39
+ text: "…",
40
+ tags: { kind: "invoice" },
41
+ scheduled_at: "2026-10-15T09:00:00Z",
42
+ attachments: [{ filename: "invoice.pdf", content: "<base64>" }],
43
+ }, { idempotencyKey: "invoice-1234" }); // safe to retry
44
+
45
+ // Templates
46
+ await mailer.emails.send({
47
+ from: "hello@yourdomain.com",
48
+ to: "user@example.com",
49
+ subject: "Welcome",
50
+ template_id: "tpl_...",
51
+ variables: { name: "Abebe" },
52
+ });
53
+
54
+ // Read & manage
55
+ const email = await mailer.emails.get(id);
56
+ const { emails, total } = await mailer.emails.list({ status: "bounced", limit: 50 });
57
+ await mailer.emails.cancel(id); // only while still queued
58
+
59
+ // Batch (up to 100)
60
+ await mailer.batch.send([{ from, to, subject, html }, ...]);
61
+ ```
62
+
63
+ ## Domains, keys, templates, webhooks
64
+
65
+ ```ts
66
+ const domain = await mailer.domains.create("yourdomain.com");
67
+ const checked = await mailer.domains.verify(domain.id); // live DNS check
68
+
69
+ const key = await mailer.apiKeys.create({ name: "prod", permission: "sending_only" });
70
+ console.log(key.key); // shown once — store it
71
+
72
+ const tpl = await mailer.templates.create({ name: "OTP", subject: "Your code", html: "<p>{{code}}</p>" });
73
+
74
+ const hook = await mailer.webhooks.create({
75
+ url: "https://yourapp.com/hooks/fetan",
76
+ events: ["email.delivered", "email.bounced"],
77
+ });
78
+ ```
79
+
80
+ ## Errors
81
+
82
+ All failures throw `FetanError` with a machine-readable `code`:
83
+
84
+ ```ts
85
+ import { FetanError } from "@nanbon/fetan";
86
+
87
+ try {
88
+ await mailer.emails.send({ from: "a@unverified.com", to: "b@c.com", subject: "x", html: "x" });
89
+ } catch (err) {
90
+ if (err instanceof FetanError && err.code === "domain_not_verified") {
91
+ // err.message → "The domain unverified.com is not verified yet"
92
+ }
93
+ }
94
+ ```
95
+
96
+ Codes: `unauthorized`, `forbidden`, `not_found`, `validation_error`, `domain_not_verified`, `rate_limited`, `conflict`, `server_error`, `network_error`.
97
+
98
+ Network errors and 5xx responses are retried automatically (exponential backoff, 3 attempts by default — configurable via `maxRetries`).
99
+
100
+ ## Build
101
+
102
+ ```bash
103
+ npm install
104
+ npm run build # outputs dist/
105
+ ```
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Fetan — official Node.js SDK for the Fetan transactional email API.
3
+ * Zero dependencies; uses the global fetch (Node 18+).
4
+ */
5
+ export interface FetanOptions {
6
+ /** Base URL of your Fetan deployment, e.g. https://mail.example.com */
7
+ baseUrl?: string;
8
+ /** Max automatic retries for network errors and 5xx responses. Default 3. */
9
+ maxRetries?: number;
10
+ /** Request timeout in milliseconds. Default 15000. */
11
+ timeoutMs?: number;
12
+ }
13
+ export type EmailStatus = "queued" | "sent" | "delivered" | "bounced" | "failed" | "complained" | "cancelled";
14
+ export interface Attachment {
15
+ filename: string;
16
+ /** Base64-encoded file content */
17
+ content: string;
18
+ content_type?: string;
19
+ }
20
+ export interface SendEmailParams {
21
+ from: string;
22
+ to: string | string[];
23
+ cc?: string | string[];
24
+ bcc?: string | string[];
25
+ reply_to?: string | string[];
26
+ subject: string;
27
+ html?: string;
28
+ text?: string;
29
+ headers?: Record<string, string>;
30
+ attachments?: Attachment[];
31
+ /** ISO 8601 datetime to schedule the email for later */
32
+ scheduled_at?: string;
33
+ tags?: Record<string, string>;
34
+ template_id?: string;
35
+ variables?: Record<string, string>;
36
+ }
37
+ export interface Email {
38
+ id: string;
39
+ from: string;
40
+ to: string[];
41
+ cc: string[];
42
+ bcc: string[];
43
+ reply_to: string[];
44
+ subject: string;
45
+ html: string | null;
46
+ text: string | null;
47
+ status: EmailStatus;
48
+ scheduled_at: string | null;
49
+ tags: Record<string, string>;
50
+ template_id: string | null;
51
+ variables: Record<string, string>;
52
+ bounce_reason: string | null;
53
+ attempts: number;
54
+ last_error: string | null;
55
+ created_at: string;
56
+ updated_at: string;
57
+ }
58
+ export interface SendEmailResponse {
59
+ id: string;
60
+ status: EmailStatus;
61
+ }
62
+ export interface BatchSendResponse {
63
+ ids: string[];
64
+ count: number;
65
+ }
66
+ export interface ListEmailsParams {
67
+ status?: EmailStatus;
68
+ domain?: string;
69
+ search?: string;
70
+ limit?: number;
71
+ offset?: number;
72
+ }
73
+ export interface Domain {
74
+ id: string;
75
+ name: string;
76
+ status: "pending" | "verified" | "failed";
77
+ records: {
78
+ type: string;
79
+ name: string;
80
+ value: string;
81
+ status: string;
82
+ }[];
83
+ created_at: string;
84
+ }
85
+ export interface ApiKey {
86
+ id: string;
87
+ name: string;
88
+ prefix: string;
89
+ permission: "full_access" | "sending_only";
90
+ domain_id: string | null;
91
+ last_used_at: string | null;
92
+ created_at: string;
93
+ }
94
+ export interface Template {
95
+ id: string;
96
+ name: string;
97
+ subject: string;
98
+ html: string;
99
+ variables: string[];
100
+ version: number;
101
+ created_at: string;
102
+ updated_at: string;
103
+ }
104
+ export interface Webhook {
105
+ id: string;
106
+ url: string;
107
+ events: string[];
108
+ active: boolean;
109
+ created_at: string;
110
+ }
111
+ export interface WebhookDelivery {
112
+ id: string;
113
+ event: string;
114
+ status: "success" | "failed" | "pending";
115
+ response_code: number | null;
116
+ attempted_at: string;
117
+ }
118
+ export type FetanErrorCode = "unauthorized" | "forbidden" | "not_found" | "validation_error" | "domain_not_verified" | "rate_limited" | "conflict" | "server_error" | "network_error";
119
+ export declare class FetanError extends Error {
120
+ readonly code: FetanErrorCode;
121
+ readonly status: number;
122
+ constructor(message: string, code: FetanErrorCode, status: number);
123
+ }
124
+ export declare class Mailer {
125
+ private readonly apiKey;
126
+ private readonly baseUrl;
127
+ private readonly maxRetries;
128
+ private readonly timeoutMs;
129
+ readonly emails: EmailsResource;
130
+ readonly batch: BatchResource;
131
+ readonly domains: DomainsResource;
132
+ readonly apiKeys: ApiKeysResource;
133
+ readonly templates: TemplatesResource;
134
+ readonly webhooks: WebhooksResource;
135
+ constructor(apiKey: string, options?: FetanOptions);
136
+ /** @internal */
137
+ request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<T>;
138
+ }
139
+ declare class EmailsResource {
140
+ private readonly m;
141
+ constructor(m: Mailer);
142
+ /** Send an email. Returns 202-style { id, status: "queued" }. */
143
+ send(params: SendEmailParams, opts?: {
144
+ idempotencyKey?: string;
145
+ }): Promise<SendEmailResponse>;
146
+ get(id: string): Promise<Email>;
147
+ list(params?: ListEmailsParams): Promise<{
148
+ emails: Email[];
149
+ total: number;
150
+ limit: number;
151
+ offset: number;
152
+ }>;
153
+ cancel(id: string): Promise<{
154
+ id: string;
155
+ status: "cancelled";
156
+ }>;
157
+ }
158
+ declare class BatchResource {
159
+ private readonly m;
160
+ constructor(m: Mailer);
161
+ /** Send up to 100 emails in one request. */
162
+ send(emails: SendEmailParams[]): Promise<BatchSendResponse>;
163
+ }
164
+ declare class DomainsResource {
165
+ private readonly m;
166
+ constructor(m: Mailer);
167
+ list(): Promise<{
168
+ domains: Domain[];
169
+ }>;
170
+ get(id: string): Promise<Domain>;
171
+ create(name: string): Promise<Domain>;
172
+ /** Trigger a DNS verification check; returns the domain with per-record status. */
173
+ verify(id: string): Promise<Domain>;
174
+ delete(id: string): Promise<{
175
+ deleted: true;
176
+ }>;
177
+ }
178
+ declare class ApiKeysResource {
179
+ private readonly m;
180
+ constructor(m: Mailer);
181
+ list(): Promise<{
182
+ api_keys: ApiKey[];
183
+ }>;
184
+ /** Creates a key. The full key is returned once, in `key` — store it immediately. */
185
+ create(params: {
186
+ name: string;
187
+ permission?: "full_access" | "sending_only";
188
+ domain_id?: string;
189
+ }): Promise<ApiKey & {
190
+ key: string;
191
+ }>;
192
+ revoke(id: string): Promise<{
193
+ revoked: true;
194
+ }>;
195
+ }
196
+ declare class TemplatesResource {
197
+ private readonly m;
198
+ constructor(m: Mailer);
199
+ list(): Promise<{
200
+ templates: Template[];
201
+ }>;
202
+ get(id: string): Promise<Template>;
203
+ create(params: {
204
+ name: string;
205
+ subject: string;
206
+ html: string;
207
+ }): Promise<Template>;
208
+ update(id: string, params: Partial<{
209
+ name: string;
210
+ subject: string;
211
+ html: string;
212
+ }>): Promise<Template>;
213
+ delete(id: string): Promise<{
214
+ deleted: true;
215
+ }>;
216
+ }
217
+ declare class WebhooksResource {
218
+ private readonly m;
219
+ constructor(m: Mailer);
220
+ list(): Promise<{
221
+ webhooks: Webhook[];
222
+ }>;
223
+ create(params: {
224
+ url: string;
225
+ events: string[];
226
+ }): Promise<Webhook & {
227
+ secret: string;
228
+ }>;
229
+ update(id: string, params: Partial<{
230
+ url: string;
231
+ events: string[];
232
+ active: boolean;
233
+ }>): Promise<Webhook>;
234
+ delete(id: string): Promise<{
235
+ deleted: true;
236
+ }>;
237
+ deliveries(id: string): Promise<{
238
+ deliveries: WebhookDelivery[];
239
+ }>;
240
+ }
241
+ export default Mailer;
package/dist/index.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Fetan — official Node.js SDK for the Fetan transactional email API.
3
+ * Zero dependencies; uses the global fetch (Node 18+).
4
+ */
5
+ export class FetanError extends Error {
6
+ code;
7
+ status;
8
+ constructor(message, code, status) {
9
+ super(message);
10
+ this.name = "FetanError";
11
+ this.code = code;
12
+ this.status = status;
13
+ }
14
+ }
15
+ function codeFor(status, message) {
16
+ if (status === 401)
17
+ return "unauthorized";
18
+ if (status === 403)
19
+ return "forbidden";
20
+ if (status === 404)
21
+ return "not_found";
22
+ if (status === 409)
23
+ return "conflict";
24
+ if (status === 429)
25
+ return "rate_limited";
26
+ if (status === 400) {
27
+ return /not verified|not added/i.test(message) ? "domain_not_verified" : "validation_error";
28
+ }
29
+ return "server_error";
30
+ }
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+ export class Mailer {
33
+ apiKey;
34
+ baseUrl;
35
+ maxRetries;
36
+ timeoutMs;
37
+ emails;
38
+ batch;
39
+ domains;
40
+ apiKeys;
41
+ templates;
42
+ webhooks;
43
+ constructor(apiKey, options = {}) {
44
+ if (!apiKey)
45
+ throw new FetanError("An API key is required: new Mailer('em_live_...')", "validation_error", 0);
46
+ this.apiKey = apiKey;
47
+ this.baseUrl = (options.baseUrl ?? "https://api.fetan.et").replace(/\/+$/, "");
48
+ this.maxRetries = options.maxRetries ?? 3;
49
+ this.timeoutMs = options.timeoutMs ?? 15000;
50
+ this.emails = new EmailsResource(this);
51
+ this.batch = new BatchResource(this);
52
+ this.domains = new DomainsResource(this);
53
+ this.apiKeys = new ApiKeysResource(this);
54
+ this.templates = new TemplatesResource(this);
55
+ this.webhooks = new WebhooksResource(this);
56
+ }
57
+ /** @internal */
58
+ async request(method, path, body, extraHeaders = {}) {
59
+ const base = this.baseUrl.replace(/\/+$/, "");
60
+ let url;
61
+ if (base.endsWith("/api/v1") || base.endsWith("/api/public/v1")) {
62
+ url = `${base}${path}`;
63
+ }
64
+ else if (base.endsWith("/api")) {
65
+ url = `${base}/v1${path}`;
66
+ }
67
+ else {
68
+ url = `${base}/api/v1${path}`;
69
+ }
70
+ let lastError = null;
71
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
74
+ try {
75
+ const res = await fetch(url, {
76
+ method,
77
+ signal: controller.signal,
78
+ headers: {
79
+ Authorization: `Bearer ${this.apiKey}`,
80
+ "Content-Type": "application/json",
81
+ ...extraHeaders,
82
+ },
83
+ body: body === undefined ? undefined : JSON.stringify(body),
84
+ });
85
+ clearTimeout(timer);
86
+ const text = await res.text();
87
+ let data = null;
88
+ try {
89
+ data = text ? JSON.parse(text) : null;
90
+ }
91
+ catch {
92
+ data = null;
93
+ }
94
+ if (!res.ok) {
95
+ const rawError = data?.error;
96
+ let message = `Request failed with status ${res.status}`;
97
+ if (typeof rawError === "string") {
98
+ message = rawError;
99
+ }
100
+ else if (typeof rawError?.message === "string") {
101
+ message = rawError.message;
102
+ }
103
+ else if (typeof data?.message === "string") {
104
+ message = data.message;
105
+ }
106
+ // Retry only on server errors; client errors fail immediately.
107
+ if (res.status >= 500 && attempt < this.maxRetries) {
108
+ lastError = new FetanError(message, "server_error", res.status);
109
+ await sleep(2 ** attempt * 300);
110
+ continue;
111
+ }
112
+ throw new FetanError(message, codeFor(res.status, message), res.status);
113
+ }
114
+ return data;
115
+ }
116
+ catch (err) {
117
+ clearTimeout(timer);
118
+ if (err instanceof FetanError)
119
+ throw err;
120
+ lastError = new FetanError(err instanceof Error ? err.message : "Network error", "network_error", 0);
121
+ if (attempt < this.maxRetries) {
122
+ await sleep(2 ** attempt * 300);
123
+ continue;
124
+ }
125
+ }
126
+ }
127
+ throw lastError ?? new FetanError("Request failed", "network_error", 0);
128
+ }
129
+ }
130
+ class EmailsResource {
131
+ m;
132
+ constructor(m) {
133
+ this.m = m;
134
+ }
135
+ /** Send an email. Returns 202-style { id, status: "queued" }. */
136
+ send(params, opts) {
137
+ return this.m.request("POST", "/emails", params, opts?.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {});
138
+ }
139
+ get(id) {
140
+ return this.m.request("GET", `/emails/${encodeURIComponent(id)}`);
141
+ }
142
+ list(params = {}) {
143
+ const qs = new URLSearchParams();
144
+ if (params.status)
145
+ qs.set("status", params.status);
146
+ if (params.domain)
147
+ qs.set("domain", params.domain);
148
+ if (params.search)
149
+ qs.set("search", params.search);
150
+ if (params.limit !== undefined)
151
+ qs.set("limit", String(params.limit));
152
+ if (params.offset !== undefined)
153
+ qs.set("offset", String(params.offset));
154
+ const suffix = qs.size ? `?${qs.toString()}` : "";
155
+ return this.m.request("GET", `/emails${suffix}`);
156
+ }
157
+ cancel(id) {
158
+ return this.m.request("POST", `/emails/${encodeURIComponent(id)}/cancel`);
159
+ }
160
+ }
161
+ class BatchResource {
162
+ m;
163
+ constructor(m) {
164
+ this.m = m;
165
+ }
166
+ /** Send up to 100 emails in one request. */
167
+ send(emails) {
168
+ return this.m.request("POST", "/emails/batch", { emails });
169
+ }
170
+ }
171
+ class DomainsResource {
172
+ m;
173
+ constructor(m) {
174
+ this.m = m;
175
+ }
176
+ list() {
177
+ return this.m.request("GET", "/domains");
178
+ }
179
+ get(id) {
180
+ return this.m.request("GET", `/domains/${encodeURIComponent(id)}`);
181
+ }
182
+ create(name) {
183
+ return this.m.request("POST", "/domains", { name });
184
+ }
185
+ /** Trigger a DNS verification check; returns the domain with per-record status. */
186
+ verify(id) {
187
+ return this.m.request("POST", `/domains/${encodeURIComponent(id)}/verify`);
188
+ }
189
+ delete(id) {
190
+ return this.m.request("DELETE", `/domains/${encodeURIComponent(id)}`);
191
+ }
192
+ }
193
+ class ApiKeysResource {
194
+ m;
195
+ constructor(m) {
196
+ this.m = m;
197
+ }
198
+ list() {
199
+ return this.m.request("GET", "/api-keys");
200
+ }
201
+ /** Creates a key. The full key is returned once, in `key` — store it immediately. */
202
+ create(params) {
203
+ return this.m.request("POST", "/api-keys", params);
204
+ }
205
+ revoke(id) {
206
+ return this.m.request("DELETE", `/api-keys/${encodeURIComponent(id)}`);
207
+ }
208
+ }
209
+ class TemplatesResource {
210
+ m;
211
+ constructor(m) {
212
+ this.m = m;
213
+ }
214
+ list() {
215
+ return this.m.request("GET", "/templates");
216
+ }
217
+ get(id) {
218
+ return this.m.request("GET", `/templates/${encodeURIComponent(id)}`);
219
+ }
220
+ create(params) {
221
+ return this.m.request("POST", "/templates", params);
222
+ }
223
+ update(id, params) {
224
+ return this.m.request("PATCH", `/templates/${encodeURIComponent(id)}`, params);
225
+ }
226
+ delete(id) {
227
+ return this.m.request("DELETE", `/templates/${encodeURIComponent(id)}`);
228
+ }
229
+ }
230
+ class WebhooksResource {
231
+ m;
232
+ constructor(m) {
233
+ this.m = m;
234
+ }
235
+ list() {
236
+ return this.m.request("GET", "/webhooks");
237
+ }
238
+ create(params) {
239
+ return this.m.request("POST", "/webhooks", params);
240
+ }
241
+ update(id, params) {
242
+ return this.m.request("PATCH", `/webhooks/${encodeURIComponent(id)}`, params);
243
+ }
244
+ delete(id) {
245
+ return this.m.request("DELETE", `/webhooks/${encodeURIComponent(id)}`);
246
+ }
247
+ deliveries(id) {
248
+ return this.m.request("GET", `/webhooks/${encodeURIComponent(id)}/deliveries`);
249
+ }
250
+ }
251
+ export default Mailer;
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
- {
2
- "name": "@nanbon/fetan",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@nanbon/fetan",
3
+ "version": "0.1.0",
4
+ "description": "Official Node.js SDK for the Fetan transactional email API",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist"],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "engines": { "node": ">=18" },
20
+ "devDependencies": {
21
+ "typescript": "^5.5.0"
22
+ },
23
+ "keywords": ["email", "transactional", "smtp", "mailer", "fetan", "ethiopia"],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/nanbon/fetan-email"
27
+ },
28
+ "homepage": "https://fetan.et",
29
+ "license": "MIT"
30
+ }