Files
email_agent/internal/reply/client.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

185 lines
5.4 KiB
Go

// Package reply drafts short replies with an LLM (FR-8).
//
// The model is reached through OmniRoute, the house AI gateway (AGENTS.md §8,
// §10): every non-JEV model goes through it. OmniRoute exposes an
// OpenAI-compatible /chat/completions endpoint, so this is a tiny client rather
// than a vendor SDK.
package reply
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand"
"net/http"
"strings"
"time"
)
// DefaultBaseURL is the OmniRoute API on the host that runs this service (.13).
// It matches the URL the other house apps use (dsh, worldmonitor). The
// OMNIROUTE_BASE_URL environment variable overrides it.
const DefaultBaseURL = "http://192.168.20.13:20129/v1"
// DefaultModel is the OmniRoute router alias for a good general chat model.
const DefaultModel = "auto/best-chat"
// ErrNotConfigured means no OmniRoute credentials are available, so no draft can
// be generated.
var ErrNotConfigured = errors.New("OmniRoute is not configured: set OMNIROUTE_API_KEY")
// Client is a minimal OpenAI-compatible chat-completions client.
type Client struct {
baseURL string
apiKey string
model string
session string
http *http.Client
logger *slog.Logger
}
// NewClient builds a Client. Empty baseURL/model fall back to the defaults.
func NewClient(baseURL, apiKey, model string, logger *slog.Logger) *Client {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
baseURL = DefaultBaseURL
}
if strings.TrimSpace(model) == "" {
model = DefaultModel
}
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
return &Client{
baseURL: baseURL,
apiKey: strings.TrimSpace(apiKey),
model: model,
session: fmt.Sprintf("email-agent-%d-%d", time.Now().Unix(), rand.Int63()),
http: &http.Client{Timeout: 90 * time.Second},
logger: logger,
}
}
// Configured reports whether an API key is present.
func (c *Client) Configured() bool { return c.apiKey != "" }
// Model returns the configured model alias.
func (c *Client) Model() string { return c.model }
// BaseURL returns the configured gateway URL (used in diagnostics only).
func (c *Client) BaseURL() string { return c.baseURL }
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
Temperature float64 `json:"temperature"`
}
type chatResponse struct {
Model string `json:"model"`
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
// Complete sends a system and user prompt and returns the assistant text.
func (c *Client) Complete(ctx context.Context, system, user string) (string, error) {
if !c.Configured() {
return "", ErrNotConfigured
}
payload, err := json.Marshal(chatRequest{
Model: c.model,
Messages: []chatMessage{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
Stream: false,
Temperature: 0.3,
})
if err != nil {
return "", fmt.Errorf("encode OmniRoute request: %w", err)
}
const maxAttempts = 3
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
text, retryable, err := c.do(ctx, payload)
if err == nil {
return text, nil
}
lastErr = err
if !retryable || attempt == maxAttempts {
break
}
delay := time.Duration(1<<(attempt-1)) * time.Second
c.logger.Warn("omniroute request failed; retrying",
"attempt", attempt, "delay_ms", delay.Milliseconds(), "error", err)
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(delay):
}
}
return "", lastErr
}
func (c *Client) do(ctx context.Context, payload []byte) (string, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return "", false, fmt.Errorf("build OmniRoute request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
// Some OmniRoute upstreams (opencode-go) require a session id.
req.Header.Set("x-opencode-session", c.session)
resp, err := c.http.Do(req)
if err != nil {
return "", true, fmt.Errorf("call OmniRoute: %w", err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", true, fmt.Errorf("read OmniRoute response: %w", err)
}
if resp.StatusCode != http.StatusOK {
retryable := resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode >= 500
return "", retryable, fmt.Errorf("OmniRoute returned %d: %s",
resp.StatusCode, strings.TrimSpace(string(raw)))
}
var out chatResponse
if err := json.Unmarshal(raw, &out); err != nil {
return "", false, fmt.Errorf("decode OmniRoute response: %w", err)
}
if out.Error != nil && out.Error.Message != "" {
return "", false, fmt.Errorf("OmniRoute error: %s", out.Error.Message)
}
if len(out.Choices) == 0 {
return "", false, errors.New("OmniRoute response contained no choices")
}
text := strings.TrimSpace(out.Choices[0].Message.Content)
if text == "" {
return "", false, errors.New("OmniRoute returned an empty reply")
}
return text, false, nil
}