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:
Paperclip Builder
2026-10-11 22:05:35 +11:00
parent ae53374ad1
commit 7ca888f0c6
31 changed files with 2556 additions and 42 deletions

117
internal/store/drafts.go Normal file
View File

@@ -0,0 +1,117 @@
// Reply-draft persistence for Phase 5 (FR-8).
//
// A draft belongs to one message. It is rewritten in place while Sam edits it
// and marked `sent` after a successful SMTP send. Only the newest draft per
// message is shown in the UI; history is kept in the actions_log (FR-13).
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Draft statuses. A draft moves draft -> sent once, on a successful send.
const (
DraftStatusDraft = "draft"
DraftStatusSent = "sent"
)
// Draft is one reply draft row.
type Draft struct {
ID int64
MessageID int64
Body string
TemplateID string
Status string
SentAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// SaveDraft inserts a new draft for a message and returns it.
func (s *Store) SaveDraft(ctx context.Context, messageID int64, body, templateID string) (Draft, error) {
const q = `INSERT INTO drafts (message_id, body, template_id, status)
VALUES (?, ?, ?, 'draft')`
res, err := s.db.ExecContext(ctx, q, messageID, body, nullString(templateID))
if err != nil {
return Draft{}, fmt.Errorf("save draft for message %d: %w", messageID, err)
}
id, err := res.LastInsertId()
if err != nil {
return Draft{}, fmt.Errorf("read draft id: %w", err)
}
return s.DraftByID(ctx, id)
}
// DraftByID loads one draft.
func (s *Store) DraftByID(ctx context.Context, id int64) (Draft, error) {
const q = `
SELECT id, message_id, body, COALESCE(template_id, ''), status,
COALESCE(sent_at, ''), created_at, updated_at
FROM drafts WHERE id = ?`
return scanDraft(s.db.QueryRowContext(ctx, q, id))
}
// LatestDraft returns the newest draft for a message. ok is false when the
// message has no draft yet.
func (s *Store) LatestDraft(ctx context.Context, messageID int64) (Draft, bool, error) {
const q = `
SELECT id, message_id, body, COALESCE(template_id, ''), status,
COALESCE(sent_at, ''), created_at, updated_at
FROM drafts WHERE message_id = ? ORDER BY id DESC LIMIT 1`
d, err := scanDraft(s.db.QueryRowContext(ctx, q, messageID))
if errors.Is(err, sql.ErrNoRows) {
return Draft{}, false, nil
}
if err != nil {
return Draft{}, false, err
}
return d, true, nil
}
// UpdateDraftBody replaces the body of a draft Sam edited and bumps updated_at.
func (s *Store) UpdateDraftBody(ctx context.Context, id int64, body string) error {
const q = `UPDATE drafts SET body = ?, updated_at = datetime('now') WHERE id = ?`
res, err := s.db.ExecContext(ctx, q, body, id)
if err != nil {
return fmt.Errorf("update draft %d: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("update draft %d: not found", id)
}
return nil
}
// MarkDraftSent records a successful send.
func (s *Store) MarkDraftSent(ctx context.Context, id int64) error {
const q = `UPDATE drafts SET status = 'sent', sent_at = datetime('now'),
updated_at = datetime('now') WHERE id = ?`
res, err := s.db.ExecContext(ctx, q, id)
if err != nil {
return fmt.Errorf("mark draft %d sent: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("mark draft %d sent: not found", id)
}
return nil
}
func scanDraft(row *sql.Row) (Draft, error) {
var (
d Draft
sentAt string
created string
updated string
)
if err := row.Scan(&d.ID, &d.MessageID, &d.Body, &d.TemplateID, &d.Status,
&sentAt, &created, &updated); err != nil {
return Draft{}, err
}
d.SentAt = parseSQLTime(sentAt)
d.CreatedAt = parseSQLTime(created)
d.UpdatedAt = parseSQLTime(updated)
return d, nil
}