Add queued email notifications for collaboration events
This commit is contained in:
141
apps/server/internal/email/postmark.go
Normal file
141
apps/server/internal/email/postmark.go
Normal file
@@ -0,0 +1,141 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user