@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 +52 -2
- package/dist/client.d.ts +25 -0
- package/dist/client.js +81 -0
- package/dist/encode.d.ts +5 -0
- package/dist/encode.js +25 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +22 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.js +2 -0
- package/package.json +48 -3
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @szymonpiatek/courier
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
package/dist/encode.d.ts
ADDED
|
@@ -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
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@szymonpiatek/courier",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|