@chronicle.app/email-core 0.0.0-stage → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # @chronicle.app/email-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ What Chronicle's mail sources share: reading an mbox, parsing an email, identifying it, and the nodes it becomes. The [email](../../plugins/email/README.md) plugin reads any mbox with it; the [gmail](../../plugins/gmail/README.md) plugin reads Gmail's API and Takeouts with it.
4
+
5
+ - `readMbox(path)` yields each message in an mbox, raw, with the date on its `From ` line, reading the file as a stream: a Takeout of a whole mailbox runs to gigabytes. A message with no Date header takes that date. `countMbox(path)` counts them the same way.
6
+ - `parseMessage(raw)` parses an RFC 5322 message with [postal-mime](https://github.com/postalsys/postal-mime): encoded words, quoted-printable and base64 bodies, and multipart alternatives are decoded. The result, a `MailMessage`, is plain data, so it can be a raw record. It keeps only the headers a source reads (the envelope, threading, mailing-list headers, and Gmail's `X-GM-*`), not the routing and signing headers that make up most of a header block.
7
+ - `messageBody(parts, { bulk })` is what the sender wrote, as plain text or Markdown, and is the `MailMessage`'s `text`:
8
+ - the text part when there's a real one, else the HTML part converted to Markdown (links, lists, and emphasis kept; hidden preheaders and images dropped), and any markup in a text part converted too;
9
+ - without quoted history and signatures, by [email-reply-parser](https://github.com/crisp-oss/email-reply-parser): reply headers in many languages, Outlook's quoted blocks, `-- ` and "Sent from my phone" signatures. A reply links to the message it answers, so the quote would only store it again. A message that is only a quote, like a forward with no note, keeps it;
10
+ - for bulk mail (one with `List-Unsubscribe`, `List-Id`, or `Precedence: bulk`), without its footer: from the first unsubscribe or "you're receiving this" line in its second half;
11
+ - with tracking parameters (`utm_*`, `fbclid`, …) taken off links and the padding characters newsletters fill their inbox preview with removed, by `tidyText` from `@chronicle.app/etl`. In bulk mail, click-tracking redirects (URLs over 200 characters) lose their URL but keep their words; a person's mail keeps long links, like a meeting's join link.
12
+ - `identityOf(message)` is its Message-ID, or else its sender, date, and subject; null when it has neither, and a source skips it.
13
+ - `messageAction(message, options)` is the sending of it: a `MessageAction` by the sender on a `Message`, with its recipients and the message it replies to, and a source's thread and labels when it has them. The sender is written in full as the action's `agent`, and as the message's `author` by key alone. A display name the mail gives an address, other than the address itself, is its `alternateName`: what that message called it, not its own name. With `account`, the message is `inAccount` it.
14
+ - `emailAccount(address)` is the account at an address, an Agent keyed by it in the `email` namespace: what a message is `inAccount`, whatever service holds it.
15
+
16
+ Identifiers the email protocol defines are keyed in its namespace (`source: 'email'`): a message by its Message-ID without the angle brackets around it (as JMAP and `mid:` URIs write it), so the same email read by any mail source is one node. A person is their lowercased address in the `email` namespace (`source: 'email'`), the identity other plugins already link an address to, so every mail source, a calendar, and those links meet on one node.
17
+
18
+ ## Tests
19
+
20
+ The tests run the parser and builder over synthetic messages and a synthetic mbox in a temporary directory.
package/dist/body.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What a person wrote in an email, as plain text or Markdown: never the HTML
3
+ * it was sent as, the earlier messages it quotes, or its signature.
4
+ *
5
+ * - The text part, when there's a real one; the HTML part as Markdown when
6
+ * there isn't, or when the text part is only a stub pointing at it.
7
+ * - Hidden preheader text and images are dropped from HTML: in mail they're
8
+ * tracking pixels, spacers, and logos, not what anyone wrote.
9
+ * - Quoted history and the signature go, by
10
+ * [email-reply-parser](https://github.com/crisp-oss/email-reply-parser):
11
+ * reply headers in many languages, Outlook's quoted blocks, `-- ` and
12
+ * "Sent from my phone" signatures. A reply links to the message it
13
+ * answers, so the quote would only store that message again.
14
+ * - Bulk mail (a newsletter, a notification) loses its footer: the
15
+ * unsubscribe and "you're receiving this" lines at its end.
16
+ * - Then `tidyText`: tracking parameters come off links, and padding
17
+ * characters go. In bulk mail, click-tracking redirects lose their URL
18
+ * but keep their words.
19
+ */
20
+ export declare function messageBody(parts: {
21
+ text?: string;
22
+ html?: string;
23
+ }, { bulk }?: {
24
+ bulk?: boolean;
25
+ }): string;
26
+ //# sourceMappingURL=body.d.ts.map
package/dist/body.js ADDED
@@ -0,0 +1,71 @@
1
+ import { htmlToMarkdown, looksLikeHtml, tidyText, withoutInvisible } from '@chronicle.app/etl';
2
+ import EmailReplyParser from 'email-reply-parser';
3
+ /**
4
+ * What a person wrote in an email, as plain text or Markdown: never the HTML
5
+ * it was sent as, the earlier messages it quotes, or its signature.
6
+ *
7
+ * - The text part, when there's a real one; the HTML part as Markdown when
8
+ * there isn't, or when the text part is only a stub pointing at it.
9
+ * - Hidden preheader text and images are dropped from HTML: in mail they're
10
+ * tracking pixels, spacers, and logos, not what anyone wrote.
11
+ * - Quoted history and the signature go, by
12
+ * [email-reply-parser](https://github.com/crisp-oss/email-reply-parser):
13
+ * reply headers in many languages, Outlook's quoted blocks, `-- ` and
14
+ * "Sent from my phone" signatures. A reply links to the message it
15
+ * answers, so the quote would only store that message again.
16
+ * - Bulk mail (a newsletter, a notification) loses its footer: the
17
+ * unsubscribe and "you're receiving this" lines at its end.
18
+ * - Then `tidyText`: tracking parameters come off links, and padding
19
+ * characters go. In bulk mail, click-tracking redirects lose their URL
20
+ * but keep their words.
21
+ */
22
+ export function messageBody(parts, { bulk = false } = {}) {
23
+ // Some senders put markup in the text part too.
24
+ const raw = withoutInvisible(parts.text ?? '').trim();
25
+ const text = looksLikeHtml(raw) ? emailHtmlToMarkdown(raw) : raw;
26
+ const fromHtml = parts.html ? emailHtmlToMarkdown(parts.html) : '';
27
+ const body = text && !isStub(text, fromHtml) ? text : fromHtml || text;
28
+ const written = withoutReplyHistory(body);
29
+ return bulk ? tidyText(withoutFooter(written), { dropLongUrls: true }) : tidyText(written);
30
+ }
31
+ /** A text part that only points at the HTML one: short, and saying so, or a sliver of it. */
32
+ function isStub(text, fromHtml) {
33
+ if (!fromHtml)
34
+ return false;
35
+ const pointsElsewhere = /view (this|it|the) (e-?mail|message|newsletter)? ?(online|in (a|your) (web )?browser)|html (version|email)|does not support html|enable html/i;
36
+ return ((text.length < 400 && pointsElsewhere.test(text)) ||
37
+ (text.length < 80 && fromHtml.length > text.length * 4));
38
+ }
39
+ /** Elements hidden from the reader: an email's preheader, and a client's own notes. */
40
+ const HIDDEN = /<(div|span|p|td|table)\b[^>]*\bstyle\s*=\s*["'][^"']*(display\s*:\s*none|visibility\s*:\s*hidden|max-height\s*:\s*0|mso-hide\s*:\s*all)[^"']*["'][^>]*>[\s\S]*?<\/\1>/gi;
41
+ function emailHtmlToMarkdown(html) {
42
+ const markdown = withoutInvisible(htmlToMarkdown(html.replaceAll(HIDDEN, '')) ?? '');
43
+ // An image in an email is a logo, a spacer, or a tracking pixel.
44
+ return markdown.replaceAll(/!\[[^\]]*\]\([^)]*\)/g, '').trim();
45
+ }
46
+ /**
47
+ * The text without quoted history or signature. A message that is nothing
48
+ * but a quote, like a forward with no note, keeps what it quotes.
49
+ */
50
+ function withoutReplyHistory(body) {
51
+ const visible = new EmailReplyParser().read(body).getVisibleText().trim();
52
+ return visible || body;
53
+ }
54
+ /** A line that starts a bulk mail's footer. */
55
+ const FOOTER = /unsubscribe|you('re| are) receiving this|you received this (e-?mail|message)|(manage|update) (your )?(e-?mail |subscription |notification )?(preferences|settings)|no longer (wish|want) to receive|this (e-?mail|message) was sent to|sent to you because|why did i get this/i;
56
+ /**
57
+ * A bulk mail without its footer: from the first footer line in its second
58
+ * half on. Only that half is searched, so a newsletter that mentions
59
+ * unsubscribing near its top keeps it; a newsletter's footer, with its
60
+ * addresses, links, and legal lines, can run long.
61
+ */
62
+ function withoutFooter(body) {
63
+ const lines = body.split('\n');
64
+ const from = Math.max(1, Math.floor(lines.length / 2));
65
+ for (let i = from; i < lines.length; i++) {
66
+ // A footer line is short; a sentence that mentions unsubscribing isn't one.
67
+ if (lines[i].length <= 160 && FOOTER.test(lines[i]))
68
+ return lines.slice(0, i).join('\n');
69
+ }
70
+ return body;
71
+ }
@@ -0,0 +1,50 @@
1
+ import type { Agent, MessageAction, Thread } from '@chronicle.app/schema';
2
+ import type { MailMessage } from './parse.js';
3
+ /**
4
+ * A message's own identity: its Message-ID; without one, its sender, sent
5
+ * time, and subject. Null when it has neither a Message-ID nor a sender and a
6
+ * date: nothing real identifies it, and a made-up id would mint a new message
7
+ * each run, so a source skips it.
8
+ */
9
+ export declare function identityOf(mail: MailMessage): string | null;
10
+ export interface MessageNodeOptions {
11
+ /**
12
+ * The mailbox owner's addresses: the sender or a recipient at one of them
13
+ * is the owner (`@me`).
14
+ */
15
+ me?: string[];
16
+ /** The source says the owner sent it (Gmail's Sent label), whatever the From says. */
17
+ sentByMe?: boolean;
18
+ /** When the message arrived, for one without a Date header. */
19
+ receivedAt?: Date;
20
+ /** The conversation the source puts it in. */
21
+ thread?: Thread;
22
+ /** The source's labels or folders for it. */
23
+ tags?: string[];
24
+ /**
25
+ * Other identities of the person at an address (an address book's other
26
+ * addresses and phone numbers for them), linked by `sameAs`.
27
+ */
28
+ identitiesOf?: (address: string) => Agent[];
29
+ /**
30
+ * The owner's other identities, such as the account the mailbox is,
31
+ * linked by `sameAs` from every agent that's the owner.
32
+ */
33
+ meIdentities?: Agent[];
34
+ /** The account the source read the message from: the message is `inAccount` it. */
35
+ account?: Agent;
36
+ }
37
+ /**
38
+ * The sending of a message: a `MessageAction` by its sender, whose object is
39
+ * the `Message`, with its recipients, the message it replies to, and its
40
+ * thread and labels when the source has them. The sending happened once, so
41
+ * the action is keyed by the Message-ID wherever the message is read, as the
42
+ * message is.
43
+ */
44
+ export declare function messageAction(mail: MailMessage, options?: MessageNodeOptions): MessageAction;
45
+ /**
46
+ * An email account by its address, keyed as a person is: the account a
47
+ * message is `inAccount`, whatever service holds it.
48
+ */
49
+ export declare function emailAccount(address: string): Agent;
50
+ //# sourceMappingURL=build.d.ts.map
package/dist/build.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * A message is named by its Message-ID in the `email` namespace: the
3
+ * protocol's own identity, so the same email from Gmail, a Takeout, or any
4
+ * mbox is one node, whichever plugin read it.
5
+ */
6
+ const MESSAGE_KEY = ['@type', 'source', 'sourceId'];
7
+ /** Without a Message-ID: who sent it, when, and under what subject. */
8
+ const KEYLESS_MESSAGE_KEY = ['@type', 'source', 'action.timestamp', 'action.agent.handle', 'name'];
9
+ const KEYLESS_ACTION_KEY = ['@type', 'source', 'timestamp', 'agent.handle', 'object.name'];
10
+ /** Identifiers defined by the email protocol (RFC 5322) live in its namespace. */
11
+ const EMAIL = 'email';
12
+ /**
13
+ * A person is their email address, in the `email` namespace: the identity
14
+ * other plugins link a person's address to (iMessage, LinkedIn, Timing, …),
15
+ * so every mail source and every link meets on one node.
16
+ */
17
+ const ADDRESS_KEY = ['@type', 'source', 'handle'];
18
+ /**
19
+ * Joins the parts of a composite identity. A control character that header
20
+ * text cannot carry, so no From/Date/Subject combination can collide with
21
+ * another by concatenation.
22
+ */
23
+ const COMPOSITE_DELIMITER = '\u001F';
24
+ /**
25
+ * A Message-ID as an identifier: without the angle brackets RFC 5322 writes
26
+ * around it, as JMAP and `mid:` URIs give it, so every source keys it alike.
27
+ */
28
+ function msgId(header) {
29
+ return header.replace(/^<(.*)>$/, '$1');
30
+ }
31
+ /**
32
+ * A message's own identity: its Message-ID; without one, its sender, sent
33
+ * time, and subject. Null when it has neither a Message-ID nor a sender and a
34
+ * date: nothing real identifies it, and a made-up id would mint a new message
35
+ * each run, so a source skips it.
36
+ */
37
+ export function identityOf(mail) {
38
+ if (mail.messageId)
39
+ return mail.messageId;
40
+ if (!mail.from || !mail.date)
41
+ return null;
42
+ return [mail.from.address.toLowerCase(), mail.date, mail.subject].join(COMPOSITE_DELIMITER);
43
+ }
44
+ /**
45
+ * The sending of a message: a `MessageAction` by its sender, whose object is
46
+ * the `Message`, with its recipients, the message it replies to, and its
47
+ * thread and labels when the source has them. The sending happened once, so
48
+ * the action is keyed by the Message-ID wherever the message is read, as the
49
+ * message is.
50
+ */
51
+ export function messageAction(mail, options = {}) {
52
+ const me = new Set((options.me ?? []).map(address => address.toLowerCase()));
53
+ const isMe = (address) => me.has(address.toLowerCase());
54
+ if (!mail.from) {
55
+ throw new Error(`Email ${mail.messageId ?? '(no Message-ID)'} has no sender`);
56
+ }
57
+ const sentAt = mail.date ?? options.receivedAt?.toISOString();
58
+ if (!sentAt) {
59
+ throw new Error(`Email ${mail.messageId ?? '(no Message-ID)'} has no date`);
60
+ }
61
+ const identities = options.identitiesOf ?? (() => []);
62
+ const person = (address, owner) => agent(address, owner, [
63
+ ...(owner ? (options.meIdentities ?? []) : []),
64
+ ...identities(address.address),
65
+ ]);
66
+ const sender = person(mail.from, options.sentByMe || isMe(mail.from.address));
67
+ const recipients = dedupe([...mail.to, ...mail.cc, ...mail.bcc]).map(address => person(address, isMe(address.address)));
68
+ const messageId = mail.messageId && msgId(mail.messageId);
69
+ const message = {
70
+ '@type': 'Message',
71
+ ...(messageId
72
+ ? { '@key': MESSAGE_KEY, source: EMAIL, sourceId: messageId }
73
+ : { '@key': KEYLESS_MESSAGE_KEY, source: EMAIL }),
74
+ ...(options.account && { inAccount: [options.account] }),
75
+ name: mail.subject,
76
+ ...(mail.text && { body: mail.text }),
77
+ // The sender in full is the action's agent; here, the same node by its key.
78
+ author: [reference(sender)],
79
+ ...(recipients.length > 0 && { recipient: recipients }),
80
+ ...(mail.inReplyTo && {
81
+ inReplyTo: [
82
+ { '@type': 'Message', '@key': MESSAGE_KEY, source: EMAIL, sourceId: msgId(mail.inReplyTo) },
83
+ ],
84
+ }),
85
+ ...(options.thread && { isPartOf: [options.thread] }),
86
+ ...(options.tags?.length && { tags: options.tags }),
87
+ };
88
+ return {
89
+ '@type': 'MessageAction',
90
+ ...(messageId
91
+ ? { '@key': MESSAGE_KEY, source: EMAIL, sourceId: messageId }
92
+ : { '@key': KEYLESS_ACTION_KEY, source: EMAIL }),
93
+ timestamp: new Date(sentAt),
94
+ agent: sender,
95
+ object: message,
96
+ };
97
+ }
98
+ /**
99
+ * An email account by its address, keyed as a person is: the account a
100
+ * message is `inAccount`, whatever service holds it.
101
+ */
102
+ export function emailAccount(address) {
103
+ return {
104
+ '@type': 'Agent',
105
+ '@key': ADDRESS_KEY,
106
+ source: EMAIL,
107
+ handle: address.toLowerCase(),
108
+ };
109
+ }
110
+ /** A node written in full elsewhere in the record, by its key alone. */
111
+ function reference(agent) {
112
+ return {
113
+ '@type': agent['@type'],
114
+ '@key': agent['@key'],
115
+ source: agent.source,
116
+ handle: agent.handle,
117
+ };
118
+ }
119
+ /** A person by their address, lowercased so one person is one node. */
120
+ function agent(address, me, identities = []) {
121
+ const handle = address.address.toLowerCase();
122
+ const name = displayName(address.name, handle);
123
+ const sameAs = [...(me ? ['@me'] : []), ...identities];
124
+ return {
125
+ '@type': 'Agent',
126
+ '@key': ADDRESS_KEY,
127
+ source: EMAIL,
128
+ handle,
129
+ ...(name && { alternateName: [name] }),
130
+ ...(sameAs.length > 0 && { sameAs }),
131
+ };
132
+ }
133
+ /**
134
+ * The display name the mail gives an address, unless it's only the address
135
+ * again. It's what one message called the address, which the sender or their
136
+ * address book chose, so it's another name the address was shown with, not
137
+ * the address's own name.
138
+ */
139
+ function displayName(name, handle) {
140
+ const trimmed = name?.trim();
141
+ return trimmed && trimmed.toLowerCase() !== handle ? trimmed : undefined;
142
+ }
143
+ /** Each address once: a person on both To and Cc is one recipient. */
144
+ function dedupe(addresses) {
145
+ const seen = new Set();
146
+ return addresses.filter(({ address }) => {
147
+ const handle = address.toLowerCase();
148
+ if (seen.has(handle))
149
+ return false;
150
+ seen.add(handle);
151
+ return true;
152
+ });
153
+ }
@@ -0,0 +1,5 @@
1
+ export { parseMessage, type MailAddress, type MailMessage } from './parse.js';
2
+ export { readMbox, countMbox, type MboxMessage } from './mbox.js';
3
+ export { emailAccount, identityOf, messageAction, type MessageNodeOptions } from './build.js';
4
+ export { messageBody } from './body.js';
5
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { parseMessage } from './parse.js';
2
+ export { readMbox, countMbox } from './mbox.js';
3
+ export { emailAccount, identityOf, messageAction } from './build.js';
4
+ export { messageBody } from './body.js';
package/dist/mbox.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /** A message in an mbox: its raw bytes, and when its `From ` line says it arrived. */
2
+ export interface MboxMessage {
3
+ raw: Buffer;
4
+ /** The envelope date as an ISO instant; null when the line has none. */
5
+ envelopeDate: string | null;
6
+ }
7
+ /**
8
+ * The messages in an mbox file, raw, one at a time: a Takeout of a whole
9
+ * mailbox runs to gigabytes, so it's read as a stream, never whole.
10
+ *
11
+ * A message starts at a `From ` line at the top of the file or after a blank
12
+ * line. A body line that began with `From ` was written as `>From `
13
+ * (mboxrd), and gets its `>` back. Lines are read as latin1, which keeps
14
+ * every byte as it was, so the parser sees the message's own bytes.
15
+ */
16
+ export declare function readMbox(path: string): AsyncGenerator<MboxMessage>;
17
+ /** How many messages an mbox file holds, by its `From ` lines, read as a stream. */
18
+ export declare function countMbox(path: string): Promise<number>;
19
+ //# sourceMappingURL=mbox.d.ts.map
package/dist/mbox.js ADDED
@@ -0,0 +1,59 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ /**
4
+ * The messages in an mbox file, raw, one at a time: a Takeout of a whole
5
+ * mailbox runs to gigabytes, so it's read as a stream, never whole.
6
+ *
7
+ * A message starts at a `From ` line at the top of the file or after a blank
8
+ * line. A body line that began with `From ` was written as `>From `
9
+ * (mboxrd), and gets its `>` back. Lines are read as latin1, which keeps
10
+ * every byte as it was, so the parser sees the message's own bytes.
11
+ */
12
+ export async function* readMbox(path) {
13
+ const lines = createInterface({
14
+ input: createReadStream(path, { encoding: 'latin1' }),
15
+ crlfDelay: Number.POSITIVE_INFINITY,
16
+ });
17
+ let message;
18
+ let envelopeDate = null;
19
+ let blank = true;
20
+ for await (const line of lines) {
21
+ if (blank && line.startsWith('From ')) {
22
+ if (message)
23
+ yield { raw: Buffer.from(message.join('\n'), 'latin1'), envelopeDate };
24
+ message = [];
25
+ envelopeDate = envelopeDateOf(line);
26
+ }
27
+ else if (message) {
28
+ message.push(/^>+From /.test(line) ? line.slice(1) : line);
29
+ }
30
+ blank = line === '';
31
+ }
32
+ if (message)
33
+ yield { raw: Buffer.from(message.join('\n'), 'latin1'), envelopeDate };
34
+ }
35
+ /**
36
+ * The date on a `From sender date` line: `Mon Mar 03 00:00:00 +0000 2025`
37
+ * (Takeout) or `Mon Jan 6 10:00:00 2025` (asctime, read as UTC).
38
+ */
39
+ function envelopeDateOf(line) {
40
+ const date = line.split(/\s+/).slice(2).join(' ');
41
+ const zoned = /[+-]\d{4}|UTC|GMT/.test(date) ? date : `${date} UTC`;
42
+ const at = Date.parse(zoned);
43
+ return Number.isNaN(at) ? null : new Date(at).toISOString();
44
+ }
45
+ /** How many messages an mbox file holds, by its `From ` lines, read as a stream. */
46
+ export async function countMbox(path) {
47
+ let count = 0;
48
+ let blank = true;
49
+ const lines = createInterface({
50
+ input: createReadStream(path, { encoding: 'latin1' }),
51
+ crlfDelay: Number.POSITIVE_INFINITY,
52
+ });
53
+ for await (const line of lines) {
54
+ if (blank && line.startsWith('From '))
55
+ count++;
56
+ blank = line === '';
57
+ }
58
+ return count;
59
+ }
@@ -0,0 +1,46 @@
1
+ /** A person on a message: their address, and the name they gave, if any. */
2
+ export interface MailAddress {
3
+ address: string;
4
+ name?: string;
5
+ }
6
+ /**
7
+ * One email, as a mail source reads it: its envelope, its text, and the names
8
+ * of its attachments. Plain data, so it can be a raw record.
9
+ */
10
+ export interface MailMessage {
11
+ /** The Message-ID header, angle brackets and all; null when there is none. */
12
+ messageId: string | null;
13
+ /** The Date header as an ISO instant; null when there is none or it doesn't parse. */
14
+ date: string | null;
15
+ subject: string;
16
+ from: MailAddress | null;
17
+ to: MailAddress[];
18
+ cc: MailAddress[];
19
+ bcc: MailAddress[];
20
+ /** The Message-ID this replies to (In-Reply-To). */
21
+ inReplyTo: string | null;
22
+ /** The conversation's earlier Message-IDs, oldest first (References). */
23
+ references: string[];
24
+ /**
25
+ * What the sender wrote, as plain text or Markdown: no HTML, quoted
26
+ * history, or signature. See `messageBody`.
27
+ */
28
+ text: string;
29
+ attachments: {
30
+ filename: string | null;
31
+ mimeType: string;
32
+ }[];
33
+ /**
34
+ * The headers a source reads, by lowercase name: the envelope, threading,
35
+ * mailing list, and Gmail's own. Routing and signing headers (`Received`,
36
+ * `DKIM-Signature`, …) are left out: they're most of a header block, and
37
+ * nothing reads them.
38
+ */
39
+ headers: Record<string, string>;
40
+ }
41
+ /**
42
+ * Parse an RFC 5322 message, MIME and all: encoded words, quoted-printable
43
+ * and base64 bodies, and multipart alternatives are decoded.
44
+ */
45
+ export declare function parseMessage(raw: Uint8Array | string): Promise<MailMessage>;
46
+ //# sourceMappingURL=parse.d.ts.map
package/dist/parse.js ADDED
@@ -0,0 +1,75 @@
1
+ import PostalMime, { decodeWords } from 'postal-mime';
2
+ import { messageBody } from './body.js';
3
+ /**
4
+ * Parse an RFC 5322 message, MIME and all: encoded words, quoted-printable
5
+ * and base64 bodies, and multipart alternatives are decoded.
6
+ */
7
+ export async function parseMessage(raw) {
8
+ const email = await PostalMime.parse(raw, { attachmentEncoding: 'base64' });
9
+ const date = email.date && !Number.isNaN(Date.parse(email.date)) ? email.date : null;
10
+ return {
11
+ messageId: email.messageId?.trim() || null,
12
+ date: date && new Date(date).toISOString(),
13
+ subject: email.subject ?? '',
14
+ from: mailboxes(email.from ? [email.from] : [])[0] ?? null,
15
+ to: mailboxes(email.to),
16
+ cc: mailboxes(email.cc),
17
+ bcc: mailboxes(email.bcc),
18
+ // The Message-ID in it: some clients write words around it.
19
+ inReplyTo: email.inReplyTo?.match(/<[^>]+>/)?.[0] ?? (email.inReplyTo?.trim() || null),
20
+ references: email.references?.match(/<[^>]+>/g) ?? [],
21
+ text: messageBody(email, { bulk: isBulk(email.headers) }),
22
+ attachments: email.attachments
23
+ .filter(attachment => attachment.disposition === 'attachment' || attachment.filename)
24
+ .map(({ filename, mimeType }) => ({ filename, mimeType })),
25
+ headers: keptHeaders(email.headers),
26
+ };
27
+ }
28
+ /**
29
+ * The headers kept, decoded (a Takeout writes a label like `Café` as an
30
+ * encoded word). Where one repeats, the first is kept: it's the one added
31
+ * last, on arrival in this mailbox, so a forwarded message's `Delivered-To`
32
+ * is this mailbox's address, not the one it was forwarded from.
33
+ */
34
+ function keptHeaders(headers) {
35
+ const kept = {};
36
+ for (const { key, value } of headers) {
37
+ if (Object.hasOwn(kept, key))
38
+ continue;
39
+ if (KEPT_HEADERS.has(key) || key.startsWith('x-gm'))
40
+ kept[key] = decodeWords(value);
41
+ }
42
+ return kept;
43
+ }
44
+ const KEPT_HEADERS = new Set([
45
+ 'from',
46
+ 'to',
47
+ 'cc',
48
+ 'bcc',
49
+ 'reply-to',
50
+ 'delivered-to',
51
+ 'subject',
52
+ 'date',
53
+ 'message-id',
54
+ 'in-reply-to',
55
+ 'references',
56
+ 'list-id',
57
+ 'list-unsubscribe',
58
+ 'precedence',
59
+ ]);
60
+ /** Mail sent to a list: a newsletter or a notification, by its list headers. */
61
+ function isBulk(headers) {
62
+ return headers.some(({ key, value }) => key === 'list-unsubscribe' ||
63
+ key === 'list-id' ||
64
+ (key === 'precedence' && /bulk|list/i.test(value)));
65
+ }
66
+ /** Addresses, with groups opened up, and a name only where one was given. */
67
+ function mailboxes(addresses) {
68
+ return (addresses ?? [])
69
+ .flatMap(address => address.group ?? [address])
70
+ .filter(mailbox => mailbox.address)
71
+ .map(mailbox => ({
72
+ address: mailbox.address,
73
+ ...(mailbox.name && { name: mailbox.name }),
74
+ }));
75
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@chronicle.app/email-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "email",
19
+ "mbox"
20
+ ],
21
+ "author": "Andrew Louis",
22
+ "license": "MIT",
23
+ "type": "module",
24
+ "description": "Email parsing, mbox reading, and message nodes shared by Chronicle mail sources",
25
+ "devDependencies": {
26
+ "@chronicle.app/etl": "0.4.0",
27
+ "@chronicle.app/schema": "0.4.0",
28
+ "@chronicle.app/tsconfig": "0.4.0",
29
+ "@types/node": "^22.10.1",
30
+ "typescript": "^5.7.2"
31
+ },
32
+ "homepage": "https://chronicle.app",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/chronicle-app/chronicle.git",
36
+ "directory": "core/email"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org/"
41
+ },
42
+ "dependencies": {
43
+ "email-reply-parser": "^2.3.9",
44
+ "postal-mime": "^4.0.2"
45
+ },
46
+ "peerDependencies": {
47
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
48
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.13.0"
52
+ },
53
+ "exports": {
54
+ ".": {
55
+ "types": "./dist/index.d.ts",
56
+ "import": "./dist/index.js"
57
+ }
58
+ }
59
+ }