Phase 5: reply drafts — template/LLM draft, edit, SMTP send (AUD-71)
Implements Phase 5 of AGENTS.md §11 (FR-8, FR-15): - Message detail pane (GET /partials/message) with body, recommended action, reply identity, template picker, editable draft, and history. - internal/reply: OmniRoute (OpenAI-compatible) chat client and a template-based draft generator. All non-JEV models go through OmniRoute (§10). - internal/mailops: SMTP send over smtp.gmail.com:587 with STARTTLS and the app password, forcing IPv4 (§9); FR-15 reply identity (send as the arrived-at X-Pm-Original-To address, else "Sam Rolfe"); threading via In-Reply-To/References; -check-smtp safe verification; and a [Gmail]/Sent Mail thread check. - Migration v5 stores messages.message_id_header and drafts.sent_at; mailfetch parses Message-ID. - Send is user-triggered only: CSRF + UI confirm + confirm=yes guard, no auto-send, duplicate-send refused. A sent reply files the original under Replied-to/<address> and logs every step (FR-13). - Tests for reply, mailops SMTP/identity/thread check, store drafts and migration v5, and the server draft/edit/send guards. - docs/phase5-replies.md, README, .env.example, docker-compose updated. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -14,9 +14,12 @@ import (
|
||||
|
||||
// Message is one row of the messages table.
|
||||
type Message struct {
|
||||
ID int64
|
||||
GmailID string
|
||||
ThreadID string
|
||||
ID int64
|
||||
GmailID string
|
||||
ThreadID string
|
||||
// MessageIDHeader is the RFC822 Message-ID header (Phase 5, FR-8). A reply
|
||||
// uses it for In-Reply-To and References so Gmail keeps the thread.
|
||||
MessageIDHeader string
|
||||
FromAddr string
|
||||
ToAddr string
|
||||
Subject string
|
||||
@@ -67,11 +70,12 @@ func ValidFolder(folder string) bool {
|
||||
func (s *Store) UpsertMessage(ctx context.Context, m *Message) (int64, error) {
|
||||
const q = `
|
||||
INSERT INTO messages (
|
||||
gmail_id, thread_id, from_addr, to_addr, subject, body_text,
|
||||
gmail_id, thread_id, message_id_header, from_addr, to_addr, subject, body_text,
|
||||
source_address, date_sent, received_at, imap_uid, imap_uidvalidity
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (gmail_id) DO UPDATE SET
|
||||
thread_id = excluded.thread_id,
|
||||
message_id_header = excluded.message_id_header,
|
||||
from_addr = excluded.from_addr,
|
||||
to_addr = excluded.to_addr,
|
||||
subject = excluded.subject,
|
||||
@@ -88,6 +92,7 @@ ON CONFLICT (gmail_id) DO UPDATE SET
|
||||
_, err := s.db.ExecContext(ctx, q,
|
||||
m.GmailID,
|
||||
nullString(m.ThreadID),
|
||||
nullString(m.MessageIDHeader),
|
||||
nullString(m.FromAddr),
|
||||
nullString(m.ToAddr),
|
||||
nullString(m.Subject),
|
||||
@@ -110,6 +115,35 @@ ON CONFLICT (gmail_id) DO UPDATE SET
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// MessageByID loads one message including the reply fields (body, source
|
||||
// address, and Message-ID header). It is the read path for a reply draft.
|
||||
func (s *Store) MessageByID(ctx context.Context, id int64) (Message, error) {
|
||||
const q = `
|
||||
SELECT m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''),
|
||||
COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''),
|
||||
COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at,
|
||||
COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0),
|
||||
COALESCE(m.folder, 'inbox'), COALESCE(m.mailbox, 'INBOX'),
|
||||
COALESCE(m.message_id_header, '')
|
||||
FROM messages m WHERE m.id = ?`
|
||||
var (
|
||||
m Message
|
||||
dateSent string
|
||||
received string
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
||||
&m.ID, &m.GmailID, &m.ThreadID, &m.FromAddr, &m.ToAddr, &m.Subject,
|
||||
&m.BodyText, &m.SourceAddress, &dateSent, &received,
|
||||
&m.IMAPUID, &m.IMAPUIDValidity, &m.Folder, &m.Mailbox, &m.MessageIDHeader,
|
||||
)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("load message %d: %w", id, err)
|
||||
}
|
||||
m.DateSent = parseSQLTime(dateSent)
|
||||
m.ReceivedAt = parseSQLTime(received)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ListMessages returns the most recent messages, newest first, joined to their
|
||||
// latest classification when one exists.
|
||||
func (s *Store) ListMessages(ctx context.Context, limit int) ([]MessageListItem, error) {
|
||||
|
||||
Reference in New Issue
Block a user