@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.
Files changed (41) hide show
  1. package/config/scheduler.json +5 -0
  2. package/package.json +1 -1
  3. package/src/basecamp.js +288 -57
  4. package/template/.claude/agents/chief-of-staff.md +6 -2
  5. package/template/.claude/agents/concierge.md +2 -3
  6. package/template/.claude/agents/librarian.md +4 -6
  7. package/template/.claude/agents/recruiter.md +269 -0
  8. package/template/.claude/settings.json +0 -4
  9. package/template/.claude/skills/analyze-cv/SKILL.md +269 -0
  10. package/template/.claude/skills/create-presentations/SKILL.md +2 -2
  11. package/template/.claude/skills/create-presentations/references/slide.css +1 -1
  12. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
  13. package/template/.claude/skills/draft-emails/SKILL.md +85 -123
  14. package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  15. package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
  16. package/template/.claude/skills/extract-entities/SKILL.md +2 -2
  17. package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
  18. package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
  19. package/template/.claude/skills/organize-files/SKILL.md +3 -3
  20. package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  21. package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
  22. package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
  23. package/template/.claude/skills/right-to-be-forgotten/SKILL.md +333 -0
  24. package/template/.claude/skills/send-chat/SKILL.md +170 -0
  25. package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
  26. package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
  27. package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
  28. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
  29. package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
  30. package/template/.claude/skills/track-candidates/SKILL.md +376 -0
  31. package/template/.claude/skills/upstream-skill/SKILL.md +207 -0
  32. package/template/.claude/skills/weekly-update/SKILL.md +250 -0
  33. package/template/CLAUDE.md +68 -40
  34. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
  35. package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
  36. package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
  37. package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
  38. package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
  39. package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
  40. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
  41. 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();