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:
184
internal/reply/client.go
Normal file
184
internal/reply/client.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user