internal/mailin/body.go

214 lines · 6011 bytes

  1package mailin
  2
  3import (
  4	"bufio"
  5	"encoding/base64"
  6	"errors"
  7	"io"
  8	"mime"
  9	"mime/multipart"
 10	"mime/quotedprintable"
 11	"net/textproto"
 12	"regexp"
 13	"strings"
 14	"unicode/utf8"
 15
 16	"golang.org/x/text/encoding/htmlindex"
 17)
 18
 19var errNoText = errors.New("no text/plain part")
 20
 21// maxParts and maxDepth bound the walk through a multipart message.
 22const (
 23	maxParts = 64
 24	maxDepth = 5
 25)
 26
 27// textBody returns the message's first text/plain part that is not an
 28// attachment, decoded to UTF-8. A message with only HTML has none, and
 29// is refused rather than converted.
 30func textBody(h textproto.MIMEHeader, body io.Reader, limit int64) (string, error) {
 31	parts := 0
 32	return walk(h, body, limit, 0, &parts)
 33}
 34
 35func walk(h textproto.MIMEHeader, body io.Reader, limit int64, depth int, parts *int) (string, error) {
 36	ct := h.Get("Content-Type")
 37	if ct == "" {
 38		ct = "text/plain"
 39	}
 40	mt, params, err := mime.ParseMediaType(ct)
 41	if err != nil {
 42		return "", errNoText
 43	}
 44	switch {
 45	case mt == "text/plain":
 46		if d, _, _ := mime.ParseMediaType(h.Get("Content-Disposition")); d == "attachment" {
 47			return "", errNoText
 48		}
 49		return decodeText(h.Get("Content-Transfer-Encoding"), params["charset"], body, limit)
 50	case strings.HasPrefix(mt, "multipart/") && depth < maxDepth:
 51		if params["boundary"] == "" {
 52			return "", errNoText
 53		}
 54		mr := multipart.NewReader(body, params["boundary"])
 55		for {
 56			// NextRawPart leaves quoted-printable to decodeText, so every
 57			// part is decoded the same way.
 58			p, err := mr.NextRawPart()
 59			if err == io.EOF {
 60				return "", errNoText
 61			}
 62			if err != nil {
 63				return "", err
 64			}
 65			if *parts++; *parts > maxParts {
 66				return "", errNoText
 67			}
 68			s, err := walk(p.Header, p, limit, depth+1, parts)
 69			if err == nil {
 70				return s, nil
 71			}
 72			if !errors.Is(err, errNoText) {
 73				return "", err
 74			}
 75		}
 76	}
 77	return "", errNoText
 78}
 79
 80var errTooLong = errors.New("reply is longer than a comment may be")
 81
 82func decodeText(cte, charset string, body io.Reader, limit int64) (string, error) {
 83	var r io.Reader = body
 84	switch strings.ToLower(strings.TrimSpace(cte)) {
 85	case "quoted-printable":
 86		r = quotedprintable.NewReader(r)
 87	case "base64":
 88		r = base64.NewDecoder(base64.StdEncoding, &skipSpace{r: bufio.NewReader(r)})
 89	case "", "7bit", "8bit", "binary":
 90	default:
 91		return "", errNoText
 92	}
 93	switch cs := strings.ToLower(strings.TrimSpace(charset)); cs {
 94	case "", "utf-8", "utf8", "us-ascii":
 95	default:
 96		enc, err := htmlindex.Get(cs)
 97		if err != nil {
 98			return "", errNoText
 99		}
100		r = enc.NewDecoder().Reader(r)
101	}
102	// Quoted history is stripped after reading, so the read allows for
103	// a reply several times the size of a comment before refusing it.
104	raw, err := io.ReadAll(io.LimitReader(r, 8*limit+1))
105	if err != nil {
106		return "", err
107	}
108	if int64(len(raw)) > 8*limit {
109		return "", errTooLong
110	}
111	return strings.ToValidUTF8(string(raw), string(utf8.RuneError)), nil
112}
113
114// skipSpace drops the line breaks and spaces base64 bodies are wrapped
115// with.
116type skipSpace struct{ r *bufio.Reader }
117
118func (s *skipSpace) Read(p []byte) (int, error) {
119	n := 0
120	for n < len(p) {
121		b, err := s.r.ReadByte()
122		if err != nil {
123			if n > 0 {
124				return n, nil
125			}
126			return 0, err
127		}
128		if b == '\r' || b == '\n' || b == ' ' || b == '\t' {
129			continue
130		}
131		p[n] = b
132		n++
133	}
134	return n, nil
135}
136
137var (
138	// "On Mon, Sep 28, 2026 at 9:00 AM gitbay <reply+…@…> wrote:", which
139	// Gmail, Apple Mail and Thunderbird all put above the quote, and
140	// which Gmail wraps onto a second line when it is long.
141	attribution = regexp.MustCompile(`(?i)^on\s.*\bwrote:\s*$`)
142	// Outlook: "-----Original Message-----", or a rule of underscores
143	// above a From:/Sent: header block.
144	originalMessage = regexp.MustCompile(`(?i)^\s*-{2,}\s*original message\s*-{2,}\s*$`)
145	underscores     = regexp.MustCompile(`^\s*_{10,}\s*$`)
146	headerFrom      = regexp.MustCompile(`(?i)^\s*\*?from:\*?\s`)
147	headerSentDate  = regexp.MustCompile(`(?i)^\s*\*?(sent|date):\*?\s`)
148	mobileSig       = regexp.MustCompile(`(?i)^sent from my \S`)
149)
150
151// stripQuoted returns the text a person wrote in a reply: quoted lines
152// ("> …") dropped wherever they are, and everything from the first
153// separator a mail client puts above the quoted message, or from the
154// signature delimiter "-- ", cut off.
155func stripQuoted(s string) string {
156	s = strings.ReplaceAll(s, "\r\n", "\n")
157	lines := strings.Split(s, "\n")
158	cut := len(lines)
159	for i, l := range lines {
160		t := strings.TrimRight(l, " \t")
161		next := ""
162		if i+1 < len(lines) {
163			next = strings.TrimSpace(lines[i+1])
164		}
165		switch {
166		case l == "-- " || t == "--":
167		case originalMessage.MatchString(t):
168		case underscores.MatchString(t):
169		case attribution.MatchString(strings.TrimSpace(t)):
170		case strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "on ") &&
171			attribution.MatchString(strings.TrimSpace(t)+" "+next):
172		case headerFrom.MatchString(t) && followedByHeader(lines[i+1:]):
173		default:
174			continue
175		}
176		cut = i
177		break
178	}
179	var out []string
180	for _, l := range lines[:cut] {
181		if strings.HasPrefix(strings.TrimLeft(l, " "), ">") {
182			continue
183		}
184		out = append(out, strings.TrimRight(l, " \t"))
185	}
186	// A phone's canned signature, when it is the last thing written.
187	for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" {
188		out = out[:len(out)-1]
189	}
190	if len(out) > 0 && mobileSig.MatchString(strings.TrimSpace(out[len(out)-1])) {
191		out = out[:len(out)-1]
192	}
193	return strings.TrimSpace(collapseBlank(strings.Join(out, "\n")))
194}
195
196// followedByHeader reports whether a Sent: or Date: line comes within
197// the next few lines, as in the header block Outlook quotes.
198func followedByHeader(rest []string) bool {
199	for i := 0; i < len(rest) && i < 4; i++ {
200		if headerSentDate.MatchString(rest[i]) {
201			return true
202		}
203	}
204	return false
205}
206
207// collapseBlank turns runs of blank lines, left where quoted lines were
208// dropped, into one.
209func collapseBlank(s string) string {
210	for strings.Contains(s, "\n\n\n") {
211		s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
212	}
213	return s
214}