feat: refactor mail system to use bd mail backend
- Mail commands (send/inbox/read/delete) now wrap bd mail CLI - Address translation: mayor/ → mayor, rig/polecat → rig-polecat - Beads stores messages as type=message issues - Legacy JSONL mode retained for crew workers (local mail) - Refinery notifications use new mail interface - Swarm landing notifications use new mail interface Closes gt-u1j.6, gt-u1j.12 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+245
-86
@@ -2,11 +2,14 @@ package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
@@ -15,23 +18,97 @@ var (
|
||||
ErrEmptyInbox = errors.New("inbox is empty")
|
||||
)
|
||||
|
||||
// Mailbox manages a JSONL-based inbox.
|
||||
// Mailbox manages messages for an identity via beads.
|
||||
type Mailbox struct {
|
||||
path string
|
||||
identity string // beads identity (e.g., "gastown-Toast")
|
||||
workDir string // directory to run bd commands in
|
||||
path string // for legacy JSONL mode (crew workers)
|
||||
legacy bool // true = use JSONL files, false = use beads
|
||||
}
|
||||
|
||||
// NewMailbox creates a mailbox at the given path.
|
||||
// NewMailbox creates a mailbox for the given JSONL path (legacy mode).
|
||||
// Used by crew workers that have local JSONL inboxes.
|
||||
func NewMailbox(path string) *Mailbox {
|
||||
return &Mailbox{path: path}
|
||||
return &Mailbox{
|
||||
path: filepath.Join(path, "inbox.jsonl"),
|
||||
legacy: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Path returns the mailbox file path.
|
||||
// NewMailboxBeads creates a mailbox backed by beads.
|
||||
func NewMailboxBeads(identity, workDir string) *Mailbox {
|
||||
return &Mailbox{
|
||||
identity: identity,
|
||||
workDir: workDir,
|
||||
legacy: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewMailboxFromAddress creates a beads-backed mailbox from a GGT address.
|
||||
func NewMailboxFromAddress(address, workDir string) *Mailbox {
|
||||
return &Mailbox{
|
||||
identity: addressToIdentity(address),
|
||||
workDir: workDir,
|
||||
legacy: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Identity returns the beads identity for this mailbox.
|
||||
func (m *Mailbox) Identity() string {
|
||||
return m.identity
|
||||
}
|
||||
|
||||
// Path returns the JSONL path for legacy mailboxes.
|
||||
func (m *Mailbox) Path() string {
|
||||
return m.path
|
||||
}
|
||||
|
||||
// List returns all messages in the mailbox.
|
||||
// List returns all open messages in the mailbox.
|
||||
func (m *Mailbox) List() ([]*Message, error) {
|
||||
if m.legacy {
|
||||
return m.listLegacy()
|
||||
}
|
||||
return m.listBeads()
|
||||
}
|
||||
|
||||
func (m *Mailbox) listBeads() ([]*Message, error) {
|
||||
// bd mail inbox --json
|
||||
cmd := exec.Command("bd", "mail", "inbox", "--json")
|
||||
cmd.Dir = m.workDir
|
||||
cmd.Env = append(cmd.Environ(), "BD_IDENTITY="+m.identity)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var beadsMsgs []BeadsMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil {
|
||||
// Empty inbox returns empty array or nothing
|
||||
if len(stdout.Bytes()) == 0 || string(stdout.Bytes()) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to GGT messages
|
||||
var messages []*Message
|
||||
for _, bm := range beadsMsgs {
|
||||
messages = append(messages, bm.ToMessage())
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (m *Mailbox) listLegacy() ([]*Message, error) {
|
||||
file, err := os.Open(m.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -68,41 +145,186 @@ func (m *Mailbox) List() ([]*Message, error) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ListUnread returns unread messages.
|
||||
// ListUnread returns unread (open) messages.
|
||||
func (m *Mailbox) ListUnread() ([]*Message, error) {
|
||||
all, err := m.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var unread []*Message
|
||||
for _, msg := range all {
|
||||
if !msg.Read {
|
||||
unread = append(unread, msg)
|
||||
if m.legacy {
|
||||
all, err := m.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var unread []*Message
|
||||
for _, msg := range all {
|
||||
if !msg.Read {
|
||||
unread = append(unread, msg)
|
||||
}
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
return unread, nil
|
||||
// For beads, inbox only returns open (unread) messages
|
||||
return m.List()
|
||||
}
|
||||
|
||||
// Get returns a message by ID.
|
||||
func (m *Mailbox) Get(id string) (*Message, error) {
|
||||
if m.legacy {
|
||||
return m.getLegacy(id)
|
||||
}
|
||||
return m.getBeads(id)
|
||||
}
|
||||
|
||||
func (m *Mailbox) getBeads(id string) (*Message, error) {
|
||||
cmd := exec.Command("bd", "mail", "read", id, "--json")
|
||||
cmd.Dir = m.workDir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bm BeadsMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &bm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bm.ToMessage(), nil
|
||||
}
|
||||
|
||||
func (m *Mailbox) getLegacy(id string) (*Message, error) {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
|
||||
// Append adds a message to the mailbox.
|
||||
// MarkRead marks a message as read.
|
||||
func (m *Mailbox) MarkRead(id string) error {
|
||||
if m.legacy {
|
||||
return m.markReadLegacy(id)
|
||||
}
|
||||
return m.markReadBeads(id)
|
||||
}
|
||||
|
||||
func (m *Mailbox) markReadBeads(id string) error {
|
||||
cmd := exec.Command("bd", "mail", "ack", id)
|
||||
cmd.Dir = m.workDir
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
if errMsg != "" {
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mailbox) markReadLegacy(id string) error {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
msg.Read = true
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
|
||||
return m.rewriteLegacy(messages)
|
||||
}
|
||||
|
||||
// Delete removes a message.
|
||||
func (m *Mailbox) Delete(id string) error {
|
||||
if m.legacy {
|
||||
return m.deleteLegacy(id)
|
||||
}
|
||||
return m.MarkRead(id) // beads: just acknowledge/close
|
||||
}
|
||||
|
||||
func (m *Mailbox) deleteLegacy(id string) error {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filtered []*Message
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
found = true
|
||||
} else {
|
||||
filtered = append(filtered, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
|
||||
return m.rewriteLegacy(filtered)
|
||||
}
|
||||
|
||||
// Count returns the total and unread message counts.
|
||||
func (m *Mailbox) Count() (total, unread int, err error) {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
total = len(messages)
|
||||
if m.legacy {
|
||||
for _, msg := range messages {
|
||||
if !msg.Read {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For beads, inbox only returns unread
|
||||
unread = total
|
||||
}
|
||||
|
||||
return total, unread, nil
|
||||
}
|
||||
|
||||
// Append adds a message to the mailbox (legacy mode only).
|
||||
// For beads mode, use Router.Send() instead.
|
||||
func (m *Mailbox) Append(msg *Message) error {
|
||||
if !m.legacy {
|
||||
return errors.New("use Router.Send() to send messages via beads")
|
||||
}
|
||||
return m.appendLegacy(msg)
|
||||
}
|
||||
|
||||
func (m *Mailbox) appendLegacy(msg *Message) error {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(m.path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
@@ -125,71 +347,8 @@ func (m *Mailbox) Append(msg *Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkRead marks a message as read.
|
||||
func (m *Mailbox) MarkRead(id string) error {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
msg.Read = true
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
|
||||
return m.rewrite(messages)
|
||||
}
|
||||
|
||||
// Delete removes a message from the mailbox.
|
||||
func (m *Mailbox) Delete(id string) error {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filtered []*Message
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
found = true
|
||||
} else {
|
||||
filtered = append(filtered, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
|
||||
return m.rewrite(filtered)
|
||||
}
|
||||
|
||||
// Count returns the total and unread message counts.
|
||||
func (m *Mailbox) Count() (total, unread int, err error) {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
total = len(messages)
|
||||
for _, msg := range messages {
|
||||
if !msg.Read {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
|
||||
return total, unread, nil
|
||||
}
|
||||
|
||||
// rewrite rewrites the mailbox with the given messages.
|
||||
func (m *Mailbox) rewrite(messages []*Message) error {
|
||||
// rewriteLegacy rewrites the mailbox with the given messages.
|
||||
func (m *Mailbox) rewriteLegacy(messages []*Message) error {
|
||||
// Sort by timestamp (oldest first for JSONL)
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Timestamp.Before(messages[j].Timestamp)
|
||||
|
||||
Reference in New Issue
Block a user