1package message
2
3import (
4 "bufio"
5 "errors"
6 "fmt"
7 "io"
8 "mime"
9 "mime/quotedprintable"
10 "net/mail"
11 "strings"
12
13 "github.com/mjl-/mox/smtp"
14)
15
16var (
17 ErrMessageSize = errors.New("message too large")
18 ErrCompose = errors.New("compose")
19)
20
21// Composer helps compose a message. Operations that fail call panic, which should
22// be caught with recover(), checking for ErrCompose and optionally ErrMessageSize.
23// Writes are buffered.
24type Composer struct {
25 Has8bit bool // Whether message contains 8bit data.
26 SMTPUTF8 bool // Whether message needs to be sent with SMTPUTF8 extension.
27 Size int64 // Total bytes written.
28
29 bw *bufio.Writer
30 maxSize int64 // If greater than zero, writes beyond maximum size raise ErrMessageSize.
31}
32
33// NewComposer initializes a new composer with a buffered writer around w, and
34// with a maximum message size if maxSize is greater than zero.
35//
36// smtputf8 must be set when the message must be delivered with smtputf8: if any
37// email address localpart has non-ascii (utf-8).
38//
39// Operations on a Composer do not return an error. Caller must use recover() to
40// catch ErrCompose and optionally ErrMessageSize errors.
41func NewComposer(w io.Writer, maxSize int64, smtputf8 bool) *Composer {
42 return &Composer{bw: bufio.NewWriter(w), maxSize: maxSize, SMTPUTF8: smtputf8, Has8bit: smtputf8}
43}
44
45// Write implements io.Writer, but calls panic (that is handled higher up) on
46// i/o errors.
47func (c *Composer) Write(buf []byte) (int, error) {
48 if c.maxSize > 0 && c.Size+int64(len(buf)) > c.maxSize {
49 c.Checkf(ErrMessageSize, "writing message")
50 }
51 n, err := c.bw.Write(buf)
52 if n > 0 {
53 c.Size += int64(n)
54 }
55 c.Checkf(err, "write")
56 return n, nil
57}
58
59// Checkf checks err, panicing with sentinel error value.
60func (c *Composer) Checkf(err error, format string, args ...any) {
61 if err != nil {
62 // We expose the original error too, needed at least for ErrMessageSize.
63 panic(fmt.Errorf("%w: %w: %v", ErrCompose, err, fmt.Sprintf(format, args...)))
64 }
65}
66
67// Flush writes any buffered output.
68func (c *Composer) Flush() {
69 err := c.bw.Flush()
70 c.Checkf(err, "flush")
71}
72
73// Header writes a message header.
74func (c *Composer) Header(k, v string) {
75 fmt.Fprintf(c, "%s: %s\r\n", k, v)
76}
77
78// NameAddress holds both an address display name, and an SMTP path address.
79type NameAddress struct {
80 DisplayName string
81 Address smtp.Address
82}
83
84// HeaderAddrs writes a message header with addresses.
85func (c *Composer) HeaderAddrs(k string, l []NameAddress) {
86 if len(l) == 0 {
87 return
88 }
89 v := ""
90 linelen := len(k) + len(": ")
91 for _, a := range l {
92 if v != "" {
93 v += ","
94 linelen++
95 }
96 addr := mail.Address{Name: a.DisplayName, Address: a.Address.Pack(c.SMTPUTF8)}
97 s := addr.String()
98 if v != "" && linelen+1+len(s) > 77 {
99 v += "\r\n\t"
100 linelen = 1
101 } else if v != "" {
102 v += " "
103 linelen++
104 }
105 v += s
106 linelen += len(s)
107 }
108 fmt.Fprintf(c, "%s: %s\r\n", k, v)
109}
110
111// Subject writes a subject message header.
112func (c *Composer) Subject(subject string) {
113 var subjectValue string
114 subjectLineLen := len("Subject: ")
115 subjectWord := false
116 for i, word := range strings.Split(subject, " ") {
117 if !c.SMTPUTF8 && !isASCII(word) {
118 word = mime.QEncoding.Encode("utf-8", word)
119 }
120 if i > 0 {
121 subjectValue += " "
122 subjectLineLen++
123 }
124 if subjectWord && subjectLineLen+len(word) > 77 {
125 subjectValue += "\r\n\t"
126 subjectLineLen = 1
127 }
128 subjectValue += word
129 subjectLineLen += len(word)
130 subjectWord = true
131 }
132 c.Header("Subject", subjectValue)
133}
134
135// Line writes an empty line.
136func (c *Composer) Line() {
137 _, _ = c.Write([]byte("\r\n"))
138}
139
140// TextPart prepares a text part to be added. Text should contain lines terminated
141// with newlines (lf), which are replaced with crlf. The returned text may be
142// quotedprintable, if needed. The returned ct and cte headers are for use with
143// Content-Type and Content-Transfer-Encoding headers.
144func (c *Composer) TextPart(subtype, text string) (textBody []byte, ct, cte string) {
145 if !strings.HasSuffix(text, "\n") {
146 text += "\n"
147 }
148 text = strings.ReplaceAll(text, "\n", "\r\n")
149 charset := "us-ascii"
150 if !isASCII(text) {
151 charset = "utf-8"
152 }
153 if NeedsQuotedPrintable(text) {
154 var sb strings.Builder
155 _, err := io.Copy(quotedprintable.NewWriter(&sb), strings.NewReader(text))
156 c.Checkf(err, "converting text to quoted printable")
157 text = sb.String()
158 cte = "quoted-printable"
159 } else if c.Has8bit || charset == "utf-8" {
160 cte = "8bit"
161 } else {
162 cte = "7bit"
163 }
164
165 ct = mime.FormatMediaType("text/"+subtype, map[string]string{"charset": charset})
166 return []byte(text), ct, cte
167}
168
169func isASCII(s string) bool {
170 for _, c := range s {
171 if c >= 0x80 {
172 return false
173 }
174 }
175 return true
176}
177