0e20eb19c1
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>
79 lines
2.9 KiB
Go
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
|
|
}
|