@forwardimpact/basecamp 2.0.0 → 2.3.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/config/scheduler.json +5 -0
- package/package.json +1 -1
- package/src/basecamp.js +288 -57
- package/template/.claude/agents/chief-of-staff.md +6 -2
- package/template/.claude/agents/concierge.md +2 -3
- package/template/.claude/agents/librarian.md +4 -6
- package/template/.claude/agents/recruiter.md +269 -0
- package/template/.claude/settings.json +0 -4
- package/template/.claude/skills/analyze-cv/SKILL.md +269 -0
- package/template/.claude/skills/create-presentations/SKILL.md +2 -2
- package/template/.claude/skills/create-presentations/references/slide.css +1 -1
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
- package/template/.claude/skills/draft-emails/SKILL.md +85 -123
- package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
- package/template/.claude/skills/extract-entities/SKILL.md +2 -2
- package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
- package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
- package/template/.claude/skills/organize-files/SKILL.md +3 -3
- package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
- package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
- package/template/.claude/skills/right-to-be-forgotten/SKILL.md +333 -0
- package/template/.claude/skills/send-chat/SKILL.md +170 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
- package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
- package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
- package/template/.claude/skills/track-candidates/SKILL.md +376 -0
- package/template/.claude/skills/upstream-skill/SKILL.md +207 -0
- package/template/.claude/skills/weekly-update/SKILL.md +250 -0
- package/template/CLAUDE.md +68 -40
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
- package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
- package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
- package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
- package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
- package/template/.claude/skills/sync-apple-mail/scripts/sync.py +0 -455
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sync Apple Mail threads to ~/.cache/fit/basecamp/apple_mail/ as markdown.
|
|
4
|
+
*
|
|
5
|
+
* Queries the macOS Mail Envelope Index SQLite database for threads with new
|
|
6
|
+
* messages since the last sync. Writes one markdown file per thread containing
|
|
7
|
+
* sender, recipients, date, body text (parsed from .emlx files), and attachment
|
|
8
|
+
* links. Attachments are copied into a per-thread subdirectory.
|
|
9
|
+
*
|
|
10
|
+
* Requires macOS with Mail app configured and Full Disk Access granted.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
14
|
+
console.log(`sync-apple-mail — sync email threads to markdown
|
|
15
|
+
|
|
16
|
+
Usage: node scripts/sync.mjs [--days N] [-h|--help]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--days N Days back to sync on first run (default: 30)
|
|
20
|
+
-h, --help Show this help message and exit
|
|
21
|
+
|
|
22
|
+
Requires macOS with Mail configured and Full Disk Access granted.`);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
import { DatabaseSync } from "node:sqlite";
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import {
|
|
29
|
+
copyFileSync,
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import { basename, join } from "node:path";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
import { globSync } from "node:fs";
|
|
38
|
+
import { parseEmlx } from "./parse-emlx.mjs";
|
|
39
|
+
|
|
40
|
+
const HOME = homedir();
|
|
41
|
+
const OUTDIR = join(HOME, ".cache/fit/basecamp/apple_mail");
|
|
42
|
+
const ATTACHMENTS_DIR = join(OUTDIR, "attachments");
|
|
43
|
+
const STATE_DIR = join(HOME, ".cache/fit/basecamp/state");
|
|
44
|
+
const STATE_FILE = join(STATE_DIR, "apple_mail_last_sync");
|
|
45
|
+
const ROWID_STATE_FILE = join(STATE_DIR, "apple_mail_last_rowid");
|
|
46
|
+
const MAX_THREADS = 500;
|
|
47
|
+
|
|
48
|
+
// --- Database helpers ---
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find the Apple Mail Envelope Index database.
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function findDb() {
|
|
55
|
+
const mailDir = join(HOME, "Library/Mail");
|
|
56
|
+
const paths = globSync(join(mailDir, "V*/MailData/Envelope Index"))
|
|
57
|
+
.sort()
|
|
58
|
+
.reverse();
|
|
59
|
+
if (paths.length === 0) {
|
|
60
|
+
console.error("Error: Apple Mail database not found. Is Mail configured?");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
return paths[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open the database in read-only mode with retry on lock.
|
|
68
|
+
* @param {string} dbPath
|
|
69
|
+
* @returns {import("node:sqlite").DatabaseSync}
|
|
70
|
+
*/
|
|
71
|
+
function openDb(dbPath) {
|
|
72
|
+
try {
|
|
73
|
+
return new DatabaseSync(dbPath, { readOnly: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err.message.includes("locked")) {
|
|
76
|
+
// Retry once after 2s
|
|
77
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
78
|
+
return new DatabaseSync(dbPath, { readOnly: true });
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Execute a read-only query and return results.
|
|
86
|
+
* @param {import("node:sqlite").DatabaseSync} db
|
|
87
|
+
* @param {string} sql
|
|
88
|
+
* @returns {Array<Record<string, any>>}
|
|
89
|
+
*/
|
|
90
|
+
function query(db, sql) {
|
|
91
|
+
try {
|
|
92
|
+
return db.prepare(sql).all();
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error(`SQLite error: ${err.message}`);
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Sync state ---
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Load the last sync timestamp. Returns Unix timestamp.
|
|
103
|
+
* @param {number} daysBack
|
|
104
|
+
* @returns {number}
|
|
105
|
+
*/
|
|
106
|
+
function loadLastSync(daysBack = 30) {
|
|
107
|
+
try {
|
|
108
|
+
const iso = readFileSync(STATE_FILE, "utf-8").trim();
|
|
109
|
+
if (iso) {
|
|
110
|
+
const dt = new Date(iso);
|
|
111
|
+
if (!isNaN(dt.getTime())) return Math.floor(dt.getTime() / 1000);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// First sync
|
|
115
|
+
}
|
|
116
|
+
return Math.floor((Date.now() - daysBack * 86400000) / 1000);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Save current time as the sync timestamp, and last ROWID. */
|
|
120
|
+
function saveSyncState(lastRowid = null) {
|
|
121
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
122
|
+
writeFileSync(STATE_FILE, new Date().toISOString());
|
|
123
|
+
if (lastRowid != null) {
|
|
124
|
+
writeFileSync(ROWID_STATE_FILE, String(lastRowid));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Load the last synced ROWID. Returns 0 on first sync.
|
|
130
|
+
* @returns {number}
|
|
131
|
+
*/
|
|
132
|
+
function loadLastRowid() {
|
|
133
|
+
try {
|
|
134
|
+
const val = readFileSync(ROWID_STATE_FILE, "utf-8").trim();
|
|
135
|
+
if (val && /^\d+$/.test(val)) return parseInt(val, 10);
|
|
136
|
+
} catch {
|
|
137
|
+
// First sync
|
|
138
|
+
}
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Convert Unix timestamp to readable date string.
|
|
144
|
+
* @param {number | null} ts
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
function unixToReadable(ts) {
|
|
148
|
+
if (ts == null) return "Unknown";
|
|
149
|
+
try {
|
|
150
|
+
return new Date(ts * 1000)
|
|
151
|
+
.toISOString()
|
|
152
|
+
.replace("T", " ")
|
|
153
|
+
.replace(/\.\d+Z/, " UTC");
|
|
154
|
+
} catch {
|
|
155
|
+
return "Unknown";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Database queries ---
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Determine which column to use for thread grouping.
|
|
163
|
+
* @param {import("node:sqlite").DatabaseSync} db
|
|
164
|
+
* @returns {string | null}
|
|
165
|
+
*/
|
|
166
|
+
function discoverThreadColumn(db) {
|
|
167
|
+
const rows = query(db, "PRAGMA table_info(messages);");
|
|
168
|
+
const columns = new Set(rows.map((r) => r.name));
|
|
169
|
+
if (columns.has("conversation_id")) return "conversation_id";
|
|
170
|
+
if (columns.has("thread_id")) return "thread_id";
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Find thread IDs with messages newer than sinceTs OR with ROWID > lastRowid.
|
|
176
|
+
* Using ROWID catches emails that Mail downloads late (date_received may
|
|
177
|
+
* predate our last sync, but ROWID always increases on insertion).
|
|
178
|
+
* @param {import("node:sqlite").DatabaseSync} db
|
|
179
|
+
* @param {string} threadCol
|
|
180
|
+
* @param {number} sinceTs
|
|
181
|
+
* @param {number} lastRowid
|
|
182
|
+
* @returns {Array<{ tid: number }>}
|
|
183
|
+
*/
|
|
184
|
+
function findChangedThreads(db, threadCol, sinceTs, lastRowid) {
|
|
185
|
+
return query(
|
|
186
|
+
db,
|
|
187
|
+
`
|
|
188
|
+
SELECT DISTINCT m.${threadCol} AS tid
|
|
189
|
+
FROM messages m
|
|
190
|
+
WHERE (m.date_received > ${sinceTs} OR m.ROWID > ${lastRowid})
|
|
191
|
+
AND m.deleted = 0
|
|
192
|
+
AND m.mailbox IN (
|
|
193
|
+
SELECT ROWID FROM mailboxes
|
|
194
|
+
WHERE url LIKE '%/Inbox%'
|
|
195
|
+
OR url LIKE '%/INBOX%'
|
|
196
|
+
OR url LIKE '%/Sent%'
|
|
197
|
+
)
|
|
198
|
+
LIMIT ${MAX_THREADS};
|
|
199
|
+
`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fetch all messages in a thread with sender info.
|
|
205
|
+
* @param {import("node:sqlite").DatabaseSync} db
|
|
206
|
+
* @param {string} threadCol
|
|
207
|
+
* @param {number} tid
|
|
208
|
+
* @returns {Array<Record<string, any>>}
|
|
209
|
+
*/
|
|
210
|
+
function fetchThreadMessages(db, threadCol, tid) {
|
|
211
|
+
return query(
|
|
212
|
+
db,
|
|
213
|
+
`
|
|
214
|
+
SELECT
|
|
215
|
+
m.ROWID AS message_id,
|
|
216
|
+
m.${threadCol} AS thread_id,
|
|
217
|
+
COALESCE(s.subject, '(No Subject)') AS subject,
|
|
218
|
+
COALESCE(m.subject_prefix, '') AS subject_prefix,
|
|
219
|
+
COALESCE(a.address, 'Unknown') AS sender,
|
|
220
|
+
COALESCE(a.comment, '') AS sender_name,
|
|
221
|
+
m.date_received,
|
|
222
|
+
COALESCE(su.summary, '') AS summary,
|
|
223
|
+
COALESCE(m.list_id_hash, 0) AS list_id_hash,
|
|
224
|
+
COALESCE(m.automated_conversation, 0) AS automated_conversation
|
|
225
|
+
FROM messages m
|
|
226
|
+
LEFT JOIN subjects s ON m.subject = s.ROWID
|
|
227
|
+
LEFT JOIN addresses a ON m.sender = a.ROWID
|
|
228
|
+
LEFT JOIN summaries su ON m.summary = su.ROWID
|
|
229
|
+
WHERE m.${threadCol} = ${tid}
|
|
230
|
+
AND m.deleted = 0
|
|
231
|
+
ORDER BY m.date_received ASC;
|
|
232
|
+
`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Batch-fetch To/Cc recipients for a set of message IDs.
|
|
238
|
+
* @param {import("node:sqlite").DatabaseSync} db
|
|
239
|
+
* @param {number[]} messageIds
|
|
240
|
+
* @returns {Record<number, Record<number, Array<Record<string, any>>>>}
|
|
241
|
+
*/
|
|
242
|
+
function fetchRecipients(db, messageIds) {
|
|
243
|
+
if (messageIds.length === 0) return {};
|
|
244
|
+
const idList = messageIds.join(",");
|
|
245
|
+
const rows = query(
|
|
246
|
+
db,
|
|
247
|
+
`
|
|
248
|
+
SELECT
|
|
249
|
+
r.message AS message_id,
|
|
250
|
+
r.type,
|
|
251
|
+
COALESCE(a.address, '') AS address,
|
|
252
|
+
COALESCE(a.comment, '') AS name
|
|
253
|
+
FROM recipients r
|
|
254
|
+
LEFT JOIN addresses a ON r.address = a.ROWID
|
|
255
|
+
WHERE r.message IN (${idList})
|
|
256
|
+
ORDER BY r.message, r.type, r.position;
|
|
257
|
+
`,
|
|
258
|
+
);
|
|
259
|
+
const result = {};
|
|
260
|
+
for (const r of rows) {
|
|
261
|
+
if (r.type === 2) continue; // Skip Bcc
|
|
262
|
+
result[r.message_id] ??= {};
|
|
263
|
+
result[r.message_id][r.type] ??= [];
|
|
264
|
+
result[r.message_id][r.type].push(r);
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Batch-fetch attachment metadata for a set of message IDs.
|
|
271
|
+
* @param {import("node:sqlite").DatabaseSync} db
|
|
272
|
+
* @param {number[]} messageIds
|
|
273
|
+
* @returns {Record<number, Array<{ attachment_id: string, name: string }>>}
|
|
274
|
+
*/
|
|
275
|
+
function fetchAttachments(db, messageIds) {
|
|
276
|
+
if (messageIds.length === 0) return {};
|
|
277
|
+
const idList = messageIds.join(",");
|
|
278
|
+
const rows = query(
|
|
279
|
+
db,
|
|
280
|
+
`
|
|
281
|
+
SELECT a.message AS message_id, a.attachment_id, a.name
|
|
282
|
+
FROM attachments a
|
|
283
|
+
WHERE a.message IN (${idList})
|
|
284
|
+
ORDER BY a.message, a.ROWID;
|
|
285
|
+
`,
|
|
286
|
+
);
|
|
287
|
+
const result = {};
|
|
288
|
+
for (const r of rows) {
|
|
289
|
+
result[r.message_id] ??= [];
|
|
290
|
+
result[r.message_id].push({
|
|
291
|
+
attachment_id: r.attachment_id,
|
|
292
|
+
name: r.name,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- File indexing ---
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Build emlx and attachment indexes with a single find traversal.
|
|
302
|
+
* @returns {{ emlxIndex: Map<number, string>, attachmentIndex: Map<string, string> }}
|
|
303
|
+
*/
|
|
304
|
+
function buildFileIndexes() {
|
|
305
|
+
const mailDir = join(HOME, "Library/Mail");
|
|
306
|
+
const emlxIndex = new Map();
|
|
307
|
+
const attachmentIndex = new Map();
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const output = execFileSync(
|
|
311
|
+
"find",
|
|
312
|
+
[
|
|
313
|
+
mailDir,
|
|
314
|
+
"(",
|
|
315
|
+
"-name",
|
|
316
|
+
"*.emlx",
|
|
317
|
+
"-o",
|
|
318
|
+
"-path",
|
|
319
|
+
"*/Attachments/*",
|
|
320
|
+
")",
|
|
321
|
+
"-type",
|
|
322
|
+
"f",
|
|
323
|
+
],
|
|
324
|
+
{ encoding: "utf-8", timeout: 60000, maxBuffer: 50 * 1024 * 1024 },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
for (const path of output.trim().split("\n")) {
|
|
328
|
+
if (!path) continue;
|
|
329
|
+
if (path.includes("/Attachments/")) {
|
|
330
|
+
const parts = path.split("/Attachments/", 2);
|
|
331
|
+
if (parts.length === 2) {
|
|
332
|
+
const segments = parts[1].split("/");
|
|
333
|
+
if (segments.length >= 3 && /^\d+$/.test(segments[0])) {
|
|
334
|
+
const msgRowid = parseInt(segments[0], 10);
|
|
335
|
+
const attId = segments[1];
|
|
336
|
+
attachmentIndex.set(`${msgRowid}:${attId}`, path);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} else if (path.endsWith(".emlx")) {
|
|
340
|
+
const name = basename(path);
|
|
341
|
+
const msgId = name.split(".")[0];
|
|
342
|
+
if (/^\d+$/.test(msgId)) {
|
|
343
|
+
const mid = parseInt(msgId, 10);
|
|
344
|
+
// Prefer .emlx over .partial.emlx (shorter name = full message)
|
|
345
|
+
const existing = emlxIndex.get(mid);
|
|
346
|
+
if (!existing || name.length < basename(existing).length) {
|
|
347
|
+
emlxIndex.set(mid, path);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
// Timeout or no results
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { emlxIndex, attachmentIndex };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Parse .emlx file for a message using pre-built index.
|
|
361
|
+
* @param {number} messageId
|
|
362
|
+
* @param {Map<number, string>} emlxIndex
|
|
363
|
+
* @returns {string | null}
|
|
364
|
+
*/
|
|
365
|
+
function parseEmlxBody(messageId, emlxIndex) {
|
|
366
|
+
const path = emlxIndex.get(messageId);
|
|
367
|
+
if (!path) return null;
|
|
368
|
+
try {
|
|
369
|
+
return parseEmlx(path);
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --- Formatting ---
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Format a recipient as 'Name <email>' or just 'email'.
|
|
379
|
+
* @param {Record<string, string>} r
|
|
380
|
+
* @returns {string}
|
|
381
|
+
*/
|
|
382
|
+
function formatRecipient(r) {
|
|
383
|
+
const name = (r.name ?? "").trim();
|
|
384
|
+
const addr = (r.address ?? "").trim();
|
|
385
|
+
if (name && addr) return `${name} <${addr}>`;
|
|
386
|
+
return addr || name;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Format sender as 'Name <email>' or just 'email'.
|
|
391
|
+
* @param {Record<string, any>} msg
|
|
392
|
+
* @returns {string}
|
|
393
|
+
*/
|
|
394
|
+
function formatSender(msg) {
|
|
395
|
+
const name = (msg.sender_name ?? "").trim();
|
|
396
|
+
const addr = (msg.sender ?? "").trim();
|
|
397
|
+
if (name && addr) return `${name} <${addr}>`;
|
|
398
|
+
return addr || name;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- Attachment copying ---
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Copy attachment files into the output attachments directory.
|
|
405
|
+
* @param {number} threadId
|
|
406
|
+
* @param {Array<Record<string, any>>} messages
|
|
407
|
+
* @param {Record<number, Array<{ attachment_id: string, name: string }>>} attachmentsByMsg
|
|
408
|
+
* @param {Map<string, string>} attachmentIndex
|
|
409
|
+
* @returns {Record<number, Array<{ name: string, available: boolean, path: string | null }>>}
|
|
410
|
+
*/
|
|
411
|
+
function copyThreadAttachments(
|
|
412
|
+
threadId,
|
|
413
|
+
messages,
|
|
414
|
+
attachmentsByMsg,
|
|
415
|
+
attachmentIndex,
|
|
416
|
+
) {
|
|
417
|
+
const results = {};
|
|
418
|
+
const seenFilenames = new Set();
|
|
419
|
+
|
|
420
|
+
for (const msg of messages) {
|
|
421
|
+
const mid = msg.message_id;
|
|
422
|
+
const msgAttachments = attachmentsByMsg[mid] ?? [];
|
|
423
|
+
if (msgAttachments.length === 0) continue;
|
|
424
|
+
|
|
425
|
+
const msgResults = [];
|
|
426
|
+
for (const att of msgAttachments) {
|
|
427
|
+
const name = att.name || "unnamed";
|
|
428
|
+
const source = attachmentIndex.get(`${mid}:${att.attachment_id}`);
|
|
429
|
+
|
|
430
|
+
if (!source || !existsSync(source)) {
|
|
431
|
+
msgResults.push({ name, available: false, path: null });
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let destName = name;
|
|
436
|
+
if (seenFilenames.has(destName)) destName = `${mid}_${name}`;
|
|
437
|
+
seenFilenames.add(destName);
|
|
438
|
+
|
|
439
|
+
const destDir = join(ATTACHMENTS_DIR, String(threadId));
|
|
440
|
+
mkdirSync(destDir, { recursive: true });
|
|
441
|
+
const destPath = join(destDir, destName);
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
copyFileSync(source, destPath);
|
|
445
|
+
msgResults.push({ name: destName, available: true, path: destPath });
|
|
446
|
+
} catch {
|
|
447
|
+
msgResults.push({ name, available: false, path: null });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
results[mid] = msgResults;
|
|
451
|
+
}
|
|
452
|
+
return results;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- Markdown output ---
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Write a thread as a markdown file.
|
|
459
|
+
* @param {number} threadId
|
|
460
|
+
* @param {Array<Record<string, any>>} messages
|
|
461
|
+
* @param {Record<number, Record<number, Array<Record<string, any>>>>} recipientsByMsg
|
|
462
|
+
* @param {Map<number, string>} emlxIndex
|
|
463
|
+
* @param {Record<number, Array<{ name: string, available: boolean, path: string | null }>> | null} attachmentResults
|
|
464
|
+
* @returns {boolean}
|
|
465
|
+
*/
|
|
466
|
+
function writeThreadMarkdown(
|
|
467
|
+
threadId,
|
|
468
|
+
messages,
|
|
469
|
+
recipientsByMsg,
|
|
470
|
+
emlxIndex,
|
|
471
|
+
attachmentResults,
|
|
472
|
+
) {
|
|
473
|
+
if (messages.length === 0) return false;
|
|
474
|
+
|
|
475
|
+
const baseSubject = messages[0].subject ?? "(No Subject)";
|
|
476
|
+
const isMailingList = messages.some((m) => (m.list_id_hash ?? 0) !== 0);
|
|
477
|
+
const isAutomated = messages.some(
|
|
478
|
+
(m) => (m.automated_conversation ?? 0) !== 0,
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const flags = [];
|
|
482
|
+
if (isMailingList) flags.push("mailing-list");
|
|
483
|
+
if (isAutomated) flags.push("automated");
|
|
484
|
+
|
|
485
|
+
const lines = [];
|
|
486
|
+
lines.push(`# ${baseSubject}`);
|
|
487
|
+
lines.push("");
|
|
488
|
+
lines.push(`**Thread ID:** ${threadId}`);
|
|
489
|
+
lines.push(`**Message Count:** ${messages.length}`);
|
|
490
|
+
if (flags.length > 0) lines.push(`**Flags:** ${flags.join(", ")}`);
|
|
491
|
+
lines.push("");
|
|
492
|
+
|
|
493
|
+
for (const msg of messages) {
|
|
494
|
+
lines.push("---");
|
|
495
|
+
lines.push("");
|
|
496
|
+
lines.push(`### From: ${formatSender(msg)}`);
|
|
497
|
+
lines.push(`**Date:** ${unixToReadable(msg.date_received)}`);
|
|
498
|
+
|
|
499
|
+
const mid = msg.message_id;
|
|
500
|
+
const msgRecips = recipientsByMsg[mid] ?? {};
|
|
501
|
+
const toList = msgRecips[0] ?? [];
|
|
502
|
+
const ccList = msgRecips[1] ?? [];
|
|
503
|
+
if (toList.length > 0)
|
|
504
|
+
lines.push(`**To:** ${toList.map(formatRecipient).join(", ")}`);
|
|
505
|
+
if (ccList.length > 0)
|
|
506
|
+
lines.push(`**Cc:** ${ccList.map(formatRecipient).join(", ")}`);
|
|
507
|
+
lines.push("");
|
|
508
|
+
|
|
509
|
+
// Body: try .emlx first, fall back to summary
|
|
510
|
+
let body = parseEmlxBody(mid, emlxIndex);
|
|
511
|
+
if (!body) body = (msg.summary ?? "").trim();
|
|
512
|
+
if (body) lines.push(body);
|
|
513
|
+
lines.push("");
|
|
514
|
+
|
|
515
|
+
// Attachments
|
|
516
|
+
if (attachmentResults) {
|
|
517
|
+
const msgAtts = attachmentResults[mid] ?? [];
|
|
518
|
+
if (msgAtts.length > 0) {
|
|
519
|
+
lines.push("**Attachments:**");
|
|
520
|
+
for (const att of msgAtts) {
|
|
521
|
+
if (att.available) {
|
|
522
|
+
lines.push(`- [${att.name}](attachments/${threadId}/${att.name})`);
|
|
523
|
+
} else {
|
|
524
|
+
lines.push(`- ${att.name} *(not available)*`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
lines.push("");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
writeFileSync(join(OUTDIR, `${threadId}.md`), lines.join("\n"));
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- Main ---
|
|
537
|
+
|
|
538
|
+
function main() {
|
|
539
|
+
// Parse --days argument
|
|
540
|
+
let daysBack = 30;
|
|
541
|
+
const daysIdx = process.argv.indexOf("--days");
|
|
542
|
+
if (daysIdx !== -1 && process.argv[daysIdx + 1]) {
|
|
543
|
+
daysBack = parseInt(process.argv[daysIdx + 1], 10);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const dbPath = findDb();
|
|
547
|
+
mkdirSync(OUTDIR, { recursive: true });
|
|
548
|
+
|
|
549
|
+
const sinceTs = loadLastSync(daysBack);
|
|
550
|
+
const sinceReadable = unixToReadable(sinceTs);
|
|
551
|
+
|
|
552
|
+
const db = openDb(dbPath);
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
const threadCol = discoverThreadColumn(db);
|
|
556
|
+
if (!threadCol) {
|
|
557
|
+
console.error(
|
|
558
|
+
"Error: Could not find conversation_id or thread_id column.",
|
|
559
|
+
);
|
|
560
|
+
process.exit(1);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const lastRowid = loadLastRowid();
|
|
564
|
+
const changed = findChangedThreads(db, threadCol, sinceTs, lastRowid);
|
|
565
|
+
const threadIds = changed.map((r) => r.tid);
|
|
566
|
+
|
|
567
|
+
// Find the max ROWID across all messages for state tracking
|
|
568
|
+
let maxRowid = lastRowid;
|
|
569
|
+
const maxRowidResult = query(
|
|
570
|
+
db,
|
|
571
|
+
`SELECT MAX(ROWID) as max_rowid FROM messages`,
|
|
572
|
+
);
|
|
573
|
+
if (maxRowidResult.length > 0 && maxRowidResult[0].max_rowid != null) {
|
|
574
|
+
maxRowid = maxRowidResult[0].max_rowid;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (threadIds.length === 0) {
|
|
578
|
+
console.log("Apple Mail Sync Complete");
|
|
579
|
+
console.log("Threads processed: 0 (no new messages)");
|
|
580
|
+
console.log(`Time range: ${sinceReadable} to now`);
|
|
581
|
+
console.log(`Output: ${OUTDIR}`);
|
|
582
|
+
saveSyncState(maxRowid);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Build .emlx and attachment file indexes (single find traversal)
|
|
587
|
+
const { emlxIndex, attachmentIndex } = buildFileIndexes();
|
|
588
|
+
|
|
589
|
+
let written = 0;
|
|
590
|
+
for (const tid of threadIds) {
|
|
591
|
+
const messages = fetchThreadMessages(db, threadCol, tid);
|
|
592
|
+
if (messages.length === 0) continue;
|
|
593
|
+
|
|
594
|
+
const msgIds = messages.map((m) => m.message_id);
|
|
595
|
+
const recipients = fetchRecipients(db, msgIds);
|
|
596
|
+
const attachmentsByMsg = fetchAttachments(db, msgIds);
|
|
597
|
+
const attachmentResults = copyThreadAttachments(
|
|
598
|
+
tid,
|
|
599
|
+
messages,
|
|
600
|
+
attachmentsByMsg,
|
|
601
|
+
attachmentIndex,
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
if (
|
|
605
|
+
writeThreadMarkdown(
|
|
606
|
+
tid,
|
|
607
|
+
messages,
|
|
608
|
+
recipients,
|
|
609
|
+
emlxIndex,
|
|
610
|
+
attachmentResults,
|
|
611
|
+
)
|
|
612
|
+
) {
|
|
613
|
+
written++;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
saveSyncState(maxRowid);
|
|
618
|
+
|
|
619
|
+
console.log("Apple Mail Sync Complete");
|
|
620
|
+
console.log(`Threads processed: ${threadIds.length}`);
|
|
621
|
+
console.log(`New/updated files: ${written}`);
|
|
622
|
+
console.log(`Time range: ${sinceReadable} to now`);
|
|
623
|
+
console.log(`Output: ${OUTDIR}`);
|
|
624
|
+
} finally {
|
|
625
|
+
db.close();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
main();
|