Files
email_agent/internal/reply/templates.go
Paperclip Builder 7ca888f0c6 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>
2026-10-11 22:05:35 +11:00

72 lines
2.4 KiB
Go

// Reply templates for Phase 5 (FR-8).
//
// A template is a short instruction that shapes the LLM draft. It is not the
// final text: the model fills it in from the message, the classification, and
// any extra note Sam types. Templates are built in for v1; a template editor is
// not in the Phase 5 requirements.
package reply
import "strings"
// Template is one reply shape.
type Template struct {
ID string
Name string
Description string
// Instruction is the template-specific part of the prompt.
Instruction string
}
// Templates is the built-in template list, in menu order.
var Templates = []Template{
{
ID: "quick-ack",
Name: "Quick acknowledgement",
Description: "Confirm receipt in one or two short sentences.",
Instruction: "Acknowledge that the message was received. If the sender asked for something, say when Sam will look at it. Do not promise anything Sam has not agreed to.",
},
{
ID: "answer",
Name: "Answer the question",
Description: "Answer the sender's question directly.",
Instruction: "Answer the sender's question directly and briefly. If the message does not contain a clear question, summarise what Sam understood and ask one clarifying question.",
},
{
ID: "confirm",
Name: "Confirm an arrangement",
Description: "Confirm a booking, time, or arrangement.",
Instruction: "Confirm the arrangement in the message: restate the key detail (date, time, or reference) and say it works for Sam.",
},
{
ID: "decline",
Name: "Politely decline",
Description: "Decline or say no thanks, politely.",
Instruction: "Politely decline the request or offer. Thank the sender and keep it warm but brief. Do not over-explain.",
},
}
// TemplateByID returns the template with the given id. ok is false when it is
// unknown. The id match is case-insensitive.
func TemplateByID(id string) (Template, bool) {
id = strings.ToLower(strings.TrimSpace(id))
for _, t := range Templates {
if t.ID == id {
return t, true
}
}
return Template{}, false
}
// TemplateForAction picks a sensible default template from the classifier's
// recommended action. An unknown action falls back to a quick acknowledgement.
func TemplateForAction(action string) Template {
switch strings.ToLower(strings.TrimSpace(action)) {
case "reply":
t, _ := TemplateByID("answer")
return t
default:
t, _ := TemplateByID("quick-ack")
return t
}
}