@chronicle.app/email 0.3.0 → 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/README.md CHANGED
@@ -63,14 +63,21 @@ The extractor parses the following email components:
63
63
 
64
64
  ## Schema
65
65
 
66
- - Each email is a `MessageAction` by the sender whose object is a `Message`.
67
- The message's `name` is the subject, its `description` is the body, and its
68
- `recipient`s are the To, CC and BCC addresses.
69
- - Each address is an `Agent` whose `handle` is the email address and whose
70
- `name` is the display name, or the address when there is none.
66
+ Messages are built by [@chronicle.app/email-core](../../core/email/README.md), the same way the Gmail plugin builds them:
71
67
 
72
- The source is `email`. A message with a Message-ID uses it as its `sourceId`;
73
- one without is identified by its sender, date and subject.
68
+ - Each email is a `MessageAction` by the sender whose object is a `Message`.
69
+ The message's `name` is the subject, its `recipient`s are the To, Cc and Bcc
70
+ addresses, and it is `inReplyTo` the message its In-Reply-To names.
71
+ - Its `body` is what the sender wrote, as plain text or Markdown: no HTML,
72
+ quoted history, or signature.
73
+ - Each address is an `Agent` whose `handle` is the email address, lowercased.
74
+ A display name is an `alternateName`, not its `name`: the sender or their
75
+ address book chose it, and it changes from message to message. A display
76
+ name that's only the address is left out.
77
+
78
+ A message is keyed by its Message-ID in the `email` namespace, so the same email read
79
+ from an mbox and from Gmail is one node. A person is their address, lowercased,
80
+ in the `email` namespace other plugins link an address to.
74
81
 
75
82
  ## Tests
76
83
 
@@ -2,7 +2,5 @@ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
2
  import { ActionAndChildren } from '@chronicle.app/schema';
3
3
  export default class EmailTransformer extends ChronicleTransformer {
4
4
  transform(record: Record): Promise<ActionAndChildren[]>;
5
- private buildEmailActions;
6
- private buildPersonFromEmail;
7
5
  }
8
6
  //# sourceMappingURL=EmailTransformer.d.ts.map
@@ -1,91 +1,9 @@
1
1
  import { ChronicleTransformer } from '@chronicle.app/etl';
2
- import { createRequire } from 'node:module';
3
- const require = createRequire(import.meta.url);
4
- const parseEmailAddresses = require('emailjs-addressparser').default;
2
+ import { messageAction } from '@chronicle.app/email-core';
5
3
  export default class EmailTransformer extends ChronicleTransformer {
6
4
  async transform(record) {
7
- const actions = [];
8
- if (record.extraction.recordType === 'emails') {
9
- actions.push(...this.buildEmailActions(record));
10
- }
11
- return actions;
12
- }
13
- buildEmailActions(record) {
14
- const actions = [];
15
- const emailData = record.data;
16
- // Build sender person with email address as handle
17
- const sender = this.buildPersonFromEmail(emailData.from);
18
- // Build recipient persons
19
- const recipients = [
20
- ...emailData.to.map((email) => this.buildPersonFromEmail(email)),
21
- ...emailData.cc.map((email) => this.buildPersonFromEmail(email)),
22
- ...emailData.bcc.map((email) => this.buildPersonFromEmail(email)),
23
- ];
24
- // Every action requires a valid agent
25
- if (!sender) {
26
- throw new Error(`Invalid or missing sender in email ${emailData.messageId ?? '(no Message-ID)'}: ${emailData.from}`);
27
- }
28
- // Identity. The Message-ID is the source's own id and stays `sourceId`. A
29
- // message without one (common in early-2000s mail) is keyed on the natural
30
- // composite of who sent it, when, and under what subject — the same three
31
- // real headers the extractor's keyOf composes, read here off the
32
- // transformed fields: the MessageAction carries them directly, the Message
33
- // reaches the first two through its carrying action (`action.*`). Dotted
34
- // paths to scalars only; a whole nested node in a key couples identity to
35
- // every mutable attribute of that node.
36
- const messageId = emailData.messageId || null;
37
- const timestamp = emailData.date ? new Date(emailData.date) : new Date();
38
- if (!messageId && (!emailData.date || Number.isNaN(timestamp.getTime()))) {
39
- throw new Error(`Email without a Message-ID needs a parseable Date to be identified: ${emailData.from} / ${emailData.subject}`);
40
- }
41
- // Build email message entity
42
- const emailMessage = {
43
- '@type': 'Message',
44
- source: 'email',
45
- ...(messageId && { sourceId: messageId }),
46
- name: emailData.subject,
47
- description: emailData.body,
48
- recipient: recipients.filter(Boolean),
49
- author: [sender],
50
- '@key': messageId
51
- ? ['@type', 'source', 'sourceId']
52
- : ['@type', 'source', 'action.timestamp', 'action.agent.handle', 'name'],
53
- };
54
- const messageAction = {
55
- '@type': 'MessageAction',
56
- timestamp,
57
- source: 'email',
58
- ...(messageId && { sourceId: messageId }),
59
- '@key': messageId
60
- ? ['source', 'sourceId', '@type']
61
- : ['@type', 'source', 'timestamp', 'agent.handle', 'object.name'],
62
- agent: sender,
63
- object: emailMessage,
64
- };
65
- actions.push(messageAction);
66
- return actions;
67
- }
68
- buildPersonFromEmail(emailString) {
69
- if (!emailString?.trim())
70
- return null;
71
- try {
72
- const parsed = parseEmailAddresses(emailString);
73
- if (!parsed || parsed.length === 0)
74
- return null;
75
- // Take the first parsed address
76
- const address = parsed[0];
77
- return {
78
- '@type': 'Agent',
79
- source: 'email',
80
- handle: address.address, // Clean email address
81
- name: address.name || address.address, // Use name if available, fallback to email
82
- '@key': ['@type', 'source', 'handle'],
83
- };
84
- }
85
- catch {
86
- // Log and skip malformed addresses
87
- console.warn(`Failed to parse email address: ${emailString}`);
88
- return null;
89
- }
5
+ if (record.extraction.recordType !== 'emails')
6
+ return [];
7
+ return [messageAction(record.data)];
90
8
  }
91
9
  }
@@ -28,27 +28,13 @@ export declare class EmailMboxExtractor extends Extractor<typeof EmailMboxExtrac
28
28
  }>;
29
29
  /**
30
30
  * The RFC 5322 Message-ID header — the message's own id, identical in any mail
31
- * tool. A message without one is keyed on the natural composite of its From,
32
- * Date, and Subject headers: real source fields, never an invented id. That
33
- * needs From and a Date that parses; a message with neither has nothing to be
34
- * identified by, and parseMbox never emits it. EmailTransformer keys the same
35
- * records on the same three facts (sender handle, sent time, subject).
31
+ * tool. A message without one is keyed on the natural composite of its
32
+ * sender, sent time, and subject: real source fields, never an invented id.
33
+ * A message with neither has nothing to be identified by, and is skipped.
34
+ * The transformer keys the same records on the same three facts.
36
35
  */
37
36
  keyOf(record: Record): string | null;
38
- /**
39
- * Message-ID when present; otherwise `From␟Date␟Subject` over the raw
40
- * headers (Subject may be empty and still takes part); null when there is no
41
- * From or no parseable Date to compose.
42
- */
43
- static identityOf(headers: {
44
- [key: string]: string;
45
- }): string | null;
46
37
  extract(): AsyncGenerator<Record>;
47
- private parseMbox;
48
- private parseMessage;
49
- private extractTextContent;
50
- private decodeMimeHeader;
51
- private parseEmailList;
52
38
  determineCount(): Promise<number | null>;
53
39
  }
54
40
  //# sourceMappingURL=EmailMboxExtractor.d.ts.map
@@ -1,17 +1,7 @@
1
1
  import { Extractor } from '@chronicle.app/etl';
2
- import { readFile } from 'node:fs/promises';
2
+ import { countMbox, identityOf, parseMessage, readMbox, } from '@chronicle.app/email-core';
3
3
  import { z } from 'zod';
4
- import { createRequire } from 'node:module';
5
4
  import EmailTransformer from '../EmailTransformer.js';
6
- const require = createRequire(import.meta.url);
7
- const { Libmime } = require('libmime');
8
- const libmime = new Libmime();
9
- /**
10
- * Joins the parts of a composite key. A control character that RFC 5322 header
11
- * text cannot carry, so no From/Date/Subject combination can collide with
12
- * another by concatenation.
13
- */
14
- const COMPOSITE_DELIMITER = '\u001F';
15
5
  export class EmailMboxExtractor extends Extractor {
16
6
  static source = 'email';
17
7
  static description = 'Messages from an mbox file';
@@ -25,214 +15,64 @@ export class EmailMboxExtractor extends Extractor {
25
15
  });
26
16
  /**
27
17
  * The RFC 5322 Message-ID header — the message's own id, identical in any mail
28
- * tool. A message without one is keyed on the natural composite of its From,
29
- * Date, and Subject headers: real source fields, never an invented id. That
30
- * needs From and a Date that parses; a message with neither has nothing to be
31
- * identified by, and parseMbox never emits it. EmailTransformer keys the same
32
- * records on the same three facts (sender handle, sent time, subject).
18
+ * tool. A message without one is keyed on the natural composite of its
19
+ * sender, sent time, and subject: real source fields, never an invented id.
20
+ * A message with neither has nothing to be identified by, and is skipped.
21
+ * The transformer keys the same records on the same three facts.
33
22
  */
34
23
  keyOf(record) {
35
- return EmailMboxExtractor.identityOf(record.data.headers);
36
- }
37
- /**
38
- * Message-ID when present; otherwise `From␟Date␟Subject` over the raw
39
- * headers (Subject may be empty and still takes part); null when there is no
40
- * From or no parseable Date to compose.
41
- */
42
- static identityOf(headers) {
43
- const messageId = headers['message-id'];
44
- if (messageId)
45
- return messageId;
46
- const { from, date } = headers;
47
- if (!from || !date || Number.isNaN(new Date(date).getTime()))
48
- return null;
49
- return [from, date, headers.subject ?? ''].join(COMPOSITE_DELIMITER);
24
+ return identityOf(record.data);
50
25
  }
51
26
  async *extract() {
52
27
  const config = this.config;
53
- try {
54
- const content = await readFile(config.input, 'utf-8');
55
- const emails = this.parseMbox(content);
56
- let count = 0;
57
- for (const email of emails) {
58
- // Apply date filtering if configured
59
- if (config.since || config.until) {
60
- const emailDate = new Date(email.date);
61
- if (config.since && emailDate < config.since)
62
- continue;
63
- if (config.until && emailDate > config.until)
64
- continue;
65
- }
66
- // Apply limit if configured
67
- if (this.shouldStopExtracting(count))
68
- break;
69
- yield this.createRecord(email, {
70
- messageId: email.messageId,
71
- from: email.from,
72
- subject: email.subject,
73
- strategy: 'mbox',
74
- });
75
- count++;
76
- }
77
- }
78
- catch (error) {
79
- throw new Error(`Failed to read mbox file: ${error}`);
80
- }
81
- }
82
- parseMbox(content) {
83
- const emails = [];
28
+ let count = 0;
84
29
  let unidentified = 0;
85
- const messages = content.split(/^From /m);
86
- // Skip the first element if it's empty (before the first "From " line)
87
- const validMessages = messages.slice(1);
88
- for (const message of validMessages) {
89
- const email = this.parseMessage('From ' + message);
90
- if (!email)
30
+ let undated = 0;
31
+ for await (const { raw, envelopeDate } of readMbox(config.input)) {
32
+ const email = await parseMessage(raw);
33
+ // No Date header: when its `From ` line says it arrived.
34
+ email.date ??= envelopeDate;
35
+ if (!email.date) {
36
+ undated++;
91
37
  continue;
92
- if (EmailMboxExtractor.identityOf(email.headers) === null) {
38
+ }
39
+ if (identityOf(email) === null) {
93
40
  // No Message-ID, and no From + parseable Date to compose a natural key
94
41
  // from: the source gives nothing real to identify this message by, and
95
42
  // a fabricated id would mint a new entity on every run. Skip and say so.
96
43
  unidentified++;
97
44
  continue;
98
45
  }
99
- emails.push(email);
100
- }
101
- if (unidentified > 0) {
102
- this.logger.warn(`Skipped ${unidentified} message(s) with no Message-ID and no From + Date to key on`);
103
- }
104
- return emails;
105
- }
106
- parseMessage(message) {
107
- const lines = message.split('\n');
108
- const headers = {};
109
- let headerSection = true;
110
- const bodyLines = [];
111
- for (const line of lines) {
112
- if (headerSection) {
113
- if (line.trim() === '') {
114
- headerSection = false;
46
+ if (config.since || config.until) {
47
+ const sent = email.date ? new Date(email.date) : undefined;
48
+ if (!sent)
115
49
  continue;
116
- }
117
- // Handle multi-line headers
118
- if (line.startsWith(' ') || line.startsWith('\t')) {
119
- const lastHeader = Object.keys(headers).pop();
120
- if (lastHeader) {
121
- headers[lastHeader] += ' ' + line.trim();
122
- }
50
+ if (config.since && sent < config.since)
51
+ continue;
52
+ if (config.until && sent > config.until)
123
53
  continue;
124
- }
125
- const colonIndex = line.indexOf(':');
126
- if (colonIndex > 0) {
127
- const key = line.slice(0, Math.max(0, colonIndex)).trim().toLowerCase();
128
- const value = line.slice(Math.max(0, colonIndex + 1)).trim();
129
- headers[key] = value;
130
- }
131
- }
132
- else {
133
- bodyLines.push(line);
134
- }
135
- }
136
- // Extract required fields
137
- const from = headers.from || '';
138
- const to = this.parseEmailList(headers.to || '');
139
- const cc = this.parseEmailList(headers.cc || '');
140
- const bcc = this.parseEmailList(headers.bcc || '');
141
- const subject = this.decodeMimeHeader(headers.subject || '');
142
- const date = headers.date || '';
143
- const messageId = headers['message-id'] ?? null;
144
- const body = this.extractTextContent(bodyLines, headers);
145
- if (!from && !subject) {
146
- return null; // Skip invalid messages
147
- }
148
- return {
149
- from,
150
- to,
151
- cc,
152
- bcc,
153
- subject,
154
- date,
155
- messageId,
156
- body,
157
- headers,
158
- };
159
- }
160
- extractTextContent(bodyLines, headers) {
161
- const contentType = headers['content-type'] || '';
162
- // If not multipart, return the body as-is
163
- if (!contentType.toLowerCase().includes('multipart')) {
164
- return bodyLines.join('\n').trim();
165
- }
166
- // Extract boundary from Content-Type header
167
- const boundaryMatch = contentType.match(/boundary=["']?([^"';]+)["']?/i);
168
- if (!boundaryMatch) {
169
- return bodyLines.join('\n').trim(); // Fallback if no boundary found
170
- }
171
- const boundary = boundaryMatch[1];
172
- const bodyText = bodyLines.join('\n');
173
- // Split by boundary
174
- const parts = bodyText.split(`--${boundary}`);
175
- const textParts = [];
176
- for (const part of parts) {
177
- if (!part.trim() || part.trim() === '--')
178
- continue;
179
- // Parse each part's headers and content
180
- const partLines = part.split('\n');
181
- const partHeaders = {};
182
- let partHeaderSection = true;
183
- const partBodyLines = [];
184
- for (const line of partLines) {
185
- if (partHeaderSection) {
186
- if (line.trim() === '') {
187
- partHeaderSection = false;
188
- continue;
189
- }
190
- const colonIndex = line.indexOf(':');
191
- if (colonIndex > 0) {
192
- const key = line.slice(0, colonIndex).trim().toLowerCase();
193
- const value = line.slice(colonIndex + 1).trim();
194
- partHeaders[key] = value;
195
- }
196
- }
197
- else {
198
- partBodyLines.push(line);
199
- }
200
- }
201
- // Only include text/plain parts
202
- const partContentType = partHeaders['content-type'] || '';
203
- if (partContentType.toLowerCase().includes('text/plain')) {
204
- textParts.push(partBodyLines.join('\n').trim());
205
54
  }
55
+ if (this.shouldStopExtracting(count))
56
+ break;
57
+ yield this.createRecord(email, {
58
+ messageId: email.messageId,
59
+ from: email.from?.address,
60
+ subject: email.subject,
61
+ strategy: 'mbox',
62
+ });
63
+ count++;
206
64
  }
207
- // Join all text parts
208
- return textParts.join('\n\n').trim();
209
- }
210
- decodeMimeHeader(headerValue) {
211
- if (!headerValue)
212
- return '';
213
- try {
214
- return libmime.decodeWords(headerValue);
65
+ if (undated > 0) {
66
+ this.logger.warn(`Skipped ${undated} message(s) with no date to place them by`);
215
67
  }
216
- catch {
217
- // If decoding fails, return the original value
218
- this.logger.warn('Failed to decode MIME header', { headerValue });
219
- return headerValue;
68
+ if (unidentified > 0) {
69
+ this.logger.warn(`Skipped ${unidentified} message(s) with no Message-ID and no From + Date to key on`);
220
70
  }
221
71
  }
222
- parseEmailList(emailString) {
223
- if (!emailString.trim())
224
- return [];
225
- return emailString
226
- .split(',')
227
- .map(email => email.trim())
228
- .filter(email => email.length > 0);
229
- }
230
72
  async determineCount() {
231
73
  const config = this.config;
232
74
  try {
233
- const content = await readFile(config.input, 'utf-8');
234
- const messages = content.split(/^From /m);
235
- return Math.max(0, messages.length - 1); // Subtract 1 for the content before first "From "
75
+ return await countMbox(config.input);
236
76
  }
237
77
  catch {
238
78
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/email",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -11,7 +11,8 @@
11
11
  "build": "tsc -b tsconfig.lib.json",
12
12
  "typecheck": "tsc -p tsconfig.json",
13
13
  "lint": "eslint src --ext .ts,.js",
14
- "test": "node --test src/*.test.js"
14
+ "test": "node --test src/*.test.js",
15
+ "shapes": "node src/shapes.test.js --update"
15
16
  },
16
17
  "keywords": [
17
18
  "chronicle",
@@ -22,9 +23,9 @@
22
23
  "type": "module",
23
24
  "description": "Chronicle plugin for email data from mbox format files",
24
25
  "devDependencies": {
25
- "@chronicle.app/etl": "0.3.0",
26
- "@chronicle.app/schema": "0.3.0",
27
- "@chronicle.app/tsconfig": "0.3.0",
26
+ "@chronicle.app/etl": "0.4.0",
27
+ "@chronicle.app/schema": "0.4.0",
28
+ "@chronicle.app/tsconfig": "0.4.0",
28
29
  "@types/node": "^22.10.1",
29
30
  "typescript": "^5.7.2"
30
31
  },
@@ -39,8 +40,7 @@
39
40
  "registry": "https://registry.npmjs.org/"
40
41
  },
41
42
  "dependencies": {
42
- "emailjs-addressparser": "^2.0.3",
43
- "libmime": "^5.3.7",
43
+ "@chronicle.app/email-core": "0.4.0",
44
44
  "zod": "^3.24.1"
45
45
  },
46
46
  "peerDependencies": {