internal/mailin/body.go
214 lines · 6011 bytes
18 symbols in this file
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}