- internal/mail: Message types with priority support - internal/mail: Mailbox JSONL operations (list, get, append, delete) - internal/mail: Router for address resolution and delivery - gt mail send: Send messages to agents - gt mail inbox: List messages (--unread, --json) - gt mail read: Read and mark messages as read - Address formats: mayor/, rig/, rig/polecat, rig/refinery - High priority messages trigger tmux notification - Auto-detect sender from GT_RIG/GT_POLECAT env vars Closes gt-u1j.6, gt-u1j.12 Generated with Claude Code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
223 lines
3.9 KiB
Go
223 lines
3.9 KiB
Go
package mail
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
)
|
|
|
|
// Common errors
|
|
var (
|
|
ErrMessageNotFound = errors.New("message not found")
|
|
ErrEmptyInbox = errors.New("inbox is empty")
|
|
)
|
|
|
|
// Mailbox manages a JSONL-based inbox.
|
|
type Mailbox struct {
|
|
path string
|
|
}
|
|
|
|
// NewMailbox creates a mailbox at the given path.
|
|
func NewMailbox(path string) *Mailbox {
|
|
return &Mailbox{path: path}
|
|
}
|
|
|
|
// Path returns the mailbox file path.
|
|
func (m *Mailbox) Path() string {
|
|
return m.path
|
|
}
|
|
|
|
// List returns all messages in the mailbox.
|
|
func (m *Mailbox) List() ([]*Message, error) {
|
|
file, err := os.Open(m.path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
var messages []*Message
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var msg Message
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
continue // Skip malformed lines
|
|
}
|
|
messages = append(messages, &msg)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sort by timestamp (newest first)
|
|
sort.Slice(messages, func(i, j int) bool {
|
|
return messages[i].Timestamp.After(messages[j].Timestamp)
|
|
})
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
// ListUnread returns unread 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)
|
|
}
|
|
}
|
|
|
|
return unread, nil
|
|
}
|
|
|
|
// Get returns a message by ID.
|
|
func (m *Mailbox) Get(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.
|
|
func (m *Mailbox) Append(msg *Message) error {
|
|
// Ensure directory exists
|
|
dir := filepath.Dir(m.path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Open for append
|
|
file, err := os.OpenFile(m.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = file.WriteString(string(data) + "\n")
|
|
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 {
|
|
// Sort by timestamp (oldest first for JSONL)
|
|
sort.Slice(messages, func(i, j int) bool {
|
|
return messages[i].Timestamp.Before(messages[j].Timestamp)
|
|
})
|
|
|
|
// Write to temp file
|
|
tmpPath := m.path + ".tmp"
|
|
file, err := os.Create(tmpPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, msg := range messages {
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
file.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
file.WriteString(string(data) + "\n")
|
|
}
|
|
|
|
if err := file.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
|
|
// Atomic rename
|
|
return os.Rename(tmpPath, m.path)
|
|
}
|