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>
118 lines
3.5 KiB
Go
118 lines
3.5 KiB
Go
// 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
|
|
}
|