Files
felhom-controller/controller/internal/mailrelay/forward.go
T
admin 0e20eb19c1 v0.88.0: app-email SMTP relay (in-process shim + per-app injection)
In-process go-smtp shim (Shape 1): apps → shim → hub → Resend, Resend key stays
hub-side. From-header allowlist (reject 5xx pre-hub), single-shot raw-MIME forward,
status→SMTP mapping. Global + per-app toggles gate compose-time env injection from
.felhom.yml smtp_mapping. Hungarian UI on settings + app config pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:45:04 +02:00

79 lines
2.9 KiB
Go

package mailrelay
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Forwarder hands a raw message off to the hub for delivery.
//
// v1 is SINGLE-SHOT (§9 rule 9): exactly one attempt, no retry loop, no durable
// spool. The hub's HTTP status is returned to the caller; the SMTP layer maps that
// status to an SMTP reply so the sending app surfaces the real outcome. Forward has
// no SMTP knowledge — the seam keeps the engine unit-testable with a fake.
type Forwarder interface {
Forward(ctx context.Context, raw []byte, mailFrom string, rcptTo []string) (status int, body string, err error)
}
// MailForwardRequest is the JSON envelope POSTed to the hub POST /api/v1/mail.
// RawMIME is a []byte so encoding/json base64-encodes it for safe transport and the
// hub gets the message back byte-for-byte (raw passthrough — never parsed/re-encoded).
type MailForwardRequest struct {
RawMIME []byte `json:"raw_mime"`
MailFrom string `json:"mail_from"`
RcptTo []string `json:"rcpt_to"`
}
// HubForwarder POSTs to the hub /api/v1/mail with the controller's existing hub
// Bearer key — the same credential the notifier and report pusher already hold, so
// there is no second place the hub credential lives (the Shape-1 decision).
type HubForwarder struct {
hubURL string
apiKey string
client *http.Client
}
// NewHubForwarder builds a forwarder against the hub base URL (e.g. https://hub.felhom.eu).
func NewHubForwarder(hubURL, apiKey string) *HubForwarder {
return &HubForwarder{
hubURL: strings.TrimRight(hubURL, "/"),
apiKey: apiKey,
// Generous timeout: the hub leg dials Resend SMTP synchronously. Still a single attempt.
client: &http.Client{Timeout: 30 * time.Second},
}
}
// Forward POSTs the raw message once and returns the hub's HTTP status + a short body
// excerpt. A transport error (hub unreachable) returns (0, "", err); the SMTP layer
// turns that into a transient 4xx so the app shows the user an error (no hang, no
// silent drop — §7 scenario D).
func (f *HubForwarder) Forward(ctx context.Context, raw []byte, mailFrom string, rcptTo []string) (int, string, error) {
payload := MailForwardRequest{RawMIME: raw, MailFrom: mailFrom, RcptTo: rcptTo}
jsonData, err := json.Marshal(payload)
if err != nil {
return 0, "", fmt.Errorf("marshaling mail forward: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.hubURL+"/api/v1/mail", bytes.NewReader(jsonData))
if err != nil {
return 0, "", fmt.Errorf("building request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+f.apiKey)
req.Header.Set("Content-Type", "application/json")
// SINGLE attempt — no retry loop (a retry would risk a duplicate send; §9 rule 9).
resp, err := f.client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("hub unreachable: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return resp.StatusCode, string(body), nil
}