mail-db.sh 27 KB

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