@posthaste/sdk 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/dist/errors.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Errors.
3
+ *
4
+ * Every failure this SDK can produce arrives as a `PosthasteError`, including
5
+ * the ones that never touched the server (a socket that would not open, a
6
+ * request that timed out). One `catch` clause, one shape.
7
+ */
8
+ export class PosthasteError extends Error {
9
+ name = 'PosthasteError';
10
+ /**
11
+ * The HTTP status. `0` when the request never got a response at all — a DNS
12
+ * failure, a refused connection, an abort. Checking `status >= 500` is
13
+ * therefore not a substitute for checking `type`.
14
+ */
15
+ status;
16
+ type;
17
+ /**
18
+ * Field-level detail, when the server sent any.
19
+ *
20
+ * `error.fields` appears on the 400s produced by the shared body validator
21
+ * and is ABSENT on the several 400s that are hand-written with a message
22
+ * only. Always treat it as optional; never index into it unchecked.
23
+ */
24
+ fields;
25
+ /**
26
+ * How long to wait, in seconds, when the server said. Taken from
27
+ * `error.retryAfterSeconds` (rate limiting) or the `Retry-After` header
28
+ * (quota exhaustion), in that order.
29
+ */
30
+ retryAfterSeconds;
31
+ /** The parsed body. `undefined` when there was nothing parseable. */
32
+ body;
33
+ constructor(init) {
34
+ super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
35
+ this.status = init.status;
36
+ this.type = init.type;
37
+ if (init.fields)
38
+ this.fields = init.fields;
39
+ if (init.retryAfterSeconds !== undefined)
40
+ this.retryAfterSeconds = init.retryAfterSeconds;
41
+ if (init.body !== undefined)
42
+ this.body = init.body;
43
+ }
44
+ /**
45
+ * True for the two refusals that mean "your allowance is spent", as opposed
46
+ * to "you are going too fast".
47
+ *
48
+ * Both arrive as 429 with a `Retry-After`, and the status alone cannot tell
49
+ * them apart — which is exactly the trap that makes a naive retry loop hammer
50
+ * a wall for the rest of the month. This SDK never retries these in-process.
51
+ */
52
+ get isQuotaExhausted() {
53
+ return this.type === 'daily_limit_reached' || this.type === 'monthly_limit_reached';
54
+ }
55
+ /** True for the per-key request-rate limiter, which is short and transient. */
56
+ get isRateLimited() {
57
+ return this.type === 'rate_limited';
58
+ }
59
+ }
60
+ export function isPosthasteError(value) {
61
+ return value instanceof PosthasteError;
62
+ }
63
+ /**
64
+ * Turn a response body into an error.
65
+ *
66
+ * The API's own refusals use `{ error: { type, message, ... } }`. Two kinds of
67
+ * response do NOT, and both are parsed defensively here rather than assumed
68
+ * away:
69
+ *
70
+ * An UNHANDLED 500. There is no `setErrorHandler` on the API, so a thrown
71
+ * exception is serialised by Fastify's default handler as
72
+ * `{ statusCode, error: "Internal Server Error", message }` — `error` is a
73
+ * STRING. Reading `body.error.type` off that yields `undefined` and reading
74
+ * `body.error.message` throws, which would turn the one response where a
75
+ * caller most needs a clear message into a crash inside the SDK.
76
+ *
77
+ * Anything that never reached the application at all — a proxy's HTML error
78
+ * page, an empty body, a truncated response.
79
+ *
80
+ * Neither invents a `type`: both become `unknown_error`, so nothing downstream
81
+ * can mistake an unclassified failure for a documented one.
82
+ */
83
+ export function errorFromResponse(status, rawBody, retryAfterHeader) {
84
+ let body;
85
+ try {
86
+ body = rawBody.length > 0 ? JSON.parse(rawBody) : undefined;
87
+ }
88
+ catch {
89
+ body = undefined;
90
+ }
91
+ const envelope = typeof body === 'object' && body !== null ? body.error : undefined;
92
+ const headerRetry = parseRetryAfter(retryAfterHeader);
93
+ // The documented shape: `error` is an object carrying a machine-readable type.
94
+ if (typeof envelope === 'object' && envelope !== null) {
95
+ const e = envelope;
96
+ const type = typeof e.type === 'string' ? e.type : 'unknown_error';
97
+ const message = typeof e.message === 'string' && e.message.length > 0
98
+ ? e.message
99
+ : `Posthaste request failed with status ${status}`;
100
+ const fields = Array.isArray(e.fields)
101
+ ? e.fields
102
+ .filter((f) => typeof f === 'object' && f !== null)
103
+ .map((f) => ({
104
+ path: typeof f.path === 'string' ? f.path : '',
105
+ message: typeof f.message === 'string' ? f.message : '',
106
+ }))
107
+ : undefined;
108
+ // The rate limiter puts its wait INSIDE the error object; the quota
109
+ // refusals put theirs in a Retry-After header. Prefer the body, because
110
+ // only it is specific to this refusal.
111
+ const bodyRetry = typeof e.retryAfterSeconds === 'number' ? e.retryAfterSeconds : undefined;
112
+ return new PosthasteError({
113
+ status,
114
+ type,
115
+ message,
116
+ ...(fields && fields.length > 0 ? { fields } : {}),
117
+ ...(bodyRetry !== undefined
118
+ ? { retryAfterSeconds: bodyRetry }
119
+ : headerRetry !== undefined
120
+ ? { retryAfterSeconds: headerRetry }
121
+ : {}),
122
+ body,
123
+ });
124
+ }
125
+ // Not the envelope. `error` may be a string ("Internal Server Error"), or
126
+ // there may be no body at all. Use whatever human-readable text exists.
127
+ const record = typeof body === 'object' && body !== null ? body : {};
128
+ const message = (typeof record.message === 'string' && record.message) ||
129
+ (typeof envelope === 'string' && envelope) ||
130
+ (rawBody.trim().length > 0 && rawBody.trim().length <= 300 ? rawBody.trim() : '') ||
131
+ `Posthaste request failed with status ${status}`;
132
+ return new PosthasteError({
133
+ status,
134
+ type: 'unknown_error',
135
+ message,
136
+ ...(headerRetry !== undefined ? { retryAfterSeconds: headerRetry } : {}),
137
+ ...(body !== undefined ? { body } : {}),
138
+ });
139
+ }
140
+ /**
141
+ * `Retry-After` is either a number of seconds or an HTTP date. The API sends
142
+ * seconds; a proxy in front of it may not.
143
+ */
144
+ export function parseRetryAfter(header) {
145
+ if (!header)
146
+ return undefined;
147
+ const trimmed = header.trim();
148
+ if (/^\d+$/.test(trimmed))
149
+ return Number(trimmed);
150
+ const at = Date.parse(trimmed);
151
+ if (Number.isNaN(at))
152
+ return undefined;
153
+ return Math.max(0, Math.ceil((at - Date.now()) / 1000));
154
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The transport.
3
+ *
4
+ * Zero dependencies: global `fetch` and nothing else. Everything interesting
5
+ * lives here — authentication, query building, timeouts, the retry policy and
6
+ * the error mapping — so every resource method below is a one-liner and none
7
+ * of them can quietly disagree about how a failure is handled.
8
+ */
9
+ import { parseRetryAfter } from './errors.js';
10
+ /**
11
+ * A structural `fetch`.
12
+ *
13
+ * Deliberately minimal rather than `typeof globalThis.fetch`: the global type
14
+ * differs between DOM lib, `@types/node` and whatever a bundler injects, and an
15
+ * SDK that only needs a status, a header and a body should not force a consumer
16
+ * into one of them. The real `fetch` satisfies this; so does a five-line test
17
+ * shim, and so does a proxy-aware wrapper.
18
+ */
19
+ export interface PosthasteResponse {
20
+ readonly status: number;
21
+ readonly headers: {
22
+ get(name: string): string | null;
23
+ };
24
+ text(): Promise<string>;
25
+ }
26
+ export interface PosthasteRequestInit {
27
+ method: string;
28
+ headers: Record<string, string>;
29
+ body?: string;
30
+ signal?: AbortSignal;
31
+ }
32
+ export type FetchLike = (url: string, init: PosthasteRequestInit) => Promise<PosthasteResponse>;
33
+ export interface PosthasteOptions {
34
+ /** A `ph_live_…` or `ph_test_…` key. Sent as `Authorization: Bearer …`. */
35
+ apiKey: string;
36
+ /** Defaults to `https://api.posthastemail.dev`. No trailing slash needed. */
37
+ baseUrl?: string;
38
+ /**
39
+ * The `fetch` to use. Defaults to the global one.
40
+ *
41
+ * Injectable because it is genuinely useful — a proxy agent, a tracing
42
+ * wrapper, a mock — and because it is how this package's own integration test
43
+ * drives a real in-process Fastify app without opening a socket.
44
+ */
45
+ fetch?: FetchLike;
46
+ /** Per-attempt, not per-call. Default 30s. `0` disables the timeout. */
47
+ timeoutMs?: number;
48
+ /** Retries AFTER the first attempt. Default 2. `0` disables retrying. */
49
+ maxRetries?: number;
50
+ /**
51
+ * Longest server-requested wait this SDK will actually sit through, in
52
+ * milliseconds. Default 60s.
53
+ *
54
+ * A `Retry-After` beyond this is honoured by NOT retrying: a quota that frees
55
+ * at midnight is not something to block a request handler on, and sleeping
56
+ * through it would look like a hang.
57
+ */
58
+ maxRetryDelayMs?: number;
59
+ /** Extra headers on every request. Cannot override `authorization`. */
60
+ headers?: Record<string, string>;
61
+ /** Appended to the SDK's own `user-agent`. */
62
+ userAgent?: string;
63
+ /**
64
+ * How the SDK waits between retries. Advanced — exists so tests can assert
65
+ * backoff without spending real seconds on it.
66
+ */
67
+ sleep?: (ms: number) => Promise<void>;
68
+ /** Injectable randomness for retry jitter. Advanced; testing only. */
69
+ random?: () => number;
70
+ }
71
+ export interface RequestOptions {
72
+ /** Abort this call from outside. Composed with the client's own timeout. */
73
+ signal?: AbortSignal;
74
+ /** Extra headers for one call. */
75
+ headers?: Record<string, string>;
76
+ }
77
+ export interface InternalRequest {
78
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
79
+ path: string;
80
+ query?: Record<string, string | number | boolean | undefined>;
81
+ body?: unknown;
82
+ /**
83
+ * Whether repeating this request is harmless.
84
+ *
85
+ * GET and DELETE always are. A POST is only idempotent when the server makes
86
+ * it so, and each call site says which it is — see `emails.send`, where the
87
+ * answer depends on whether the caller supplied an `idempotencyKey`.
88
+ */
89
+ idempotent: boolean;
90
+ options?: RequestOptions;
91
+ }
92
+ /** Kept in step with package.json — it is the only place it is stated twice. */
93
+ export declare const SDK_VERSION = "0.1.0";
94
+ export declare class HttpClient {
95
+ private readonly apiKey;
96
+ private readonly baseUrl;
97
+ private readonly doFetch;
98
+ private readonly timeoutMs;
99
+ private readonly maxRetries;
100
+ private readonly maxRetryDelayMs;
101
+ private readonly extraHeaders;
102
+ private readonly userAgent;
103
+ private readonly sleep;
104
+ private readonly random;
105
+ constructor(options: PosthasteOptions);
106
+ /** A request whose 204 or empty body is expected. */
107
+ requestVoid(req: InternalRequest): Promise<void>;
108
+ request<T>(req: InternalRequest): Promise<T>;
109
+ /**
110
+ * Send, retry, and map a failure onto a `PosthasteError`.
111
+ *
112
+ * Returns the status alongside the body text because the send path needs the
113
+ * status itself — 202 and 200 are both success there and mean different
114
+ * things — and a caller cannot recover it from the JSON.
115
+ */
116
+ send(req: InternalRequest): Promise<{
117
+ status: number;
118
+ text: string;
119
+ }>;
120
+ /**
121
+ * How long to wait before repeating this request, or `null` for "do not".
122
+ *
123
+ * The decision branches on `error.type` and NOT on the status, which is the
124
+ * whole point. All three of these are 429:
125
+ *
126
+ * `rate_limited` — the per-key request limiter. Transient, measured in
127
+ * seconds, and exactly what a retry is for.
128
+ * `daily_limit_reached` — the warmup cap. `Retry-After` is the seconds
129
+ * until midnight UTC.
130
+ * `monthly_limit_reached` — the plan allowance. `Retry-After` can be
131
+ * weeks.
132
+ *
133
+ * A retry loop written against the status treats the last two as a hiccup and
134
+ * hammers a wall it cannot get through until the calendar moves — burning the
135
+ * caller's own rate limit on requests that are all going to be refused.
136
+ */
137
+ private retryDelayFor;
138
+ private backoff;
139
+ /**
140
+ * Exponential with FULL jitter.
141
+ *
142
+ * Without jitter every client that failed on the same upstream blip retries
143
+ * in the same millisecond, and the recovery attempt is itself a thundering
144
+ * herd against a service that has only just come back.
145
+ */
146
+ private jitteredBackoff;
147
+ private attempt;
148
+ private buildUrl;
149
+ }
150
+ export { parseRetryAfter };
package/dist/http.js ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The transport.
3
+ *
4
+ * Zero dependencies: global `fetch` and nothing else. Everything interesting
5
+ * lives here — authentication, query building, timeouts, the retry policy and
6
+ * the error mapping — so every resource method below is a one-liner and none
7
+ * of them can quietly disagree about how a failure is handled.
8
+ */
9
+ import { PosthasteError, errorFromResponse, parseRetryAfter } from './errors.js';
10
+ const DEFAULT_BASE_URL = 'https://api.posthastemail.dev';
11
+ const DEFAULT_TIMEOUT_MS = 30_000;
12
+ const DEFAULT_MAX_RETRIES = 2;
13
+ const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
14
+ /** First backoff step. Doubles per attempt, with full jitter, capped at 8s. */
15
+ const BASE_BACKOFF_MS = 500;
16
+ const MAX_BACKOFF_MS = 8_000;
17
+ /** Kept in step with package.json — it is the only place it is stated twice. */
18
+ export const SDK_VERSION = '0.1.0';
19
+ const defaultSleep = (ms) => new Promise((resolve) => {
20
+ const timer = setTimeout(resolve, ms);
21
+ // Never hold a process open just to wait out a retry.
22
+ timer.unref?.();
23
+ });
24
+ export class HttpClient {
25
+ apiKey;
26
+ baseUrl;
27
+ doFetch;
28
+ timeoutMs;
29
+ maxRetries;
30
+ maxRetryDelayMs;
31
+ extraHeaders;
32
+ userAgent;
33
+ sleep;
34
+ random;
35
+ constructor(options) {
36
+ if (!options.apiKey || typeof options.apiKey !== 'string') {
37
+ throw new PosthasteError({
38
+ status: 0,
39
+ type: 'invalid_request',
40
+ message: 'A Posthaste API key is required: new Posthaste({ apiKey }).',
41
+ });
42
+ }
43
+ const fetchImpl = options.fetch ?? globalThis.fetch;
44
+ if (!fetchImpl) {
45
+ throw new PosthasteError({
46
+ status: 0,
47
+ type: 'invalid_request',
48
+ message: 'No global fetch is available in this runtime. Pass one: new Posthaste({ apiKey, fetch }).',
49
+ });
50
+ }
51
+ this.apiKey = options.apiKey;
52
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
53
+ this.doFetch = fetchImpl;
54
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
55
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
56
+ this.maxRetryDelayMs = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
57
+ this.extraHeaders = options.headers ?? {};
58
+ this.userAgent = options.userAgent
59
+ ? `posthaste-sdk-node/${SDK_VERSION} ${options.userAgent}`
60
+ : `posthaste-sdk-node/${SDK_VERSION}`;
61
+ this.sleep = options.sleep ?? defaultSleep;
62
+ this.random = options.random ?? Math.random;
63
+ }
64
+ /** A request whose 204 or empty body is expected. */
65
+ async requestVoid(req) {
66
+ await this.send(req);
67
+ }
68
+ async request(req) {
69
+ const { status, text } = await this.send(req);
70
+ if (status === 204 || text.length === 0)
71
+ return undefined;
72
+ try {
73
+ return JSON.parse(text);
74
+ }
75
+ catch (cause) {
76
+ throw new PosthasteError({
77
+ status,
78
+ type: 'unknown_error',
79
+ message: `Posthaste returned a ${status} whose body is not JSON.`,
80
+ cause,
81
+ });
82
+ }
83
+ }
84
+ /**
85
+ * Send, retry, and map a failure onto a `PosthasteError`.
86
+ *
87
+ * Returns the status alongside the body text because the send path needs the
88
+ * status itself — 202 and 200 are both success there and mean different
89
+ * things — and a caller cannot recover it from the JSON.
90
+ */
91
+ async send(req) {
92
+ const url = this.buildUrl(req.path, req.query);
93
+ const headers = {
94
+ ...this.extraHeaders,
95
+ ...(req.options?.headers ?? {}),
96
+ accept: 'application/json',
97
+ 'user-agent': this.userAgent,
98
+ // Last, so neither the per-client nor the per-call headers can replace it.
99
+ authorization: `Bearer ${this.apiKey}`,
100
+ };
101
+ let body;
102
+ if (req.body !== undefined) {
103
+ body = JSON.stringify(req.body);
104
+ headers['content-type'] = 'application/json';
105
+ }
106
+ let attempt = 0;
107
+ for (;;) {
108
+ let response;
109
+ try {
110
+ response = await this.attempt(url, { method: req.method, headers, body }, req.options);
111
+ }
112
+ catch (cause) {
113
+ const error = transportError(cause);
114
+ // A connection that never produced a response is safe to repeat only
115
+ // under the same rule as everything else: the request has to be one
116
+ // that repeating cannot duplicate.
117
+ if (req.idempotent && attempt < this.maxRetries) {
118
+ await this.backoff(attempt, undefined);
119
+ attempt += 1;
120
+ continue;
121
+ }
122
+ throw error;
123
+ }
124
+ if (response.status < 400) {
125
+ return { status: response.status, text: await response.text() };
126
+ }
127
+ const text = await response.text();
128
+ const error = errorFromResponse(response.status, text, response.headers.get('retry-after') ?? null);
129
+ const wait = this.retryDelayFor(error, req, attempt);
130
+ if (wait === null)
131
+ throw error;
132
+ await this.sleep(wait);
133
+ attempt += 1;
134
+ }
135
+ }
136
+ /**
137
+ * How long to wait before repeating this request, or `null` for "do not".
138
+ *
139
+ * The decision branches on `error.type` and NOT on the status, which is the
140
+ * whole point. All three of these are 429:
141
+ *
142
+ * `rate_limited` — the per-key request limiter. Transient, measured in
143
+ * seconds, and exactly what a retry is for.
144
+ * `daily_limit_reached` — the warmup cap. `Retry-After` is the seconds
145
+ * until midnight UTC.
146
+ * `monthly_limit_reached` — the plan allowance. `Retry-After` can be
147
+ * weeks.
148
+ *
149
+ * A retry loop written against the status treats the last two as a hiccup and
150
+ * hammers a wall it cannot get through until the calendar moves — burning the
151
+ * caller's own rate limit on requests that are all going to be refused.
152
+ */
153
+ retryDelayFor(error, req, attempt) {
154
+ if (attempt >= this.maxRetries)
155
+ return null;
156
+ if (!req.idempotent)
157
+ return null;
158
+ // Quota exhaustion. Never in-process, whatever Retry-After says.
159
+ if (error.isQuotaExhausted)
160
+ return null;
161
+ const retryable = error.isRateLimited || error.status === 408 || error.status === 429 || error.status >= 500;
162
+ if (!retryable)
163
+ return null;
164
+ if (error.retryAfterSeconds !== undefined) {
165
+ const requested = error.retryAfterSeconds * 1000;
166
+ // Honour it — unless honouring it would mean blocking for longer than a
167
+ // caller could reasonably want, in which case give them the error back
168
+ // and let them decide.
169
+ if (requested > this.maxRetryDelayMs)
170
+ return null;
171
+ return Math.max(0, requested);
172
+ }
173
+ return this.jitteredBackoff(attempt);
174
+ }
175
+ async backoff(attempt, retryAfterMs) {
176
+ await this.sleep(retryAfterMs ?? this.jitteredBackoff(attempt));
177
+ }
178
+ /**
179
+ * Exponential with FULL jitter.
180
+ *
181
+ * Without jitter every client that failed on the same upstream blip retries
182
+ * in the same millisecond, and the recovery attempt is itself a thundering
183
+ * herd against a service that has only just come back.
184
+ */
185
+ jitteredBackoff(attempt) {
186
+ const ceiling = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt);
187
+ return Math.round(this.random() * ceiling);
188
+ }
189
+ async attempt(url, init, options) {
190
+ if (this.timeoutMs <= 0 && !options?.signal) {
191
+ return this.doFetch(url, init);
192
+ }
193
+ const controller = new AbortController();
194
+ const abort = () => controller.abort(options?.signal?.reason);
195
+ let timer;
196
+ if (options?.signal) {
197
+ if (options.signal.aborted)
198
+ abort();
199
+ else
200
+ options.signal.addEventListener('abort', abort, { once: true });
201
+ }
202
+ if (this.timeoutMs > 0) {
203
+ timer = setTimeout(() => controller.abort(new TimeoutAbort()), this.timeoutMs);
204
+ timer.unref?.();
205
+ }
206
+ try {
207
+ return await this.doFetch(url, { ...init, signal: controller.signal });
208
+ }
209
+ finally {
210
+ if (timer)
211
+ clearTimeout(timer);
212
+ options?.signal?.removeEventListener('abort', abort);
213
+ }
214
+ }
215
+ buildUrl(path, query) {
216
+ const search = [];
217
+ for (const [key, value] of Object.entries(query ?? {})) {
218
+ // `undefined` means "not asked for". `null` is never produced by the
219
+ // param builders, and an empty string is a real, if odd, filter.
220
+ if (value === undefined)
221
+ continue;
222
+ search.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
223
+ }
224
+ return `${this.baseUrl}${path}${search.length > 0 ? `?${search.join('&')}` : ''}`;
225
+ }
226
+ }
227
+ /** Marker so an abort we caused is distinguishable from one the caller caused. */
228
+ class TimeoutAbort extends Error {
229
+ constructor() {
230
+ super('Posthaste request timed out');
231
+ this.name = 'PosthasteTimeout';
232
+ }
233
+ }
234
+ function transportError(cause) {
235
+ const timedOut = cause instanceof TimeoutAbort ||
236
+ (typeof cause === 'object' &&
237
+ cause !== null &&
238
+ cause.name === 'PosthasteTimeout') ||
239
+ (typeof cause === 'object' &&
240
+ cause !== null &&
241
+ cause.cause instanceof TimeoutAbort);
242
+ if (timedOut) {
243
+ return new PosthasteError({
244
+ status: 0,
245
+ type: 'timeout',
246
+ message: 'The Posthaste request timed out before the server responded.',
247
+ cause,
248
+ });
249
+ }
250
+ const message = cause instanceof Error ? cause.message : String(cause);
251
+ return new PosthasteError({
252
+ status: 0,
253
+ type: 'connection_error',
254
+ message: `Could not reach Posthaste: ${message}`,
255
+ cause,
256
+ });
257
+ }
258
+ export { parseRetryAfter };
package/dist/ids.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Public identifiers.
3
+ *
4
+ * The API never returns a bare UUID. Every id is `<prefix>_<22 chars of
5
+ * base64url>`, which means a value that turns up in a log or a support ticket
6
+ * announces what it is, and passing a domain id where a message id belongs is
7
+ * visible rather than silent.
8
+ *
9
+ * The template-literal types below encode that on the way OUT — a `MessageId`
10
+ * read off a response is known to start `msg_`. They are deliberately NOT used
11
+ * on the way IN: an id loaded from your own database is a `string`, and
12
+ * demanding a cast to pass it back would be a type that punishes correct code.
13
+ * Every method therefore accepts a plain `string` and returns a prefixed one.
14
+ */
15
+ export type PrefixedId<Prefix extends string> = `${Prefix}_${string}`;
16
+ export type AccountId = PrefixedId<'acct'>;
17
+ export type ApiKeyId = PrefixedId<'key'>;
18
+ export type DomainId = PrefixedId<'dom'>;
19
+ export type MessageId = PrefixedId<'msg'>;
20
+ export type EventId = PrefixedId<'evt'>;
21
+ export type SuppressionId = PrefixedId<'sup'>;
22
+ export type WebhookId = PrefixedId<'whk'>;
23
+ export type SubscriptionId = PrefixedId<'sub'>;
24
+ export type PaymentId = PrefixedId<'pay'>;
25
+ export type InvoiceId = PrefixedId<'inv'>;
package/dist/ids.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Public identifiers.
3
+ *
4
+ * The API never returns a bare UUID. Every id is `<prefix>_<22 chars of
5
+ * base64url>`, which means a value that turns up in a log or a support ticket
6
+ * announces what it is, and passing a domain id where a message id belongs is
7
+ * visible rather than silent.
8
+ *
9
+ * The template-literal types below encode that on the way OUT — a `MessageId`
10
+ * read off a response is known to start `msg_`. They are deliberately NOT used
11
+ * on the way IN: an id loaded from your own database is a `string`, and
12
+ * demanding a cast to pass it back would be a type that punishes correct code.
13
+ * Every method therefore accepts a plain `string` and returns a prefixed one.
14
+ */
15
+ export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @posthaste/sdk — the official TypeScript SDK for the Posthaste
3
+ * transactional email API.
4
+ *
5
+ * Zero runtime dependencies: global `fetch` and `node:crypto`, nothing else.
6
+ */
7
+ export { Posthaste } from './client.js';
8
+ export { AccountResource, ApiKeysResource, BillingResource, DomainsResource, EmailsResource, MessagesResource, SuppressionsResource, WebhooksResource, } from './client.js';
9
+ export { PosthasteError, isPosthasteError, type PosthasteErrorType, type KnownErrorType, type FieldError, } from './errors.js';
10
+ export { SDK_VERSION, type PosthasteOptions, type RequestOptions, type FetchLike, type PosthasteRequestInit, type PosthasteResponse, } from './http.js';
11
+ export { autoPaginate, collect, type PageFetcher } from './pagination.js';
12
+ export { verifyWebhook, parseWebhookEvent, SIGNATURE_HEADER, DELIVERY_ID_HEADER, ATTEMPT_HEADER, type VerifyWebhookOptions, type WebhookVerifyResult, type WebhookVerifyFailure, } from './webhooks.js';
13
+ export type { AccountId, ApiKeyId, DomainId, EventId, InvoiceId, MessageId, PaymentId, PrefixedId, SubscriptionId, SuppressionId, WebhookId, } from './ids.js';
14
+ export { EVENT_TYPES, MESSAGE_STATUSES, SCOPES, SUPPRESSION_REASONS, type Account, type AccountPlan, type AccountSending, type AccountStatus, type AccountSubscriptionSummary, type ApiKey, type Billing, type BillingCharge, type BillingEvent, type BillingHistory, type BillingPayment, type BillingPlan, type BillingProfile, type BillingSubscription, type ChainVerification, type CheckStatus, type CloudflarePublishResult, type ConnectCloudflareParams, type CreateDomainParams, type CreateSuppressionParams, type CreateWebhookParams, type CreatedDomain, type CreatedSuppression, type CreatedWebhook, type DnsHost, type DnsRecord, type Domain, type DomainSetup, type DomainStatus, type DomainVerification, type EventType, type Invoice, type InvoiceDetail, type KeyEnvironment, type List, type ListMessagesParams, type ListSuppressionsParams, type Message, type MessageContent, type MessageStats, type MessageStatsParams, type MessageStatus, type MessageSummary, type Page, type PaginationParams, type PaymentStatus, type PlanId, type RecordCheck, type Scope, type SendEmailParams, type SendEmailResult, type StatsDay, type StatsTotals, type SubscriptionStatus, type Suppression, type SuppressionReason, type Usage, type UsageDay, type WaybillEntry, type Webhook, type WebhookEvent, type WebhookStatus, } from './types.js';
15
+ export { Posthaste as default } from './client.js';
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @posthaste/sdk — the official TypeScript SDK for the Posthaste
3
+ * transactional email API.
4
+ *
5
+ * Zero runtime dependencies: global `fetch` and `node:crypto`, nothing else.
6
+ */
7
+ export { Posthaste } from './client.js';
8
+ export { AccountResource, ApiKeysResource, BillingResource, DomainsResource, EmailsResource, MessagesResource, SuppressionsResource, WebhooksResource, } from './client.js';
9
+ export { PosthasteError, isPosthasteError, } from './errors.js';
10
+ export { SDK_VERSION, } from './http.js';
11
+ export { autoPaginate, collect } from './pagination.js';
12
+ export { verifyWebhook, parseWebhookEvent, SIGNATURE_HEADER, DELIVERY_ID_HEADER, ATTEMPT_HEADER, } from './webhooks.js';
13
+ export { EVENT_TYPES, MESSAGE_STATUSES, SCOPES, SUPPRESSION_REASONS, } from './types.js';
14
+ export { Posthaste as default } from './client.js';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Keyset pagination.
3
+ *
4
+ * The API pages by cursor rather than by offset: `limit` + `before`, answering
5
+ * with `{ data, hasMore, nextCursor }`. `before` is the id of the last row you
6
+ * received, which is what `nextCursor` hands you.
7
+ *
8
+ * THE TRAP THIS MODULE EXISTS TO CLOSE
9
+ *
10
+ * The obvious loop is `while (nextCursor) { … }`. It is wrong. `nextCursor` is
11
+ * derived from the last row of a page, and at least one endpoint on this API
12
+ * returns a non-null cursor on its final page — the documentation calls it out
13
+ * for `/v1/inbound/messages`. A cursor loop over such an endpoint asks for the
14
+ * page after the last one, gets an empty page with the same cursor back, and
15
+ * spins forever. Nothing about it looks wrong in a log; it just never finishes.
16
+ *
17
+ * `hasMore` is the server's actual answer to "is there another page", computed
18
+ * by fetching one row more than you asked for. It is the only correct
19
+ * condition, so `autoPaginate` is the API this SDK puts in front of people —
20
+ * `list` is still there for anyone who wants a single page.
21
+ */
22
+ import type { Page, PaginationParams } from './types.js';
23
+ export type PageFetcher<TItem, TParams> = (params: TParams) => Promise<Page<TItem>>;
24
+ /**
25
+ * Walk every page and yield every item.
26
+ *
27
+ * Belt and braces, because an infinite loop in someone's job runner is a bad
28
+ * way to learn about a contract change:
29
+ *
30
+ * - stops on `hasMore === false`, always;
31
+ * - stops if a page comes back empty, because there is nothing to advance to;
32
+ * - stops if the cursor does not MOVE, which is what a non-null terminal
33
+ * cursor looks like from here.
34
+ */
35
+ export declare function autoPaginate<TItem extends {
36
+ id: string;
37
+ }, TParams extends PaginationParams>(fetchPage: PageFetcher<TItem, TParams>, params: TParams): AsyncGenerator<TItem, void, undefined>;
38
+ /**
39
+ * Collect an auto-paginated stream into an array.
40
+ *
41
+ * `maxItems` is not optional garnish. Draining an unbounded list into memory is
42
+ * how a helper like this turns into an incident on the one account that has
43
+ * four million messages, so there is a ceiling and the caller chooses it.
44
+ */
45
+ export declare function collect<T>(source: AsyncIterable<T>, maxItems: number): Promise<T[]>;