Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
{"id":"beadle-g4g","title":"Implement top-level slash commands for beadle-email (/mail or /send TBD)","description":"Implement top-level slash commands for beadle-email once naming is resolved (beadle-bkw). Commands should use MCP tools in allowed-tools frontmatter (MCP-first, not Bash). Potential commands: check inbox, send email, send conversation summary. Each command needs proper allowed-tools declaration referencing mcp__plugin_beadle_email__ tools.","status":"closed","priority":2,"issue_type":"task","owner":"jmf@pobox.com","created_at":"2026-03-13T14:54:46.277581-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-13T17:13:24.034484-07:00","closed_at":"2026-03-13T17:13:24.034484-07:00","close_reason":"Slash commands /mail, /inbox, /send implemented and merged in PR #10. Both prod and dev tool prefixes in allowed-tools.","dependencies":[{"issue_id":"beadle-g4g","depends_on_id":"beadle-bkw","type":"blocks","created_at":"2026-03-13T14:55:14.297497-07:00","created_by":"\"jmf-pobox\""},{"issue_id":"beadle-g4g","depends_on_id":"beadle-fsj","type":"blocks","created_at":"2026-03-13T14:55:14.415297-07:00","created_by":"\"jmf-pobox\""}]}
{"id":"beadle-glm","title":"SessionStart hook: auto-allow MCP permissions, deploy commands, first-run setup","description":"Implement SessionStart hook per punt-kit hooks.md standard. Must: (1) auto-allow beadle-email MCP tool permissions in ~/.claude/settings.json, (2) deploy top-level commands from commands/ to ~/.claude/commands/ using diff-and-copy pattern, (3) emit hookSpecificOutput with additionalContext describing setup state. Shell script is thin gate, delegates to beadle-email binary. Must handle both dev and prod plugin namespaces.","status":"closed","priority":1,"issue_type":"task","owner":"jmf@pobox.com","created_at":"2026-03-13T14:54:40.629858-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-13T17:31:27.549751-07:00","closed_at":"2026-03-13T17:31:27.549751-07:00","close_reason":"SessionStart hook refined and merged in PR #11. Dev mode detection, mode-specific permissions, first-run binary check, jq fallback.","dependencies":[{"issue_id":"beadle-glm","depends_on_id":"beadle-fsj","type":"blocks","created_at":"2026-03-13T14:55:14.050895-07:00","created_by":"\"jmf-pobox\""}]}
{"id":"beadle-grl","title":"feat(cli): add identity subcommand to show/set per-repo identity","description":"beadle-email has no CLI or MCP tool to show or set the per-repo identity. Users must manually create .punt-labs/ethos/config.yaml. This causes permission errors when the repo's ethos active identity differs from the one with contacts/permissions configured. Add: beadle-email identity (show resolved identity + source), beadle-email identity set \u003chandle\u003e (write per-repo ethos config). May also need a corresponding MCP tool so Claude Code sessions can self-diagnose identity issues.","status":"closed","priority":2,"issue_type":"feature","owner":"jmf@pobox.com","created_at":"2026-03-21T12:06:38.531888-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-21T13:48:27.419423-07:00","closed_at":"2026-03-21T13:48:27.419423-07:00","close_reason":"PR #66 merged: identity subcommand + whoami MCP tool"}
{"id":"beadle-iue","title":"feat(email): batch archive MCP tool","description":"Add a batch_move or batch_archive MCP tool that accepts an array of message IDs and moves them all in one call. Current move_message handles one message at a time — archiving 588 messages requires 588 individual IMAP MOVE commands. A batch tool would reduce this to one call with one IMAP session. Critical for inbox hygiene when GitHub notifications accumulate.","status":"in_progress","priority":2,"issue_type":"task","owner":"claude@punt-labs.com","created_at":"2026-04-18T10:21:49.921967163-07:00","created_by":"J F","updated_at":"2026-04-18T12:41:55.711635303-07:00"}
{"id":"beadle-iyr","title":"T4: CLIRunner — compound step execution","description":"Phase 3. Extend CLIRunner with compound steps support. Goroutine-per-step with io.Pipe chaining. All steps start concurrently under shared context.WithTimeout. First nonzero exit cancels context. Per-step stderr logging with step[N] labels. Final step stdout capped at 1MB via io.LimitReader on read side. Validate stdin rules at load time (step 0 = pipe, step N\u003e0 = stdout). Tests: 2-step chain, mid-chain failure, timeout, stderr routing. Depends on: beadle-427. Parent: beadle-mvd","status":"closed","priority":2,"issue_type":"task","owner":"claude@punt-labs.com","created_at":"2026-04-18T07:02:38.337684297-07:00","created_by":"J F","updated_at":"2026-04-18T09:34:11.886483785-07:00","closed_at":"2026-04-18T09:34:11.886483785-07:00","close_reason":"Closed"}
{"id":"beadle-j1b","title":"Add install and uninstall subcommands","status":"closed","priority":1,"issue_type":"task","owner":"jmf@pobox.com","created_at":"2026-03-18T06:03:35.808438-07:00","created_by":"\"jmf-pobox\"","updated_at":"2026-03-18T09:03:41.550669-07:00","closed_at":"2026-03-18T09:03:41.550669-07:00","close_reason":"Merged in PR #38. CLI parity (list/read/send/move/folders), global flags (--json/--verbose/--quiet), install/uninstall subcommands."}
{"id":"beadle-j25","title":"fix(chain): SendResult.Method should reflect actual SMTP server, not 'proton-bridge-smtp'","description":"TrySendChain hardcodes Method='proton-bridge-smtp' for all SMTP sends regardless of which server was used. Since #126 added smtp_host, the method could be Fastmail, Resend SMTP, or any other server. Should use the actual hostname or a generic 'smtp' label.","status":"closed","priority":3,"issue_type":"bug","owner":"claude@punt-labs.com","created_at":"2026-04-11T14:02:38.304423708-07:00","created_by":"J F","updated_at":"2026-04-12T06:17:30.478292035-07:00","closed_at":"2026-04-12T06:17:30.478292035-07:00","close_reason":"Closed"}
Expand Down
2 changes: 2 additions & 0 deletions .ethos/missions.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@
{"id":"m-2026-04-18-075","created_at":"2026-04-18T14:56:51Z","closed_at":"2026-04-18T15:26:22Z","status":"closed","type":"implement","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":["internal/daemon/"],"success_criteria":["make check passes","all design decisions implemented"],"rounds_used":1,"rounds_budgeted":3,"verdict":"pass","files_changed":["internal/daemon/command.go","internal/daemon/command_test.go","internal/daemon/runner.go","internal/daemon/runner_test.go","internal/daemon/pipeline.go","internal/daemon/pipeline_test.go","internal/daemon/handler.go","internal/daemon/schema.go","internal/daemon/schema_test.go"],"pipeline":"standard-2026-04-18-7c0858"}
{"id":"m-2026-04-18-076","created_at":"2026-04-18T14:56:51Z","closed_at":"2026-04-18T15:32:28Z","status":"closed","type":"test","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":["internal/daemon/"],"success_criteria":["coverage does not decrease","edge cases and error paths covered"],"rounds_used":1,"rounds_budgeted":2,"verdict":"pass","files_changed":["internal/daemon/pipeline_edge_test.go"],"pipeline":"standard-2026-04-18-7c0858"}
{"id":"m-2026-04-18-077","created_at":"2026-04-18T14:56:51Z","closed_at":"2026-04-18T15:37:18Z","status":"failed","type":"report","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":[".tmp/review-pipeline-v2.md"],"success_criteria":["findings reported with severity and file:line references"],"rounds_used":1,"rounds_budgeted":1,"verdict":"fail","files_changed":[],"pipeline":"standard-2026-04-18-7c0858"}
{"id":"m-2026-04-18-106","created_at":"2026-04-18T19:42:09Z","closed_at":"2026-04-18T19:47:19Z","status":"closed","type":"implement","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":["internal/mcp/"],"success_criteria":["make check passes","new code has tests"],"rounds_used":1,"rounds_budgeted":3,"verdict":"pass","files_changed":["internal/mcp/tools.go","internal/mcp/format.go","internal/mcp/smoke_test.go","internal/mcp/handler_test.go"],"pipeline":"quick-2026-04-18-cc18e3"}
{"id":"m-2026-04-18-107","created_at":"2026-04-18T19:42:09Z","closed_at":"2026-04-18T19:52:22Z","status":"closed","type":"report","leader":"claude","worker":"bwk","evaluator":"mdm","write_set":[".tmp/review-batch-move.md"],"success_criteria":["findings reported with severity and file:line references"],"rounds_used":1,"rounds_budgeted":1,"verdict":"pass","files_changed":[],"pipeline":"quick-2026-04-18-cc18e3"}
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- `batch_move_messages` MCP tool: move multiple messages to another folder
in one call. Accepts an array of UIDs, returns an aggregate summary
("moved N messages to Archive").
- Pipeline v2: CLI runner executes binaries directly (milliseconds, not
45-second Claude sessions). Binary whitelist with `filepath.EvalSymlinks`
at both load and execution time. Typed arg assembly (fixed, positional,
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Ensure `~/.local/bin` is on your `PATH`. Configure your MCP client to run `beadl

## Features

- **17 MCP tools** --- list, read, send, move/archive, download attachments, verify signatures, inspect MIME, classify trust, list folders, address book (list/find/add/remove contacts), whoami, `switch_identity`, inbox polling (set interval, get status)
- **18 MCP tools** --- list, read, send, move/archive, batch move, download attachments, verify signatures, inspect MIME, classify trust, list folders, address book (list/find/add/remove contacts), whoami, `switch_identity`, inbox polling (set interval, get status)
- **Multi-identity via ethos** --- identity resolved per-request from ethos sidecar. Repo-local config pins identity. Mid-session switching via `switch_identity` tool. Fallback to `default-identity` file
- **Two-dimensional trust** --- transport trust (trusted/verified/untrusted/unverified) + identity permissions (rwx per contact per identity). Both must pass before autonomous action
- **Four-level transport trust** --- trusted (Proton-to-Proton E2E), verified (valid PGP), untrusted (bad PGP), unverified (no signature)
Expand All @@ -125,6 +125,7 @@ Ensure `~/.local/bin` is on your `PATH`. Configure your MCP client to run `beadl
| `read_message` | Read full message body, headers, attachments, and trust classification. |
| `send_email` | Send via Proton Bridge SMTP (primary) or Resend API (fallback). Resolves contact names inline. |
| `move_message` | Move a message to another folder. Defaults to Archive. |
| `batch_move_messages` | Move multiple messages to another folder in one call. Returns the count of messages moved. |
| `list_folders` | List all IMAP mailbox folders. |
| `show_mime` | Inspect multipart MIME structure, PGP parts, and attachments. |
| `verify_signature` | Verify PGP signature on a message. Returns signer info and key ID. |
Expand Down
25 changes: 25 additions & 0 deletions internal/email/imap.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,31 @@ func (c *Client) MoveMessage(srcFolder string, uid uint32, dstFolder string) err
return nil
}

// MoveMessages moves multiple messages by UID from one folder to another.
// Issues a single SELECT followed by a single MOVE command. UIDs that
// don't exist on the server are silently ignored by the IMAP protocol.
func (c *Client) MoveMessages(srcFolder string, uids []uint32, dstFolder string) error {
if len(uids) == 0 {
return nil
}

_, err := c.imap.Select(srcFolder, &imap.SelectOptions{ReadOnly: false}).Wait()
if err != nil {
return fmt.Errorf("select %q: %w", srcFolder, err)
}

imapUIDs := make([]imap.UID, len(uids))
for i, u := range uids {
imapUIDs[i] = imap.UID(u)
}

_, err = c.imap.Move(imap.UIDSetNum(imapUIDs...), dstFolder).Wait()
if err != nil {
Comment thread
claude-puntlabs marked this conversation as resolved.
return fmt.Errorf("move %d messages to %q: %w", len(uids), dstFolder, err)
}
return nil
}

func formatAddress(addr imap.Address) string {
if addr.Name != "" {
return fmt.Sprintf("%s <%s@%s>", addr.Name, addr.Mailbox, addr.Host)
Expand Down
5 changes: 5 additions & 0 deletions internal/mcp/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ func formatMoveResult(r *moveResult) string {
return fmt.Sprintf("moved #%s → %s", r.MessageID, r.Destination)
}

// formatBatchMoveResult formats a batch move summary.
func formatBatchMoveResult(count int, destination string) string {
return fmt.Sprintf("moved %d messages to %s", count, destination)
}

// formatDownloadResult formats a download result.
func formatDownloadResult(r *downloadResult) string {
return fmt.Sprintf("%s: %s (%d bytes)\n%s", r.Status, r.Filename, r.Size, r.Path)
Expand Down
59 changes: 59 additions & 0 deletions internal/mcp/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,65 @@ func TestHandler_MoveMessage(t *testing.T) {
assert.Contains(t, r.text(), "moved")
}

func TestHandler_BatchMoveMessages(t *testing.T) {
s, env, fix := setupHandler(t)
env.AddContact("Alice", "alice@test.com", "r--")

uid1 := fix.AddMessage("INBOX", "alice@test.com", "Msg 1", "body 1")
uid2 := fix.AddMessage("INBOX", "alice@test.com", "Msg 2", "body 2")
uid3 := fix.AddMessage("INBOX", "alice@test.com", "Msg 3", "body 3")
fix.AddMessage("Archive", "system@test.com", "Placeholder", "x")

r := callTool(t, s, "batch_move_messages", map[string]any{
"message_ids": []any{
fmt.Sprintf("%d", uid1),
fmt.Sprintf("%d", uid2),
fmt.Sprintf("%d", uid3),
},
"destination": "Archive",
})
assert.False(t, r.IsError, "batch move failed: %s", r.text())
assert.Contains(t, r.text(), "moved 3 messages")
assert.Contains(t, r.text(), "Archive")
}

func TestHandler_BatchMoveMessages_InvalidUID(t *testing.T) {
s, env, fix := setupHandler(t)
env.AddContact("Alice", "alice@test.com", "r--")

uid1 := fix.AddMessage("INBOX", "alice@test.com", "Msg 1", "body 1")
fix.AddMessage("Archive", "system@test.com", "Placeholder", "x")

r := callTool(t, s, "batch_move_messages", map[string]any{
"message_ids": []any{
fmt.Sprintf("%d", uid1),
"not-a-number",
},
"destination": "Archive",
})
assert.True(t, r.IsError, "invalid UID should produce error")
assert.Contains(t, r.text(), "#not-a-number")
assert.Contains(t, r.text(), "invalid")
}

func TestHandler_BatchMoveMessages_Empty(t *testing.T) {
s, _, _ := setupHandler(t)

r := callTool(t, s, "batch_move_messages", map[string]any{
"message_ids": []any{},
})
assert.False(t, r.IsError, "batch move failed: %s", r.text())
assert.Contains(t, r.text(), "moved 0 messages")
}

func TestHandler_BatchMoveMessages_MissingParam(t *testing.T) {
s, _, _ := setupHandler(t)

r := callTool(t, s, "batch_move_messages", map[string]any{})
assert.True(t, r.IsError, "missing message_ids should produce error")
assert.Contains(t, r.text(), "message_ids is required")
}

func TestHandler_Contacts_CRUD(t *testing.T) {
s, _, _ := setupHandler(t)

Expand Down
5 changes: 3 additions & 2 deletions internal/mcp/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ func TestMCPSmoke_ToolRegistration(t *testing.T) {
expectedTools := []string{
"list_messages", "read_message", "list_folders", "send_email",
"verify_signature", "show_mime", "check_trust", "move_message",
"download_attachment", "list_contacts", "find_contact",
"add_contact", "remove_contact", "whoami", "switch_identity",
"batch_move_messages", "download_attachment", "list_contacts",
"find_contact", "add_contact", "remove_contact", "whoami",
"switch_identity",
}

for _, expected := range expectedTools {
Expand Down
69 changes: 69 additions & 0 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func RegisterTools(s *server.MCPServer, resolver *identity.Resolver, logger *slo
s.AddTool(showMIMETool(), h.showMIME)
s.AddTool(checkTrustTool(), h.checkTrust)
s.AddTool(moveMessageTool(), h.moveMessage)
s.AddTool(batchMoveMessagesTool(), h.batchMoveMessages)
s.AddTool(downloadAttachmentTool(), h.downloadAttachment)

s.AddTool(listContactsTool(), h.listContacts)
Expand Down Expand Up @@ -292,6 +293,25 @@ func moveMessageTool() mcplib.Tool {
)
}

func batchMoveMessagesTool() mcplib.Tool {
return mcplib.NewTool("batch_move_messages",
mcplib.WithDescription("Move multiple messages to another folder in one call. Returns the count of messages moved."),
mcplib.WithArray("message_ids",
mcplib.Required(),
mcplib.Description("Message UIDs to move (from list_messages)"),
mcplib.WithStringItems(),
Comment thread
claude-puntlabs marked this conversation as resolved.
),
mcplib.WithString("folder",
mcplib.Description("Source IMAP folder name"),
mcplib.DefaultString("INBOX"),
),
mcplib.WithString("destination",
mcplib.Description("Destination folder name"),
mcplib.DefaultString("Archive"),
),
)
}

// --- Contact Tool Definitions ---

func listContactsTool() mcplib.Tool {
Expand Down Expand Up @@ -795,6 +815,55 @@ func (h *handler) moveMessage(ctx context.Context, req mcplib.CallToolRequest) (
})
}

func (h *handler) batchMoveMessages(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
_, cfg, _, err := h.resolveIdentityAndConfig()
if err != nil {
return mcplib.NewToolResultError(err.Error()), nil
}

ids, err := stringSliceParam(req, "message_ids")
if err != nil {
return mcplib.NewToolResultError(err.Error()), nil
}
if ids == nil {
return mcplib.NewToolResultError("message_ids is required"), nil
}

folder := stringParam(req, "folder", "INBOX")
destination := stringParam(req, "destination", "Archive")

if len(ids) == 0 {
return textResult(formatBatchMoveResult(0, destination))
}

// Parse all UIDs up front so we can report invalid IDs before
// opening a connection.
uids := make([]uint32, 0, len(ids))
var parseErrs []string
for _, id := range ids {
uid, parseErr := strconv.ParseUint(id, 10, 32)
if parseErr != nil {
parseErrs = append(parseErrs, fmt.Sprintf("#%s: invalid id", id))
continue
}
if uid == 0 {
parseErrs = append(parseErrs, fmt.Sprintf("#%s: invalid id", id))
continue
}
Comment thread
claude-puntlabs marked this conversation as resolved.
uids = append(uids, uint32(uid))
}
if len(parseErrs) > 0 {
return mcplib.NewToolResultError(fmt.Sprintf("invalid message_ids: %s", strings.Join(parseErrs, ", "))), nil
}

return h.withClient(cfg, func(c *email.Client) (*mcplib.CallToolResult, error) {
if err := c.MoveMessages(folder, uids, destination); err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("batch move: %v", err)), nil
}
return textResult(formatBatchMoveResult(len(uids), destination))
})
}

func (h *handler) downloadAttachment(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
id, cfg, store, err := h.resolveContext()
if err != nil {
Expand Down
Loading