Files
email_agent/internal/mailfetch/message.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

254 lines
6.4 KiB
Go

package mailfetch
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"regexp"
"strings"
"time"
"unicode/utf8"
)
// maxBodyBytes caps how much body text is read from one message.
const maxBodyBytes = 1 << 20 // 1 MiB
// ParsedMessage is the subset of an RFC822 message the agent stores.
type ParsedMessage struct {
From string
To string
Subject string
Date time.Time
// MessageID is the RFC822 Message-ID header, used for reply threading
// (Phase 5, FR-8).
MessageID string
SourceAddress string
// SourceHeader records which header the source address came from, so FR-2
// decisions are auditable.
SourceHeader string
BodyText string
}
// ParseMessage parses a raw RFC822 message.
func ParseMessage(raw []byte) (*ParsedMessage, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("parse RFC822 message: %w", err)
}
h := msg.Header
pm := &ParsedMessage{
From: decodeHeader(h.Get("From")),
To: decodeHeader(h.Get("To")),
Subject: decodeHeader(h.Get("Subject")),
Date: parseDate(h.Get("Date")),
MessageID: strings.TrimSpace(h.Get("Message-Id")),
}
pm.SourceAddress, pm.SourceHeader = SourceAddress(textproto.MIMEHeader(h))
pm.BodyText = extractBody(textproto.MIMEHeader(h), msg.Body)
return pm, nil
}
// SourceAddress resolves the address the mail arrived at (FR-2).
//
// Order:
// 1. X-Pm-Original-To (added by PurelyMail)
// 2. To
// 3. the PurelyMail "Received ... for <address>" clause
// 4. Return-Path
//
// Delivered-To is deliberately never used: Gmail overwrites it.
func SourceAddress(h textproto.MIMEHeader) (address, header string) {
if v := headerAddress(h.Get("X-Pm-Original-To")); v != "" {
return v, "X-Pm-Original-To"
}
if v := headerAddress(h.Get("To")); v != "" {
return v, "To"
}
for _, rv := range h.Values("Received") {
if v := receivedFor(rv); v != "" {
return v, "Received"
}
}
if v := headerAddress(h.Get("Return-Path")); v != "" {
return v, "Return-Path"
}
return "", ""
}
// headerAddress returns the first address in a header value.
func headerAddress(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if list, err := mail.ParseAddressList(v); err == nil && len(list) > 0 {
return strings.TrimSpace(list[0].Address)
}
if a, err := mail.ParseAddress(v); err == nil {
return strings.TrimSpace(a.Address)
}
v = strings.TrimSpace(strings.TrimPrefix(v, "<"))
if i := strings.IndexAny(v, ">,;"); i >= 0 {
v = v[:i]
}
return strings.Trim(strings.TrimSpace(v), "<>")
}
var receivedForRE = regexp.MustCompile(`(?i)\bfor\s+<?([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+[A-Za-z0-9])>?`)
// receivedFor extracts the address from a "Received: ... for <addr>" clause.
func receivedFor(v string) string {
m := receivedForRE.FindStringSubmatch(v)
if m == nil {
return ""
}
return strings.Trim(m[1], "<>")
}
func decodeHeader(v string) string {
if v == "" {
return ""
}
dec := mime.WordDecoder{}
if s, err := dec.DecodeHeader(v); err == nil {
return s
}
return v
}
func parseDate(v string) time.Time {
if t, err := mail.ParseDate(v); err == nil {
return t.UTC()
}
for _, layout := range []string{time.RFC1123Z, time.RFC1123, "Mon, 2 Jan 2006 15:04:05 -0700"} {
if t, err := time.Parse(layout, v); err == nil {
return t.UTC()
}
}
return time.Time{}
}
// --- body decoding ----------------------------------------------------------
func extractBody(h textproto.MIMEHeader, body io.Reader) string {
mediaType, params := parseMediaType(h.Get("Content-Type"))
decoded := decodeTransfer(h.Get("Content-Transfer-Encoding"), body)
switch {
case mediaType == "" || mediaType == "text/plain":
return toUTF8(readAll(decoded))
case mediaType == "text/html":
return htmlToText(toUTF8(readAll(decoded)))
case strings.HasPrefix(mediaType, "multipart/"):
return extractMultipart(params["boundary"], decoded)
default:
// application/*, image/*, … have no text to show.
return ""
}
}
func extractMultipart(boundary string, r io.Reader) string {
if boundary == "" {
return ""
}
mr := multipart.NewReader(r, boundary)
var plain, html []string
for {
part, err := mr.NextPart()
if err != nil {
break
}
mediaType, _ := parseMediaType(part.Header.Get("Content-Type"))
sub := extractBody(textproto.MIMEHeader(part.Header), part)
_ = part.Close()
if sub == "" {
continue
}
if mediaType == "text/html" {
html = append(html, sub)
} else {
plain = append(plain, sub)
}
}
if len(plain) > 0 {
return strings.TrimSpace(strings.Join(plain, "\n"))
}
return strings.TrimSpace(strings.Join(html, "\n"))
}
func parseMediaType(v string) (string, map[string]string) {
if strings.TrimSpace(v) == "" {
return "", nil
}
mt, params, err := mime.ParseMediaType(v)
if err != nil {
head := strings.TrimSpace(strings.SplitN(v, ";", 2)[0])
return strings.ToLower(head), nil
}
return strings.ToLower(mt), params
}
func decodeTransfer(encoding string, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
return quotedprintable.NewReader(r)
default:
return r
}
}
func readAll(r io.Reader) []byte {
b, err := io.ReadAll(io.LimitReader(r, maxBodyBytes))
if err != nil {
return b
}
return b
}
// toUTF8 returns valid UTF-8 text, falling back to a Latin-1 interpretation of
// bytes that are not valid UTF-8 (no charset table is available without adding a
// dependency; forwarded personal mail is overwhelmingly UTF-8 or Latin-1).
func toUTF8(b []byte) string {
if utf8.Valid(b) {
return strings.TrimSpace(string(b))
}
var sb strings.Builder
for _, c := range b {
sb.WriteRune(rune(c))
}
return strings.TrimSpace(sb.String())
}
var (
htmlBlockRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(?:script|style)>`)
htmlTagRE = regexp.MustCompile(`(?s)<[^>]*>`)
htmlSpaceRE = regexp.MustCompile(`[ \t]+`)
htmlNLRE = regexp.MustCompile(`\n{3,}`)
)
func htmlToText(s string) string {
if s == "" {
return ""
}
s = htmlBlockRE.ReplaceAllString(s, " ")
s = htmlTagRE.ReplaceAllString(s, " ")
s = strings.NewReplacer(
"&nbsp;", " ",
"&amp;", "&",
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&#39;", "'",
).Replace(s)
s = htmlSpaceRE.ReplaceAllString(s, " ")
s = htmlNLRE.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}