@forwardimpact/basecamp 1.0.0 → 2.2.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 +1 -1
- package/config/scheduler.json +18 -17
- package/package.json +3 -3
- package/src/basecamp.js +532 -259
- package/template/.claude/agents/chief-of-staff.md +103 -0
- package/template/.claude/agents/concierge.md +75 -0
- package/template/.claude/agents/librarian.md +59 -0
- package/template/.claude/agents/postman.md +73 -0
- package/template/.claude/agents/recruiter.md +222 -0
- package/template/.claude/settings.json +0 -4
- package/template/.claude/skills/analyze-cv/SKILL.md +267 -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/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 +375 -0
- package/template/.claude/skills/weekly-update/SKILL.md +250 -0
- package/template/CLAUDE.md +73 -29
- package/template/knowledge/Briefings/.gitkeep +0 -0
- 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
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Parse a macOS Mail .emlx or .partial.emlx file and output the plain text body.
|
|
3
|
-
|
|
4
|
-
Usage: python3 scripts/parse-emlx.py <path-to-emlx-file>
|
|
5
|
-
|
|
6
|
-
The .emlx format is: first line = byte count, then RFC822 message, then Apple
|
|
7
|
-
plist. This script extracts and prints the plain text body.
|
|
8
|
-
|
|
9
|
-
If the email has no text/plain part (HTML-only), falls back to stripping HTML
|
|
10
|
-
tags and outputting as plain text.
|
|
11
|
-
|
|
12
|
-
Exit codes:
|
|
13
|
-
0 — success (body printed to stdout)
|
|
14
|
-
1 — file not found or parse error (message on stderr)
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import email
|
|
18
|
-
import html as html_mod
|
|
19
|
-
import re
|
|
20
|
-
import sys
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def html_to_text(html):
|
|
24
|
-
"""Strip HTML tags and convert to plain text. Uses only stdlib."""
|
|
25
|
-
# Remove style and script blocks
|
|
26
|
-
text = re.sub(
|
|
27
|
-
r"<(style|script)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE
|
|
28
|
-
)
|
|
29
|
-
# Replace br and p tags with newlines
|
|
30
|
-
text = re.sub(r"<br\s*/?\s*>", "\n", text, flags=re.IGNORECASE)
|
|
31
|
-
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
|
|
32
|
-
# Strip remaining tags
|
|
33
|
-
text = re.sub(r"<[^>]+>", "", text)
|
|
34
|
-
# Decode HTML entities
|
|
35
|
-
text = html_mod.unescape(text)
|
|
36
|
-
# Collapse whitespace
|
|
37
|
-
text = re.sub(r"[ \t]+", " ", text)
|
|
38
|
-
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
39
|
-
return text.strip()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def extract_body(msg):
|
|
43
|
-
"""Extract plain text body from an email message, with HTML fallback."""
|
|
44
|
-
body = None
|
|
45
|
-
html_body = None
|
|
46
|
-
|
|
47
|
-
if msg.is_multipart():
|
|
48
|
-
for part in msg.walk():
|
|
49
|
-
ct = part.get_content_type()
|
|
50
|
-
if ct == "text/plain" and body is None:
|
|
51
|
-
charset = part.get_content_charset() or "utf-8"
|
|
52
|
-
payload = part.get_payload(decode=True)
|
|
53
|
-
if payload:
|
|
54
|
-
body = payload.decode(charset, errors="replace")
|
|
55
|
-
elif ct == "text/html" and html_body is None:
|
|
56
|
-
charset = part.get_content_charset() or "utf-8"
|
|
57
|
-
payload = part.get_payload(decode=True)
|
|
58
|
-
if payload:
|
|
59
|
-
html_body = payload.decode(charset, errors="replace")
|
|
60
|
-
else:
|
|
61
|
-
ct = msg.get_content_type()
|
|
62
|
-
charset = msg.get_content_charset() or "utf-8"
|
|
63
|
-
payload = msg.get_payload(decode=True)
|
|
64
|
-
if payload:
|
|
65
|
-
text = payload.decode(charset, errors="replace")
|
|
66
|
-
if ct == "text/plain":
|
|
67
|
-
body = text
|
|
68
|
-
elif ct == "text/html":
|
|
69
|
-
html_body = text
|
|
70
|
-
|
|
71
|
-
if body:
|
|
72
|
-
return body
|
|
73
|
-
elif html_body:
|
|
74
|
-
return html_to_text(html_body)
|
|
75
|
-
return None
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def parse_emlx(path):
|
|
79
|
-
try:
|
|
80
|
-
with open(path, "rb") as f:
|
|
81
|
-
byte_count = int(f.readline())
|
|
82
|
-
raw = f.read(byte_count)
|
|
83
|
-
msg = email.message_from_bytes(raw)
|
|
84
|
-
|
|
85
|
-
print(f"From: {msg.get('From', 'Unknown')}")
|
|
86
|
-
print(f"Date: {msg.get('Date', '')}")
|
|
87
|
-
print("---")
|
|
88
|
-
|
|
89
|
-
body = extract_body(msg)
|
|
90
|
-
if body:
|
|
91
|
-
print(body)
|
|
92
|
-
except FileNotFoundError:
|
|
93
|
-
print(f"Error: File not found: {path}", file=sys.stderr)
|
|
94
|
-
sys.exit(1)
|
|
95
|
-
except Exception as e:
|
|
96
|
-
print(f"Error parsing {path}: {e}", file=sys.stderr)
|
|
97
|
-
sys.exit(1)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if __name__ == "__main__":
|
|
101
|
-
if len(sys.argv) != 2:
|
|
102
|
-
print("Usage: python3 scripts/parse-emlx.py <path>", file=sys.stderr)
|
|
103
|
-
sys.exit(1)
|
|
104
|
-
parse_emlx(sys.argv[1])
|
|
@@ -1,455 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Sync Apple Mail threads to ~/.cache/fit/basecamp/apple_mail/ as markdown.
|
|
3
|
-
|
|
4
|
-
Queries the macOS Mail SQLite database for threads with new messages since
|
|
5
|
-
the last sync and writes one markdown file per thread.
|
|
6
|
-
|
|
7
|
-
Usage: python3 scripts/sync.py [--days N]
|
|
8
|
-
|
|
9
|
-
Options:
|
|
10
|
-
--days N How many days back to sync on first run (default: 30)
|
|
11
|
-
|
|
12
|
-
Requires: macOS with Mail app configured and Full Disk Access granted.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import importlib.util
|
|
17
|
-
import json
|
|
18
|
-
import os
|
|
19
|
-
import shutil
|
|
20
|
-
import subprocess
|
|
21
|
-
import sys
|
|
22
|
-
import time
|
|
23
|
-
from datetime import datetime, timezone, timedelta
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
|
|
26
|
-
OUTDIR = os.path.expanduser("~/.cache/fit/basecamp/apple_mail")
|
|
27
|
-
ATTACHMENTS_DIR = os.path.join(OUTDIR, "attachments")
|
|
28
|
-
STATE_DIR = os.path.expanduser("~/.cache/fit/basecamp/state")
|
|
29
|
-
STATE_FILE = os.path.join(STATE_DIR, "apple_mail_last_sync")
|
|
30
|
-
|
|
31
|
-
# Import parse-emlx module directly (avoids subprocess per message)
|
|
32
|
-
_emlx_spec = importlib.util.spec_from_file_location(
|
|
33
|
-
"parse_emlx", os.path.join(os.path.dirname(__file__), "parse-emlx.py")
|
|
34
|
-
)
|
|
35
|
-
_emlx_mod = importlib.util.module_from_spec(_emlx_spec)
|
|
36
|
-
_emlx_spec.loader.exec_module(_emlx_mod)
|
|
37
|
-
|
|
38
|
-
MAX_THREADS = 500
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def find_db():
|
|
42
|
-
"""Find the Apple Mail Envelope Index database."""
|
|
43
|
-
import glob
|
|
44
|
-
mail_dir = os.path.expanduser("~/Library/Mail")
|
|
45
|
-
# V10, V9, etc. — always in {version}/MailData/Envelope Index
|
|
46
|
-
paths = sorted(glob.glob(os.path.join(mail_dir, "V*/MailData/Envelope Index")),
|
|
47
|
-
reverse=True)
|
|
48
|
-
if not paths:
|
|
49
|
-
print("Error: Apple Mail database not found. Is Mail configured?")
|
|
50
|
-
sys.exit(1)
|
|
51
|
-
db = paths[0]
|
|
52
|
-
if not os.access(db, os.R_OK):
|
|
53
|
-
print("Error: Cannot read Mail database. Grant Full Disk Access to terminal.")
|
|
54
|
-
sys.exit(1)
|
|
55
|
-
return db
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def query(db, sql, retry=True):
|
|
59
|
-
"""Execute a read-only SQLite query and return JSON results."""
|
|
60
|
-
result = subprocess.run(
|
|
61
|
-
["sqlite3", "-readonly", "-json", db, sql],
|
|
62
|
-
capture_output=True, text=True
|
|
63
|
-
)
|
|
64
|
-
if result.returncode != 0:
|
|
65
|
-
if retry and "database is locked" in result.stderr:
|
|
66
|
-
time.sleep(2)
|
|
67
|
-
return query(db, sql, retry=False)
|
|
68
|
-
print(f"SQLite error: {result.stderr.strip()}", file=sys.stderr)
|
|
69
|
-
return []
|
|
70
|
-
return json.loads(result.stdout) if result.stdout.strip() else []
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def load_last_sync(days_back=30):
|
|
74
|
-
"""Load the last sync timestamp. Returns Unix timestamp.
|
|
75
|
-
|
|
76
|
-
Args:
|
|
77
|
-
days_back: How many days back to look on first sync (default: 30).
|
|
78
|
-
"""
|
|
79
|
-
try:
|
|
80
|
-
iso = Path(STATE_FILE).read_text().strip()
|
|
81
|
-
if iso:
|
|
82
|
-
dt = datetime.fromisoformat(iso)
|
|
83
|
-
if dt.tzinfo is None:
|
|
84
|
-
dt = dt.replace(tzinfo=timezone.utc)
|
|
85
|
-
return int(dt.timestamp())
|
|
86
|
-
except (FileNotFoundError, ValueError):
|
|
87
|
-
pass
|
|
88
|
-
# First sync: N days ago
|
|
89
|
-
return int((datetime.now(timezone.utc) - timedelta(days=days_back)).timestamp())
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def save_sync_state():
|
|
93
|
-
"""Save current time as the sync timestamp."""
|
|
94
|
-
os.makedirs(STATE_DIR, exist_ok=True)
|
|
95
|
-
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
-
Path(STATE_FILE).write_text(now)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def unix_to_readable(ts):
|
|
100
|
-
"""Convert Unix timestamp to readable date string."""
|
|
101
|
-
if ts is None:
|
|
102
|
-
return "Unknown"
|
|
103
|
-
try:
|
|
104
|
-
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
|
105
|
-
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
106
|
-
except (ValueError, OSError):
|
|
107
|
-
return "Unknown"
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def discover_thread_column(db):
|
|
111
|
-
"""Determine which column to use for thread grouping."""
|
|
112
|
-
rows = query(db, "PRAGMA table_info(messages);")
|
|
113
|
-
columns = {r["name"] for r in rows}
|
|
114
|
-
if "conversation_id" in columns:
|
|
115
|
-
return "conversation_id"
|
|
116
|
-
if "thread_id" in columns:
|
|
117
|
-
return "thread_id"
|
|
118
|
-
return None
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def find_changed_threads(db, thread_col, since_ts):
|
|
122
|
-
"""Find thread IDs with messages newer than since_ts."""
|
|
123
|
-
return query(db, f"""
|
|
124
|
-
SELECT DISTINCT m.{thread_col} AS tid
|
|
125
|
-
FROM messages m
|
|
126
|
-
WHERE m.date_received > {since_ts}
|
|
127
|
-
AND m.deleted = 0
|
|
128
|
-
AND m.mailbox IN (
|
|
129
|
-
SELECT ROWID FROM mailboxes
|
|
130
|
-
WHERE url LIKE '%/Inbox%'
|
|
131
|
-
OR url LIKE '%/INBOX%'
|
|
132
|
-
OR url LIKE '%/Sent%'
|
|
133
|
-
)
|
|
134
|
-
LIMIT {MAX_THREADS};
|
|
135
|
-
""")
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def fetch_thread_messages(db, thread_col, tid):
|
|
139
|
-
"""Fetch all messages in a thread with sender info."""
|
|
140
|
-
return query(db, f"""
|
|
141
|
-
SELECT
|
|
142
|
-
m.ROWID AS message_id,
|
|
143
|
-
m.{thread_col} AS thread_id,
|
|
144
|
-
COALESCE(s.subject, '(No Subject)') AS subject,
|
|
145
|
-
COALESCE(m.subject_prefix, '') AS subject_prefix,
|
|
146
|
-
COALESCE(a.address, 'Unknown') AS sender,
|
|
147
|
-
COALESCE(a.comment, '') AS sender_name,
|
|
148
|
-
m.date_received,
|
|
149
|
-
COALESCE(su.summary, '') AS summary,
|
|
150
|
-
COALESCE(m.list_id_hash, 0) AS list_id_hash,
|
|
151
|
-
COALESCE(m.automated_conversation, 0) AS automated_conversation
|
|
152
|
-
FROM messages m
|
|
153
|
-
LEFT JOIN subjects s ON m.subject = s.ROWID
|
|
154
|
-
LEFT JOIN addresses a ON m.sender = a.ROWID
|
|
155
|
-
LEFT JOIN summaries su ON m.summary = su.ROWID
|
|
156
|
-
WHERE m.{thread_col} = {tid}
|
|
157
|
-
AND m.deleted = 0
|
|
158
|
-
ORDER BY m.date_received ASC;
|
|
159
|
-
""")
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def fetch_recipients(db, message_ids):
|
|
163
|
-
"""Batch-fetch To/Cc recipients for a set of message IDs."""
|
|
164
|
-
if not message_ids:
|
|
165
|
-
return {}
|
|
166
|
-
id_list = ",".join(str(mid) for mid in message_ids)
|
|
167
|
-
rows = query(db, f"""
|
|
168
|
-
SELECT
|
|
169
|
-
r.message AS message_id,
|
|
170
|
-
r.type,
|
|
171
|
-
COALESCE(a.address, '') AS address,
|
|
172
|
-
COALESCE(a.comment, '') AS name
|
|
173
|
-
FROM recipients r
|
|
174
|
-
LEFT JOIN addresses a ON r.address = a.ROWID
|
|
175
|
-
WHERE r.message IN ({id_list})
|
|
176
|
-
ORDER BY r.message, r.type, r.position;
|
|
177
|
-
""")
|
|
178
|
-
# Group by message_id → {0: To list, 1: Cc list}
|
|
179
|
-
result = {}
|
|
180
|
-
for r in rows:
|
|
181
|
-
mid = r["message_id"]
|
|
182
|
-
rtype = r["type"]
|
|
183
|
-
if rtype == 2: # Skip Bcc
|
|
184
|
-
continue
|
|
185
|
-
result.setdefault(mid, {}).setdefault(rtype, []).append(r)
|
|
186
|
-
return result
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def fetch_attachments(db, message_ids):
|
|
190
|
-
"""Batch-fetch attachment metadata for a set of message IDs."""
|
|
191
|
-
if not message_ids:
|
|
192
|
-
return {}
|
|
193
|
-
id_list = ",".join(str(mid) for mid in message_ids)
|
|
194
|
-
rows = query(db, f"""
|
|
195
|
-
SELECT a.message AS message_id, a.attachment_id, a.name
|
|
196
|
-
FROM attachments a
|
|
197
|
-
WHERE a.message IN ({id_list})
|
|
198
|
-
ORDER BY a.message, a.ROWID;
|
|
199
|
-
""")
|
|
200
|
-
result = {}
|
|
201
|
-
for r in rows:
|
|
202
|
-
mid = r["message_id"]
|
|
203
|
-
result.setdefault(mid, []).append({
|
|
204
|
-
"attachment_id": r["attachment_id"],
|
|
205
|
-
"name": r["name"],
|
|
206
|
-
})
|
|
207
|
-
return result
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def build_file_indexes():
|
|
211
|
-
"""Build emlx and attachment indexes with a single find traversal.
|
|
212
|
-
|
|
213
|
-
Returns (emlx_index, attachment_index):
|
|
214
|
-
emlx_index: {message_rowid: path} for .emlx files
|
|
215
|
-
attachment_index: {(message_rowid, attachment_id): path} for attachment files
|
|
216
|
-
"""
|
|
217
|
-
mail_dir = os.path.expanduser("~/Library/Mail")
|
|
218
|
-
try:
|
|
219
|
-
result = subprocess.run(
|
|
220
|
-
["find", mail_dir, "(", "-name", "*.emlx", "-o",
|
|
221
|
-
"-path", "*/Attachments/*", ")", "-type", "f"],
|
|
222
|
-
capture_output=True, text=True, timeout=60
|
|
223
|
-
)
|
|
224
|
-
emlx_index = {}
|
|
225
|
-
attachment_index = {}
|
|
226
|
-
for path in result.stdout.strip().splitlines():
|
|
227
|
-
if "/Attachments/" in path:
|
|
228
|
-
# Path: .../Attachments/{msg_rowid}/{attachment_id}/{filename}
|
|
229
|
-
parts = path.split("/Attachments/", 1)
|
|
230
|
-
if len(parts) == 2:
|
|
231
|
-
segments = parts[1].split("/")
|
|
232
|
-
if len(segments) >= 3 and segments[0].isdigit():
|
|
233
|
-
msg_rowid = int(segments[0])
|
|
234
|
-
att_id = segments[1]
|
|
235
|
-
attachment_index[(msg_rowid, att_id)] = path
|
|
236
|
-
elif path.endswith(".emlx"):
|
|
237
|
-
basename = os.path.basename(path)
|
|
238
|
-
msg_id = basename.split(".")[0]
|
|
239
|
-
if msg_id.isdigit():
|
|
240
|
-
mid = int(msg_id)
|
|
241
|
-
# Prefer .emlx over .partial.emlx (shorter name = full message)
|
|
242
|
-
if mid not in emlx_index or len(basename) < len(os.path.basename(emlx_index[mid])):
|
|
243
|
-
emlx_index[mid] = path
|
|
244
|
-
return emlx_index, attachment_index
|
|
245
|
-
except (subprocess.TimeoutExpired, Exception):
|
|
246
|
-
return {}, {}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def parse_emlx(message_id, emlx_index):
|
|
250
|
-
"""Parse .emlx file for a message using pre-built index. Returns body text or None."""
|
|
251
|
-
path = emlx_index.get(message_id)
|
|
252
|
-
if not path:
|
|
253
|
-
return None
|
|
254
|
-
try:
|
|
255
|
-
import email as email_lib
|
|
256
|
-
with open(path, "rb") as f:
|
|
257
|
-
byte_count = int(f.readline())
|
|
258
|
-
raw = f.read(byte_count)
|
|
259
|
-
msg = email_lib.message_from_bytes(raw)
|
|
260
|
-
return _emlx_mod.extract_body(msg)
|
|
261
|
-
except Exception:
|
|
262
|
-
return None
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def format_recipient(r):
|
|
266
|
-
"""Format a recipient as 'Name <email>' or just 'email'."""
|
|
267
|
-
name = (r.get("name") or "").strip()
|
|
268
|
-
addr = (r.get("address") or "").strip()
|
|
269
|
-
if name and addr:
|
|
270
|
-
return f"{name} <{addr}>"
|
|
271
|
-
return addr or name
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def format_sender(msg):
|
|
275
|
-
"""Format sender as 'Name <email>' or just 'email'."""
|
|
276
|
-
name = (msg.get("sender_name") or "").strip()
|
|
277
|
-
addr = (msg.get("sender") or "").strip()
|
|
278
|
-
if name and addr:
|
|
279
|
-
return f"{name} <{addr}>"
|
|
280
|
-
return addr or name
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def copy_thread_attachments(thread_id, messages, attachments_by_msg, attachment_index):
|
|
284
|
-
"""Copy attachment files into the output attachments directory.
|
|
285
|
-
|
|
286
|
-
Returns {message_id: [{name, available, path}, ...]} for markdown listing.
|
|
287
|
-
"""
|
|
288
|
-
results = {}
|
|
289
|
-
seen_filenames = set()
|
|
290
|
-
for msg in messages:
|
|
291
|
-
mid = msg["message_id"]
|
|
292
|
-
msg_attachments = attachments_by_msg.get(mid, [])
|
|
293
|
-
if not msg_attachments:
|
|
294
|
-
continue
|
|
295
|
-
msg_results = []
|
|
296
|
-
for att in msg_attachments:
|
|
297
|
-
att_id = att["attachment_id"]
|
|
298
|
-
name = att["name"] or "unnamed"
|
|
299
|
-
source = attachment_index.get((mid, att_id))
|
|
300
|
-
if not source or not os.path.isfile(source):
|
|
301
|
-
msg_results.append({"name": name, "available": False, "path": None})
|
|
302
|
-
continue
|
|
303
|
-
# Handle filename collisions by prefixing with message_id
|
|
304
|
-
dest_name = name
|
|
305
|
-
if dest_name in seen_filenames:
|
|
306
|
-
dest_name = f"{mid}_{name}"
|
|
307
|
-
seen_filenames.add(dest_name)
|
|
308
|
-
dest_dir = os.path.join(ATTACHMENTS_DIR, str(thread_id))
|
|
309
|
-
os.makedirs(dest_dir, exist_ok=True)
|
|
310
|
-
dest_path = os.path.join(dest_dir, dest_name)
|
|
311
|
-
try:
|
|
312
|
-
shutil.copy2(source, dest_path)
|
|
313
|
-
msg_results.append({"name": dest_name, "available": True, "path": dest_path})
|
|
314
|
-
except OSError:
|
|
315
|
-
msg_results.append({"name": name, "available": False, "path": None})
|
|
316
|
-
results[mid] = msg_results
|
|
317
|
-
return results
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
def write_thread_markdown(thread_id, messages, recipients_by_msg, emlx_index, attachment_results=None):
|
|
321
|
-
"""Write a thread as a markdown file."""
|
|
322
|
-
if not messages:
|
|
323
|
-
return False
|
|
324
|
-
|
|
325
|
-
# Use base subject from first message (without prefix)
|
|
326
|
-
base_subject = messages[0].get("subject", "(No Subject)")
|
|
327
|
-
|
|
328
|
-
# Determine flags
|
|
329
|
-
is_mailing_list = any(m.get("list_id_hash", 0) != 0 for m in messages)
|
|
330
|
-
is_automated = any(m.get("automated_conversation", 0) != 0 for m in messages)
|
|
331
|
-
flags = []
|
|
332
|
-
if is_mailing_list:
|
|
333
|
-
flags.append("mailing-list")
|
|
334
|
-
if is_automated:
|
|
335
|
-
flags.append("automated")
|
|
336
|
-
|
|
337
|
-
lines = []
|
|
338
|
-
lines.append(f"# {base_subject}")
|
|
339
|
-
lines.append("")
|
|
340
|
-
lines.append(f"**Thread ID:** {thread_id}")
|
|
341
|
-
lines.append(f"**Message Count:** {len(messages)}")
|
|
342
|
-
if flags:
|
|
343
|
-
lines.append(f"**Flags:** {', '.join(flags)}")
|
|
344
|
-
lines.append("")
|
|
345
|
-
|
|
346
|
-
for msg in messages:
|
|
347
|
-
lines.append("---")
|
|
348
|
-
lines.append("")
|
|
349
|
-
lines.append(f"### From: {format_sender(msg)}")
|
|
350
|
-
lines.append(f"**Date:** {unix_to_readable(msg.get('date_received'))}")
|
|
351
|
-
|
|
352
|
-
# To/Cc recipients
|
|
353
|
-
mid = msg.get("message_id")
|
|
354
|
-
msg_recips = recipients_by_msg.get(mid, {})
|
|
355
|
-
to_list = msg_recips.get(0, [])
|
|
356
|
-
cc_list = msg_recips.get(1, [])
|
|
357
|
-
if to_list:
|
|
358
|
-
lines.append(f"**To:** {', '.join(format_recipient(r) for r in to_list)}")
|
|
359
|
-
if cc_list:
|
|
360
|
-
lines.append(f"**Cc:** {', '.join(format_recipient(r) for r in cc_list)}")
|
|
361
|
-
|
|
362
|
-
lines.append("")
|
|
363
|
-
|
|
364
|
-
# Body: try .emlx first, fall back to summary
|
|
365
|
-
body = parse_emlx(mid, emlx_index)
|
|
366
|
-
if not body:
|
|
367
|
-
body = msg.get("summary", "").strip()
|
|
368
|
-
if body:
|
|
369
|
-
lines.append(body)
|
|
370
|
-
lines.append("")
|
|
371
|
-
|
|
372
|
-
# Attachments
|
|
373
|
-
if attachment_results:
|
|
374
|
-
msg_atts = attachment_results.get(mid, [])
|
|
375
|
-
if msg_atts:
|
|
376
|
-
lines.append("**Attachments:**")
|
|
377
|
-
for att in msg_atts:
|
|
378
|
-
if att["available"]:
|
|
379
|
-
lines.append(f"- [{att['name']}](attachments/{thread_id}/{att['name']})")
|
|
380
|
-
else:
|
|
381
|
-
lines.append(f"- {att['name']} *(not available)*")
|
|
382
|
-
lines.append("")
|
|
383
|
-
|
|
384
|
-
filepath = os.path.join(OUTDIR, f"{thread_id}.md")
|
|
385
|
-
Path(filepath).write_text("\n".join(lines))
|
|
386
|
-
return True
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def main():
|
|
390
|
-
parser = argparse.ArgumentParser(description="Sync Apple Mail threads.")
|
|
391
|
-
parser.add_argument("--days", type=int, default=30,
|
|
392
|
-
help="How many days back to sync on first run (default: 30)")
|
|
393
|
-
args = parser.parse_args()
|
|
394
|
-
|
|
395
|
-
db = find_db()
|
|
396
|
-
os.makedirs(OUTDIR, exist_ok=True)
|
|
397
|
-
|
|
398
|
-
# Load sync state
|
|
399
|
-
since_ts = load_last_sync(days_back=args.days)
|
|
400
|
-
since_readable = unix_to_readable(since_ts)
|
|
401
|
-
|
|
402
|
-
# Discover thread column
|
|
403
|
-
thread_col = discover_thread_column(db)
|
|
404
|
-
if not thread_col:
|
|
405
|
-
print("Error: Could not find conversation_id or thread_id column.")
|
|
406
|
-
sys.exit(1)
|
|
407
|
-
|
|
408
|
-
# Find changed threads
|
|
409
|
-
changed = find_changed_threads(db, thread_col, since_ts)
|
|
410
|
-
thread_ids = [r["tid"] for r in changed]
|
|
411
|
-
|
|
412
|
-
if not thread_ids:
|
|
413
|
-
print("Apple Mail Sync Complete")
|
|
414
|
-
print("Threads processed: 0 (no new messages)")
|
|
415
|
-
print(f"Time range: {since_readable} to now")
|
|
416
|
-
print(f"Output: {OUTDIR}")
|
|
417
|
-
save_sync_state()
|
|
418
|
-
return
|
|
419
|
-
|
|
420
|
-
# Build .emlx and attachment file indexes (single find traversal)
|
|
421
|
-
emlx_index, attachment_index = build_file_indexes()
|
|
422
|
-
|
|
423
|
-
# Process each thread
|
|
424
|
-
written = 0
|
|
425
|
-
for tid in thread_ids:
|
|
426
|
-
messages = fetch_thread_messages(db, thread_col, tid)
|
|
427
|
-
if not messages:
|
|
428
|
-
continue
|
|
429
|
-
|
|
430
|
-
msg_ids = [m["message_id"] for m in messages]
|
|
431
|
-
|
|
432
|
-
# Batch-fetch recipients and attachments for all messages in thread
|
|
433
|
-
recipients = fetch_recipients(db, msg_ids)
|
|
434
|
-
attachments_by_msg = fetch_attachments(db, msg_ids)
|
|
435
|
-
|
|
436
|
-
# Copy attachment files to output directory
|
|
437
|
-
attachment_results = copy_thread_attachments(
|
|
438
|
-
tid, messages, attachments_by_msg, attachment_index
|
|
439
|
-
)
|
|
440
|
-
|
|
441
|
-
if write_thread_markdown(tid, messages, recipients, emlx_index, attachment_results):
|
|
442
|
-
written += 1
|
|
443
|
-
|
|
444
|
-
# Save sync state (even on partial success)
|
|
445
|
-
save_sync_state()
|
|
446
|
-
|
|
447
|
-
print("Apple Mail Sync Complete")
|
|
448
|
-
print(f"Threads processed: {len(thread_ids)}")
|
|
449
|
-
print(f"New/updated files: {written}")
|
|
450
|
-
print(f"Time range: {since_readable} to now")
|
|
451
|
-
print(f"Output: {OUTDIR}")
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if __name__ == "__main__":
|
|
455
|
-
main()
|