@forwardimpact/outpost 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +15 -0
- package/bin/fit-outpost.js +3 -0
- package/config/scheduler.json +34 -0
- package/package.json +63 -0
- package/src/agent-runner.js +189 -0
- package/src/index.js +5 -0
- package/src/kb-manager.js +228 -0
- package/src/outpost.js +417 -0
- package/src/scheduler.js +195 -0
- package/src/socket-server.js +336 -0
- package/src/state-manager.js +126 -0
- package/templates/.claude/agents/chief-of-staff.md +58 -0
- package/templates/.claude/agents/concierge.md +46 -0
- package/templates/.claude/agents/head-hunter.md +57 -0
- package/templates/.claude/agents/librarian.md +60 -0
- package/templates/.claude/agents/postman.md +47 -0
- package/templates/.claude/agents/recruiter.md +63 -0
- package/templates/.claude/settings.json +88 -0
- package/templates/.claude/skills/candidate-report/SKILL.md +139 -0
- package/templates/.claude/skills/candidate-report/references/report-template.html +203 -0
- package/templates/.claude/skills/candidate-report/references/report.css +438 -0
- package/templates/.claude/skills/candidate-report/references/rubric.md +76 -0
- package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +48 -0
- package/templates/.claude/skills/deck-create/SKILL.md +75 -0
- package/templates/.claude/skills/deck-create/references/slide.css +35 -0
- package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +47 -0
- package/templates/.claude/skills/deck-summarize/SKILL.md +139 -0
- package/templates/.claude/skills/deck-summarize/references/brief-template.md +85 -0
- package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +210 -0
- package/templates/.claude/skills/doc-collab/SKILL.md +112 -0
- package/templates/.claude/skills/doc-create/SKILL.md +105 -0
- package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +50 -0
- package/templates/.claude/skills/draft-emails/SKILL.md +141 -0
- package/templates/.claude/skills/draft-emails/references/template.md +26 -0
- package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +139 -0
- package/templates/.claude/skills/extract-entities/SKILL.md +187 -0
- package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +14 -0
- package/templates/.claude/skills/extract-entities/references/conditions.md +55 -0
- package/templates/.claude/skills/extract-entities/references/content.md +104 -0
- package/templates/.claude/skills/extract-entities/references/links.md +48 -0
- package/templates/.claude/skills/extract-entities/references/recruitment.md +57 -0
- package/templates/.claude/skills/extract-entities/references/resolution.md +71 -0
- package/templates/.claude/skills/extract-entities/references/sources.md +70 -0
- package/templates/.claude/skills/extract-entities/references/templates-conditions.md +36 -0
- package/templates/.claude/skills/extract-entities/references/templates-goals-priorities.md +63 -0
- package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +67 -0
- package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +68 -0
- package/templates/.claude/skills/extract-entities/scripts/state.mjs +131 -0
- package/templates/.claude/skills/hyprnote-follow/SKILL.md +174 -0
- package/templates/.claude/skills/hyprnote-follow/references/coaching.md +118 -0
- package/templates/.claude/skills/hyprnote-follow/references/debrief.md +59 -0
- package/templates/.claude/skills/hyprnote-follow/references/meeting-types.md +14 -0
- package/templates/.claude/skills/hyprnote-follow/scripts/follow.mjs +243 -0
- package/templates/.claude/skills/hyprnote-process/SKILL.md +147 -0
- package/templates/.claude/skills/hyprnote-process/references/extraction.md +84 -0
- package/templates/.claude/skills/hyprnote-process/references/sessions.md +81 -0
- package/templates/.claude/skills/hyprnote-process/scripts/scan.mjs +246 -0
- package/templates/.claude/skills/hyprnote-trim/SKILL.md +177 -0
- package/templates/.claude/skills/manage-tasks/SKILL.md +152 -0
- package/templates/.claude/skills/manage-tasks/references/format.md +73 -0
- package/templates/.claude/skills/meeting-prep/SKILL.md +180 -0
- package/templates/.claude/skills/organize-files/SKILL.md +146 -0
- package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/templates/.claude/skills/organize-files/scripts/summarize.mjs +91 -0
- package/templates/.claude/skills/req-assess/SKILL.md +137 -0
- package/templates/.claude/skills/req-assess/references/interview-template.md +62 -0
- package/templates/.claude/skills/req-assess/references/panel-template.md +117 -0
- package/templates/.claude/skills/req-assess/references/rubric.md +65 -0
- package/templates/.claude/skills/req-decide/SKILL.md +163 -0
- package/templates/.claude/skills/req-decide/references/rubric.md +63 -0
- package/templates/.claude/skills/req-decide/references/template.md +118 -0
- package/templates/.claude/skills/req-forget/SKILL.md +154 -0
- package/templates/.claude/skills/req-forget/references/classify.md +23 -0
- package/templates/.claude/skills/req-forget/references/locations.md +59 -0
- package/templates/.claude/skills/req-forget/references/report-template.md +67 -0
- package/templates/.claude/skills/req-scan/SKILL.md +145 -0
- package/templates/.claude/skills/req-scan/references/fallbacks.md +76 -0
- package/templates/.claude/skills/req-scan/references/filters.md +83 -0
- package/templates/.claude/skills/req-scan/references/sources.md +76 -0
- package/templates/.claude/skills/req-scan/references/state.md +54 -0
- package/templates/.claude/skills/req-scan/references/template.md +36 -0
- package/templates/.claude/skills/req-scan/scripts/state.mjs +396 -0
- package/templates/.claude/skills/req-screen/SKILL.md +151 -0
- package/templates/.claude/skills/req-screen/references/rubric.md +94 -0
- package/templates/.claude/skills/req-screen/references/template.md +91 -0
- package/templates/.claude/skills/req-track/SKILL.md +189 -0
- package/templates/.claude/skills/req-track/references/fields.md +114 -0
- package/templates/.claude/skills/req-track/references/signals.md +42 -0
- package/templates/.claude/skills/req-track/references/statuses.md +32 -0
- package/templates/.claude/skills/req-track/references/templates.md +127 -0
- package/templates/.claude/skills/req-workday/SKILL.md +162 -0
- package/templates/.claude/skills/req-workday/references/field-mapping.md +40 -0
- package/templates/.claude/skills/req-workday/references/status-mapping.md +38 -0
- package/templates/.claude/skills/req-workday/references/templates.md +126 -0
- package/templates/.claude/skills/req-workday/references/xlsx-format.md +56 -0
- package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +313 -0
- package/templates/.claude/skills/send-chat/SKILL.md +170 -0
- package/templates/.claude/skills/sync-apple-calendar/SKILL.md +145 -0
- package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +307 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +332 -0
- package/templates/.claude/skills/sync-apple-mail/SKILL.md +151 -0
- package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +115 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +389 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +372 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +239 -0
- package/templates/.claude/skills/sync-teams/SKILL.md +177 -0
- package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +147 -0
- package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +288 -0
- package/templates/.claude/skills/sync-teams/scripts/sync.mjs +381 -0
- package/templates/.claude/skills/upstream-skill/SKILL.md +130 -0
- package/templates/.claude/skills/upstream-skill/references/examples.md +80 -0
- package/templates/.claude/skills/weekly-update/SKILL.md +141 -0
- package/templates/.claude/skills/weekly-update/references/template.md +55 -0
- package/templates/CLAUDE.md +134 -0
- package/templates/USER.md +3 -0
- package/templates/apm.yml +5 -0
- package/templates/knowledge/Briefings/.gitkeep +0 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { globSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
// node:sqlite is loaded lazily via createRequire so this module can be imported
|
|
15
|
+
// in test environments (bun, older node) that lack the built-in. The only call
|
|
16
|
+
// site is openDb below; production runs on Node 22+ where node:sqlite resolves.
|
|
17
|
+
const requireModule = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
const HOME = homedir();
|
|
20
|
+
export const OUTDIR = join(HOME, ".cache/fit/outpost/apple_mail");
|
|
21
|
+
export const ATTACHMENTS_DIR = join(OUTDIR, "attachments");
|
|
22
|
+
const STATE_DIR = join(HOME, ".cache/fit/outpost/state");
|
|
23
|
+
const STATE_FILE = join(STATE_DIR, "apple_mail_last_sync");
|
|
24
|
+
const ROWID_STATE_FILE = join(STATE_DIR, "apple_mail_last_rowid");
|
|
25
|
+
export const MAX_THREADS = 500;
|
|
26
|
+
|
|
27
|
+
/** Locate the Apple Mail SQLite database file under ~/Library/Mail. */
|
|
28
|
+
export function findDb() {
|
|
29
|
+
const mailDir = join(HOME, "Library/Mail");
|
|
30
|
+
const paths = globSync(join(mailDir, "V*/MailData/Envelope Index"))
|
|
31
|
+
.sort()
|
|
32
|
+
.reverse();
|
|
33
|
+
if (paths.length === 0) {
|
|
34
|
+
console.error("Error: Apple Mail database not found. Is Mail configured?");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
return paths[0];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Open a read-only SQLite connection, retrying once if the database is locked. */
|
|
41
|
+
export function openDb(dbPath) {
|
|
42
|
+
const { DatabaseSync } = requireModule("node:sqlite");
|
|
43
|
+
try {
|
|
44
|
+
return new DatabaseSync(dbPath, { readOnly: true });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.message.includes("locked")) {
|
|
47
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
48
|
+
return new DatabaseSync(dbPath, { readOnly: true });
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Execute a SQL query and return all rows, logging errors and returning an empty array on failure. */
|
|
55
|
+
export function query(db, sql) {
|
|
56
|
+
try {
|
|
57
|
+
return db.prepare(sql).all();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(`SQLite error: ${err.message}`);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Load the last sync timestamp from disk, falling back to daysBack days ago on first run. */
|
|
65
|
+
export function loadLastSync(daysBack = 30) {
|
|
66
|
+
try {
|
|
67
|
+
const iso = readFileSync(STATE_FILE, "utf-8").trim();
|
|
68
|
+
if (iso) {
|
|
69
|
+
const dt = new Date(iso);
|
|
70
|
+
if (!isNaN(dt.getTime())) return Math.floor(dt.getTime() / 1000);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// First sync
|
|
74
|
+
}
|
|
75
|
+
return Math.floor((Date.now() - daysBack * 86400000) / 1000);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Persist the current sync timestamp and optionally the last-seen message ROWID to disk. */
|
|
79
|
+
export function saveSyncState(lastRowid = null) {
|
|
80
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
81
|
+
writeFileSync(STATE_FILE, new Date().toISOString());
|
|
82
|
+
if (lastRowid != null) {
|
|
83
|
+
writeFileSync(ROWID_STATE_FILE, String(lastRowid));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Load the last-seen message ROWID from disk, returning 0 on first run. */
|
|
88
|
+
export function loadLastRowid() {
|
|
89
|
+
try {
|
|
90
|
+
const val = readFileSync(ROWID_STATE_FILE, "utf-8").trim();
|
|
91
|
+
if (val && /^\d+$/.test(val)) return parseInt(val, 10);
|
|
92
|
+
} catch {
|
|
93
|
+
// First sync
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Convert a Unix timestamp in seconds to a human-readable UTC date string. */
|
|
99
|
+
export function unixToReadable(ts) {
|
|
100
|
+
if (ts == null) return "Unknown";
|
|
101
|
+
try {
|
|
102
|
+
return new Date(ts * 1000)
|
|
103
|
+
.toISOString()
|
|
104
|
+
.replace("T", " ")
|
|
105
|
+
.replace(/\.\d+Z/, " UTC");
|
|
106
|
+
} catch {
|
|
107
|
+
return "Unknown";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Detect whether the messages table uses conversation_id or thread_id for threading. */
|
|
112
|
+
export function discoverThreadColumn(db) {
|
|
113
|
+
const rows = query(db, "PRAGMA table_info(messages);");
|
|
114
|
+
const columns = new Set(rows.map((r) => r.name));
|
|
115
|
+
if (columns.has("conversation_id")) return "conversation_id";
|
|
116
|
+
if (columns.has("thread_id")) return "thread_id";
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Return distinct thread IDs that have new messages since the given timestamp or ROWID. */
|
|
121
|
+
export function findChangedThreads(db, threadCol, sinceTs, lastRowid) {
|
|
122
|
+
return query(
|
|
123
|
+
db,
|
|
124
|
+
`
|
|
125
|
+
SELECT DISTINCT m.${threadCol} AS tid
|
|
126
|
+
FROM messages m
|
|
127
|
+
WHERE (m.date_received > ${sinceTs} OR m.ROWID > ${lastRowid})
|
|
128
|
+
AND m.deleted = 0
|
|
129
|
+
AND m.mailbox IN (
|
|
130
|
+
SELECT ROWID FROM mailboxes
|
|
131
|
+
WHERE url LIKE '%/Inbox%'
|
|
132
|
+
OR url LIKE '%/INBOX%'
|
|
133
|
+
OR url LIKE '%/Sent%'
|
|
134
|
+
)
|
|
135
|
+
LIMIT ${MAX_THREADS};
|
|
136
|
+
`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fetch all messages in a thread ordered by date, including sender and summary metadata. */
|
|
141
|
+
export function fetchThreadMessages(db, threadCol, tid) {
|
|
142
|
+
return query(
|
|
143
|
+
db,
|
|
144
|
+
`
|
|
145
|
+
SELECT
|
|
146
|
+
m.ROWID AS message_id,
|
|
147
|
+
m.${threadCol} AS thread_id,
|
|
148
|
+
COALESCE(s.subject, '(No Subject)') AS subject,
|
|
149
|
+
COALESCE(m.subject_prefix, '') AS subject_prefix,
|
|
150
|
+
COALESCE(a.address, 'Unknown') AS sender,
|
|
151
|
+
COALESCE(a.comment, '') AS sender_name,
|
|
152
|
+
m.date_received,
|
|
153
|
+
COALESCE(su.summary, '') AS summary,
|
|
154
|
+
COALESCE(m.list_id_hash, 0) AS list_id_hash,
|
|
155
|
+
COALESCE(m.automated_conversation, 0) AS automated_conversation
|
|
156
|
+
FROM messages m
|
|
157
|
+
LEFT JOIN subjects s ON m.subject = s.ROWID
|
|
158
|
+
LEFT JOIN addresses a ON m.sender = a.ROWID
|
|
159
|
+
LEFT JOIN summaries su ON m.summary = su.ROWID
|
|
160
|
+
WHERE m.${threadCol} = ${tid}
|
|
161
|
+
AND m.deleted = 0
|
|
162
|
+
ORDER BY m.date_received ASC;
|
|
163
|
+
`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Fetch To and CC recipients for the given message IDs, grouped by message and type. */
|
|
168
|
+
export function fetchRecipients(db, messageIds) {
|
|
169
|
+
if (messageIds.length === 0) return {};
|
|
170
|
+
const idList = messageIds.join(",");
|
|
171
|
+
const rows = query(
|
|
172
|
+
db,
|
|
173
|
+
`
|
|
174
|
+
SELECT
|
|
175
|
+
r.message AS message_id,
|
|
176
|
+
r.type,
|
|
177
|
+
COALESCE(a.address, '') AS address,
|
|
178
|
+
COALESCE(a.comment, '') AS name
|
|
179
|
+
FROM recipients r
|
|
180
|
+
LEFT JOIN addresses a ON r.address = a.ROWID
|
|
181
|
+
WHERE r.message IN (${idList})
|
|
182
|
+
ORDER BY r.message, r.type, r.position;
|
|
183
|
+
`,
|
|
184
|
+
);
|
|
185
|
+
const result = {};
|
|
186
|
+
for (const r of rows) {
|
|
187
|
+
if (r.type === 2) continue;
|
|
188
|
+
result[r.message_id] ??= {};
|
|
189
|
+
result[r.message_id][r.type] ??= [];
|
|
190
|
+
result[r.message_id][r.type].push(r);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Fetch attachment metadata for the given message IDs, grouped by message. */
|
|
196
|
+
export function fetchAttachments(db, messageIds) {
|
|
197
|
+
if (messageIds.length === 0) return {};
|
|
198
|
+
const idList = messageIds.join(",");
|
|
199
|
+
const rows = query(
|
|
200
|
+
db,
|
|
201
|
+
`
|
|
202
|
+
SELECT a.message AS message_id, a.attachment_id, a.name
|
|
203
|
+
FROM attachments a
|
|
204
|
+
WHERE a.message IN (${idList})
|
|
205
|
+
ORDER BY a.message, a.ROWID;
|
|
206
|
+
`,
|
|
207
|
+
);
|
|
208
|
+
const result = {};
|
|
209
|
+
for (const r of rows) {
|
|
210
|
+
result[r.message_id] ??= [];
|
|
211
|
+
result[r.message_id].push({
|
|
212
|
+
attachment_id: r.attachment_id,
|
|
213
|
+
name: r.name,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const FALLBACK_ATTACHMENT_NAME = "unnamed";
|
|
220
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — stripping ASCII control bytes from attacker-controlled filenames.
|
|
221
|
+
const CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/g;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Coerce an arbitrary `attachments.name` value into a single, non-empty
|
|
225
|
+
* basename safe to join under a per-thread destDir. Strips path separators
|
|
226
|
+
* (POSIX and win32), strips ASCII control bytes, then takes the last
|
|
227
|
+
* non-empty/non-dot segment. Returns `"unnamed"` for any input that
|
|
228
|
+
* collapses to empty, `.`, or `..`. Never throws.
|
|
229
|
+
*/
|
|
230
|
+
export function sanitiseAttachmentName(raw) {
|
|
231
|
+
if (typeof raw !== "string") return FALLBACK_ATTACHMENT_NAME;
|
|
232
|
+
const stripped = raw.replace(CONTROL_CHARS_RE, "");
|
|
233
|
+
const segments = stripped.split(/[/\\]/);
|
|
234
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
235
|
+
const seg = segments[i];
|
|
236
|
+
if (seg && seg !== "." && seg !== "..") return seg;
|
|
237
|
+
}
|
|
238
|
+
return FALLBACK_ATTACHMENT_NAME;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function indexAttachmentPath(path, attachmentIndex) {
|
|
242
|
+
const parts = path.split("/Attachments/", 2);
|
|
243
|
+
if (parts.length !== 2) return;
|
|
244
|
+
const segments = parts[1].split("/");
|
|
245
|
+
if (segments.length >= 3 && /^\d+$/.test(segments[0])) {
|
|
246
|
+
const msgRowid = parseInt(segments[0], 10);
|
|
247
|
+
attachmentIndex.set(`${msgRowid}:${segments[1]}`, path);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function indexEmlxPath(path, emlxIndex) {
|
|
252
|
+
const name = basename(path);
|
|
253
|
+
const msgId = name.split(".")[0];
|
|
254
|
+
if (!/^\d+$/.test(msgId)) return;
|
|
255
|
+
const mid = parseInt(msgId, 10);
|
|
256
|
+
const existing = emlxIndex.get(mid);
|
|
257
|
+
if (!existing || name.length < basename(existing).length) {
|
|
258
|
+
emlxIndex.set(mid, path);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function scanMailFiles(mailDir) {
|
|
263
|
+
return execFileSync(
|
|
264
|
+
"find",
|
|
265
|
+
[
|
|
266
|
+
mailDir,
|
|
267
|
+
"(",
|
|
268
|
+
"-name",
|
|
269
|
+
"*.emlx",
|
|
270
|
+
"-o",
|
|
271
|
+
"-path",
|
|
272
|
+
"*/Attachments/*",
|
|
273
|
+
")",
|
|
274
|
+
"-type",
|
|
275
|
+
"f",
|
|
276
|
+
],
|
|
277
|
+
{ encoding: "utf-8", timeout: 60000, maxBuffer: 50 * 1024 * 1024 },
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Scan ~/Library/Mail for .emlx and attachment files and build lookup indexes by message ID. */
|
|
282
|
+
export function buildFileIndexes() {
|
|
283
|
+
const mailDir = join(HOME, "Library/Mail");
|
|
284
|
+
const emlxIndex = new Map();
|
|
285
|
+
const attachmentIndex = new Map();
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const output = scanMailFiles(mailDir);
|
|
289
|
+
for (const path of output.trim().split("\n")) {
|
|
290
|
+
if (!path) continue;
|
|
291
|
+
if (path.includes("/Attachments/")) {
|
|
292
|
+
indexAttachmentPath(path, attachmentIndex);
|
|
293
|
+
} else if (path.endsWith(".emlx")) {
|
|
294
|
+
indexEmlxPath(path, emlxIndex);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} catch {
|
|
298
|
+
// Timeout or no results
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { emlxIndex, attachmentIndex };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function copySingleAttachment(
|
|
305
|
+
att,
|
|
306
|
+
mid,
|
|
307
|
+
threadId,
|
|
308
|
+
attachmentIndex,
|
|
309
|
+
seenFilenames,
|
|
310
|
+
attachmentsDir = ATTACHMENTS_DIR,
|
|
311
|
+
) {
|
|
312
|
+
const name = sanitiseAttachmentName(att.name);
|
|
313
|
+
const source = attachmentIndex.get(`${mid}:${att.attachment_id}`);
|
|
314
|
+
|
|
315
|
+
if (!source || !existsSync(source)) {
|
|
316
|
+
return { name, available: false, path: null };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let destName = name;
|
|
320
|
+
if (seenFilenames.has(destName)) destName = `${mid}_${name}`;
|
|
321
|
+
seenFilenames.add(destName);
|
|
322
|
+
|
|
323
|
+
const destDir = join(attachmentsDir, String(threadId));
|
|
324
|
+
mkdirSync(destDir, { recursive: true });
|
|
325
|
+
const destPath = join(destDir, destName);
|
|
326
|
+
|
|
327
|
+
const resolvedDir = resolve(destDir);
|
|
328
|
+
const resolvedPath = resolve(destPath);
|
|
329
|
+
const dirWithSep = resolvedDir.endsWith(sep)
|
|
330
|
+
? resolvedDir
|
|
331
|
+
: resolvedDir + sep;
|
|
332
|
+
if (!resolvedPath.startsWith(dirWithSep)) {
|
|
333
|
+
return { name: destName, available: false, path: null };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
copyFileSync(source, destPath);
|
|
338
|
+
return { name: destName, available: true, path: destPath };
|
|
339
|
+
} catch {
|
|
340
|
+
return { name: destName, available: false, path: null };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Copy all attachments for a thread's messages into the cache directory, deduplicating filenames. `attachmentsDir` defaults to the module-level `ATTACHMENTS_DIR`; tests inject a temp directory. */
|
|
345
|
+
export function copyThreadAttachments(
|
|
346
|
+
threadId,
|
|
347
|
+
messages,
|
|
348
|
+
attachmentsByMsg,
|
|
349
|
+
attachmentIndex,
|
|
350
|
+
attachmentsDir = ATTACHMENTS_DIR,
|
|
351
|
+
) {
|
|
352
|
+
const results = {};
|
|
353
|
+
const seenFilenames = new Set();
|
|
354
|
+
|
|
355
|
+
for (const msg of messages) {
|
|
356
|
+
const mid = msg.message_id;
|
|
357
|
+
const msgAttachments = attachmentsByMsg[mid] ?? [];
|
|
358
|
+
if (msgAttachments.length === 0) continue;
|
|
359
|
+
|
|
360
|
+
results[mid] = msgAttachments.map((att) =>
|
|
361
|
+
copySingleAttachment(
|
|
362
|
+
att,
|
|
363
|
+
mid,
|
|
364
|
+
threadId,
|
|
365
|
+
attachmentIndex,
|
|
366
|
+
seenFilenames,
|
|
367
|
+
attachmentsDir,
|
|
368
|
+
),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return results;
|
|
372
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Sync Apple Mail threads to ~/.cache/fit/outpost/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 { mkdirSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { parseEmlx } from "./parse-emlx.mjs";
|
|
29
|
+
import {
|
|
30
|
+
OUTDIR,
|
|
31
|
+
findDb,
|
|
32
|
+
openDb,
|
|
33
|
+
query,
|
|
34
|
+
loadLastSync,
|
|
35
|
+
saveSyncState,
|
|
36
|
+
loadLastRowid,
|
|
37
|
+
unixToReadable,
|
|
38
|
+
discoverThreadColumn,
|
|
39
|
+
findChangedThreads,
|
|
40
|
+
fetchThreadMessages,
|
|
41
|
+
fetchRecipients,
|
|
42
|
+
fetchAttachments,
|
|
43
|
+
buildFileIndexes,
|
|
44
|
+
copyThreadAttachments,
|
|
45
|
+
} from "./sync-helpers.mjs";
|
|
46
|
+
|
|
47
|
+
function formatRecipient(r) {
|
|
48
|
+
const name = (r.name ?? "").trim();
|
|
49
|
+
const addr = (r.address ?? "").trim();
|
|
50
|
+
if (name && addr) return `${name} <${addr}>`;
|
|
51
|
+
return addr || name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatSender(msg) {
|
|
55
|
+
const name = (msg.sender_name ?? "").trim();
|
|
56
|
+
const addr = (msg.sender ?? "").trim();
|
|
57
|
+
if (name && addr) return `${name} <${addr}>`;
|
|
58
|
+
return addr || name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseEmlxBody(messageId, emlxIndex) {
|
|
62
|
+
const path = emlxIndex.get(messageId);
|
|
63
|
+
if (!path) return null;
|
|
64
|
+
try {
|
|
65
|
+
return parseEmlx(path);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatMessageHeader(msg, recipientsByMsg) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
lines.push(`### From: ${formatSender(msg)}`);
|
|
74
|
+
lines.push(`**Date:** ${unixToReadable(msg.date_received)}`);
|
|
75
|
+
|
|
76
|
+
const mid = msg.message_id;
|
|
77
|
+
const msgRecips = recipientsByMsg[mid] ?? {};
|
|
78
|
+
const toList = msgRecips[0] ?? [];
|
|
79
|
+
const ccList = msgRecips[1] ?? [];
|
|
80
|
+
if (toList.length > 0)
|
|
81
|
+
lines.push(`**To:** ${toList.map(formatRecipient).join(", ")}`);
|
|
82
|
+
if (ccList.length > 0)
|
|
83
|
+
lines.push(`**Cc:** ${ccList.map(formatRecipient).join(", ")}`);
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatMessageAttachments(mid, threadId, attachmentResults) {
|
|
88
|
+
if (!attachmentResults) return [];
|
|
89
|
+
const msgAtts = attachmentResults[mid] ?? [];
|
|
90
|
+
if (msgAtts.length === 0) return [];
|
|
91
|
+
const lines = ["**Attachments:**"];
|
|
92
|
+
for (const att of msgAtts) {
|
|
93
|
+
if (att.available) {
|
|
94
|
+
lines.push(`- [${att.name}](attachments/${threadId}/${att.name})`);
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(`- ${att.name} *(not available)*`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeThreadMarkdown(
|
|
103
|
+
threadId,
|
|
104
|
+
messages,
|
|
105
|
+
recipientsByMsg,
|
|
106
|
+
emlxIndex,
|
|
107
|
+
attachmentResults,
|
|
108
|
+
) {
|
|
109
|
+
if (messages.length === 0) return false;
|
|
110
|
+
|
|
111
|
+
const baseSubject = messages[0].subject ?? "(No Subject)";
|
|
112
|
+
const isMailingList = messages.some((m) => (m.list_id_hash ?? 0) !== 0);
|
|
113
|
+
const isAutomated = messages.some(
|
|
114
|
+
(m) => (m.automated_conversation ?? 0) !== 0,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const flags = [];
|
|
118
|
+
if (isMailingList) flags.push("mailing-list");
|
|
119
|
+
if (isAutomated) flags.push("automated");
|
|
120
|
+
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push(`# ${baseSubject}`);
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push(`**Thread ID:** ${threadId}`);
|
|
125
|
+
lines.push(`**Message Count:** ${messages.length}`);
|
|
126
|
+
if (flags.length > 0) lines.push(`**Flags:** ${flags.join(", ")}`);
|
|
127
|
+
lines.push("");
|
|
128
|
+
|
|
129
|
+
for (const msg of messages) {
|
|
130
|
+
lines.push("---");
|
|
131
|
+
lines.push("");
|
|
132
|
+
lines.push(...formatMessageHeader(msg, recipientsByMsg));
|
|
133
|
+
lines.push("");
|
|
134
|
+
|
|
135
|
+
const mid = msg.message_id;
|
|
136
|
+
let body = parseEmlxBody(mid, emlxIndex);
|
|
137
|
+
if (!body) body = (msg.summary ?? "").trim();
|
|
138
|
+
if (body) lines.push(body);
|
|
139
|
+
lines.push("");
|
|
140
|
+
|
|
141
|
+
const attLines = formatMessageAttachments(mid, threadId, attachmentResults);
|
|
142
|
+
if (attLines.length > 0) {
|
|
143
|
+
lines.push(...attLines);
|
|
144
|
+
lines.push("");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
writeFileSync(join(OUTDIR, `${threadId}.md`), lines.join("\n"));
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function main() {
|
|
153
|
+
let daysBack = 30;
|
|
154
|
+
const daysIdx = process.argv.indexOf("--days");
|
|
155
|
+
if (daysIdx !== -1 && process.argv[daysIdx + 1]) {
|
|
156
|
+
daysBack = parseInt(process.argv[daysIdx + 1], 10);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const dbPath = findDb();
|
|
160
|
+
mkdirSync(OUTDIR, { recursive: true });
|
|
161
|
+
|
|
162
|
+
const sinceTs = loadLastSync(daysBack);
|
|
163
|
+
const sinceReadable = unixToReadable(sinceTs);
|
|
164
|
+
const db = openDb(dbPath);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const threadCol = discoverThreadColumn(db);
|
|
168
|
+
if (!threadCol) {
|
|
169
|
+
console.error(
|
|
170
|
+
"Error: Could not find conversation_id or thread_id column.",
|
|
171
|
+
);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const lastRowid = loadLastRowid();
|
|
176
|
+
const changed = findChangedThreads(db, threadCol, sinceTs, lastRowid);
|
|
177
|
+
const threadIds = changed.map((r) => r.tid);
|
|
178
|
+
|
|
179
|
+
let maxRowid = lastRowid;
|
|
180
|
+
const maxRowidResult = query(
|
|
181
|
+
db,
|
|
182
|
+
`SELECT MAX(ROWID) as max_rowid FROM messages`,
|
|
183
|
+
);
|
|
184
|
+
if (maxRowidResult.length > 0 && maxRowidResult[0].max_rowid != null) {
|
|
185
|
+
maxRowid = maxRowidResult[0].max_rowid;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (threadIds.length === 0) {
|
|
189
|
+
console.log("Apple Mail Sync Complete");
|
|
190
|
+
console.log("Threads processed: 0 (no new messages)");
|
|
191
|
+
console.log(`Time range: ${sinceReadable} to now`);
|
|
192
|
+
console.log(`Output: ${OUTDIR}`);
|
|
193
|
+
saveSyncState(maxRowid);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const { emlxIndex, attachmentIndex } = buildFileIndexes();
|
|
198
|
+
|
|
199
|
+
let written = 0;
|
|
200
|
+
for (const tid of threadIds) {
|
|
201
|
+
const messages = fetchThreadMessages(db, threadCol, tid);
|
|
202
|
+
if (messages.length === 0) continue;
|
|
203
|
+
|
|
204
|
+
const msgIds = messages.map((m) => m.message_id);
|
|
205
|
+
const recipients = fetchRecipients(db, msgIds);
|
|
206
|
+
const attachmentsByMsg = fetchAttachments(db, msgIds);
|
|
207
|
+
const attachmentResults = copyThreadAttachments(
|
|
208
|
+
tid,
|
|
209
|
+
messages,
|
|
210
|
+
attachmentsByMsg,
|
|
211
|
+
attachmentIndex,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (
|
|
215
|
+
writeThreadMarkdown(
|
|
216
|
+
tid,
|
|
217
|
+
messages,
|
|
218
|
+
recipients,
|
|
219
|
+
emlxIndex,
|
|
220
|
+
attachmentResults,
|
|
221
|
+
)
|
|
222
|
+
) {
|
|
223
|
+
written++;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
saveSyncState(maxRowid);
|
|
228
|
+
|
|
229
|
+
console.log("Apple Mail Sync Complete");
|
|
230
|
+
console.log(`Threads processed: ${threadIds.length}`);
|
|
231
|
+
console.log(`New/updated files: ${written}`);
|
|
232
|
+
console.log(`Time range: ${sinceReadable} to now`);
|
|
233
|
+
console.log(`Output: ${OUTDIR}`);
|
|
234
|
+
} finally {
|
|
235
|
+
db.close();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
main();
|