@@ -76,8 +76,32 @@ const (
7676type BrevoConfig struct {
7777 APIKey string
7878 TemplateIDs map [string ]int
79+
80+ // SenderEmail / SenderName are the "From:" identity used by the
81+ // raw-render path (EventEmail.HTMLBody non-empty). The dashboard-
82+ // template path ignores these because Brevo's template editor has
83+ // its own sender field. Both come from env at boot:
84+ // BREVO_SENDER_EMAIL — defaults to noreply@instanode.dev
85+ // BREVO_SENDER_NAME — defaults to "instanode"
86+ //
87+ // They live in code-controlled config (not the Brevo dashboard) so a
88+ // rendered email cannot silently inherit a personal email left in
89+ // the dashboard sender field. The defaults are intentionally
90+ // production-safe — a worker that boots with the secret absent
91+ // still sends from noreply@instanode.dev, not someone's gmail.
92+ SenderEmail string
93+ SenderName string
7994}
8095
96+ // Default sender identity used when BREVO_SENDER_EMAIL / BREVO_SENDER_NAME
97+ // are absent from BrevoConfig. Kept here (not in config.Load) so a test
98+ // or in-process caller that constructs BrevoConfig directly gets the
99+ // same safe defaults the production code path uses.
100+ const (
101+ defaultBrevoSenderEmail = "noreply@instanode.dev"
102+ defaultBrevoSenderName = "instanode"
103+ )
104+
81105// BrevoProvider is the live implementation. Constructed once at boot via
82106// NewBrevoProvider and reused across every forwarder tick. http.Client
83107// is goroutine-safe; templates is read-only after construction.
@@ -88,6 +112,13 @@ type BrevoProvider struct {
88112 // url is overridable so tests can point at an httptest.Server. Always
89113 // brevoSendURL in production.
90114 url string
115+
116+ // senderEmail / senderName are the From identity for the raw-render
117+ // path (EventEmail.HTMLBody non-empty). The template path leaves
118+ // these unused — Brevo's template carries its own sender. Both are
119+ // populated by NewBrevoProvider from BrevoConfig (with defaults).
120+ senderEmail string
121+ senderName string
91122}
92123
93124// NewBrevoProvider validates BrevoConfig and returns the live provider.
@@ -106,11 +137,21 @@ func NewBrevoProvider(cfg BrevoConfig) (*BrevoProvider, error) {
106137 if tmpls == nil {
107138 tmpls = map [string ]int {}
108139 }
140+ senderEmail := cfg .SenderEmail
141+ if senderEmail == "" {
142+ senderEmail = defaultBrevoSenderEmail
143+ }
144+ senderName := cfg .SenderName
145+ if senderName == "" {
146+ senderName = defaultBrevoSenderName
147+ }
109148 return & BrevoProvider {
110- apiKey : cfg .APIKey ,
111- templates : tmpls ,
112- httpc : & http.Client {Timeout : brevoHTTPTimeout },
113- url : brevoSendURL ,
149+ apiKey : cfg .APIKey ,
150+ templates : tmpls ,
151+ httpc : & http.Client {Timeout : brevoHTTPTimeout },
152+ url : brevoSendURL ,
153+ senderEmail : senderEmail ,
154+ senderName : senderName ,
114155 }, nil
115156}
116157
@@ -134,10 +175,62 @@ type brevoSendRequest struct {
134175 Params map [string ]string `json:"params,omitempty"`
135176}
136177
137- // SendEvent implements EmailProvider.SendEvent. Maps EventEmail.Kind to a
138- // Brevo templateId via p.templates, builds the JSON body, POSTs, and
139- // classifies the response per the table at the top of this file.
178+ // brevoSender mirrors the Brevo `sender` object used by the raw-HTML
179+ // path. Brevo accepts {"email": "...", "name": "..."}; the template path
180+ // doesn't send this object because the template carries its own sender.
181+ type brevoSender struct {
182+ Email string `json:"email"`
183+ Name string `json:"name,omitempty"`
184+ }
185+
186+ // brevoRawSendRequest is the wire payload for the raw-HTML path. Used
187+ // when EventEmail.HTMLBody is non-empty — we send subject + htmlContent +
188+ // textContent + sender directly and DO NOT include templateId. This
189+ // bypasses the Brevo dashboard template entirely so the email body is
190+ // fully controlled by Go code at deploy time.
191+ type brevoRawSendRequest struct {
192+ To []brevoRecipient `json:"to"`
193+ Sender brevoSender `json:"sender"`
194+ Subject string `json:"subject"`
195+ HTMLContent string `json:"htmlContent"`
196+ TextContent string `json:"textContent,omitempty"`
197+ }
198+
199+ // SendEvent implements EmailProvider.SendEvent. Two paths:
200+ //
201+ // 1. Raw-render path (preferred for new kinds) — when EventEmail.HTMLBody
202+ // is non-empty, we send Subject + HTMLBody + TextBody + Sender directly.
203+ // The template id is NOT consulted; p.templates can lack an entry for
204+ // this Kind without producing SkippedNoTemplate. This is the path used
205+ // for "anon.expiry_warning" so the email body is controlled entirely
206+ // by worker code (no out-of-band Brevo dashboard edit required).
207+ //
208+ // 2. Template path (legacy / dashboard-controlled) — when HTMLBody is
209+ // empty, look up EventEmail.Kind in p.templates and POST with
210+ // templateId + params. Kinds with no entry produce SkippedNoTemplate.
211+ //
212+ // Both paths classify the HTTP response identically per the table at the
213+ // top of this file.
140214func (p * BrevoProvider ) SendEvent (ctx context.Context , evt EventEmail ) error {
215+ if evt .Recipient == "" {
216+ // Defensive — the forwarder filters orphan rows before reaching
217+ // here, but a future caller path might not. Permanent because
218+ // the row will never sprout an email retroactively. Checked
219+ // before the template lookup so a raw-render event with empty
220+ // recipient short-circuits the same way.
221+ return & SendError {
222+ Class : SendClassPermanent ,
223+ Message : "brevo: empty recipient" ,
224+ }
225+ }
226+
227+ // Raw-render path takes precedence — explicit HTML body means the
228+ // caller already rendered the email and wants us to send those bytes
229+ // verbatim. We do NOT consult p.templates in this branch.
230+ if evt .HTMLBody != "" {
231+ return p .sendRaw (ctx , evt )
232+ }
233+
141234 tmplID , ok := p .templates [evt .Kind ]
142235 if ! ok {
143236 // Operator hasn't mapped this kind to a Brevo template yet —
@@ -149,16 +242,6 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
149242 }
150243 }
151244
152- if evt .Recipient == "" {
153- // Defensive — the forwarder filters orphan rows before reaching
154- // here, but a future caller path might not. Permanent because
155- // the row will never sprout an email retroactively.
156- return & SendError {
157- Class : SendClassPermanent ,
158- Message : "brevo: empty recipient" ,
159- }
160- }
161-
162245 body , err := json .Marshal (brevoSendRequest {
163246 To : []brevoRecipient {{Email : evt .Recipient , Name : evt .RecipientName }},
164247 TemplateID : tmplID ,
@@ -193,12 +276,88 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
193276 req .Header .Set (headerIdempotency , evt .IdempotencyKey )
194277 }
195278
279+ return p .doRequest (ctx , evt , body , brevoSendPathTemplate , tmplID )
280+ }
281+
282+ // brevoSendPath enumerates which SendEvent branch we took, used only for
283+ // log labels so an operator can tell "we sent via template id 6" from
284+ // "we sent the rendered HTML" at a glance.
285+ type brevoSendPath string
286+
287+ const (
288+ brevoSendPathTemplate brevoSendPath = "template"
289+ brevoSendPathRaw brevoSendPath = "raw_html"
290+ )
291+
292+ // sendRaw is the raw-HTML send path. The caller (typically a per-kind
293+ // builder) has already rendered the subject + html + plain-text body in
294+ // Go; we POST them verbatim with the configured sender identity. The
295+ // dashboard-template path is bypassed entirely — Brevo just relays the
296+ // bytes. This is how anon.expiry_warning escapes the broken dashboard
297+ // template that hardcoded "6 hours" and rendered empty fields.
298+ func (p * BrevoProvider ) sendRaw (ctx context.Context , evt EventEmail ) error {
299+ if evt .Subject == "" {
300+ // Subject is mandatory in the raw path — a Brevo POST with an
301+ // empty subject string still delivers, but the recipient sees
302+ // "(no subject)" which is its own bug. Permanent so we advance
303+ // past the row; the caller is supposed to render a subject.
304+ slog .Error ("email.brevo.raw_missing_subject" ,
305+ "kind" , evt .Kind ,
306+ "recipient" , evt .Recipient ,
307+ )
308+ return & SendError {
309+ Class : SendClassPermanent ,
310+ Message : "brevo: raw send missing subject" ,
311+ }
312+ }
313+ body , err := json .Marshal (brevoRawSendRequest {
314+ To : []brevoRecipient {{Email : evt .Recipient , Name : evt .RecipientName }},
315+ Sender : brevoSender {Email : p .senderEmail , Name : p .senderName },
316+ Subject : evt .Subject ,
317+ HTMLContent : evt .HTMLBody ,
318+ TextContent : evt .TextBody ,
319+ })
320+ if err != nil {
321+ slog .Error ("email.brevo.raw_marshal_failed" ,
322+ "kind" , evt .Kind ,
323+ "recipient" , evt .Recipient ,
324+ "error" , err ,
325+ )
326+ return & SendError {Class : SendClassPermanent , Cause : err , Message : "brevo: raw marshal" }
327+ }
328+ return p .doRequest (ctx , evt , body , brevoSendPathRaw , 0 )
329+ }
330+
331+ // doRequest is the shared HTTP send + response classify path used by
332+ // both template and raw branches. Identical wire-level behavior — the
333+ // only difference is the log label and the absence of a template id in
334+ // the raw path.
335+ func (p * BrevoProvider ) doRequest (ctx context.Context , evt EventEmail , body []byte , path brevoSendPath , tmplID int ) error {
336+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , p .url , bytes .NewReader (body ))
337+ if err != nil {
338+ // Request construction failure is almost certainly a malformed URL —
339+ // a programming bug. Transient so the operator sees it on every tick
340+ // until they fix it, instead of advancing past silently.
341+ slog .Error ("email.brevo.request_build_failed" ,
342+ "kind" , evt .Kind ,
343+ "path" , string (path ),
344+ "error" , err ,
345+ )
346+ return & SendError {Class : SendClassTransient , Cause : err , Message : "brevo: build request" }
347+ }
348+ req .Header .Set (headerAPIKey , p .apiKey )
349+ req .Header .Set (headerContentType , contentTypeJSON )
350+ if evt .IdempotencyKey != "" {
351+ req .Header .Set (headerIdempotency , evt .IdempotencyKey )
352+ }
353+
196354 resp , err := p .httpc .Do (req )
197355 if err != nil {
198356 // Network error, timeout, dns failure. Transient by definition.
199357 slog .Warn ("email.brevo.http_failed" ,
200358 "kind" , evt .Kind ,
201359 "recipient" , evt .Recipient ,
360+ "path" , string (path ),
202361 "error" , err ,
203362 )
204363 return & SendError {Class : SendClassTransient , Cause : err , Message : "brevo: http" }
@@ -212,6 +371,7 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
212371 "kind" , evt .Kind ,
213372 "recipient" , evt .Recipient ,
214373 "status" , resp .StatusCode ,
374+ "path" , string (path ),
215375 "template_id" , tmplID ,
216376 )
217377 return nil
@@ -225,6 +385,7 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
225385 "kind" , evt .Kind ,
226386 "recipient" , evt .Recipient ,
227387 "status" , resp .StatusCode ,
388+ "path" , string (path ),
228389 "body" , string (respBody ),
229390 )
230391 return & SendError {
@@ -238,6 +399,7 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
238399 "kind" , evt .Kind ,
239400 "recipient" , evt .Recipient ,
240401 "status" , resp .StatusCode ,
402+ "path" , string (path ),
241403 "body" , string (respBody ),
242404 )
243405 return & SendError {
0 commit comments