Browse Source

feat(skills): Add file attachments to agentmail send/reply

Path references stored in DB (not copies), resolved to absolute on send.
Recipients see file size and can Read directly. Hook delivery includes
attachment hints. Nonexistent files rejected at send time. 20 new tests
covering single/multi attach, spaced paths, mixed flags, missing files,
hook display, and no-regression on plain sends. (96/96 passing)

Also fixes: reply dispatch dropping --attach flags, silent fallback on
bad paths, trailing newline in stored attachment lists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
fe8edf112a

+ 15 - 0
hooks/check-mail.sh

@@ -59,11 +59,26 @@ while read -r msg_id; do
   urgent=""
   [ "$priority" = "urgent" ] && urgent=" [URGENT]"
 
+  attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};" 2>/dev/null)
+
   echo ""
   echo "--- #${msg_id} from ${from_name} (${from_hash})${urgent} @ ${timestamp} ---"
   echo "Subject: ${subject}"
   echo "${body}"
 
+  # Show attachments
+  if [ -n "$attachments" ]; then
+    echo ""
+    while IFS= read -r apath; do
+      [ -z "$apath" ] && continue
+      if [ -e "$apath" ]; then
+        echo "[Attached: ${apath} ($(wc -c < "$apath" | tr -d ' ') bytes)] <-- Use Read tool to view"
+      else
+        echo "[Attached: ${apath} (missing)]"
+      fi
+    done <<< "$attachments"
+  fi
+
   # Show thread context if this is part of a conversation
   if [ -n "$thread_id" ]; then
     thread_root="$thread_id"

+ 19 - 0
skills/agentmail/SKILL.md

@@ -31,7 +31,9 @@ Parse the user's input after `agentmail` (or `/agentmail`) and run the matching
 | `agentmail read 42` | `bash "$MAIL" read 42` |
 | `agentmail send <project> "<subject>" "<body>"` | `bash "$MAIL" send "<project>" "<subject>" "<body>"` |
 | `agentmail send --urgent <project> "<subject>" "<body>"` | `bash "$MAIL" send --urgent "<project>" "<subject>" "<body>"` |
+| `agentmail send --attach <path> <project> "<subject>" "<body>"` | `bash "$MAIL" send --attach "<path>" "<project>" "<subject>" "<body>"` |
 | `agentmail reply <id> "<body>"` | `bash "$MAIL" reply <id> "<body>"` |
+| `agentmail reply --attach <path> <id> "<body>"` | `bash "$MAIL" reply --attach "<path>" <id> "<body>"` |
 | `agentmail broadcast "<subject>" "<body>"` | `bash "$MAIL" broadcast "<subject>" "<body>"` |
 | `agentmail search <keyword>` | `bash "$MAIL" search "<keyword>"` |
 | `agentmail status` | `bash "$MAIL" status` |
@@ -86,6 +88,23 @@ A global PreToolUse hook checks for mail on every tool call (no cooldown). Silen
 Use agentmail read to read messages.
 ```
 
+## Attachments
+
+Send file references with `--attach <path>` (repeatable). Paths are resolved to absolute and stored as references - files are not copied.
+
+```bash
+# Send with one attachment
+agentmail send --attach src/config.ts my-api "Config update" "Updated the auth config"
+
+# Send with multiple attachments
+agentmail send --attach src/schema.sql --attach docs/API.md my-api "Schema + docs" "See attached"
+
+# Reply with attachment
+agentmail reply --attach output/report.json 42 "Here's the analysis"
+```
+
+Recipients see attachment paths with file sizes and can read them directly with the Read tool. If a file has been moved or deleted since sending, it shows as `(missing)`.
+
 ## When to Send
 
 - You've completed work another session depends on

+ 84 - 16
skills/agentmail/scripts/mail-db.sh

@@ -84,12 +84,25 @@ SQL
   # Migration: add thread_id column if missing
   sqlite3 "$MAIL_DB" "SELECT thread_id FROM messages LIMIT 0;" 2>/dev/null || \
     sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN thread_id INTEGER REFERENCES messages(id);" 2>/dev/null
+  # Migration: add attachments column if missing
+  sqlite3 "$MAIL_DB" "SELECT attachments FROM messages LIMIT 0;" 2>/dev/null || \
+    sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN attachments TEXT DEFAULT '';" 2>/dev/null
 }
 
 sql_escape() {
   printf '%s' "$1" | sed "s/'/''/g"
 }
 
+# Resolve attachment path to absolute, validate existence
+resolve_attach() {
+  local p="$1"
+  if [ ! -e "$p" ]; then
+    echo "Error: attachment not found: $p" >&2
+    return 1
+  fi
+  (cd "$(dirname "$p")" && echo "$(pwd -P)/$(basename "$p")")
+}
+
 # Read body from argument or stdin (use - or omit for stdin)
 read_body() {
   local arg="$1"
@@ -224,13 +237,22 @@ read_mail() {
   echo "id | from_project | subject | body | timestamp"
   while read -r msg_id; do
     [ -z "$msg_id" ] && continue
-    local from_hash subj body ts from_name
+    local from_hash subj body ts from_name attachments
     from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
     subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
     body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
     ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
+    attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};")
     from_name=$(display_name "$from_hash")
     echo "${msg_id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}"
+    if [ -n "$attachments" ]; then
+      while IFS= read -r apath; do
+        [ -z "$apath" ] && continue
+        local astat="missing"
+        [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
+        echo "  [Attached: ${apath} (${astat})]"
+      done <<< "$attachments"
+    fi
   done <<< "$ids"
   sqlite3 "$MAIL_DB" \
     "UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;"
@@ -248,26 +270,40 @@ read_one() {
   local exists
   exists=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE id=${msg_id};")
   [ "${exists:-0}" -eq 0 ] && return 0
-  local from_hash to_hash subj body ts from_name to_name
+  local from_hash to_hash subj body ts from_name to_name attachments
   from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${msg_id};")
   to_hash=$(sqlite3 "$MAIL_DB" "SELECT to_project FROM messages WHERE id=${msg_id};")
   subj=$(sqlite3 "$MAIL_DB" "SELECT subject FROM messages WHERE id=${msg_id};")
   body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${msg_id};")
   ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${msg_id};")
+  attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${msg_id};")
   from_name=$(display_name "$from_hash")
   to_name=$(display_name "$to_hash")
   echo "id | from_project | to_project | subject | body | timestamp"
   echo "${msg_id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}"
+  if [ -n "$attachments" ]; then
+    while IFS= read -r apath; do
+      [ -z "$apath" ] && continue
+      local astat="missing"
+      [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
+      echo "  [Attached: ${apath} (${astat})]"
+    done <<< "$attachments"
+  fi
   sqlite3 "$MAIL_DB" \
     "UPDATE messages SET read=1 WHERE id=${msg_id};"
 }
 
 send() {
   local priority="normal"
-  if [ "${1:-}" = "--urgent" ]; then
-    priority="urgent"
-    shift
-  fi
+  local -a attach_paths=()
+  # Parse flags before positional args
+  while [ $# -gt 0 ]; do
+    case "$1" in
+      --urgent) priority="urgent"; shift ;;
+      --attach) shift; local resolved; resolved=$(resolve_attach "$1") || return 1; attach_paths+=("$resolved"); shift ;;
+      *) break ;;
+    esac
+  done
   local to_input="${1:?to_project required}"
   local subject="${2:-no subject}"
   local body
@@ -281,16 +317,24 @@ send() {
   local from_id to_id
   from_id=$(get_project_id)
   to_id=$(resolve_target "$to_input")
-  local safe_subject safe_body
+  local safe_subject safe_body safe_attachments
   safe_subject=$(sql_escape "$subject")
   safe_body=$(sql_escape "$body")
+  # Join attachment paths with newlines
+  local attachments=""
+  if [ ${#attach_paths[@]} -gt 0 ]; then
+    attachments=$(IFS=$'\n'; echo "${attach_paths[*]}")
+  fi
+  safe_attachments=$(sql_escape "$attachments")
   sqlite3 "$MAIL_DB" \
-    "INSERT INTO messages (from_project, to_project, subject, body, priority) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}');"
+    "INSERT INTO messages (from_project, to_project, subject, body, priority, attachments) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}', '${safe_attachments}');"
   # Signal the recipient
   touch "/tmp/agentmail_signal_${to_id}"
   local to_name
   to_name=$(display_name "$to_id")
-  echo "Sent to ${to_name} (${to_id}): ${subject}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
+  local attach_note=""
+  [ ${#attach_paths[@]} -gt 0 ] && attach_note=" [${#attach_paths[@]} attachment(s)]"
+  echo "Sent to ${to_name} (${to_id}): ${subject}${attach_note}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
 }
 
 sent() {
@@ -369,6 +413,14 @@ clear_old() {
 }
 
 reply() {
+  local -a attach_paths=()
+  # Parse flags before positional args
+  while [ $# -gt 0 ]; do
+    case "$1" in
+      --attach) shift; local resolved; resolved=$(resolve_attach "$1") || return 1; attach_paths+=("$resolved"); shift ;;
+      *) break ;;
+    esac
+  done
   local msg_id="$1"
   local body
   body=$(read_body "${2:-}")
@@ -396,16 +448,23 @@ reply() {
   local thread_id="${orig_thread:-$msg_id}"
   local from_id
   from_id=$(get_project_id)
-  local safe_subject safe_body
+  local safe_subject safe_body safe_attachments
   safe_subject=$(sql_escape "Re: ${orig_subject}")
   safe_body=$(sql_escape "$body")
+  local attachments=""
+  if [ ${#attach_paths[@]} -gt 0 ]; then
+    attachments=$(IFS=$'\n'; echo "${attach_paths[*]}")
+  fi
+  safe_attachments=$(sql_escape "$attachments")
   sqlite3 "$MAIL_DB" \
-    "INSERT INTO messages (from_project, to_project, subject, body, thread_id) VALUES ('${from_id}', '${orig_from_hash}', '${safe_subject}', '${safe_body}', ${thread_id});"
+    "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}');"
   # Signal the recipient
   touch "/tmp/agentmail_signal_${orig_from_hash}"
   local orig_name
   orig_name=$(display_name "$orig_from_hash")
-  echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}"
+  local attach_note=""
+  [ ${#attach_paths[@]} -gt 0 ] && attach_note=" [${#attach_paths[@]} attachment(s)]"
+  echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}${attach_note}"
 }
 
 thread() {
@@ -428,14 +487,23 @@ thread() {
   echo "=== Thread #${thread_root} ==="
   while read -r tid; do
     [ -z "$tid" ] && continue
-    local from_hash body ts from_name
+    local from_hash body ts from_name attachments
     from_hash=$(sqlite3 "$MAIL_DB" "SELECT from_project FROM messages WHERE id=${tid};")
     body=$(sqlite3 "$MAIL_DB" "SELECT body FROM messages WHERE id=${tid};")
     ts=$(sqlite3 "$MAIL_DB" "SELECT timestamp FROM messages WHERE id=${tid};")
+    attachments=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${tid};")
     from_name=$(display_name "$from_hash")
     echo ""
     echo "--- #${tid} ${from_name} @ ${ts} ---"
     echo "${body}"
+    if [ -n "$attachments" ]; then
+      while IFS= read -r apath; do
+        [ -z "$apath" ] && continue
+        local astat="missing"
+        [ -e "$apath" ] && astat="$(wc -c < "$apath" | tr -d ' ') bytes"
+        echo "  [Attached: ${apath} (${astat})]"
+      done <<< "$attachments"
+    fi
     msg_count=$((msg_count + 1))
   done <<< "$ids"
   echo ""
@@ -617,7 +685,7 @@ case "${1:-help}" in
   unread)     list_unread ;;
   read)       if [ -n "${2:-}" ]; then read_one "$2"; else read_mail; fi ;;
   send)       shift; send "$@" ;;
-  reply)      reply "${2:?message_id required}" "${3:-}" ;;
+  reply)      shift; reply "$@" ;;
   sent)       sent "${2:-20}" ;;
   thread)     thread "${2:?message_id required}" ;;
   list)       list_all "${2:-20}" ;;
@@ -639,8 +707,8 @@ case "${1:-help}" in
     echo "  count                   Count unread messages"
     echo "  unread                  List unread messages (brief)"
     echo "  read [id]               Read messages and mark as read"
-    echo "  send [--urgent] <to> <subj> <body|->  Send (- or pipe for stdin body)"
-    echo "  reply <id> <body|->     Reply (- or pipe for stdin body)"
+    echo "  send [--urgent] [--attach <path>]... <to> <subj> <body|->  Send with optional attachments"
+    echo "  reply [--attach <path>]... <id> <body|->  Reply with optional attachments"
     echo "  sent [limit]            Show sent messages (outbox)"
     echo "  thread <id>             View full conversation thread"
     echo "  list [limit]            List recent messages (default 20)"

+ 93 - 0
skills/agentmail/scripts/test-mail.sh

@@ -491,6 +491,99 @@ assert_empty "hook silent after signal cleared (2)" "$result2"
 bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 
 echo ""
+echo "=== Attachments ==="
+
+# Create temp files for attachment tests
+ATTACH_DIR=$(mktemp -d)
+echo "file one content" > "$ATTACH_DIR/file1.txt"
+echo "file two content" > "$ATTACH_DIR/file2.txt"
+mkdir -p "$ATTACH_DIR/sub dir"
+echo "spaced path" > "$ATTACH_DIR/sub dir/spaced.txt"
+
+# T: Send with single attachment
+result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" "claude-mods" "attach test" "one file" 2>&1)
+assert_contains "send with attachment succeeds" "1 attachment" "$result"
+
+# T: Attachment path stored as absolute
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
+assert_contains "attachment path is absolute" "$ATTACH_DIR/file1.txt" "$stored"
+
+# T: Read shows attachment with size
+result=$(bash "$MAIL_SCRIPT" read "$last_id" 2>&1)
+assert_contains "read shows Attached" "[Attached:" "$result"
+assert_contains "read shows file size" "bytes" "$result"
+
+# T: Send with multiple attachments
+result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" --attach "$ATTACH_DIR/file2.txt" "claude-mods" "multi attach" "two files" 2>&1)
+assert_contains "send with 2 attachments" "2 attachment" "$result"
+
+# T: Multiple attachment paths stored correctly
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+attach_count=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};" | grep -c '.')
+assert "two attachment paths stored" "2" "$attach_count"
+
+# T: No trailing empty line in stored attachments
+trailing=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};" | tail -1)
+assert_not_empty "no trailing empty line" "$trailing"
+
+# T: Nonexistent file rejected
+result=$(bash "$MAIL_SCRIPT" send --attach "/tmp/nonexistent_$$.txt" "claude-mods" "fail" "body" 2>&1)
+exit_code=$?
+assert_contains "nonexistent attach rejected" "not found" "$result"
+assert_exit_code "nonexistent attach exits 1" "1" "$exit_code"
+
+# T: Send without attachment still works (no regression)
+result=$(bash "$MAIL_SCRIPT" send "claude-mods" "no attach" "plain message" 2>&1)
+assert_contains "send without attach works" "Sent to" "$result"
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+stored=$(sqlite3 "$MAIL_DB" "SELECT COALESCE(attachments,'') FROM messages WHERE id=${last_id};")
+assert "no-attach message has empty attachments" "" "$stored"
+
+# T: Reply with attachment via dispatch
+base_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+result=$(bash "$MAIL_SCRIPT" reply --attach "$ATTACH_DIR/file1.txt" "$base_id" "reply with file" 2>&1)
+assert_contains "reply with attachment succeeds" "1 attachment" "$result"
+
+# T: Reply attachment stored correctly
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
+assert_contains "reply attachment path stored" "$ATTACH_DIR/file1.txt" "$stored"
+
+# T: Attachment with spaces in path
+result=$(bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/sub dir/spaced.txt" "claude-mods" "spaced path" "path has spaces" 2>&1)
+assert_contains "spaced path attachment succeeds" "1 attachment" "$result"
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+stored=$(sqlite3 "$MAIL_DB" "SELECT attachments FROM messages WHERE id=${last_id};")
+assert_contains "spaced path preserved" "sub dir/spaced.txt" "$stored"
+
+# T: Hook shows attachments
+bash "$MAIL_SCRIPT" send --attach "$ATTACH_DIR/file1.txt" "claude-mods" "hook attach" "check hook" >/dev/null 2>&1
+clear_cooldown
+result=$(bash "$HOOK_SCRIPT" 2>&1)
+assert_contains "hook shows attachment" "[Attached:" "$result"
+assert_contains "hook shows Read hint" "Use Read tool" "$result"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+# T: Mixed flags - --urgent with --attach
+result=$(bash "$MAIL_SCRIPT" send --urgent --attach "$ATTACH_DIR/file1.txt" "claude-mods" "urgent+attach" "both flags" 2>&1)
+assert_contains "urgent+attach shows attachment" "1 attachment" "$result"
+assert_contains "urgent+attach shows URGENT" "URGENT" "$result"
+
+# T: Deleted file shows as missing
+VANISH="$ATTACH_DIR/vanish.txt"
+echo "temporary" > "$VANISH"
+bash "$MAIL_SCRIPT" send --attach "$VANISH" "claude-mods" "vanish test" "file will disappear" >/dev/null 2>&1
+rm -f "$VANISH"
+last_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages ORDER BY id DESC LIMIT 1;")
+result=$(bash "$MAIL_SCRIPT" read "$last_id" 2>&1)
+assert_contains "deleted file shows missing" "missing" "$result"
+
+# Clean up temp dir
+rm -rf "$ATTACH_DIR"
+bash "$MAIL_SCRIPT" read >/dev/null 2>&1
+
+echo ""
 echo "=== Purge ==="
 
 # T54: Purge removes messages for current project