tenseleyflow/shithub / cc90ea1

Browse files

fix(email): strip display name from SMTP envelope so MAIL FROM is RFC-5321 valid

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
cc90ea167edaabd2d63d433a786a82b7f1af9ada
Parents
a59d3fc
Tree
edefc54

3 changed files

StatusFile+-
M internal/auth/email/sender.go 19 1
M internal/auth/email/sender_test.go 16 0
M internal/issues/issues.go 7 16
internal/auth/email/sender.gomodified
@@ -73,6 +73,12 @@ type SMTPSender struct {
7373
 
7474
 // Send implements Sender. Builds a multipart/alternative body so MUAs can
7575
 // pick the variant they prefer.
76
+//
77
+// The SMTP envelope sender is the bare address — `MAIL FROM:<...>` per
78
+// RFC 5321 forbids nested angle brackets, so a configured `From` like
79
+// `shithub <noreply@shithub.local>` only goes in the message header.
80
+// The displayed-name form lives unchanged in the `From:` header inside
81
+// the body.
7682
 func (s *SMTPSender) Send(_ context.Context, m Message) error {
7783
 	if m.From == "" {
7884
 		m.From = s.From
@@ -87,7 +93,19 @@ func (s *SMTPSender) Send(_ context.Context, m Message) error {
8793
 		}
8894
 		auth = smtp.PlainAuth("", s.Username, s.Password, host)
8995
 	}
90
-	return smtp.SendMail(s.Addr, auth, m.From, []string{m.To}, body)
96
+	return smtp.SendMail(s.Addr, auth, envelopeAddress(m.From), []string{m.To}, body)
97
+}
98
+
99
+// envelopeAddress extracts the bare email address from a possibly-
100
+// displayed-name form like `Name <addr@example>`. Returns the input
101
+// unchanged when no angle brackets are present (already bare).
102
+func envelopeAddress(from string) string {
103
+	if i := strings.IndexByte(from, '<'); i >= 0 {
104
+		if j := strings.IndexByte(from[i+1:], '>'); j >= 0 {
105
+			return from[i+1 : i+1+j]
106
+		}
107
+	}
108
+	return from
91109
 }
92110
 
93111
 // boundary is a fixed multipart boundary. Per RFC 2046 the boundary need
internal/auth/email/sender_test.gomodified
@@ -9,6 +9,22 @@ import (
99
 	"testing"
1010
 )
1111
 
12
+func TestEnvelopeAddress_StripsDisplayName(t *testing.T) {
13
+	t.Parallel()
14
+	cases := []struct{ in, want string }{
15
+		{"shithub <noreply@shithub.local>", "noreply@shithub.local"},
16
+		{"<bare@example.com>", "bare@example.com"},
17
+		{"plain@example.com", "plain@example.com"},
18
+		{"Name With Spaces <a@b>", "a@b"},
19
+		{"malformed <still works", "malformed <still works"},
20
+	}
21
+	for _, c := range cases {
22
+		if got := envelopeAddress(c.in); got != c.want {
23
+			t.Errorf("envelopeAddress(%q): want %q, got %q", c.in, c.want, got)
24
+		}
25
+	}
26
+}
27
+
1228
 func TestStdoutSender_WritesBothBodies(t *testing.T) {
1329
 	t.Parallel()
1430
 	var buf bytes.Buffer
internal/issues/issues.gomodified
@@ -370,22 +370,13 @@ func SetLock(ctx context.Context, deps Deps, actorUserID, issueID int64, locked
370370
 	return nil
371371
 }
372372
 
373
-// renderBodyHTML wraps markdown.RenderHTML with a logger-aware error
374
-// path. Body length is bounded upstream (orchestrator validation +
375
-// DB CHECK at 65535), so ErrInputTooLarge is structurally impossible
376
-// here — but if it ever fires, log loudly: it means a precondition
377
-// somewhere upstream regressed. The audit (S00-S25, M) flagged the
378
-// `_`-discard pattern as the kind of slop where a real bug could hide.
379
-func renderBodyHTML(ctx context.Context, deps Deps, body string) string {
380
-	html, _ := renderBody(ctx, deps, body)
381
-	return html
382
-}
383
-
384
-// renderBody is the mention-aware variant. Returns the cleaned HTML
385
-// plus the resolved mentions list — callers that emit notification
386
-// events use the mentions to fan out @-pings. The `_` shimming under
387
-// renderBodyHTML keeps existing call sites that don't care about
388
-// mentions untouched.
373
+// renderBody renders markdown to sanitized HTML and returns the
374
+// resolved mention list. Body length is bounded upstream
375
+// (orchestrator validation + DB CHECK at 65535), so
376
+// ErrInputTooLarge is structurally impossible here — but if it ever
377
+// fires, log loudly: it means a precondition somewhere upstream
378
+// regressed. Mentions feed the S29 fan-out worker via the event
379
+// payload's `mentions` array.
389380
 func renderBody(ctx context.Context, deps Deps, body string) (string, []mdrender.Mention) {
390381
 	if body == "" {
391382
 		return "", nil