@szymonpiatek/courier 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,53 @@
1
- # Temporary Holding Version
1
+ # @szymonpiatek/courier
2
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.
3
+ Klient TypeScript do API [Couriera](https://github.com/SzymonPiatek/Courier): wysyłka maili przez jeden endpoint zamiast własnej konfiguracji SMTP. Bez zależności, na natywnym `fetch` (Node 22+, przeglądarki).
4
+
5
+ ```bash
6
+ npm install @szymonpiatek/courier
7
+ ```
8
+
9
+ ## Użycie
10
+
11
+ Klucz API (`crk_…`) tworzysz w panelu Couriera (Klucze API) i przypisujesz do kont SMTP, z których może wysyłać.
12
+
13
+ ```ts
14
+ import { createClient, CourierError } from '@szymonpiatek/courier';
15
+
16
+ const courier = createClient({
17
+ baseUrl: 'https://mail.example.com', // bez /v1
18
+ apiKey: process.env.COURIER_API_KEY!,
19
+ });
20
+
21
+ try {
22
+ const { id, status } = await courier.messages.send(
23
+ {
24
+ to: 'klient@example.com',
25
+ subject: 'Potwierdzenie',
26
+ html: '<p>Dziękujemy!</p>',
27
+ attachments: [{ filename: 'faktura.pdf', content: pdfBytes }], // bajty albo base64
28
+ },
29
+ { idempotencyKey: 'zamowienie-4821' },
30
+ );
31
+ } catch (err) {
32
+ if (err instanceof CourierError && err.retryable) {
33
+ // 429 / 503 / błąd sieci: ponów po err.retryAfter sekundach
34
+ }
35
+ }
36
+ ```
37
+
38
+ Odpowiedź `202` (`status: 'QUEUED'`) oznacza przyjęcie do kolejki. Wysyła ją osobny worker, więc stan sprawdzisz przez `courier.messages.get(id)`.
39
+
40
+ ## API
41
+
42
+ | Metoda | Opis |
43
+ | -------------------------------- | ---------------------------------------------------------------------------- |
44
+ | `messages.send(input, options?)` | Dodaje wiadomość do kolejki. `idempotencyKey` pozwala bezpiecznie ponawiać |
45
+ | `messages.get(id)` | Szczegóły wiadomości wraz ze zdarzeniami |
46
+ | `messages.list(query?)` | Strona wyników (filtry: `status`, `account`, `from`, `to`, `since`, `until`) |
47
+ | `messages.iterate(query?)` | Iterator po wszystkich stronach, od najnowszych |
48
+
49
+ `CourierError` ma pola `status`, `code`, `retryable` i `retryAfter`. Opcje klienta: `fetch` (własna implementacja) i `timeoutMs` (domyślnie 30 s).
50
+
51
+ ## Licencja
52
+
53
+ MIT
@@ -0,0 +1,25 @@
1
+ import type { ListQuery, MessageDetail, MessagePage, MessageSummary, SendMessageInput, SendOptions, SendResult } from './types.js';
2
+ export interface ClientOptions {
3
+ /** Adres Couriera, np. https://mail.example.com (bez /v1). */
4
+ baseUrl: string;
5
+ /** Klucz API z panelu (crk_…). */
6
+ apiKey: string;
7
+ /** Własna implementacja fetch (testy, proxy); domyślnie globalna. */
8
+ fetch?: typeof fetch;
9
+ /** Limit czasu jednego zapytania (domyślnie 30 s). */
10
+ timeoutMs?: number;
11
+ }
12
+ export interface CourierClient {
13
+ messages: {
14
+ send(input: SendMessageInput, options?: SendOptions): Promise<SendResult>;
15
+ get(id: string, options?: {
16
+ signal?: AbortSignal;
17
+ }): Promise<MessageDetail>;
18
+ list(query?: ListQuery, options?: {
19
+ signal?: AbortSignal;
20
+ }): Promise<MessagePage>;
21
+ /** Przechodzi po wszystkich stronach wyników, od najnowszych. */
22
+ iterate(query?: Omit<ListQuery, 'cursor'>): AsyncGenerator<MessageSummary, void, void>;
23
+ };
24
+ }
25
+ export declare function createClient(options: ClientOptions): CourierClient;
package/dist/client.js ADDED
@@ -0,0 +1,81 @@
1
+ import { CourierError } from './errors.js';
2
+ import { toRequestBody } from './encode.js';
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+ function queryString(query) {
5
+ const params = new URLSearchParams();
6
+ for (const [key, value] of Object.entries(query)) {
7
+ if (value === undefined || value === null || value === '')
8
+ continue;
9
+ params.set(key, value instanceof Date ? value.toISOString() : String(value));
10
+ }
11
+ const text = params.toString();
12
+ return text ? `?${text}` : '';
13
+ }
14
+ export function createClient(options) {
15
+ const base = options.baseUrl.replace(/\/+$/, '');
16
+ const doFetch = options.fetch ?? fetch;
17
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
18
+ async function request(path, init) {
19
+ const timeout = AbortSignal.timeout(timeoutMs);
20
+ const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
21
+ let res;
22
+ try {
23
+ res = await doFetch(`${base}${path}`, {
24
+ method: init.method ?? 'GET',
25
+ signal,
26
+ headers: {
27
+ Authorization: `Bearer ${options.apiKey}`,
28
+ Accept: 'application/json',
29
+ ...(init.body !== undefined && { 'Content-Type': 'application/json' }),
30
+ ...init.headers,
31
+ },
32
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
33
+ });
34
+ }
35
+ catch (err) {
36
+ const reason = err instanceof Error ? err.message : String(err);
37
+ throw new CourierError(0, 'network_error', `Request failed: ${reason}`);
38
+ }
39
+ const body = await res.json().catch(() => null);
40
+ if (!res.ok) {
41
+ const error = body
42
+ ?.error;
43
+ throw new CourierError(res.status, error?.code ?? 'error', error?.message ?? `HTTP ${res.status}`, error?.issues, Number(res.headers.get('Retry-After')) || undefined);
44
+ }
45
+ return { status: res.status, body };
46
+ }
47
+ const client = {
48
+ messages: {
49
+ async send(input, sendOptions = {}) {
50
+ const { status, body } = await request('/v1/messages', {
51
+ method: 'POST',
52
+ body: toRequestBody(input),
53
+ signal: sendOptions.signal,
54
+ headers: sendOptions.idempotencyKey
55
+ ? { 'Idempotency-Key': sendOptions.idempotencyKey }
56
+ : undefined,
57
+ });
58
+ const receipt = body;
59
+ return { id: receipt.id, status: receipt.status, replayed: status === 200 };
60
+ },
61
+ async get(id, getOptions = {}) {
62
+ const { body } = await request(`/v1/messages/${encodeURIComponent(id)}`, getOptions);
63
+ return body;
64
+ },
65
+ async list(query = {}, listOptions = {}) {
66
+ const { body } = await request(`/v1/messages${queryString(query)}`, listOptions);
67
+ return body;
68
+ },
69
+ async *iterate(query = {}) {
70
+ let cursor;
71
+ do {
72
+ const page = await client.messages.list({ ...query, cursor });
73
+ yield* page.data;
74
+ cursor = page.nextCursor ?? undefined;
75
+ } while (cursor);
76
+ },
77
+ },
78
+ };
79
+ return client;
80
+ }
81
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,5 @@
1
+ import type { SendMessageInput } from './types.js';
2
+ /** Bajty -> base64 w Node i w przeglądarce (porcjami, żeby nie przepełnić stosu). */
3
+ export declare function toBase64(bytes: Uint8Array): string;
4
+ /** Ciało zapytania `POST /v1/messages`: załączniki jako base64, reszta bez zmian. */
5
+ export declare function toRequestBody(input: SendMessageInput): Record<string, unknown>;
package/dist/encode.js ADDED
@@ -0,0 +1,25 @@
1
+ /** Bajty -> base64 w Node i w przeglądarce (porcjami, żeby nie przepełnić stosu). */
2
+ export function toBase64(bytes) {
3
+ if (typeof Buffer !== 'undefined')
4
+ return Buffer.from(bytes).toString('base64');
5
+ let binary = '';
6
+ for (let i = 0; i < bytes.length; i += 0x8000) {
7
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
8
+ }
9
+ return btoa(binary);
10
+ }
11
+ /** Ciało zapytania `POST /v1/messages`: załączniki jako base64, reszta bez zmian. */
12
+ export function toRequestBody(input) {
13
+ const { attachments, ...rest } = input;
14
+ return {
15
+ ...rest,
16
+ ...(attachments && {
17
+ attachments: attachments.map(({ filename, content, contentType }) => ({
18
+ filename,
19
+ ...(contentType && { contentType }),
20
+ content: typeof content === 'string' ? content : toBase64(content),
21
+ })),
22
+ }),
23
+ };
24
+ }
25
+ //# sourceMappingURL=encode.js.map
@@ -0,0 +1,19 @@
1
+ /** Błąd odpowiedzi API (status 4xx/5xx) albo problem z połączeniem (status 0). */
2
+ export declare class CourierError extends Error {
3
+ readonly status: number;
4
+ readonly code: string;
5
+ readonly details?: {
6
+ path: string;
7
+ message: string;
8
+ }[] | undefined;
9
+ /** Sekundy do ponowienia (nagłówek Retry-After), np. przy 429. */
10
+ readonly retryAfter?: number | undefined;
11
+ constructor(status: number, code: string, message: string, details?: {
12
+ path: string;
13
+ message: string;
14
+ }[] | undefined,
15
+ /** Sekundy do ponowienia (nagłówek Retry-After), np. przy 429. */
16
+ retryAfter?: number | undefined);
17
+ /** Warto ponowić: limit zapytań, niedostępna kolejka albo błąd sieci. */
18
+ get retryable(): boolean;
19
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,22 @@
1
+ /** Błąd odpowiedzi API (status 4xx/5xx) albo problem z połączeniem (status 0). */
2
+ export class CourierError extends Error {
3
+ status;
4
+ code;
5
+ details;
6
+ retryAfter;
7
+ constructor(status, code, message, details,
8
+ /** Sekundy do ponowienia (nagłówek Retry-After), np. przy 429. */
9
+ retryAfter) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.details = details;
14
+ this.retryAfter = retryAfter;
15
+ this.name = 'CourierError';
16
+ }
17
+ /** Warto ponowić: limit zapytań, niedostępna kolejka albo błąd sieci. */
18
+ get retryable() {
19
+ return this.status === 0 || this.status === 429 || this.status === 503;
20
+ }
21
+ }
22
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,4 @@
1
+ export { createClient, type ClientOptions, type CourierClient } from './client.js';
2
+ export { CourierError } from './errors.js';
3
+ export { toRequestBody } from './encode.js';
4
+ export type * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createClient } from './client.js';
2
+ export { CourierError } from './errors.js';
3
+ export { toRequestBody } from './encode.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,79 @@
1
+ export type MessageStatus = 'QUEUED' | 'SENDING' | 'SENT' | 'FAILED';
2
+ /** Załącznik w zapytaniu: `content` to bajty albo gotowy napis base64. */
3
+ export interface AttachmentInput {
4
+ filename: string;
5
+ content: string | Uint8Array;
6
+ /** Domyślnie application/octet-stream. */
7
+ contentType?: string;
8
+ }
9
+ export interface SendMessageInput {
10
+ to: string | string[];
11
+ cc?: string | string[];
12
+ bcc?: string | string[];
13
+ subject: string;
14
+ html?: string;
15
+ text?: string;
16
+ /** Adres nadawcy; potrzebny, gdy klucz ma kilka kont (albo podaj `account`). */
17
+ from?: string;
18
+ account?: string;
19
+ headers?: Record<string, string>;
20
+ attachments?: AttachmentInput[];
21
+ }
22
+ export interface SendOptions {
23
+ /** Ten sam klucz zwraca istniejącą wiadomość zamiast tworzyć drugą (bezpieczne ponowienia). */
24
+ idempotencyKey?: string;
25
+ signal?: AbortSignal;
26
+ }
27
+ export interface SendResult {
28
+ id: string;
29
+ status: MessageStatus;
30
+ /** true, gdy serwer rozpoznał powtórzone zapytanie (ten sam Idempotency-Key). */
31
+ replayed: boolean;
32
+ }
33
+ export interface MessageSummary {
34
+ id: string;
35
+ accountId: string;
36
+ status: MessageStatus;
37
+ from: string;
38
+ to: string[];
39
+ subject: string;
40
+ attempts: number;
41
+ lastError: string | null;
42
+ createdAt: string;
43
+ sentAt: string | null;
44
+ }
45
+ export interface MessageEvent {
46
+ type: 'QUEUED' | 'ATTEMPT' | 'SENT' | 'FAILED' | 'OPENED' | 'CLICKED';
47
+ detail: unknown;
48
+ createdAt: string;
49
+ }
50
+ export interface MessageDetail extends MessageSummary {
51
+ cc: string[];
52
+ bcc: string[];
53
+ html: string | null;
54
+ text: string | null;
55
+ headers: Record<string, string> | null;
56
+ messageId: string | null;
57
+ attachments: {
58
+ id: string;
59
+ filename: string;
60
+ contentType: string;
61
+ size: number;
62
+ }[];
63
+ events: MessageEvent[];
64
+ }
65
+ export interface ListQuery {
66
+ status?: MessageStatus;
67
+ account?: string;
68
+ from?: string;
69
+ to?: string;
70
+ since?: Date | string;
71
+ until?: Date | string;
72
+ /** 1–100, domyślnie 25. */
73
+ limit?: number;
74
+ cursor?: string;
75
+ }
76
+ export interface MessagePage {
77
+ data: MessageSummary[];
78
+ nextCursor: string | null;
79
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@szymonpiatek/courier",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Klient TypeScript do API Couriera (bez zależności, natywny fetch).",
5
+ "keywords": [
6
+ "courier",
7
+ "email",
8
+ "smtp",
9
+ "api-client"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Szymon Piątek",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/SzymonPiatek/Courier.git",
16
+ "directory": "packages/client"
17
+ },
18
+ "homepage": "https://github.com/SzymonPiatek/Courier/tree/main/packages/client#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/SzymonPiatek/Courier/issues"
21
+ },
22
+ "type": "module",
23
+ "main": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "!dist/**/*.map"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "sideEffects": false,
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "devDependencies": {
43
+ "vitest": "^5.0.5"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.build.json",
47
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
48
+ "test": "vitest run",
49
+ "typecheck": "tsc -p tsconfig.json --noEmit"
50
+ }
6
51
  }