@procedures/mail 0.0.0-stage → 0.2.3

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/src/mime.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The headers and the readable text of a raw RFC 822 message. It reads multipart bodies,
3
+ * quoted-printable and base64, and HTML-only messages. It is not a mail client: it ignores
4
+ * attachments, inline images and the headers of the parts.
5
+ */
6
+
7
+ interface Parsed {
8
+ readonly headers: ReadonlyMap<string, string>;
9
+ /** The body, still in its transfer encoding, one character for each byte */
10
+ readonly body: string;
11
+ }
12
+
13
+ const split = (text: string): Parsed => {
14
+ const at = text.search(/\r?\n\r?\n/);
15
+ const head = at === -1 ? text : text.slice(0, at);
16
+ const body = at === -1 ? "" : text.slice(at).replace(/^\r?\n\r?\n/, "");
17
+ const headers = new Map<string, string>();
18
+ // A header continues on each line that starts with white space (RFC 5322 folding)
19
+ for (const line of head.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/)) {
20
+ const colon = line.indexOf(":");
21
+ if (colon <= 0) continue;
22
+ headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
23
+ }
24
+ return { headers, body };
25
+ };
26
+
27
+ const param = (value: string, name: string): string | undefined => {
28
+ const match = new RegExp(`(?:^|;)\\s*${name}\\s*=\\s*(?:"([^"]*)"|([^;\\s]+))`, "i").exec(value);
29
+ return match?.[1] ?? match?.[2];
30
+ };
31
+
32
+ const quotedPrintable = (text: string): string =>
33
+ text.replace(/=\r?\n/g, "").replace(/=([0-9A-F]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)));
34
+
35
+ const decode = (body: string, encoding: string | undefined, charset: string | undefined): string => {
36
+ const transfer = (encoding ?? "7bit").toLowerCase();
37
+ const bytes =
38
+ transfer === "base64"
39
+ ? Buffer.from(body.replace(/\s+/g, ""), "base64")
40
+ : transfer === "quoted-printable"
41
+ ? Buffer.from(quotedPrintable(body), "latin1")
42
+ : Buffer.from(body, "latin1");
43
+ const set = (charset ?? "utf-8").toLowerCase();
44
+ return set === "utf-8" || set === "utf8" || set === "us-ascii" ? bytes.toString("utf8") : bytes.toString("latin1");
45
+ };
46
+
47
+ const ENTITIES: Readonly<Record<string, string>> = {
48
+ nbsp: " ",
49
+ lt: "<",
50
+ gt: ">",
51
+ quot: '"',
52
+ apos: "'",
53
+ ndash: "-",
54
+ mdash: "-",
55
+ rsquo: "'",
56
+ lsquo: "'",
57
+ ldquo: '"',
58
+ rdquo: '"',
59
+ };
60
+
61
+ /** `&amp;` is the last replacement, so `&amp;lt;` stays the text "&lt;". */
62
+ const entities = (text: string): string =>
63
+ text
64
+ .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
65
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
66
+ .replace(/&([a-z]+);/gi, (whole, name: string) => ENTITIES[name.toLowerCase()] ?? whole)
67
+ .replace(/&amp;/gi, "&");
68
+
69
+ /** This function gives the text of an HTML body as lines. A cell, a row, a paragraph or a break ends a line. */
70
+ export const htmlText = (html: string): string =>
71
+ entities(
72
+ html
73
+ .replace(/<(script|style|head)\b[\s\S]*?<\/\1>/gi, " ")
74
+ .replace(/<br\s*\/?>/gi, "\n")
75
+ .replace(/<\/(?:p|div|tr|td|th|li|h[1-6]|table|tbody|section)>/gi, "\n")
76
+ .replace(/<[^>]+>/g, " ")
77
+ )
78
+ .replace(/[ \t ]+/g, " ")
79
+ .replace(/ *\n */g, "\n")
80
+ .replace(/\n{2,}/g, "\n")
81
+ .trim();
82
+
83
+ const walk = (text: string, plain: string[], html: string[], depth: number): void => {
84
+ if (depth > 8) return;
85
+ const { headers, body } = split(text);
86
+ const type = headers.get("content-type") ?? "text/plain";
87
+ const mime = (type.split(";")[0] ?? "").trim().toLowerCase();
88
+ if (mime.startsWith("multipart/")) {
89
+ const boundary = param(type, "boundary");
90
+ if (boundary === undefined) return;
91
+ const delimiter = `--${boundary}`;
92
+ let current: string[] | undefined;
93
+ for (const line of body.split(/\r?\n/)) {
94
+ if (line.startsWith(delimiter)) {
95
+ if (current !== undefined) walk(current.join("\r\n"), plain, html, depth + 1);
96
+ // After the closing delimiter comes the epilogue, which is not content
97
+ if (line.startsWith(`${delimiter}--`)) return;
98
+ current = [];
99
+ continue;
100
+ }
101
+ current?.push(line);
102
+ }
103
+ if (current !== undefined) walk(current.join("\r\n"), plain, html, depth + 1);
104
+ return;
105
+ }
106
+ if (!mime.startsWith("text/")) return;
107
+ if (/^attachment/i.test(headers.get("content-disposition") ?? "")) return;
108
+ const decoded = decode(body, headers.get("content-transfer-encoding"), param(type, "charset"));
109
+ if (mime === "text/html") html.push(decoded);
110
+ else if (mime === "text/plain") plain.push(decoded);
111
+ };
112
+
113
+ const asText = (raw: Uint8Array | string): string => (typeof raw === "string" ? raw : Buffer.from(raw).toString("latin1"));
114
+
115
+ /** This function gives the top-level headers of a raw message, unfolded, with lowercase names. */
116
+ export const messageHeaders = (raw: Uint8Array | string): ReadonlyMap<string, string> => split(asText(raw)).headers;
117
+
118
+ /** This function decodes the RFC 2047 encoded words (`=?UTF-8?Q?...?=`) of a header value. */
119
+ export const decodeWords = (value: string): string =>
120
+ value.replace(/\?=\s+=\?/g, "?==?").replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (_, charset: string, encoding: string, text: string) => {
121
+ const bytes =
122
+ encoding.toUpperCase() === "B" ? Buffer.from(text, "base64") : Buffer.from(quotedPrintable(text.replace(/_/g, " ")), "latin1");
123
+ return /^utf-?8$/i.test(charset) ? bytes.toString("utf8") : bytes.toString("latin1");
124
+ });
125
+
126
+ /** This function gives the text of a message: its plain parts, or its HTML as lines when it has no plain part. */
127
+ export const messageText = (raw: Uint8Array | string): string => {
128
+ const plain: string[] = [];
129
+ const html: string[] = [];
130
+ walk(asText(raw), plain, html, 0);
131
+ if (plain.some((part) => part.trim().length > 0)) {
132
+ return plain
133
+ .join("\n")
134
+ .replace(/\r\n/g, "\n")
135
+ .replace(/[ \t ]+/g, " ")
136
+ .trim();
137
+ }
138
+ return html.map(htmlText).join("\n").trim();
139
+ };
140
+
141
+ /** An address of a header: `Name <user@host>`, `<user@host>` or `user@host`. */
142
+ export interface MailAddress {
143
+ readonly name?: string | undefined;
144
+ readonly address: string;
145
+ }
146
+
147
+ /** This function reads the addresses of an address header (From, To, Cc). */
148
+ export const parseAddresses = (value: string): MailAddress[] => {
149
+ const out: MailAddress[] = [];
150
+ // Commas inside quotes do not separate addresses
151
+ for (const part of value.match(/(?:"[^"]*"|[^,])+/g) ?? []) {
152
+ const text = decodeWords(part.trim());
153
+ const angle = /^(.*?)<([^<>]+)>\s*$/.exec(text);
154
+ const address = (angle ? angle[2] : text)?.trim() ?? "";
155
+ if (!address.includes("@")) continue;
156
+ const name = angle?.[1]?.trim().replace(/^"|"$/g, "").trim();
157
+ out.push(name ? { name, address } : { address });
158
+ }
159
+ return out;
160
+ };
package/src/module.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The procedures of @procedures/mail as data. Importing this file registers nothing.
3
+ */
4
+
5
+ import { defineModule, registerModules, PROCEDURE_REGISTRY, type ProcedureModule, type ProcedureRegistry } from "@procedures/client";
6
+ import { mailProcedures } from "./procedures.js";
7
+
8
+ export const mailModule: ProcedureModule = defineModule("@procedures/mail", mailProcedures);
9
+
10
+ export default mailModule;
11
+
12
+ /**
13
+ * This function registers the procedures of @procedures/mail in a registry. The default is the
14
+ * global registry.
15
+ */
16
+ export function registerMailProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY): void {
17
+ registerModules(registry, [mailModule]);
18
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The procedures of `@procedures/mail`: `mail.parse` and `mail.headers`.
3
+ */
4
+
5
+ import { createProcedure, zodAdapter, type AnyProcedure, type ProcedureContext } from "@procedures/client";
6
+ import { z } from "zod";
7
+ import { readHeaders, type ImapClientFactory, type MailHeader } from "./imap.js";
8
+ import { decodeWords, messageHeaders, messageText, parseAddresses, type MailAddress } from "./mime.js";
9
+
10
+ const addressSchema = z.object({ name: z.string().optional(), address: z.string() });
11
+
12
+ // =============================================================================
13
+ // mail.parse
14
+ // =============================================================================
15
+
16
+ const ParseInputSchema = z.object({
17
+ raw: z.string().describe("The raw message (RFC 822): the headers, an empty line, the body"),
18
+ });
19
+ type ParseInput = z.infer<typeof ParseInputSchema>;
20
+
21
+ interface ParseOutput {
22
+ headers: Record<string, string>;
23
+ subject?: string | undefined;
24
+ from: MailAddress[];
25
+ to: MailAddress[];
26
+ date?: string | undefined;
27
+ text: string;
28
+ }
29
+ const ParseOutputSchema = z.object({
30
+ headers: z.record(z.string(), z.string()),
31
+ subject: z.string().optional(),
32
+ from: z.array(addressSchema),
33
+ to: z.array(addressSchema),
34
+ date: z.string().optional(),
35
+ text: z.string(),
36
+ });
37
+
38
+ /** This function gives the headers, the addresses and the text of a raw message. */
39
+ export function parseMessage(raw: string): ParseOutput {
40
+ const headers = messageHeaders(raw);
41
+ const subject = headers.get("subject");
42
+ const dateHeader = headers.get("date");
43
+ const date = dateHeader !== undefined && !Number.isNaN(Date.parse(dateHeader)) ? new Date(dateHeader).toISOString() : undefined;
44
+ return {
45
+ headers: Object.fromEntries(headers),
46
+ ...(subject !== undefined ? { subject: decodeWords(subject) } : {}),
47
+ from: parseAddresses(headers.get("from") ?? ""),
48
+ to: parseAddresses(headers.get("to") ?? ""),
49
+ ...(date !== undefined ? { date } : {}),
50
+ text: messageText(raw),
51
+ };
52
+ }
53
+
54
+ export const mailParseProcedure: AnyProcedure = createProcedure()
55
+ .path(["mail", "parse"])
56
+ .input(zodAdapter<ParseInput>(ParseInputSchema))
57
+ .output(zodAdapter<ParseOutput>(ParseOutputSchema))
58
+ .meta({
59
+ description: "Read the headers, the addresses and the text of a raw email message",
60
+ output: "json",
61
+ effects: { reads: [], writes: [] },
62
+ })
63
+ .handler(async (input: ParseInput): Promise<ParseOutput> => parseMessage(input.raw))
64
+ .build() as AnyProcedure;
65
+
66
+ // =============================================================================
67
+ // mail.headers
68
+ // =============================================================================
69
+
70
+ const HeadersInputSchema = z.object({
71
+ host: z.string().min(1).describe("The IMAP server, for example imap.gmail.com"),
72
+ port: z.number().int().min(1).max(65_535).default(993).describe("The port (default: 993)"),
73
+ secure: z.boolean().default(true).describe("Use TLS from the start (default: true)"),
74
+ user: z.string().min(1).describe("The user name of the mailbox"),
75
+ passwordEnv: z
76
+ .string()
77
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
78
+ .default("IMAP_PASSWORD")
79
+ .describe("The name of the environment variable that holds the password. The password is never an input, so no log records it."),
80
+ mailbox: z.string().min(1).default("INBOX").describe("The mailbox (default: INBOX)"),
81
+ sinceDays: z.number().int().min(1).max(365).default(1).describe("The messages of this number of days (default: 1)"),
82
+ limit: z.number().int().min(1).max(5_000).default(200).describe("The maximum number of messages, the newest first (default: 200)"),
83
+ timeoutMs: z.number().int().positive().max(600_000).default(60_000).describe("The time limit, in milliseconds (default: 60000)"),
84
+ });
85
+ type HeadersInput = z.infer<typeof HeadersInputSchema>;
86
+
87
+ interface HeadersOutput {
88
+ messages: MailHeader[];
89
+ }
90
+ const HeadersOutputSchema = z.object({
91
+ messages: z.array(
92
+ z.object({
93
+ uid: z.number(),
94
+ subject: z.string(),
95
+ from: z.array(addressSchema),
96
+ to: z.array(addressSchema),
97
+ date: z.string().optional(),
98
+ seen: z.boolean(),
99
+ })
100
+ ),
101
+ });
102
+
103
+ /** Tests replace the IMAP client. */
104
+ let clientFactory: ImapClientFactory | undefined;
105
+
106
+ /** This function replaces the IMAP client of `mail.headers` (for tests). Undefined restores `imapflow`. */
107
+ export function setImapClientFactory(factory: ImapClientFactory | undefined): void {
108
+ clientFactory = factory;
109
+ }
110
+
111
+ export const mailHeadersProcedure: AnyProcedure = createProcedure()
112
+ .path(["mail", "headers"])
113
+ .input(zodAdapter<HeadersInput>(HeadersInputSchema))
114
+ .output(zodAdapter<HeadersOutput>(HeadersOutputSchema))
115
+ .meta({
116
+ description: "Give the headers of the recent messages of an IMAP mailbox, read only (no message becomes read)",
117
+ args: ["host", "user"],
118
+ output: "json",
119
+ effects: { reads: ["network"], writes: [] },
120
+ })
121
+ .handler(async (input: HeadersInput, ctx?: ProcedureContext): Promise<HeadersOutput> => {
122
+ const password = process.env[input.passwordEnv];
123
+ if (!password) throw new Error(`Set the environment variable ${input.passwordEnv} to the password of the mailbox`);
124
+ const messages = await readHeaders(
125
+ { host: input.host, port: input.port, secure: input.secure, user: input.user, password },
126
+ {
127
+ mailbox: input.mailbox,
128
+ since: new Date(Date.now() - input.sinceDays * 86_400_000),
129
+ limit: input.limit,
130
+ timeoutMs: input.timeoutMs,
131
+ signal: ctx?.signal,
132
+ clientFactory,
133
+ }
134
+ );
135
+ return { messages };
136
+ })
137
+ .build() as AnyProcedure;
138
+
139
+ export const mailProcedures: AnyProcedure[] = [mailParseProcedure, mailHeadersProcedure];
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Importing this file registers the procedures of @procedures/mail in the global registry:
3
+ * `import "@procedures/mail/register"`. The package root registers nothing.
4
+ */
5
+
6
+ import { registerMailProcedures } from "./module.js";
7
+
8
+ export * from "./module.js";
9
+
10
+ registerMailProcedures();