@mxraven/mail 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 @@
1
+ {"version":3,"file":"index.cjs","names":["Buffer"],"sources":["../../src/feedback/errors.ts","../../src/feedback/client.ts"],"sourcesContent":["/**\n * A non-success response from the feedback service.\n *\n * @public\n */\nexport class FeedbackError extends Error {\n /** The HTTP response status. */\n readonly statusCode: number;\n\n /**\n * The service's error message when one was returned, otherwise the HTTP\n * status text.\n */\n readonly detail: string;\n\n /**\n * @param options - The response status and service error message.\n */\n constructor(options: { statusCode: number; detail: string }) {\n super(\n options.detail === \"\"\n ? `feedback: request failed with status ${options.statusCode}`\n : `feedback: request failed with status ${options.statusCode}: ${options.detail}`,\n );\n this.name = \"FeedbackError\";\n this.statusCode = options.statusCode;\n this.detail = options.detail;\n }\n\n /**\n * Reports whether the request may succeed if retried later.\n *\n * Rate limits (429) and server-side failures (5xx) are retryable.\n */\n get retryable(): boolean {\n return this.statusCode === 429 || this.statusCode >= 500;\n }\n}\n","/**\n * The mxRaven feedback and one-click unsubscribe client.\n */\n\nimport { Buffer } from \"node:buffer\";\n\nimport { FeedbackError } from \"./errors.js\";\n\n/** A minimal `fetch`-compatible function. @public */\nexport type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;\n\n/** The training label for a message. */\nexport const disposition = {\n /** Marks the message as spam. */\n spam: \"spam\",\n /** Marks the message as not spam. */\n ham: \"ham\",\n} as const;\n\n/** The training label for a message. */\nexport type Disposition = (typeof disposition)[keyof typeof disposition];\n\n/** The outcome of a successful learning request. */\nexport interface LearningResult {\n /** The service status, normally `learned`. */\n readonly status: string;\n /** The training label that was applied. */\n readonly disposition: Disposition;\n /** The tenant that owns the matched message. */\n readonly tenantId: string;\n /** The listener that processed the matched message. */\n readonly listenerId: string;\n /** Which stored hash matched the submitted bytes. */\n readonly matchedHashKind: string;\n}\n\n/** Options for a {@link Client}. */\nexport interface ClientOptions {\n /** The feedback service base URL, for example `https://feedback.mxraven.com`. */\n readonly baseUrl: string;\n /** The submission API key username. Required for learning. */\n readonly username?: string;\n /** The submission API key secret. Required for learning. */\n readonly secret?: string;\n /** The `fetch` implementation to use. Defaults to the global `fetch`. */\n readonly fetch?: FetchLike;\n /** The request timeout in milliseconds. Defaults to 30000. */\n readonly timeout?: number;\n}\n\n/** Per-call options for feedback requests. */\nexport interface RequestOptions {\n /** Cancels the request. */\n readonly signal?: AbortSignal;\n}\n\ninterface Credentials {\n readonly username: string;\n readonly secret: string;\n}\n\nconst DEFAULT_TIMEOUT = 30_000;\nconst MAX_ERROR_BODY = 4096;\nconst MAX_RESULT_BODY = 64 * 1024;\n\n/**\n * Calls the mxRaven feedback service.\n *\n * Learning requests authenticate with the same submission API key used for SMTP\n * submission, over HTTP Basic auth. A client is safe for concurrent use.\n *\n * @example\n * ```ts\n * const secret = process.env.MXRAVEN_SECRET;\n * if (secret === undefined || secret === \"\") {\n * throw new Error(\"MXRAVEN_SECRET is required\");\n * }\n *\n * const client = new Client({\n * baseUrl: \"https://feedback.mxraven.com\",\n * username: \"mxr_tx_ab12cd34ef56\",\n * secret,\n * });\n * const result = await client.learnSpam(rawMessageBytes);\n * ```\n *\n * @public\n */\nexport class Client {\n private readonly baseUrl: string;\n private readonly credentials: Credentials | undefined;\n private readonly fetchImpl: FetchLike;\n private readonly timeout: number;\n\n /**\n * @param options - The base URL, credentials, and transport options.\n * @throws `Error` When the base URL is missing or an option is invalid.\n */\n constructor(options: ClientOptions) {\n const baseUrl = options.baseUrl.trim();\n if (baseUrl === \"\") {\n throw new Error(\"feedback: base URL is required\");\n }\n\n const username = (options.username ?? \"\").trim();\n const secret = options.secret ?? \"\";\n if ((username === \"\") !== (secret === \"\")) {\n throw new Error(\"feedback: username and secret must both be provided\");\n }\n\n const timeout = options.timeout ?? DEFAULT_TIMEOUT;\n if (!Number.isFinite(timeout) || timeout <= 0) {\n throw new Error(`feedback: invalid timeout ${timeout}`);\n }\n\n this.baseUrl = baseUrl.replace(/\\/+$/, \"\");\n this.credentials = username === \"\" ? undefined : { username, secret };\n this.fetchImpl = options.fetch ?? globalThis.fetch;\n this.timeout = timeout;\n }\n\n /**\n * Submits one training example.\n *\n * `rawMime` must be the exact raw RFC 822 bytes that mxRaven processed; the\n * service matches them against stored evidence by SHA-256. A message with no\n * matching evidence fails with a {@link FeedbackError} whose `statusCode` is\n * 404.\n *\n * @param label - The training label to apply.\n * @param rawMime - The exact raw message bytes.\n * @param options - An optional cancellation signal.\n * @returns The service's learning result.\n * @throws {@link FeedbackError} When the service returns a non-success status.\n *\n * @public\n */\n async learn(\n label: Disposition,\n rawMime: Uint8Array,\n options: RequestOptions = {},\n ): Promise<LearningResult> {\n if (label !== disposition.spam && label !== disposition.ham) {\n throw new Error(`feedback: invalid disposition ${JSON.stringify(label)}`);\n }\n const credentials = this.credentials;\n if (credentials === undefined) {\n throw new Error(\"feedback: credentials are required for learning\");\n }\n\n const endpoint = `${this.baseUrl}/v1/feedback/learn/${label}`;\n const response = await this.perform(\n endpoint,\n {\n \"Content-Type\": \"message/rfc822\",\n Authorization: `Basic ${encodeBasicAuth(credentials)}`,\n },\n rawMime,\n \"submit learning request\",\n options,\n );\n if (!response.ok) {\n throw await this.toError(response);\n }\n\n const body = await readBounded(response, MAX_RESULT_BODY, \"learning response\");\n return parseLearningResult(body);\n }\n\n /** Teaches the spam filter that `rawMime` is spam. */\n learnSpam(rawMime: Uint8Array, options: RequestOptions = {}): Promise<LearningResult> {\n return this.learn(disposition.spam, rawMime, options);\n }\n\n /** Teaches the spam filter that `rawMime` is not spam. */\n learnHam(rawMime: Uint8Array, options: RequestOptions = {}): Promise<LearningResult> {\n return this.learn(disposition.ham, rawMime, options);\n }\n\n /**\n * Performs an RFC 8058 one-click unsubscribe for a token.\n *\n * This is the operation a recipient mail client performs against the\n * `List-Unsubscribe` URL; applications rarely call it directly. It is\n * unauthenticated.\n *\n * @param token - The signed unsubscribe token.\n * @param options - An optional cancellation signal.\n * @throws {@link FeedbackError} When the service returns a non-success status.\n *\n * @public\n */\n async unsubscribe(token: string, options: RequestOptions = {}): Promise<void> {\n const value = token.trim();\n if (value === \"\") {\n throw new Error(\"feedback: unsubscribe token is empty\");\n }\n\n const endpoint = `${this.baseUrl}/v1/feedback/unsubscribe/${encodeURIComponent(value)}`;\n const response = await this.perform(\n endpoint,\n { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n \"List-Unsubscribe=One-Click\",\n \"submit unsubscribe request\",\n options,\n );\n if (!response.ok) {\n throw await this.toError(response);\n }\n }\n\n private async perform(\n endpoint: string,\n headers: Record<string, string>,\n body: Uint8Array | string,\n label: string,\n options: RequestOptions,\n ): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(\n () => controller.abort(new Error(\"feedback: request timed out\")),\n this.timeout,\n );\n timer.unref();\n\n const signal = options.signal;\n const onAbort = (): void => controller.abort(signal?.reason);\n signal?.addEventListener(\"abort\", onAbort, { once: true });\n\n try {\n return await this.fetchImpl(endpoint, {\n method: \"POST\",\n headers,\n body,\n signal: controller.signal,\n });\n } catch (error) {\n throw new Error(`feedback: ${label}`, { cause: error });\n } finally {\n clearTimeout(timer);\n signal?.removeEventListener(\"abort\", onAbort);\n }\n }\n\n private async toError(response: Response): Promise<FeedbackError> {\n let detail = `${response.status} ${response.statusText}`.trim();\n try {\n const body = await readBounded(response, MAX_ERROR_BODY, \"error response\");\n const parsed = JSON.parse(new TextDecoder().decode(body)) as { error?: unknown };\n if (typeof parsed.error === \"string\" && parsed.error.trim() !== \"\") {\n detail = parsed.error.trim();\n }\n } catch {\n // Keep the status text when the error body is unusable.\n }\n return new FeedbackError({ statusCode: response.status, detail });\n }\n}\n\n/** Encodes HTTP Basic credentials. */\nfunction encodeBasicAuth(credentials: Credentials): string {\n return Buffer.from(`${credentials.username}:${credentials.secret}`, \"utf8\").toString(\"base64\");\n}\n\n/** Parses a learning response into its public shape. */\nfunction parseLearningResult(body: Uint8Array): LearningResult {\n let parsed: unknown;\n try {\n parsed = JSON.parse(new TextDecoder().decode(body));\n } catch (error) {\n throw new Error(\"feedback: decode learning response\", { cause: error });\n }\n if (parsed === null || typeof parsed !== \"object\") {\n throw new Error(\"feedback: decode learning response\");\n }\n\n const wire = parsed as Record<string, unknown>;\n return {\n status: typeof wire.status === \"string\" ? wire.status : \"\",\n disposition: wire.disposition === disposition.ham ? disposition.ham : disposition.spam,\n tenantId: typeof wire.tenant_id === \"string\" ? wire.tenant_id : \"\",\n listenerId: typeof wire.listener_id === \"string\" ? wire.listener_id : \"\",\n matchedHashKind: typeof wire.matched_hash_kind === \"string\" ? wire.matched_hash_kind : \"\",\n };\n}\n\n/** Reads a response body, rejecting when it exceeds a byte limit. */\nasync function readBounded(response: Response, limit: number, label: string): Promise<Uint8Array> {\n const stream = response.body;\n if (stream === null) {\n return new Uint8Array();\n }\n\n const reader = stream.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) {\n break;\n }\n if (value !== undefined) {\n total += value.byteLength;\n if (total > limit) {\n void reader.cancel().catch(() => undefined);\n throw new Error(`feedback: ${label} exceeds ${limit} bytes`);\n }\n chunks.push(value);\n }\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return result;\n}\n"],"mappings":"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"}
@@ -0,0 +1,149 @@
1
+ //#region src/feedback/client.d.ts
2
+ /**
3
+ * The mxRaven feedback and one-click unsubscribe client.
4
+ */
5
+ /** A minimal `fetch`-compatible function. @public */
6
+ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
7
+ /** The training label for a message. */
8
+ export declare const disposition: {
9
+ /** Marks the message as spam. */
10
+ readonly spam: "spam";
11
+ /** Marks the message as not spam. */
12
+ readonly ham: "ham";
13
+ };
14
+ /** The training label for a message. */
15
+ type Disposition = (typeof disposition)[keyof typeof disposition];
16
+ /** The outcome of a successful learning request. */
17
+ interface LearningResult {
18
+ /** The service status, normally `learned`. */
19
+ readonly status: string;
20
+ /** The training label that was applied. */
21
+ readonly disposition: Disposition;
22
+ /** The tenant that owns the matched message. */
23
+ readonly tenantId: string;
24
+ /** The listener that processed the matched message. */
25
+ readonly listenerId: string;
26
+ /** Which stored hash matched the submitted bytes. */
27
+ readonly matchedHashKind: string;
28
+ }
29
+ /** Options for a {@link Client}. */
30
+ interface ClientOptions {
31
+ /** The feedback service base URL, for example `https://feedback.mxraven.com`. */
32
+ readonly baseUrl: string;
33
+ /** The submission API key username. Required for learning. */
34
+ readonly username?: string;
35
+ /** The submission API key secret. Required for learning. */
36
+ readonly secret?: string;
37
+ /** The `fetch` implementation to use. Defaults to the global `fetch`. */
38
+ readonly fetch?: FetchLike;
39
+ /** The request timeout in milliseconds. Defaults to 30000. */
40
+ readonly timeout?: number;
41
+ }
42
+ /** Per-call options for feedback requests. */
43
+ interface RequestOptions {
44
+ /** Cancels the request. */
45
+ readonly signal?: AbortSignal;
46
+ }
47
+ /**
48
+ * Calls the mxRaven feedback service.
49
+ *
50
+ * Learning requests authenticate with the same submission API key used for SMTP
51
+ * submission, over HTTP Basic auth. A client is safe for concurrent use.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const secret = process.env.MXRAVEN_SECRET;
56
+ * if (secret === undefined || secret === "") {
57
+ * throw new Error("MXRAVEN_SECRET is required");
58
+ * }
59
+ *
60
+ * const client = new Client({
61
+ * baseUrl: "https://feedback.mxraven.com",
62
+ * username: "mxr_tx_ab12cd34ef56",
63
+ * secret,
64
+ * });
65
+ * const result = await client.learnSpam(rawMessageBytes);
66
+ * ```
67
+ *
68
+ * @public
69
+ */
70
+ export declare class Client {
71
+ private readonly baseUrl;
72
+ private readonly credentials;
73
+ private readonly fetchImpl;
74
+ private readonly timeout;
75
+ /**
76
+ * @param options - The base URL, credentials, and transport options.
77
+ * @throws `Error` When the base URL is missing or an option is invalid.
78
+ */
79
+ constructor(options: ClientOptions);
80
+ /**
81
+ * Submits one training example.
82
+ *
83
+ * `rawMime` must be the exact raw RFC 822 bytes that mxRaven processed; the
84
+ * service matches them against stored evidence by SHA-256. A message with no
85
+ * matching evidence fails with a {@link FeedbackError} whose `statusCode` is
86
+ * 404.
87
+ *
88
+ * @param label - The training label to apply.
89
+ * @param rawMime - The exact raw message bytes.
90
+ * @param options - An optional cancellation signal.
91
+ * @returns The service's learning result.
92
+ * @throws {@link FeedbackError} When the service returns a non-success status.
93
+ *
94
+ * @public
95
+ */
96
+ learn(label: Disposition, rawMime: Uint8Array, options?: RequestOptions): Promise<LearningResult>;
97
+ /** Teaches the spam filter that `rawMime` is spam. */
98
+ learnSpam(rawMime: Uint8Array, options?: RequestOptions): Promise<LearningResult>;
99
+ /** Teaches the spam filter that `rawMime` is not spam. */
100
+ learnHam(rawMime: Uint8Array, options?: RequestOptions): Promise<LearningResult>;
101
+ /**
102
+ * Performs an RFC 8058 one-click unsubscribe for a token.
103
+ *
104
+ * This is the operation a recipient mail client performs against the
105
+ * `List-Unsubscribe` URL; applications rarely call it directly. It is
106
+ * unauthenticated.
107
+ *
108
+ * @param token - The signed unsubscribe token.
109
+ * @param options - An optional cancellation signal.
110
+ * @throws {@link FeedbackError} When the service returns a non-success status.
111
+ *
112
+ * @public
113
+ */
114
+ unsubscribe(token: string, options?: RequestOptions): Promise<void>;
115
+ private perform;
116
+ private toError;
117
+ }
118
+ //#endregion
119
+ //#region src/feedback/errors.d.ts
120
+ /**
121
+ * A non-success response from the feedback service.
122
+ *
123
+ * @public
124
+ */
125
+ export declare class FeedbackError extends Error {
126
+ /** The HTTP response status. */
127
+ readonly statusCode: number;
128
+ /**
129
+ * The service's error message when one was returned, otherwise the HTTP
130
+ * status text.
131
+ */
132
+ readonly detail: string;
133
+ /**
134
+ * @param options - The response status and service error message.
135
+ */
136
+ constructor(options: {
137
+ statusCode: number;
138
+ detail: string;
139
+ });
140
+ /**
141
+ * Reports whether the request may succeed if retried later.
142
+ *
143
+ * Rate limits (429) and server-side failures (5xx) are retryable.
144
+ */
145
+ get retryable(): boolean;
146
+ }
147
+ //#endregion
148
+ export type { ClientOptions, Disposition, FetchLike, LearningResult, RequestOptions };
149
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/feedback/client.ts","../../src/feedback/errors.ts"],"mappings":";;;;;KASY,aAAa,aAAa,OAAO,gBAAgB,QAAQ;;qBAGxD;;WAAA;;WAAA;;;KAQD,sBAAsB,0BAA0B;;UAG3C;;WAEN;;WAEA,aAAa;;WAEb;;WAEA;;WAEA;;;UAIM;;WAEN;;WAEA;;WAEA;;WAEA,QAAQ;;WAER;;;UAIM;;WAEN,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;qBAmCP;mBACM;mBACA;mBACA;mBACA;;;;;EAML,YAAA,SAAS;;;;;;;;;;;;;;;;;EAuCf,MACJ,OAAO,aACP,SAAS,YACT,UAAS,iBACR,QAAQ;;EA6BX,UAAU,SAAS,YAAY,UAAS,iBAAsB,QAAQ;;EAKtE,SAAS,SAAS,YAAY,UAAS,iBAAsB,QAAQ;;;;;;;;;;;;;;EAiB/D,YAAY,eAAe,UAAS,iBAAsB;UAmBlD;UAiCA;;;;;;;;;qBC/OH,sBAAsB;;WAExB;;;;;WAMA;;;;EAKG,YAAA;IAAW;IAAoB;;;;;;;MAgBvC"}
@@ -0,0 +1,149 @@
1
+ //#region src/feedback/client.d.ts
2
+ /**
3
+ * The mxRaven feedback and one-click unsubscribe client.
4
+ */
5
+ /** A minimal `fetch`-compatible function. @public */
6
+ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
7
+ /** The training label for a message. */
8
+ export declare const disposition: {
9
+ /** Marks the message as spam. */
10
+ readonly spam: "spam";
11
+ /** Marks the message as not spam. */
12
+ readonly ham: "ham";
13
+ };
14
+ /** The training label for a message. */
15
+ type Disposition = (typeof disposition)[keyof typeof disposition];
16
+ /** The outcome of a successful learning request. */
17
+ interface LearningResult {
18
+ /** The service status, normally `learned`. */
19
+ readonly status: string;
20
+ /** The training label that was applied. */
21
+ readonly disposition: Disposition;
22
+ /** The tenant that owns the matched message. */
23
+ readonly tenantId: string;
24
+ /** The listener that processed the matched message. */
25
+ readonly listenerId: string;
26
+ /** Which stored hash matched the submitted bytes. */
27
+ readonly matchedHashKind: string;
28
+ }
29
+ /** Options for a {@link Client}. */
30
+ interface ClientOptions {
31
+ /** The feedback service base URL, for example `https://feedback.mxraven.com`. */
32
+ readonly baseUrl: string;
33
+ /** The submission API key username. Required for learning. */
34
+ readonly username?: string;
35
+ /** The submission API key secret. Required for learning. */
36
+ readonly secret?: string;
37
+ /** The `fetch` implementation to use. Defaults to the global `fetch`. */
38
+ readonly fetch?: FetchLike;
39
+ /** The request timeout in milliseconds. Defaults to 30000. */
40
+ readonly timeout?: number;
41
+ }
42
+ /** Per-call options for feedback requests. */
43
+ interface RequestOptions {
44
+ /** Cancels the request. */
45
+ readonly signal?: AbortSignal;
46
+ }
47
+ /**
48
+ * Calls the mxRaven feedback service.
49
+ *
50
+ * Learning requests authenticate with the same submission API key used for SMTP
51
+ * submission, over HTTP Basic auth. A client is safe for concurrent use.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const secret = process.env.MXRAVEN_SECRET;
56
+ * if (secret === undefined || secret === "") {
57
+ * throw new Error("MXRAVEN_SECRET is required");
58
+ * }
59
+ *
60
+ * const client = new Client({
61
+ * baseUrl: "https://feedback.mxraven.com",
62
+ * username: "mxr_tx_ab12cd34ef56",
63
+ * secret,
64
+ * });
65
+ * const result = await client.learnSpam(rawMessageBytes);
66
+ * ```
67
+ *
68
+ * @public
69
+ */
70
+ export declare class Client {
71
+ private readonly baseUrl;
72
+ private readonly credentials;
73
+ private readonly fetchImpl;
74
+ private readonly timeout;
75
+ /**
76
+ * @param options - The base URL, credentials, and transport options.
77
+ * @throws `Error` When the base URL is missing or an option is invalid.
78
+ */
79
+ constructor(options: ClientOptions);
80
+ /**
81
+ * Submits one training example.
82
+ *
83
+ * `rawMime` must be the exact raw RFC 822 bytes that mxRaven processed; the
84
+ * service matches them against stored evidence by SHA-256. A message with no
85
+ * matching evidence fails with a {@link FeedbackError} whose `statusCode` is
86
+ * 404.
87
+ *
88
+ * @param label - The training label to apply.
89
+ * @param rawMime - The exact raw message bytes.
90
+ * @param options - An optional cancellation signal.
91
+ * @returns The service's learning result.
92
+ * @throws {@link FeedbackError} When the service returns a non-success status.
93
+ *
94
+ * @public
95
+ */
96
+ learn(label: Disposition, rawMime: Uint8Array, options?: RequestOptions): Promise<LearningResult>;
97
+ /** Teaches the spam filter that `rawMime` is spam. */
98
+ learnSpam(rawMime: Uint8Array, options?: RequestOptions): Promise<LearningResult>;
99
+ /** Teaches the spam filter that `rawMime` is not spam. */
100
+ learnHam(rawMime: Uint8Array, options?: RequestOptions): Promise<LearningResult>;
101
+ /**
102
+ * Performs an RFC 8058 one-click unsubscribe for a token.
103
+ *
104
+ * This is the operation a recipient mail client performs against the
105
+ * `List-Unsubscribe` URL; applications rarely call it directly. It is
106
+ * unauthenticated.
107
+ *
108
+ * @param token - The signed unsubscribe token.
109
+ * @param options - An optional cancellation signal.
110
+ * @throws {@link FeedbackError} When the service returns a non-success status.
111
+ *
112
+ * @public
113
+ */
114
+ unsubscribe(token: string, options?: RequestOptions): Promise<void>;
115
+ private perform;
116
+ private toError;
117
+ }
118
+ //#endregion
119
+ //#region src/feedback/errors.d.ts
120
+ /**
121
+ * A non-success response from the feedback service.
122
+ *
123
+ * @public
124
+ */
125
+ export declare class FeedbackError extends Error {
126
+ /** The HTTP response status. */
127
+ readonly statusCode: number;
128
+ /**
129
+ * The service's error message when one was returned, otherwise the HTTP
130
+ * status text.
131
+ */
132
+ readonly detail: string;
133
+ /**
134
+ * @param options - The response status and service error message.
135
+ */
136
+ constructor(options: {
137
+ statusCode: number;
138
+ detail: string;
139
+ });
140
+ /**
141
+ * Reports whether the request may succeed if retried later.
142
+ *
143
+ * Rate limits (429) and server-side failures (5xx) are retryable.
144
+ */
145
+ get retryable(): boolean;
146
+ }
147
+ //#endregion
148
+ export type { ClientOptions, Disposition, FetchLike, LearningResult, RequestOptions };
149
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/feedback/client.ts","../../src/feedback/errors.ts"],"mappings":";;;;;KASY,aAAa,aAAa,OAAO,gBAAgB,QAAQ;;qBAGxD;;WAAA;;WAAA;;;KAQD,sBAAsB,0BAA0B;;UAG3C;;WAEN;;WAEA,aAAa;;WAEb;;WAEA;;WAEA;;;UAIM;;WAEN;;WAEA;;WAEA;;WAEA,QAAQ;;WAER;;;UAIM;;WAEN,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;qBAmCP;mBACM;mBACA;mBACA;mBACA;;;;;EAML,YAAA,SAAS;;;;;;;;;;;;;;;;;EAuCf,MACJ,OAAO,aACP,SAAS,YACT,UAAS,iBACR,QAAQ;;EA6BX,UAAU,SAAS,YAAY,UAAS,iBAAsB,QAAQ;;EAKtE,SAAS,SAAS,YAAY,UAAS,iBAAsB,QAAQ;;;;;;;;;;;;;;EAiB/D,YAAY,eAAe,UAAS,iBAAsB;UAmBlD;UAiCA;;;;;;;;;qBC/OH,sBAAsB;;WAExB;;;;;WAMA;;;;EAKG,YAAA;IAAW;IAAoB;;;;;;;MAgBvC"}
@@ -0,0 +1,243 @@
1
+ import { Buffer } from "node:buffer";
2
+ //#region src/feedback/errors.ts
3
+ /**
4
+ * A non-success response from the feedback service.
5
+ *
6
+ * @public
7
+ */
8
+ var FeedbackError = class extends Error {
9
+ /** The HTTP response status. */
10
+ statusCode;
11
+ /**
12
+ * The service's error message when one was returned, otherwise the HTTP
13
+ * status text.
14
+ */
15
+ detail;
16
+ /**
17
+ * @param options - The response status and service error message.
18
+ */
19
+ constructor(options) {
20
+ super(options.detail === "" ? `feedback: request failed with status ${options.statusCode}` : `feedback: request failed with status ${options.statusCode}: ${options.detail}`);
21
+ this.name = "FeedbackError";
22
+ this.statusCode = options.statusCode;
23
+ this.detail = options.detail;
24
+ }
25
+ /**
26
+ * Reports whether the request may succeed if retried later.
27
+ *
28
+ * Rate limits (429) and server-side failures (5xx) are retryable.
29
+ */
30
+ get retryable() {
31
+ return this.statusCode === 429 || this.statusCode >= 500;
32
+ }
33
+ };
34
+ //#endregion
35
+ //#region src/feedback/client.ts
36
+ /**
37
+ * The mxRaven feedback and one-click unsubscribe client.
38
+ */
39
+ /** The training label for a message. */
40
+ const disposition = {
41
+ /** Marks the message as spam. */
42
+ spam: "spam",
43
+ /** Marks the message as not spam. */
44
+ ham: "ham"
45
+ };
46
+ const DEFAULT_TIMEOUT = 3e4;
47
+ const MAX_ERROR_BODY = 4096;
48
+ const MAX_RESULT_BODY = 65536;
49
+ /**
50
+ * Calls the mxRaven feedback service.
51
+ *
52
+ * Learning requests authenticate with the same submission API key used for SMTP
53
+ * submission, over HTTP Basic auth. A client is safe for concurrent use.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * const secret = process.env.MXRAVEN_SECRET;
58
+ * if (secret === undefined || secret === "") {
59
+ * throw new Error("MXRAVEN_SECRET is required");
60
+ * }
61
+ *
62
+ * const client = new Client({
63
+ * baseUrl: "https://feedback.mxraven.com",
64
+ * username: "mxr_tx_ab12cd34ef56",
65
+ * secret,
66
+ * });
67
+ * const result = await client.learnSpam(rawMessageBytes);
68
+ * ```
69
+ *
70
+ * @public
71
+ */
72
+ var Client = class {
73
+ baseUrl;
74
+ credentials;
75
+ fetchImpl;
76
+ timeout;
77
+ /**
78
+ * @param options - The base URL, credentials, and transport options.
79
+ * @throws `Error` When the base URL is missing or an option is invalid.
80
+ */
81
+ constructor(options) {
82
+ const baseUrl = options.baseUrl.trim();
83
+ if (baseUrl === "") throw new Error("feedback: base URL is required");
84
+ const username = (options.username ?? "").trim();
85
+ const secret = options.secret ?? "";
86
+ if (username === "" !== (secret === "")) throw new Error("feedback: username and secret must both be provided");
87
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
88
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error(`feedback: invalid timeout ${timeout}`);
89
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
90
+ this.credentials = username === "" ? void 0 : {
91
+ username,
92
+ secret
93
+ };
94
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
95
+ this.timeout = timeout;
96
+ }
97
+ /**
98
+ * Submits one training example.
99
+ *
100
+ * `rawMime` must be the exact raw RFC 822 bytes that mxRaven processed; the
101
+ * service matches them against stored evidence by SHA-256. A message with no
102
+ * matching evidence fails with a {@link FeedbackError} whose `statusCode` is
103
+ * 404.
104
+ *
105
+ * @param label - The training label to apply.
106
+ * @param rawMime - The exact raw message bytes.
107
+ * @param options - An optional cancellation signal.
108
+ * @returns The service's learning result.
109
+ * @throws {@link FeedbackError} When the service returns a non-success status.
110
+ *
111
+ * @public
112
+ */
113
+ async learn(label, rawMime, options = {}) {
114
+ if (label !== disposition.spam && label !== disposition.ham) throw new Error(`feedback: invalid disposition ${JSON.stringify(label)}`);
115
+ const credentials = this.credentials;
116
+ if (credentials === void 0) throw new Error("feedback: credentials are required for learning");
117
+ const endpoint = `${this.baseUrl}/v1/feedback/learn/${label}`;
118
+ const response = await this.perform(endpoint, {
119
+ "Content-Type": "message/rfc822",
120
+ Authorization: `Basic ${encodeBasicAuth(credentials)}`
121
+ }, rawMime, "submit learning request", options);
122
+ if (!response.ok) throw await this.toError(response);
123
+ return parseLearningResult(await readBounded(response, MAX_RESULT_BODY, "learning response"));
124
+ }
125
+ /** Teaches the spam filter that `rawMime` is spam. */
126
+ learnSpam(rawMime, options = {}) {
127
+ return this.learn(disposition.spam, rawMime, options);
128
+ }
129
+ /** Teaches the spam filter that `rawMime` is not spam. */
130
+ learnHam(rawMime, options = {}) {
131
+ return this.learn(disposition.ham, rawMime, options);
132
+ }
133
+ /**
134
+ * Performs an RFC 8058 one-click unsubscribe for a token.
135
+ *
136
+ * This is the operation a recipient mail client performs against the
137
+ * `List-Unsubscribe` URL; applications rarely call it directly. It is
138
+ * unauthenticated.
139
+ *
140
+ * @param token - The signed unsubscribe token.
141
+ * @param options - An optional cancellation signal.
142
+ * @throws {@link FeedbackError} When the service returns a non-success status.
143
+ *
144
+ * @public
145
+ */
146
+ async unsubscribe(token, options = {}) {
147
+ const value = token.trim();
148
+ if (value === "") throw new Error("feedback: unsubscribe token is empty");
149
+ const endpoint = `${this.baseUrl}/v1/feedback/unsubscribe/${encodeURIComponent(value)}`;
150
+ const response = await this.perform(endpoint, { "Content-Type": "application/x-www-form-urlencoded" }, "List-Unsubscribe=One-Click", "submit unsubscribe request", options);
151
+ if (!response.ok) throw await this.toError(response);
152
+ }
153
+ async perform(endpoint, headers, body, label, options) {
154
+ const controller = new AbortController();
155
+ const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("feedback: request timed out")), this.timeout);
156
+ timer.unref();
157
+ const signal = options.signal;
158
+ const onAbort = () => controller.abort(signal?.reason);
159
+ signal?.addEventListener("abort", onAbort, { once: true });
160
+ try {
161
+ return await this.fetchImpl(endpoint, {
162
+ method: "POST",
163
+ headers,
164
+ body,
165
+ signal: controller.signal
166
+ });
167
+ } catch (error) {
168
+ throw new Error(`feedback: ${label}`, { cause: error });
169
+ } finally {
170
+ clearTimeout(timer);
171
+ signal?.removeEventListener("abort", onAbort);
172
+ }
173
+ }
174
+ async toError(response) {
175
+ let detail = `${response.status} ${response.statusText}`.trim();
176
+ try {
177
+ const body = await readBounded(response, MAX_ERROR_BODY, "error response");
178
+ const parsed = JSON.parse(new TextDecoder().decode(body));
179
+ if (typeof parsed.error === "string" && parsed.error.trim() !== "") detail = parsed.error.trim();
180
+ } catch {}
181
+ return new FeedbackError({
182
+ statusCode: response.status,
183
+ detail
184
+ });
185
+ }
186
+ };
187
+ /** Encodes HTTP Basic credentials. */
188
+ function encodeBasicAuth(credentials) {
189
+ return Buffer.from(`${credentials.username}:${credentials.secret}`, "utf8").toString("base64");
190
+ }
191
+ /** Parses a learning response into its public shape. */
192
+ function parseLearningResult(body) {
193
+ let parsed;
194
+ try {
195
+ parsed = JSON.parse(new TextDecoder().decode(body));
196
+ } catch (error) {
197
+ throw new Error("feedback: decode learning response", { cause: error });
198
+ }
199
+ if (parsed === null || typeof parsed !== "object") throw new Error("feedback: decode learning response");
200
+ const wire = parsed;
201
+ return {
202
+ status: typeof wire.status === "string" ? wire.status : "",
203
+ disposition: wire.disposition === disposition.ham ? disposition.ham : disposition.spam,
204
+ tenantId: typeof wire.tenant_id === "string" ? wire.tenant_id : "",
205
+ listenerId: typeof wire.listener_id === "string" ? wire.listener_id : "",
206
+ matchedHashKind: typeof wire.matched_hash_kind === "string" ? wire.matched_hash_kind : ""
207
+ };
208
+ }
209
+ /** Reads a response body, rejecting when it exceeds a byte limit. */
210
+ async function readBounded(response, limit, label) {
211
+ const stream = response.body;
212
+ if (stream === null) return /* @__PURE__ */ new Uint8Array();
213
+ const reader = stream.getReader();
214
+ const chunks = [];
215
+ let total = 0;
216
+ try {
217
+ for (;;) {
218
+ const { done, value } = await reader.read();
219
+ if (done) break;
220
+ if (value !== void 0) {
221
+ total += value.byteLength;
222
+ if (total > limit) {
223
+ reader.cancel().catch(() => void 0);
224
+ throw new Error(`feedback: ${label} exceeds ${limit} bytes`);
225
+ }
226
+ chunks.push(value);
227
+ }
228
+ }
229
+ } finally {
230
+ reader.releaseLock();
231
+ }
232
+ const result = new Uint8Array(total);
233
+ let offset = 0;
234
+ for (const chunk of chunks) {
235
+ result.set(chunk, offset);
236
+ offset += chunk.byteLength;
237
+ }
238
+ return result;
239
+ }
240
+ //#endregion
241
+ export { Client, FeedbackError, disposition };
242
+
243
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/feedback/errors.ts","../../src/feedback/client.ts"],"sourcesContent":["/**\n * A non-success response from the feedback service.\n *\n * @public\n */\nexport class FeedbackError extends Error {\n /** The HTTP response status. */\n readonly statusCode: number;\n\n /**\n * The service's error message when one was returned, otherwise the HTTP\n * status text.\n */\n readonly detail: string;\n\n /**\n * @param options - The response status and service error message.\n */\n constructor(options: { statusCode: number; detail: string }) {\n super(\n options.detail === \"\"\n ? `feedback: request failed with status ${options.statusCode}`\n : `feedback: request failed with status ${options.statusCode}: ${options.detail}`,\n );\n this.name = \"FeedbackError\";\n this.statusCode = options.statusCode;\n this.detail = options.detail;\n }\n\n /**\n * Reports whether the request may succeed if retried later.\n *\n * Rate limits (429) and server-side failures (5xx) are retryable.\n */\n get retryable(): boolean {\n return this.statusCode === 429 || this.statusCode >= 500;\n }\n}\n","/**\n * The mxRaven feedback and one-click unsubscribe client.\n */\n\nimport { Buffer } from \"node:buffer\";\n\nimport { FeedbackError } from \"./errors.js\";\n\n/** A minimal `fetch`-compatible function. @public */\nexport type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;\n\n/** The training label for a message. */\nexport const disposition = {\n /** Marks the message as spam. */\n spam: \"spam\",\n /** Marks the message as not spam. */\n ham: \"ham\",\n} as const;\n\n/** The training label for a message. */\nexport type Disposition = (typeof disposition)[keyof typeof disposition];\n\n/** The outcome of a successful learning request. */\nexport interface LearningResult {\n /** The service status, normally `learned`. */\n readonly status: string;\n /** The training label that was applied. */\n readonly disposition: Disposition;\n /** The tenant that owns the matched message. */\n readonly tenantId: string;\n /** The listener that processed the matched message. */\n readonly listenerId: string;\n /** Which stored hash matched the submitted bytes. */\n readonly matchedHashKind: string;\n}\n\n/** Options for a {@link Client}. */\nexport interface ClientOptions {\n /** The feedback service base URL, for example `https://feedback.mxraven.com`. */\n readonly baseUrl: string;\n /** The submission API key username. Required for learning. */\n readonly username?: string;\n /** The submission API key secret. Required for learning. */\n readonly secret?: string;\n /** The `fetch` implementation to use. Defaults to the global `fetch`. */\n readonly fetch?: FetchLike;\n /** The request timeout in milliseconds. Defaults to 30000. */\n readonly timeout?: number;\n}\n\n/** Per-call options for feedback requests. */\nexport interface RequestOptions {\n /** Cancels the request. */\n readonly signal?: AbortSignal;\n}\n\ninterface Credentials {\n readonly username: string;\n readonly secret: string;\n}\n\nconst DEFAULT_TIMEOUT = 30_000;\nconst MAX_ERROR_BODY = 4096;\nconst MAX_RESULT_BODY = 64 * 1024;\n\n/**\n * Calls the mxRaven feedback service.\n *\n * Learning requests authenticate with the same submission API key used for SMTP\n * submission, over HTTP Basic auth. A client is safe for concurrent use.\n *\n * @example\n * ```ts\n * const secret = process.env.MXRAVEN_SECRET;\n * if (secret === undefined || secret === \"\") {\n * throw new Error(\"MXRAVEN_SECRET is required\");\n * }\n *\n * const client = new Client({\n * baseUrl: \"https://feedback.mxraven.com\",\n * username: \"mxr_tx_ab12cd34ef56\",\n * secret,\n * });\n * const result = await client.learnSpam(rawMessageBytes);\n * ```\n *\n * @public\n */\nexport class Client {\n private readonly baseUrl: string;\n private readonly credentials: Credentials | undefined;\n private readonly fetchImpl: FetchLike;\n private readonly timeout: number;\n\n /**\n * @param options - The base URL, credentials, and transport options.\n * @throws `Error` When the base URL is missing or an option is invalid.\n */\n constructor(options: ClientOptions) {\n const baseUrl = options.baseUrl.trim();\n if (baseUrl === \"\") {\n throw new Error(\"feedback: base URL is required\");\n }\n\n const username = (options.username ?? \"\").trim();\n const secret = options.secret ?? \"\";\n if ((username === \"\") !== (secret === \"\")) {\n throw new Error(\"feedback: username and secret must both be provided\");\n }\n\n const timeout = options.timeout ?? DEFAULT_TIMEOUT;\n if (!Number.isFinite(timeout) || timeout <= 0) {\n throw new Error(`feedback: invalid timeout ${timeout}`);\n }\n\n this.baseUrl = baseUrl.replace(/\\/+$/, \"\");\n this.credentials = username === \"\" ? undefined : { username, secret };\n this.fetchImpl = options.fetch ?? globalThis.fetch;\n this.timeout = timeout;\n }\n\n /**\n * Submits one training example.\n *\n * `rawMime` must be the exact raw RFC 822 bytes that mxRaven processed; the\n * service matches them against stored evidence by SHA-256. A message with no\n * matching evidence fails with a {@link FeedbackError} whose `statusCode` is\n * 404.\n *\n * @param label - The training label to apply.\n * @param rawMime - The exact raw message bytes.\n * @param options - An optional cancellation signal.\n * @returns The service's learning result.\n * @throws {@link FeedbackError} When the service returns a non-success status.\n *\n * @public\n */\n async learn(\n label: Disposition,\n rawMime: Uint8Array,\n options: RequestOptions = {},\n ): Promise<LearningResult> {\n if (label !== disposition.spam && label !== disposition.ham) {\n throw new Error(`feedback: invalid disposition ${JSON.stringify(label)}`);\n }\n const credentials = this.credentials;\n if (credentials === undefined) {\n throw new Error(\"feedback: credentials are required for learning\");\n }\n\n const endpoint = `${this.baseUrl}/v1/feedback/learn/${label}`;\n const response = await this.perform(\n endpoint,\n {\n \"Content-Type\": \"message/rfc822\",\n Authorization: `Basic ${encodeBasicAuth(credentials)}`,\n },\n rawMime,\n \"submit learning request\",\n options,\n );\n if (!response.ok) {\n throw await this.toError(response);\n }\n\n const body = await readBounded(response, MAX_RESULT_BODY, \"learning response\");\n return parseLearningResult(body);\n }\n\n /** Teaches the spam filter that `rawMime` is spam. */\n learnSpam(rawMime: Uint8Array, options: RequestOptions = {}): Promise<LearningResult> {\n return this.learn(disposition.spam, rawMime, options);\n }\n\n /** Teaches the spam filter that `rawMime` is not spam. */\n learnHam(rawMime: Uint8Array, options: RequestOptions = {}): Promise<LearningResult> {\n return this.learn(disposition.ham, rawMime, options);\n }\n\n /**\n * Performs an RFC 8058 one-click unsubscribe for a token.\n *\n * This is the operation a recipient mail client performs against the\n * `List-Unsubscribe` URL; applications rarely call it directly. It is\n * unauthenticated.\n *\n * @param token - The signed unsubscribe token.\n * @param options - An optional cancellation signal.\n * @throws {@link FeedbackError} When the service returns a non-success status.\n *\n * @public\n */\n async unsubscribe(token: string, options: RequestOptions = {}): Promise<void> {\n const value = token.trim();\n if (value === \"\") {\n throw new Error(\"feedback: unsubscribe token is empty\");\n }\n\n const endpoint = `${this.baseUrl}/v1/feedback/unsubscribe/${encodeURIComponent(value)}`;\n const response = await this.perform(\n endpoint,\n { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n \"List-Unsubscribe=One-Click\",\n \"submit unsubscribe request\",\n options,\n );\n if (!response.ok) {\n throw await this.toError(response);\n }\n }\n\n private async perform(\n endpoint: string,\n headers: Record<string, string>,\n body: Uint8Array | string,\n label: string,\n options: RequestOptions,\n ): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(\n () => controller.abort(new Error(\"feedback: request timed out\")),\n this.timeout,\n );\n timer.unref();\n\n const signal = options.signal;\n const onAbort = (): void => controller.abort(signal?.reason);\n signal?.addEventListener(\"abort\", onAbort, { once: true });\n\n try {\n return await this.fetchImpl(endpoint, {\n method: \"POST\",\n headers,\n body,\n signal: controller.signal,\n });\n } catch (error) {\n throw new Error(`feedback: ${label}`, { cause: error });\n } finally {\n clearTimeout(timer);\n signal?.removeEventListener(\"abort\", onAbort);\n }\n }\n\n private async toError(response: Response): Promise<FeedbackError> {\n let detail = `${response.status} ${response.statusText}`.trim();\n try {\n const body = await readBounded(response, MAX_ERROR_BODY, \"error response\");\n const parsed = JSON.parse(new TextDecoder().decode(body)) as { error?: unknown };\n if (typeof parsed.error === \"string\" && parsed.error.trim() !== \"\") {\n detail = parsed.error.trim();\n }\n } catch {\n // Keep the status text when the error body is unusable.\n }\n return new FeedbackError({ statusCode: response.status, detail });\n }\n}\n\n/** Encodes HTTP Basic credentials. */\nfunction encodeBasicAuth(credentials: Credentials): string {\n return Buffer.from(`${credentials.username}:${credentials.secret}`, \"utf8\").toString(\"base64\");\n}\n\n/** Parses a learning response into its public shape. */\nfunction parseLearningResult(body: Uint8Array): LearningResult {\n let parsed: unknown;\n try {\n parsed = JSON.parse(new TextDecoder().decode(body));\n } catch (error) {\n throw new Error(\"feedback: decode learning response\", { cause: error });\n }\n if (parsed === null || typeof parsed !== \"object\") {\n throw new Error(\"feedback: decode learning response\");\n }\n\n const wire = parsed as Record<string, unknown>;\n return {\n status: typeof wire.status === \"string\" ? wire.status : \"\",\n disposition: wire.disposition === disposition.ham ? disposition.ham : disposition.spam,\n tenantId: typeof wire.tenant_id === \"string\" ? wire.tenant_id : \"\",\n listenerId: typeof wire.listener_id === \"string\" ? wire.listener_id : \"\",\n matchedHashKind: typeof wire.matched_hash_kind === \"string\" ? wire.matched_hash_kind : \"\",\n };\n}\n\n/** Reads a response body, rejecting when it exceeds a byte limit. */\nasync function readBounded(response: Response, limit: number, label: string): Promise<Uint8Array> {\n const stream = response.body;\n if (stream === null) {\n return new Uint8Array();\n }\n\n const reader = stream.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) {\n break;\n }\n if (value !== undefined) {\n total += value.byteLength;\n if (total > limit) {\n void reader.cancel().catch(() => undefined);\n throw new Error(`feedback: ${label} exceeds ${limit} bytes`);\n }\n chunks.push(value);\n }\n }\n } finally {\n reader.releaseLock();\n }\n\n const result = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n result.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return result;\n}\n"],"mappings":"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"}