| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "net/http" |
| 10 | ) |
| 11 | |
| 12 | // emailPayload is what the API puts in delivery_job.payload for kind 'email'. |
| 13 | type emailPayload struct { |
| 14 | To string `json:"to"` |
| 15 | Subject string `json:"subject"` |
| 16 | HTML string `json:"html"` |
| 17 | Text string `json:"text"` |
| 18 | } |
| 19 | |
| 20 | // relaySender makes the request apps/api/src/mail/proxy.transport.ts makes. The relay URL is operator config, not user input, so it skips the SSRF guard. |
| 21 | type relaySender struct { |
| 22 | url, secret, from string |
| 23 | client *http.Client |
| 24 | } |
| 25 | |
| 26 | func (r relaySender) send(ctx context.Context, j Job) error { |
| 27 | if r.url == "" { |
| 28 | return fmt.Errorf("EMAIL_PROXY is not set") |
| 29 | } |
| 30 | var p emailPayload |
| 31 | if err := json.Unmarshal(j.Payload, &p); err != nil { |
| 32 | return fmt.Errorf("decode email payload: %w", err) |
| 33 | } |
| 34 | body, err := json.Marshal(map[string]string{ |
| 35 | "to": p.To, |
| 36 | "from": r.from, |
| 37 | "subject": p.Subject, |
| 38 | "htmlBody": p.HTML, |
| 39 | "textBody": p.Text, |
| 40 | "secret": r.secret, |
| 41 | }) |
| 42 | if err != nil { |
| 43 | return err |
| 44 | } |
| 45 | |
| 46 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.url, bytes.NewReader(body)) |
| 47 | if err != nil { |
| 48 | return err |
| 49 | } |
| 50 | req.Header.Set("Content-Type", "application/json") |
| 51 | res, err := r.client.Do(req) |
| 52 | if err != nil { |
| 53 | return err |
| 54 | } |
| 55 | defer res.Body.Close() |
| 56 | if res.StatusCode < 200 || res.StatusCode > 299 { |
| 57 | msg, _ := io.ReadAll(io.LimitReader(res.Body, 4<<10)) |
| 58 | return fmt.Errorf("email relay responded with %d: %s", res.StatusCode, msg) |
| 59 | } |
| 60 | return nil |
| 61 | } |