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 }