Go · 1728 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package email
4
5 import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "fmt"
10 "net/http"
11 "time"
12 )
13
14 // PostmarkSender posts messages to the Postmark transactional API. The
15 // integration is intentionally thin: one HTTP POST per message, no
16 // templates, no batching. Postmark's templating story stays out-of-band.
17 type PostmarkSender struct {
18 ServerToken string // X-Postmark-Server-Token
19 From string // verified sender address
20 HTTP *http.Client
21 }
22
23 // postmarkAPI is the canonical endpoint. Hard-coded — Postmark is a SaaS,
24 // not something operators self-host.
25 const postmarkAPI = "https://api.postmarkapp.com/email"
26
27 // Send implements Sender.
28 func (p *PostmarkSender) Send(ctx context.Context, m Message) error {
29 if m.From == "" {
30 m.From = p.From
31 }
32 payload := map[string]string{
33 "From": m.From,
34 "To": m.To,
35 "Subject": m.Subject,
36 "HtmlBody": m.HTML,
37 "TextBody": m.Text,
38 }
39 body, err := json.Marshal(payload)
40 if err != nil {
41 return fmt.Errorf("postmark: marshal: %w", err)
42 }
43 req, err := http.NewRequestWithContext(ctx, http.MethodPost, postmarkAPI, bytes.NewReader(body))
44 if err != nil {
45 return fmt.Errorf("postmark: request: %w", err)
46 }
47 req.Header.Set("Accept", "application/json")
48 req.Header.Set("Content-Type", "application/json")
49 req.Header.Set("X-Postmark-Server-Token", p.ServerToken)
50
51 client := p.HTTP
52 if client == nil {
53 client = &http.Client{Timeout: 10 * time.Second}
54 }
55 resp, err := client.Do(req)
56 if err != nil {
57 return fmt.Errorf("postmark: send: %w", err)
58 }
59 defer func() { _ = resp.Body.Close() }()
60
61 if resp.StatusCode/100 != 2 {
62 return fmt.Errorf("postmark: status %d", resp.StatusCode)
63 }
64 return nil
65 }
66