Assist: archive window reads from mail history

- Archive tag accepts YYYYMMdd date (defaults to today)
- Loads messages from ~/.local/share/anvillm/mail/{date}-*.jsonl
- Shows both sent and received with From/To columns
- Inbox '- <id>' now deletes instead of archiving
This commit is contained in:
Levi Neely 2026-03-18 17:18:15 +01:00
parent b03324baaf
commit a5bf4a8397
1 changed files with 181 additions and 36 deletions

View File

@ -281,11 +281,11 @@ func main() {
fmt.Fprintf(os.Stderr, "Error opening inbox window: %v\n", err)
}
case "Archive":
owner := "user"
if arg != "" {
owner = arg
date := time.Now().Format("20060102")
if len(arg) == 8 && isDigits(arg) {
date = arg
}
if err := openArchiveWindow(owner); err != nil {
if err := openArchiveWindow("user", date); err != nil {
fmt.Fprintf(os.Stderr, "Error opening archive window: %v\n", err)
}
case "Tasks":
@ -1217,8 +1217,8 @@ func handleInboxWindow(w *acme.Win, owner string) {
edits := parseMailEdits(string(body), messages)
var errs []string
for _, edit := range edits {
if err := archiveMessage(edit.msgID); err != nil {
errs = append(errs, fmt.Sprintf("archive %s: %v", edit.msgID, err))
if err := deleteInboxMessage(edit.msgID); err != nil {
errs = append(errs, fmt.Sprintf("delete %s: %v", edit.msgID, err))
}
}
if len(errs) > 0 {
@ -1300,64 +1300,66 @@ func expandUUID(shortID string, messages []Message) string {
return shortID
}
func openArchiveWindow(owner string) error {
func isDigits(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
func openArchiveWindow(owner, date string) error {
w, err := acme.New()
if err != nil {
return err
}
if owner == "user" {
if date != "" {
w.Name(fmt.Sprintf("/AnviLLM/archive/%s", date))
} else if owner == "user" {
w.Name("/AnviLLM/archive")
} else {
w.Name(fmt.Sprintf("/AnviLLM/%s/archive", owner))
}
w.Write("tag", []byte("Get Put "))
w.Write("tag", []byte("Get "))
go handleArchiveWindow(w, owner)
go handleArchiveWindow(w, owner, date)
return nil
}
func handleArchiveWindow(w *acme.Win, owner string) {
func handleArchiveWindow(w *acme.Win, owner, date string) {
defer w.CloseFiles()
folder := owner + "/completed"
refreshMailboxWindow(w, folder, "Archive")
var messages []Message
var loadErr error
if date != "" {
messages, loadErr = loadMailHistory(owner, date)
} else {
messages, _, loadErr = listMessages(owner + "/completed")
}
refreshArchiveWindowWithMessages(w, messages, owner, date, loadErr)
for e := range w.EventChan() {
switch e.C2 {
case 'x', 'X':
switch string(e.Text) {
case "Get":
refreshMailboxWindow(w, folder, "Archive")
case "Put":
messages, _, err := listMessages(folder)
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing messages: %v\n", err)
continue
if date != "" {
messages, loadErr = loadMailHistory(owner, date)
} else {
messages, _, loadErr = listMessages(owner + "/completed")
}
body, err := w.ReadAll("body")
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading body: %v\n", err)
continue
}
edits := parseMailEdits(string(body), messages)
var errs []string
for _, edit := range edits {
if err := deleteArchivedMessage(edit.msgID); err != nil {
errs = append(errs, fmt.Sprintf("delete %s: %v", edit.msgID, err))
}
}
if len(errs) > 0 {
fmt.Fprintf(os.Stderr, "Put errors: %s\n", strings.Join(errs, "; "))
}
refreshMailboxWindow(w, folder, "Archive")
refreshArchiveWindowWithMessages(w, messages, owner, date, loadErr)
default:
w.WriteEvent(e)
}
case 'l', 'L':
text := strings.TrimSpace(string(e.Text))
if isHexString(text) {
openMessageWindowByPrefix(text, folder)
openArchiveMessageByPrefix(text, messages)
} else {
w.WriteEvent(e)
}
@ -1367,6 +1369,132 @@ func handleArchiveWindow(w *acme.Win, owner string) {
}
}
func loadMailHistory(owner, date string) ([]Message, error) {
agentID := owner
if agentID == "" {
agentID = "user"
}
mailDir := filepath.Join(os.Getenv("HOME"), ".local/share/anvillm/mail", agentID)
sentFile := filepath.Join(mailDir, date+"-sent.jsonl")
recvFile := filepath.Join(mailDir, date+"-recv.jsonl")
var messages []Message
for _, f := range []string{sentFile, recvFile} {
data, err := os.ReadFile(f)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var entry struct {
Ts int64 `json:"ts"`
Data struct {
ID string `json:"id"`
From string `json:"from"`
To string `json:"to"`
Type string `json:"type"`
Subject string `json:"subject"`
Body string `json:"body"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
messages = append(messages, Message{
ID: entry.Data.ID,
From: entry.Data.From,
To: entry.Data.To,
Type: entry.Data.Type,
Subject: entry.Data.Subject,
Body: entry.Data.Body,
Timestamp: entry.Ts,
})
}
}
return messages, nil
}
func refreshArchiveWindowWithMessages(w *acme.Win, messages []Message, owner, date string, loadErr error) {
if loadErr != nil {
w.Addr(",")
w.Write("data", []byte(fmt.Sprintf("Error reading archive: %v\n", loadErr)))
w.Ctl("clean")
return
}
sort.Slice(messages, func(i, j int) bool {
return messages[i].Timestamp > messages[j].Timestamp
})
var buf strings.Builder
if date != "" {
buf.WriteString(fmt.Sprintf("Archive %s\n", date))
} else {
buf.WriteString(fmt.Sprintf("%s/completed Archive\n", owner))
}
buf.WriteString(strings.Repeat("=", 120) + "\n\n")
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-12s %-18s %s\n", "ID", "Date", "From", "To", "Type", "Subject"))
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-12s %-18s %s\n", "----------", "--------------------", "------------", "------------", "------------------", strings.Repeat("-", 40)))
for _, msg := range messages {
from := msg.From
if from == "" {
from = "-"
}
to := msg.To
if to == "" {
to = "-"
}
shortID := shortUUID(msg.ID)
dateStr := formatTimestamp(msg.Timestamp)
buf.WriteString(fmt.Sprintf("%-10s %-20s %-12s %-12s %-18s %s\n", shortID, dateStr, from, to, msg.Type, msg.Subject))
}
w.Addr(",")
w.Write("data", []byte(buf.String()))
w.Ctl("clean")
w.Addr("0")
w.Ctl("dot=addr")
w.Ctl("show")
}
func openArchiveMessageByPrefix(prefix string, messages []Message) error {
for _, m := range messages {
if strings.HasPrefix(m.ID, prefix) {
return openArchiveMessageWindow(&m)
}
}
return fmt.Errorf("message not found: %s", prefix)
}
func openArchiveMessageWindow(msg *Message) error {
w, err := acme.New()
if err != nil {
return err
}
w.Name(fmt.Sprintf("/AnviLLM/archive/%s", shortUUID(msg.ID)))
var buf strings.Builder
buf.WriteString(fmt.Sprintf("From: %s\n", msg.From))
buf.WriteString(fmt.Sprintf("To: %s\n", msg.To))
buf.WriteString(fmt.Sprintf("Type: %s\n", msg.Type))
buf.WriteString(fmt.Sprintf("Subject: %s\n", msg.Subject))
buf.WriteString(fmt.Sprintf("Date: %s\n", formatTimestamp(msg.Timestamp)))
buf.WriteString("\n")
buf.WriteString(msg.Body)
w.Write("body", []byte(buf.String()))
w.Ctl("clean")
return nil
}
func listInboxMessages() ([]Message, []string, error) {
return listMessages("user/inbox")
}
@ -1707,6 +1835,23 @@ func deleteArchivedMessage(msgID string) error {
return err
}
// deleteInboxMessage removes a message from the inbox without archiving.
func deleteInboxMessage(msgID string) error {
if !isConnected() {
return fmt.Errorf("not connected to anvilsrv")
}
fid, err := fs.Open("user/ctl", plan9.OWRITE)
if err != nil {
return err
}
defer fid.Close()
ctlMsg := fmt.Sprintf("delete %s", msgID)
_, err = fid.Write([]byte(ctlMsg))
return err
}
// mailEdit represents a single inline archive/delete action from a mailbox window body.
type mailEdit struct {
msgID string // full message ID (expanded from short-ID prefix)