mail-db.sh 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. #!/bin/bash
  2. # mail-db.sh - SQLite mail database operations
  3. # Global mail database at ~/.claude/mail.db
  4. # Project identity: 6-char ID derived from git root commit (stable across
  5. # renames, moves, clones) with fallback to canonical path hash for non-git dirs.
  6. set -euo pipefail
  7. MAIL_DB="$HOME/.claude/mail.db"
  8. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  9. # ============================================================================
  10. # Identity - git-rooted project IDs
  11. # ============================================================================
  12. # Get canonical path (resolves symlinks + case on macOS)
  13. canonical_path() {
  14. if [ -d "${1:-$PWD}" ]; then
  15. (cd "${1:-$PWD}" && pwd -P)
  16. else
  17. printf '%s' "${1:-$PWD}"
  18. fi
  19. }
  20. # Generate 6-char project ID
  21. # Priority: git root commit hash > canonical path hash
  22. project_hash() {
  23. local dir="${1:-$PWD}"
  24. # Try git root commit (first commit in repo history)
  25. if [ -d "$dir" ]; then
  26. local root_commit
  27. root_commit=$(git -C "$dir" rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
  28. if [ -n "$root_commit" ]; then
  29. echo "${root_commit:0:6}"
  30. return 0
  31. fi
  32. fi
  33. # Fallback: hash of canonical path
  34. local path
  35. path=$(canonical_path "$dir")
  36. printf '%s' "$path" | shasum -a 256 | cut -c1-6
  37. }
  38. # Get display name (basename of canonical path)
  39. project_name() {
  40. basename "$(canonical_path "${1:-$PWD}")"
  41. }
  42. # ============================================================================
  43. # Database
  44. # ============================================================================
  45. init_db() {
  46. mkdir -p "$(dirname "$MAIL_DB")"
  47. sqlite3 "$MAIL_DB" <<'SQL'
  48. CREATE TABLE IF NOT EXISTS messages (
  49. id INTEGER PRIMARY KEY AUTOINCREMENT,
  50. from_project TEXT NOT NULL,
  51. to_project TEXT NOT NULL,
  52. subject TEXT DEFAULT '',
  53. body TEXT NOT NULL,
  54. timestamp TEXT DEFAULT (datetime('now')),
  55. read INTEGER DEFAULT 0,
  56. priority TEXT DEFAULT 'normal'
  57. );
  58. CREATE INDEX IF NOT EXISTS idx_unread ON messages(to_project, read);
  59. CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
  60. CREATE TABLE IF NOT EXISTS projects (
  61. hash TEXT PRIMARY KEY,
  62. name TEXT NOT NULL,
  63. path TEXT NOT NULL,
  64. registered TEXT DEFAULT (datetime('now'))
  65. );
  66. SQL
  67. # Migration: add priority column if missing
  68. sqlite3 "$MAIL_DB" "SELECT priority FROM messages LIMIT 0;" 2>/dev/null || \
  69. sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN priority TEXT DEFAULT 'normal';" 2>/dev/null
  70. # Migration: create projects table if missing (for existing installs)
  71. sqlite3 "$MAIL_DB" "SELECT hash FROM projects LIMIT 0;" 2>/dev/null || \
  72. sqlite3 "$MAIL_DB" "CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, registered TEXT DEFAULT (datetime('now')));" 2>/dev/null
  73. # Migration: add thread_id column if missing
  74. sqlite3 "$MAIL_DB" "SELECT thread_id FROM messages LIMIT 0;" 2>/dev/null || \
  75. sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN thread_id INTEGER REFERENCES messages(id);" 2>/dev/null
  76. }
  77. sql_escape() {
  78. printf '%s' "$1" | sed "s/'/''/g"
  79. }
  80. # Read body from argument or stdin (use - or omit for stdin)
  81. read_body() {
  82. local arg="$1"
  83. if [ "$arg" = "-" ] || [ -z "$arg" ]; then
  84. cat
  85. else
  86. printf '%s' "$arg"
  87. fi
  88. }
  89. # Register current project in the projects table (idempotent)
  90. register_project() {
  91. local hash name path
  92. hash=$(project_hash "${1:-$PWD}")
  93. name=$(sql_escape "$(project_name "${1:-$PWD}")")
  94. path=$(sql_escape "$(canonical_path "${1:-$PWD}")")
  95. sqlite3 "$MAIL_DB" \
  96. "INSERT OR REPLACE INTO projects (hash, name, path) VALUES ('${hash}', '${name}', '${path}');"
  97. }
  98. # Get project ID for current directory
  99. get_project_id() {
  100. project_hash "${1:-$PWD}"
  101. }
  102. # Resolve a user-supplied name/hash to a project hash
  103. # Accepts: hash (6 chars), project name, or path
  104. resolve_target() {
  105. local target="$1"
  106. local safe_target
  107. safe_target=$(sql_escape "$target")
  108. # 1. Exact hash match
  109. if [[ ${#target} -eq 6 ]] && [[ "$target" =~ ^[0-9a-f]+$ ]]; then
  110. local found
  111. found=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE hash='${safe_target}';")
  112. if [ -n "$found" ]; then
  113. echo "$found"
  114. return 0
  115. fi
  116. fi
  117. # 2. Name match (case-insensitive)
  118. local by_name
  119. by_name=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE LOWER(name)=LOWER('${safe_target}') ORDER BY registered DESC LIMIT 1;")
  120. if [ -n "$by_name" ]; then
  121. echo "$by_name"
  122. return 0
  123. fi
  124. # 3. Path match - target might be a directory
  125. if [ -d "$target" ]; then
  126. local hash
  127. hash=$(project_hash "$target")
  128. echo "$hash"
  129. return 0
  130. fi
  131. # 4. Generate hash from target as a string (for unknown projects)
  132. # Register it so replies work
  133. local hash
  134. hash=$(printf '%s' "$target" | shasum -a 256 | cut -c1-6)
  135. sqlite3 "$MAIL_DB" \
  136. "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${hash}', '${safe_target}', '${safe_target}');"
  137. echo "$hash"
  138. }
  139. # Look up display name for a hash
  140. display_name() {
  141. local hash="$1"
  142. local name
  143. name=$(sqlite3 "$MAIL_DB" "SELECT name FROM projects WHERE hash='${hash}';")
  144. if [ -n "$name" ]; then
  145. echo "$name"
  146. else
  147. echo "$hash"
  148. fi
  149. }
  150. # ============================================================================
  151. # Identicon display (inline, compact)
  152. # ============================================================================
  153. show_identicon() {
  154. local target="${1:-$PWD}"
  155. if [ -f "$SCRIPT_DIR/identicon.sh" ]; then
  156. bash "$SCRIPT_DIR/identicon.sh" "$target"
  157. fi
  158. }
  159. # ============================================================================
  160. # Mail operations
  161. # ============================================================================
  162. count_unread() {
  163. init_db
  164. register_project
  165. local pid
  166. pid=$(get_project_id)
  167. sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;"
  168. }
  169. list_unread() {
  170. init_db
  171. register_project
  172. local pid
  173. pid=$(get_project_id)
  174. local rows
  175. rows=$(sqlite3 -separator '|' "$MAIL_DB" \
  176. "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp DESC;")
  177. [ -z "$rows" ] && return 0
  178. while IFS='|' read -r id from_hash subj ts; do
  179. local from_name
  180. from_name=$(display_name "$from_hash")
  181. echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${ts}"
  182. done <<< "$rows"
  183. }
  184. read_mail() {
  185. init_db
  186. register_project
  187. local pid
  188. pid=$(get_project_id)
  189. # Use ASCII record separator (0x1E) to avoid splitting on pipes/newlines in body
  190. local RS=$'\x1e'
  191. local count
  192. count=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;")
  193. [ "${count:-0}" -eq 0 ] && return 0
  194. # Query each message individually to preserve multi-line bodies
  195. local ids
  196. ids=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp ASC;")
  197. echo "id | from_project | subject | body | timestamp"
  198. while read -r msg_id; do
  199. [ -z "$msg_id" ] && continue
  200. local from_hash subj body ts from_name
  201. from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
  202. subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
  203. body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
  204. ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
  205. from_name=$(display_name "$from_hash")
  206. echo "${msg_id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}"
  207. done <<< "$ids"
  208. sqlite3 "$MAIL_DB" \
  209. "UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;"
  210. # Clear signal file
  211. rm -f "/tmp/agentmail_signal_${pid}"
  212. }
  213. read_one() {
  214. local msg_id="$1"
  215. if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
  216. echo "Error: message ID must be numeric" >&2
  217. return 1
  218. fi
  219. init_db
  220. local exists
  221. exists=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE id=${msg_id};")
  222. [ "${exists:-0}" -eq 0 ] && return 0
  223. local from_hash to_hash subj body ts from_name to_name
  224. from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
  225. to_hash=$(sqlite3 "$MAIL_DB" "SELECT to_project FROM messages WHERE id=${msg_id};")
  226. subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
  227. body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
  228. ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
  229. from_name=$(display_name "$from_hash")
  230. to_name=$(display_name "$to_hash")
  231. echo "id | from_project | to_project | subject | body | timestamp"
  232. echo "${msg_id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}"
  233. sqlite3 "$MAIL_DB" \
  234. "UPDATE messages SET read=1 WHERE id=${msg_id};"
  235. }
  236. send() {
  237. local priority="normal"
  238. if [ "${1:-}" = "--urgent" ]; then
  239. priority="urgent"
  240. shift
  241. fi
  242. local to_input="${1:?to_project required}"
  243. local subject="${2:-no subject}"
  244. local body
  245. body=$(read_body "${3:-}")
  246. if [ -z "$body" ]; then
  247. echo "Error: message body cannot be empty" >&2
  248. return 1
  249. fi
  250. init_db
  251. register_project
  252. local from_id to_id
  253. from_id=$(get_project_id)
  254. to_id=$(resolve_target "$to_input")
  255. local safe_subject safe_body
  256. safe_subject=$(sql_escape "$subject")
  257. safe_body=$(sql_escape "$body")
  258. sqlite3 "$MAIL_DB" \
  259. "INSERT INTO messages (from_project, to_project, subject, body, priority) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}');"
  260. # Signal the recipient
  261. touch "/tmp/agentmail_signal_${to_id}"
  262. local to_name
  263. to_name=$(display_name "$to_id")
  264. echo "Sent to ${to_name} (${to_id}): ${subject}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
  265. }
  266. sent() {
  267. local limit="${1:-20}"
  268. init_db
  269. register_project
  270. local pid
  271. pid=$(get_project_id)
  272. local rows
  273. rows=$(sqlite3 -separator '|' "$MAIL_DB" \
  274. "SELECT id, to_project, subject, timestamp FROM messages WHERE from_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};")
  275. [ -z "$rows" ] && echo "No sent messages" && return 0
  276. echo "id | to | subject | timestamp"
  277. while IFS='|' read -r id to_hash subj ts; do
  278. local to_name
  279. to_name=$(display_name "$to_hash")
  280. echo "${id} | ${to_name} (${to_hash}) | ${subj} | ${ts}"
  281. done <<< "$rows"
  282. }
  283. search() {
  284. local keyword="$1"
  285. if [ -z "$keyword" ]; then
  286. echo "Error: search keyword required" >&2
  287. return 1
  288. fi
  289. init_db
  290. register_project
  291. local pid
  292. pid=$(get_project_id)
  293. local safe_keyword
  294. safe_keyword=$(sql_escape "$keyword")
  295. local rows
  296. rows=$(sqlite3 -separator '|' "$MAIL_DB" \
  297. "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' AND (subject LIKE '%${safe_keyword}%' OR body LIKE '%${safe_keyword}%') ORDER BY timestamp DESC LIMIT 20;")
  298. [ -z "$rows" ] && return 0
  299. echo "id | from | subject | status | timestamp"
  300. while IFS='|' read -r id from_hash subj status ts; do
  301. local from_name
  302. from_name=$(display_name "$from_hash")
  303. echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}"
  304. done <<< "$rows"
  305. }
  306. list_all() {
  307. init_db
  308. register_project
  309. local pid
  310. pid=$(get_project_id)
  311. local limit="${1:-20}"
  312. if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
  313. limit=20
  314. fi
  315. local rows
  316. rows=$(sqlite3 -separator '|' "$MAIL_DB" \
  317. "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};")
  318. [ -z "$rows" ] && return 0
  319. echo "id | from | subject | status | timestamp"
  320. while IFS='|' read -r id from_hash subj status ts; do
  321. local from_name
  322. from_name=$(display_name "$from_hash")
  323. echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}"
  324. done <<< "$rows"
  325. }
  326. clear_old() {
  327. init_db
  328. local days="${1:-7}"
  329. if ! [[ "$days" =~ ^[0-9]+$ ]]; then
  330. days=7
  331. fi
  332. local deleted
  333. deleted=$(sqlite3 "$MAIL_DB" \
  334. "DELETE FROM messages WHERE read=1 AND timestamp < datetime('now', '-${days} days'); SELECT changes();")
  335. echo "Cleared ${deleted} read messages older than ${days} days"
  336. }
  337. reply() {
  338. local msg_id="$1"
  339. local body
  340. body=$(read_body "${2:-}")
  341. if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
  342. echo "Error: message ID must be numeric" >&2
  343. return 1
  344. fi
  345. if [ -z "$body" ]; then
  346. echo "Error: reply body cannot be empty" >&2
  347. return 1
  348. fi
  349. init_db
  350. register_project
  351. local orig
  352. orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject, thread_id FROM messages WHERE id=${msg_id};")
  353. if [ -z "$orig" ]; then
  354. echo "Error: message #${msg_id} not found" >&2
  355. return 1
  356. fi
  357. local orig_from_hash orig_subject orig_thread
  358. orig_from_hash=$(echo "$orig" | cut -d'|' -f1)
  359. orig_subject=$(echo "$orig" | cut -d'|' -f2)
  360. orig_thread=$(echo "$orig" | cut -d'|' -f3)
  361. # Thread ID: inherit from parent, or use parent's ID as thread root
  362. local thread_id="${orig_thread:-$msg_id}"
  363. local from_id
  364. from_id=$(get_project_id)
  365. local safe_subject safe_body
  366. safe_subject=$(sql_escape "Re: ${orig_subject}")
  367. safe_body=$(sql_escape "$body")
  368. sqlite3 "$MAIL_DB" \
  369. "INSERT INTO messages (from_project, to_project, subject, body, thread_id) VALUES ('${from_id}', '${orig_from_hash}', '${safe_subject}', '${safe_body}', ${thread_id});"
  370. # Signal the recipient
  371. touch "/tmp/agentmail_signal_${orig_from_hash}"
  372. local orig_name
  373. orig_name=$(display_name "$orig_from_hash")
  374. echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}"
  375. }
  376. thread() {
  377. local msg_id="$1"
  378. if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
  379. echo "Error: message ID must be numeric" >&2
  380. return 1
  381. fi
  382. init_db
  383. # Find the thread root: either the message itself or its thread_id
  384. local thread_root
  385. thread_root=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(thread_id, id) FROM messages WHERE id=${msg_id};" 2>/dev/null)
  386. [ -z "$thread_root" ] && echo "Message not found" && return 1
  387. # Get all message IDs in this thread (root + replies)
  388. local ids
  389. ids=$(sqlite3 "$MAIL_DB" \
  390. "SELECT id FROM messages WHERE id=${thread_root} OR thread_id=${thread_root} ORDER BY timestamp ASC;")
  391. [ -z "$ids" ] && echo "No thread found" && return 0
  392. local msg_count=0
  393. echo "=== Thread #${thread_root} ==="
  394. while read -r tid; do
  395. [ -z "$tid" ] && continue
  396. local from_hash body ts from_name
  397. from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${tid};")
  398. body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${tid};")
  399. ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${tid};")
  400. from_name=$(display_name "$from_hash")
  401. echo ""
  402. echo "--- #${tid} ${from_name} @ ${ts} ---"
  403. echo "${body}"
  404. msg_count=$((msg_count + 1))
  405. done <<< "$ids"
  406. echo ""
  407. echo "=== End of thread (${msg_count} messages) ==="
  408. }
  409. broadcast() {
  410. local subject="$1"
  411. local body="$2"
  412. if [ -z "$body" ]; then
  413. echo "Error: message body cannot be empty" >&2
  414. return 1
  415. fi
  416. init_db
  417. register_project
  418. local from_id
  419. from_id=$(get_project_id)
  420. local targets
  421. targets=$(sqlite3 "$MAIL_DB" \
  422. "SELECT hash FROM projects WHERE hash != '${from_id}' ORDER BY name;")
  423. local count=0
  424. local safe_subject safe_body
  425. safe_subject=$(sql_escape "$subject")
  426. safe_body=$(sql_escape "$body")
  427. while IFS= read -r target_hash; do
  428. [ -z "$target_hash" ] && continue
  429. sqlite3 "$MAIL_DB" \
  430. "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${target_hash}', '${safe_subject}', '${safe_body}');"
  431. touch "/tmp/agentmail_signal_${target_hash}"
  432. count=$((count + 1))
  433. done <<< "$targets"
  434. echo "Broadcast to ${count} project(s): ${subject}"
  435. }
  436. status() {
  437. init_db
  438. register_project
  439. local pid
  440. pid=$(get_project_id)
  441. local unread total
  442. unread=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;")
  443. total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}';")
  444. echo "Inbox: ${unread} unread / ${total} total"
  445. if [ "${unread:-0}" -gt 0 ]; then
  446. local senders
  447. senders=$(sqlite3 -separator '|' "$MAIL_DB" \
  448. "SELECT from_project, COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;")
  449. while IFS='|' read -r from_hash cnt; do
  450. local from_name
  451. from_name=$(display_name "$from_hash")
  452. echo " ${from_name} (${from_hash}): ${cnt} message(s)"
  453. done <<< "$senders"
  454. fi
  455. }
  456. purge() {
  457. init_db
  458. if [ "${1:-}" = "--all" ]; then
  459. local count
  460. count=$(sqlite3 "$MAIL_DB" "DELETE FROM messages; SELECT changes();")
  461. echo "Purged all ${count} message(s) from database"
  462. else
  463. register_project
  464. local pid
  465. pid=$(get_project_id)
  466. local count
  467. count=$(sqlite3 "$MAIL_DB" \
  468. "DELETE FROM messages WHERE to_project='${pid}' OR from_project='${pid}'; SELECT changes();")
  469. local name
  470. name=$(project_name)
  471. echo "Purged ${count} message(s) for ${name} (${pid})"
  472. fi
  473. }
  474. alias_project() {
  475. local old_name="$1"
  476. local new_name="$2"
  477. if [ -z "$old_name" ] || [ -z "$new_name" ]; then
  478. echo "Error: both old and new project names required" >&2
  479. return 1
  480. fi
  481. init_db
  482. # Resolve old name to hash, then update the display name
  483. local old_hash
  484. old_hash=$(resolve_target "$old_name")
  485. local safe_new
  486. safe_new=$(sql_escape "$new_name")
  487. local safe_old
  488. safe_old=$(sql_escape "$old_name")
  489. sqlite3 "$MAIL_DB" \
  490. "UPDATE projects SET name='${safe_new}' WHERE hash='${old_hash}';"
  491. # Also update path if it matches the old name (phantom projects)
  492. sqlite3 "$MAIL_DB" \
  493. "UPDATE projects SET path='${safe_new}' WHERE hash='${old_hash}' AND path='${safe_old}';"
  494. echo "Renamed '${old_name}' -> '${new_name}' (hash: ${old_hash})"
  495. }
  496. list_projects() {
  497. init_db
  498. register_project
  499. local rows
  500. rows=$(sqlite3 -separator '|' "$MAIL_DB" \
  501. "SELECT hash, name, path FROM projects ORDER BY name;")
  502. [ -z "$rows" ] && echo "No known projects" && return 0
  503. local my_id
  504. my_id=$(get_project_id)
  505. while IFS='|' read -r hash name path; do
  506. local marker=""
  507. [ "$hash" = "$my_id" ] && marker=" (you)"
  508. echo ""
  509. # Show identicon if available
  510. if [ -f "$SCRIPT_DIR/identicon.sh" ]; then
  511. bash "$SCRIPT_DIR/identicon.sh" "$path" --compact 2>/dev/null || true
  512. fi
  513. echo "${name} ${hash}${marker}"
  514. echo "${path}"
  515. done <<< "$rows"
  516. }
  517. # Migrate old basename-style messages to hash IDs
  518. migrate() {
  519. init_db
  520. register_project
  521. echo "Migrating old messages to hash-based IDs..."
  522. # Find all unique project names in messages that aren't 6-char hex hashes
  523. local old_names
  524. old_names=$(sqlite3 "$MAIL_DB" \
  525. "SELECT DISTINCT from_project FROM messages WHERE LENGTH(from_project) != 6 OR from_project GLOB '*[^0-9a-f]*' UNION SELECT DISTINCT to_project FROM messages WHERE LENGTH(to_project) != 6 OR to_project GLOB '*[^0-9a-f]*';")
  526. if [ -z "$old_names" ]; then
  527. echo "No messages need migration."
  528. return 0
  529. fi
  530. local count=0
  531. while IFS= read -r old_name; do
  532. [ -z "$old_name" ] && continue
  533. # Try to find the project path - check common locations
  534. local found_path=""
  535. for base_dir in "$HOME/projects" "$HOME/Projects" "$HOME/code" "$HOME/Code" "$HOME/dev" "$HOME/repos"; do
  536. if [ -d "${base_dir}/${old_name}" ]; then
  537. found_path=$(cd "${base_dir}/${old_name}" && pwd -P)
  538. break
  539. fi
  540. done
  541. local new_hash
  542. if [ -n "$found_path" ]; then
  543. new_hash=$(printf '%s' "$found_path" | shasum -a 256 | cut -c1-6)
  544. local safe_name safe_path
  545. safe_name=$(sql_escape "$old_name")
  546. safe_path=$(sql_escape "$found_path")
  547. sqlite3 "$MAIL_DB" \
  548. "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_path}');"
  549. else
  550. # Can't find directory - hash the name itself
  551. new_hash=$(printf '%s' "$old_name" | shasum -a 256 | cut -c1-6)
  552. local safe_name
  553. safe_name=$(sql_escape "$old_name")
  554. sqlite3 "$MAIL_DB" \
  555. "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_name}');"
  556. fi
  557. local safe_old
  558. safe_old=$(sql_escape "$old_name")
  559. sqlite3 "$MAIL_DB" "UPDATE messages SET from_project='${new_hash}' WHERE from_project='${safe_old}';"
  560. sqlite3 "$MAIL_DB" "UPDATE messages SET to_project='${new_hash}' WHERE to_project='${safe_old}';"
  561. echo " ${old_name} -> ${new_hash}$([ -n "$found_path" ] && echo " (${found_path})" || echo " (name only)")"
  562. count=$((count + 1))
  563. done <<< "$old_names"
  564. echo "Migrated ${count} project name(s)."
  565. }
  566. # ============================================================================
  567. # Dispatch
  568. # ============================================================================
  569. case "${1:-help}" in
  570. init) init_db && echo "Mail database initialized at $MAIL_DB" ;;
  571. count) count_unread ;;
  572. unread) list_unread ;;
  573. read) if [ -n "${2:-}" ]; then read_one "$2"; else read_mail; fi ;;
  574. send) shift; send "$@" ;;
  575. reply) reply "${2:?message_id required}" "${3:-}" ;;
  576. sent) sent "${2:-20}" ;;
  577. thread) thread "${2:?message_id required}" ;;
  578. list) list_all "${2:-20}" ;;
  579. clear) clear_old "${2:-7}" ;;
  580. broadcast) broadcast "${2:-no subject}" "${3:?body required}" ;;
  581. search) search "${2:?keyword required}" ;;
  582. status) status ;;
  583. purge) purge "${2:-}" ;;
  584. alias) alias_project "${2:?old name required}" "${3:?new name required}" ;;
  585. projects) list_projects ;;
  586. migrate) migrate ;;
  587. id) init_db; register_project; echo "$(project_name) $(get_project_id)" ;;
  588. help)
  589. echo "Usage: mail-db.sh <command> [args]"
  590. echo ""
  591. echo "Commands:"
  592. echo " init Initialize database"
  593. echo " id Show this project's name and hash"
  594. echo " count Count unread messages"
  595. echo " unread List unread messages (brief)"
  596. echo " read [id] Read messages and mark as read"
  597. echo " send [--urgent] <to> <subj> <body|-> Send (- or pipe for stdin body)"
  598. echo " reply <id> <body|-> Reply (- or pipe for stdin body)"
  599. echo " sent [limit] Show sent messages (outbox)"
  600. echo " thread <id> View full conversation thread"
  601. echo " list [limit] List recent messages (default 20)"
  602. echo " clear [days] Clear read messages older than N days"
  603. echo " broadcast <subj> <body> Send to all known projects"
  604. echo " search <keyword> Search messages by keyword"
  605. echo " status Inbox summary"
  606. echo " purge [--all] Delete all messages for this project"
  607. echo " alias <old> <new> Rename project display name"
  608. echo " projects List known projects with identicons"
  609. echo " migrate Convert old basename messages to hash IDs"
  610. ;;
  611. *) echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;;
  612. esac