Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 70 additions & 10 deletions sender/sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ var (
osHostname = os.Hostname
)

const (
recommendedHeaderLineLen = 78
maxReferencesHeaderLineLen = 998
referencesHeaderPrefix = "References: "
referencesFoldPrefix = " "
)

// smimeOuterBoundary returns a fresh, high-entropy MIME boundary for an S/MIME
// multipart/signed wrapper. If crypto/rand cannot supply randomness it returns
// an error rather than degrading to a predictable, time-based fallback.
Expand Down Expand Up @@ -115,6 +122,67 @@ func generateMessageID(from string) string {
return fmt.Sprintf("<%x@%s>", buf, from)
}

func referencesHeaderValue(inReplyTo string, references []string) string {
ids := make([]string, 0, len(references)+1)
for _, reference := range references {
reference = strings.TrimSpace(reference)
if reference != "" {
ids = append(ids, reference)
}
}
inReplyTo = strings.TrimSpace(inReplyTo)
if inReplyTo != "" {
ids = append(ids, inReplyTo)
}
if len(ids) == 0 {
return ""
}

ids = trimReferencesHeaderIDs(ids)
return foldReferencesHeaderIDs(ids)
}

func trimReferencesHeaderIDs(ids []string) []string {
ids = append([]string(nil), ids...)
for len(ids) > 1 && len(referencesHeaderPrefix)+len(strings.Join(ids, " ")) > maxReferencesHeaderLineLen {
if len(ids) > 2 {
ids = append(ids[:1], ids[2:]...)
} else {
ids = ids[1:]
}
}
return ids
}

func foldReferencesHeaderIDs(ids []string) string {
firstLineLimit := recommendedHeaderLineLen - len(referencesHeaderPrefix)
continuationLineLimit := recommendedHeaderLineLen - len(referencesFoldPrefix)
lines := make([]string, 0, len(ids))
current := ""
currentLimit := firstLineLimit

for _, id := range ids {
if current == "" {
current = id
continue
}

if len(current)+1+len(id) <= currentLimit {
current += " " + id
continue
}

lines = append(lines, current)
current = id
currentLimit = continuationLineLimit
}
if current != "" {
lines = append(lines, current)
}

return strings.Join(lines, "\r\n"+referencesFoldPrefix)
}

// containsMarkup returns true if the string contains Markdown or HTML elements.
func containsMarkup(body string) bool {
// Parse the Markdown into an AST. We will consider most AST node kinds as
Expand Down Expand Up @@ -225,11 +293,7 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody

if inReplyTo != "" {
headers["In-Reply-To"] = inReplyTo
if len(references) > 0 {
headers["References"] = strings.Join(references, " ") + " " + inReplyTo
} else {
headers["References"] = inReplyTo
}
headers["References"] = referencesHeaderValue(inReplyTo, references)
}

// prepare final message buffer and S/MIME payload placeholder
Expand Down Expand Up @@ -797,11 +861,7 @@ func SendCalendarReply(account *config.Account, to []string, subject, plainBody

if inReplyTo != "" {
fmt.Fprintf(&msg, "In-Reply-To: %s\r\n", inReplyTo)
if len(references) > 0 {
fmt.Fprintf(&msg, "References: %s %s\r\n", strings.Join(references, " "), inReplyTo)
} else {
fmt.Fprintf(&msg, "References: %s\r\n", inReplyTo)
}
fmt.Fprintf(&msg, "References: %s\r\n", referencesHeaderValue(inReplyTo, references))
}

// Build multipart/mixed containing:
Expand Down
43 changes: 43 additions & 0 deletions sender/sender_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sender

import (
"errors"
"fmt"
"io"
"strings"
"testing"
Expand Down Expand Up @@ -92,6 +93,48 @@ func TestSMTPHelloHostname(t *testing.T) {
}
}

func TestReferencesHeaderValueFoldsLines(t *testing.T) {
references := []string{
"<11111111111111111111111111111111@example.com>",
"<22222222222222222222222222222222@example.com>",
"<33333333333333333333333333333333@example.com>",
}

value := referencesHeaderValue("<reply@example.com>", references)
if !strings.Contains(value, "\r\n ") {
t.Fatalf("expected folded References value, got %q", value)
}

for _, line := range strings.Split("References: "+value, "\r\n") {
if len(line) > recommendedHeaderLineLen {
t.Fatalf("expected folded line to stay within %d chars, got %d: %q", recommendedHeaderLineLen, len(line), line)
}
}
}

func TestReferencesHeaderValueTrimsOldestMiddleIDs(t *testing.T) {
references := make([]string, 0, 80)
for i := 0; i < 80; i++ {
references = append(references, fmt.Sprintf("<%032d@example.com>", i))
}
inReplyTo := "<final@example.com>"

value := referencesHeaderValue(inReplyTo, references)
unfolded := strings.ReplaceAll(value, "\r\n ", " ")
if len("References: "+unfolded) > maxReferencesHeaderLineLen {
t.Fatalf("expected trimmed References header within %d chars, got %d", maxReferencesHeaderLineLen, len("References: "+unfolded))
}
if !strings.Contains(unfolded, references[0]) {
t.Fatalf("expected root reference %q to be retained in %q", references[0], unfolded)
}
if strings.Contains(unfolded, references[1]) {
t.Fatalf("expected older middle reference %q to be trimmed from %q", references[1], unfolded)
}
if !strings.Contains(unfolded, inReplyTo) {
t.Fatalf("expected in-reply-to %q to be retained in %q", inReplyTo, unfolded)
}
}

// TestGenerateMessageID ensures the Message-ID has the correct format.
func TestGenerateMessageID(t *testing.T) {
from := "test@example.com"
Expand Down
Loading