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>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user