13 "github.com/mjl-/mox/smtp"
17 ErrMessageSize = errors.New("message too large")
18 ErrCompose = errors.New("compose")
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.
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.
30 maxSize int64 // If greater than zero, writes beyond maximum size raise ErrMessageSize.
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// Operations on a Composer do not return an error. Caller must use recover() to
36// catch ErrCompose and optionally ErrMessageSize errors.
37func NewComposer(w io.Writer, maxSize int64) *Composer {
38 return &Composer{bw: bufio.NewWriter(w), maxSize: maxSize}
41// Write implements io.Writer, but calls panic (that is handled higher up) on
43func (c *Composer) Write(buf []byte) (int, error) {
44 if c.maxSize > 0 && c.Size+int64(len(buf)) > c.maxSize {
45 c.Checkf(ErrMessageSize, "writing message")
47 n, err := c.bw.Write(buf)
51 c.Checkf(err, "write")
55// Checkf checks err, panicing with sentinel error value.
56func (c *Composer) Checkf(err error, format string, args ...any) {
58 // We expose the original error too, needed at least for ErrMessageSize.
59 panic(fmt.Errorf("%w: %w: %v", ErrCompose, err, fmt.Sprintf(format, args...)))
63// Flush writes any buffered output.
64func (c *Composer) Flush() {
66 c.Checkf(err, "flush")
69// Header writes a message header.
70func (c *Composer) Header(k, v string) {
71 fmt.Fprintf(c, "%s: %s\r\n", k, v)
74// NameAddress holds both an address display name, and an SMTP path address.
75type NameAddress struct {
80// HeaderAddrs writes a message header with addresses.
81func (c *Composer) HeaderAddrs(k string, l []NameAddress) {
86 linelen := len(k) + len(": ")
92 addr := mail.Address{Name: a.DisplayName, Address: a.Address.Pack(c.SMTPUTF8)}
94 if v != "" && linelen+1+len(s) > 77 {
104 fmt.Fprintf(c, "%s: %s\r\n", k, v)
107// Subject writes a subject message header.
108func (c *Composer) Subject(subject string) {
109 var subjectValue string
110 subjectLineLen := len("Subject: ")
112 for i, word := range strings.Split(subject, " ") {
113 if !c.SMTPUTF8 && !isASCII(word) {
114 word = mime.QEncoding.Encode("utf-8", word)
120 if subjectWord && subjectLineLen+len(word) > 77 {
121 subjectValue += "\r\n\t"
125 subjectLineLen += len(word)
128 c.Header("Subject", subjectValue)
131// Line writes an empty line.
132func (c *Composer) Line() {
133 _, _ = c.Write([]byte("\r\n"))
136// TextPart prepares a text part to be added. Text should contain lines terminated
137// with newlines (lf), which are replaced with crlf. The returned text may be
138// quotedprintable, if needed. The returned ct and cte headers are for use with
139// Content-Type and Content-Transfer-Encoding headers.
140func (c *Composer) TextPart(text string) (textBody []byte, ct, cte string) {
141 if !strings.HasSuffix(text, "\n") {
144 text = strings.ReplaceAll(text, "\n", "\r\n")
145 charset := "us-ascii"
149 if NeedsQuotedPrintable(text) {
150 var sb strings.Builder
151 _, err := io.Copy(quotedprintable.NewWriter(&sb), strings.NewReader(text))
152 c.Checkf(err, "converting text to quoted printable")
154 cte = "quoted-printable"
155 } else if c.Has8bit || charset == "utf-8" {
161 ct = mime.FormatMediaType("text/plain", map[string]string{"charset": charset})
162 return []byte(text), ct, cte
165func isASCII(s string) bool {
166 for _, c := range s {