mail-db.sh 25 KB

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