mail-db.sh 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. #!/bin/bash
  2. # mail-db.sh - SQLite mail database operations
  3. # Global mail database at ~/.claude/mail.db
  4. # Project identity derived from basename of working directory
  5. set -euo pipefail
  6. MAIL_DB="$HOME/.claude/mail.db"
  7. # Ensure database and schema exist
  8. init_db() {
  9. mkdir -p "$(dirname "$MAIL_DB")"
  10. sqlite3 "$MAIL_DB" <<'SQL'
  11. CREATE TABLE IF NOT EXISTS messages (
  12. id INTEGER PRIMARY KEY AUTOINCREMENT,
  13. from_project TEXT NOT NULL,
  14. to_project TEXT NOT NULL,
  15. subject TEXT DEFAULT '',
  16. body TEXT NOT NULL,
  17. timestamp TEXT DEFAULT (datetime('now')),
  18. read INTEGER DEFAULT 0,
  19. priority TEXT DEFAULT 'normal'
  20. );
  21. CREATE INDEX IF NOT EXISTS idx_unread ON messages(to_project, read);
  22. CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
  23. SQL
  24. # Migration: add priority column if missing
  25. sqlite3 "$MAIL_DB" "SELECT priority FROM messages LIMIT 0;" 2>/dev/null || \
  26. sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN priority TEXT DEFAULT 'normal';" 2>/dev/null
  27. }
  28. # Sanitize string for safe SQL interpolation (escape single quotes)
  29. sql_escape() {
  30. printf '%s' "$1" | sed "s/'/''/g"
  31. }
  32. # Get project name from cwd
  33. get_project() {
  34. basename "$PWD"
  35. }
  36. # Count unread messages for current project
  37. count_unread() {
  38. init_db
  39. local project
  40. project=$(sql_escape "$(get_project)")
  41. sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}' AND read=0;"
  42. }
  43. # List unread messages (brief) for current project
  44. list_unread() {
  45. init_db
  46. local project
  47. project=$(sql_escape "$(get_project)")
  48. sqlite3 -separator ' | ' "$MAIL_DB" \
  49. "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${project}' AND read=0 ORDER BY timestamp DESC;"
  50. }
  51. # Read all unread messages (full) and mark as read
  52. read_mail() {
  53. init_db
  54. local project
  55. project=$(sql_escape "$(get_project)")
  56. sqlite3 -header -separator ' | ' "$MAIL_DB" \
  57. "SELECT id, from_project, subject, body, timestamp FROM messages WHERE to_project='${project}' AND read=0 ORDER BY timestamp ASC;"
  58. sqlite3 "$MAIL_DB" \
  59. "UPDATE messages SET read=1 WHERE to_project='${project}' AND read=0;"
  60. }
  61. # Read a single message by ID and mark as read
  62. read_one() {
  63. local msg_id="$1"
  64. # Validate ID is numeric
  65. if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
  66. echo "Error: message ID must be numeric" >&2
  67. return 1
  68. fi
  69. init_db
  70. sqlite3 -header -separator ' | ' "$MAIL_DB" \
  71. "SELECT id, from_project, to_project, subject, body, timestamp FROM messages WHERE id=${msg_id};"
  72. sqlite3 "$MAIL_DB" \
  73. "UPDATE messages SET read=1 WHERE id=${msg_id};"
  74. }
  75. # Send a message (optional --urgent flag before args)
  76. send() {
  77. local priority="normal"
  78. if [ "${1:-}" = "--urgent" ]; then
  79. priority="urgent"
  80. shift
  81. fi
  82. local to_project="${1:?to_project required}"
  83. local subject="${2:-no subject}"
  84. local body="${3:?body required}"
  85. if [ -z "$body" ]; then
  86. echo "Error: message body cannot be empty" >&2
  87. return 1
  88. fi
  89. init_db
  90. local from_project
  91. from_project=$(sql_escape "$(get_project)")
  92. local safe_to safe_subject safe_body
  93. safe_to=$(sql_escape "$to_project")
  94. safe_subject=$(sql_escape "$subject")
  95. safe_body=$(sql_escape "$body")
  96. sqlite3 "$MAIL_DB" \
  97. "INSERT INTO messages (from_project, to_project, subject, body, priority) VALUES ('${from_project}', '${safe_to}', '${safe_subject}', '${safe_body}', '${priority}');"
  98. echo "Sent to ${to_project}: ${subject}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
  99. }
  100. # Search messages by keyword
  101. search() {
  102. local keyword="$1"
  103. if [ -z "$keyword" ]; then
  104. echo "Error: search keyword required" >&2
  105. return 1
  106. fi
  107. init_db
  108. local project
  109. project=$(sql_escape "$(get_project)")
  110. local safe_keyword
  111. safe_keyword=$(sql_escape "$keyword")
  112. sqlite3 -header -separator ' | ' "$MAIL_DB" \
  113. "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END as status, timestamp FROM messages WHERE to_project='${project}' AND (subject LIKE '%${safe_keyword}%' OR body LIKE '%${safe_keyword}%') ORDER BY timestamp DESC LIMIT 20;"
  114. }
  115. # List all messages (read and unread) for current project
  116. list_all() {
  117. init_db
  118. local project
  119. project=$(sql_escape "$(get_project)")
  120. local limit="${1:-20}"
  121. # Validate limit is numeric
  122. if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
  123. limit=20
  124. fi
  125. sqlite3 -header -separator ' | ' "$MAIL_DB" \
  126. "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END as status, timestamp FROM messages WHERE to_project='${project}' ORDER BY timestamp DESC LIMIT ${limit};"
  127. }
  128. # Clear old read messages (default: older than 7 days)
  129. clear_old() {
  130. init_db
  131. local days="${1:-7}"
  132. # Validate days is numeric
  133. if ! [[ "$days" =~ ^[0-9]+$ ]]; then
  134. days=7
  135. fi
  136. local deleted
  137. deleted=$(sqlite3 "$MAIL_DB" \
  138. "DELETE FROM messages WHERE read=1 AND timestamp < datetime('now', '-${days} days'); SELECT changes();")
  139. echo "Cleared ${deleted} read messages older than ${days} days"
  140. }
  141. # Reply to a message by ID
  142. reply() {
  143. local msg_id="$1"
  144. local body="$2"
  145. if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
  146. echo "Error: message ID must be numeric" >&2
  147. return 1
  148. fi
  149. if [ -z "$body" ]; then
  150. echo "Error: reply body cannot be empty" >&2
  151. return 1
  152. fi
  153. init_db
  154. # Get original sender and subject
  155. local orig
  156. orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject FROM messages WHERE id=${msg_id};")
  157. if [ -z "$orig" ]; then
  158. echo "Error: message #${msg_id} not found" >&2
  159. return 1
  160. fi
  161. local orig_from orig_subject
  162. orig_from=$(echo "$orig" | cut -d'|' -f1)
  163. orig_subject=$(echo "$orig" | cut -d'|' -f2)
  164. local from_project
  165. from_project=$(sql_escape "$(get_project)")
  166. local safe_to safe_subject safe_body
  167. safe_to=$(sql_escape "$orig_from")
  168. safe_subject=$(sql_escape "Re: ${orig_subject}")
  169. safe_body=$(sql_escape "$body")
  170. sqlite3 "$MAIL_DB" \
  171. "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_project}', '${safe_to}', '${safe_subject}', '${safe_body}');"
  172. echo "Replied to ${orig_from}: Re: ${orig_subject}"
  173. }
  174. # Broadcast a message to all known projects (except self)
  175. broadcast() {
  176. local subject="$1"
  177. local body="$2"
  178. if [ -z "$body" ]; then
  179. echo "Error: message body cannot be empty" >&2
  180. return 1
  181. fi
  182. init_db
  183. local from_project
  184. from_project=$(get_project)
  185. local targets
  186. targets=$(sqlite3 "$MAIL_DB" \
  187. "SELECT DISTINCT from_project FROM messages UNION SELECT DISTINCT to_project FROM messages ORDER BY 1;")
  188. local count=0
  189. local safe_subject safe_body safe_from
  190. safe_from=$(sql_escape "$from_project")
  191. safe_subject=$(sql_escape "$subject")
  192. safe_body=$(sql_escape "$body")
  193. while IFS= read -r target; do
  194. [ -z "$target" ] && continue
  195. [ "$target" = "$from_project" ] && continue
  196. local safe_to
  197. safe_to=$(sql_escape "$target")
  198. sqlite3 "$MAIL_DB" \
  199. "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${safe_from}', '${safe_to}', '${safe_subject}', '${safe_body}');"
  200. count=$((count + 1))
  201. done <<< "$targets"
  202. echo "Broadcast to ${count} project(s): ${subject}"
  203. }
  204. # Show inbox status summary
  205. status() {
  206. init_db
  207. local project
  208. project=$(sql_escape "$(get_project)")
  209. local unread total
  210. unread=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}' AND read=0;")
  211. total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}';")
  212. local projects
  213. projects=$(sqlite3 "$MAIL_DB" \
  214. "SELECT COUNT(DISTINCT from_project) FROM messages WHERE to_project='${project}' AND read=0;")
  215. echo "Inbox: ${unread} unread / ${total} total"
  216. if [ "${unread:-0}" -gt 0 ]; then
  217. echo "From: ${projects} project(s)"
  218. sqlite3 -separator ': ' "$MAIL_DB" \
  219. "SELECT from_project, COUNT(*) || ' message(s)' FROM messages WHERE to_project='${project}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;"
  220. fi
  221. }
  222. # List all known projects (that have sent or received mail)
  223. list_projects() {
  224. init_db
  225. sqlite3 "$MAIL_DB" \
  226. "SELECT DISTINCT from_project FROM messages UNION SELECT DISTINCT to_project FROM messages ORDER BY 1;"
  227. }
  228. # Dispatch
  229. case "${1:-help}" in
  230. init) init_db && echo "Mail database initialized at $MAIL_DB" ;;
  231. count) count_unread ;;
  232. unread) list_unread ;;
  233. read) if [ -n "${2:-}" ]; then read_one "$2"; else read_mail; fi ;;
  234. send) shift; send "$@" ;;
  235. reply) reply "${2:?message_id required}" "${3:?body required}" ;;
  236. list) list_all "${2:-20}" ;;
  237. clear) clear_old "${2:-7}" ;;
  238. broadcast) broadcast "${2:-no subject}" "${3:?body required}" ;;
  239. search) search "${2:?keyword required}" ;;
  240. status) status ;;
  241. projects) list_projects ;;
  242. help)
  243. echo "Usage: mail-db.sh <command> [args]"
  244. echo ""
  245. echo "Commands:"
  246. echo " init Initialize database"
  247. echo " count Count unread messages"
  248. echo " unread List unread messages (brief)"
  249. echo " read [id] Read messages and mark as read"
  250. echo " send [--urgent] <to> <subj> <body>"
  251. echo " Send a message"
  252. echo " reply <id> <body> Reply to a message"
  253. echo " list [limit] List recent messages (default 20)"
  254. echo " clear [days] Clear read messages older than N days"
  255. echo " broadcast <subj> <body> Send to all known projects"
  256. echo " search <keyword> Search messages by keyword"
  257. echo " status Inbox summary"
  258. echo " projects List known projects"
  259. ;;
  260. *) echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;;
  261. esac