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:
117
internal/store/drafts.go
Normal file
117
internal/store/drafts.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user