package email import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "time" ) const ( defaultPostmarkURL = "https://api.postmarkapp.com" postmarkTimeout = 15 * time.Second ) // PostmarkSender sends email via the Postmark API. type PostmarkSender struct { serverToken string apiURL string from string client *http.Client logger *slog.Logger } // NewPostmarkSender constructs a Postmark sender. apiURL may be empty to use // the public Postmark endpoint; tests can point it at an httptest server. func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *PostmarkSender { if apiURL == "" { apiURL = defaultPostmarkURL } return &PostmarkSender{ serverToken: serverToken, apiURL: strings.TrimRight(apiURL, "/"), from: from, client: &http.Client{Timeout: postmarkTimeout}, logger: logger, } } type postmarkRequest struct { From string `json:"From"` To string `json:"To"` Subject string `json:"Subject"` TextBody string `json:"TextBody"` Headers []postmarkHeader `json:"Headers,omitempty"` } type postmarkHeader struct { Name string `json:"Name"` Value string `json:"Value"` } type postmarkResponse struct { ErrorCode int `json:"ErrorCode"` Message string `json:"Message"` To string `json:"To"` } // WithReplyTo sets a Reply-To header on the outgoing message. Returns a copy // of the sender with the reply-to address applied for the next Send call. // Not safe for concurrent use; callers should construct a fresh sender per // send if they need different reply-to addresses. func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error { if p.serverToken == "" { return fmt.Errorf("postmark server token not configured") } if p.from == "" { return fmt.Errorf("postmark from address not configured") } if len(to) == 0 { return fmt.Errorf("postmark send requires at least one recipient") } payload := postmarkRequest{ From: p.from, To: strings.Join(to, ","), Subject: subject, TextBody: body, } bodyBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal postmark request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiURL+"/email", bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("build postmark request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Postmark-Server-Token", p.serverToken) resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("postmark request: %w", err) } defer resp.Body.Close() respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) if resp.StatusCode >= 500 { return &TemporaryError{Cause: fmt.Errorf("postmark server error: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))} } if resp.StatusCode != http.StatusOK { return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes))) } var parsed postmarkResponse if err := json.Unmarshal(respBytes, &parsed); err != nil { return fmt.Errorf("decode postmark response: %w", err) } if parsed.ErrorCode != 0 { // Postmark distinguishes "inactive recipient" (406) and other errors. Treat // 406 as non-retryable; others bubble up as generic failures. return fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message) } p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject) return nil } func truncate(s string) string { if len(s) > 256 { return s[:256] + "..." } return s } // TemporaryError wraps a failure that callers may retry. The queue worker // uses errors.As to detect it and apply exponential backoff. type TemporaryError struct { Cause error } func (t *TemporaryError) Error() string { return t.Cause.Error() } func (t *TemporaryError) Unwrap() error { return t.Cause }