mox/message/compose.go

163 lines
3.7 KiB
Go
Raw Normal View History

package message
import (
"bufio"
"errors"
"fmt"
"io"
"mime"
"mime/quotedprintable"
"net/mail"
"strings"
"github.com/mjl-/mox/smtp"
)
var errCompose = errors.New("compose")
// Composer helps compose a message. Operations that fail call panic, which can be
// caught with Composer.Recover. Writes are buffered.
type Composer struct {
Has8bit bool // Whether message contains 8bit data.
SMTPUTF8 bool // Whether message needs to be sent with SMTPUTF8 extension.
bw *bufio.Writer
}
func NewComposer(w io.Writer) *Composer {
return &Composer{bw: bufio.NewWriter(w)}
}
// Write implements io.Writer, but calls panic (that is handled higher up) on
// i/o errors.
func (c *Composer) Write(buf []byte) (int, error) {
n, err := c.bw.Write(buf)
c.Checkf(err, "write")
return n, nil
}
// Recover recovers the sentinel panic error value, storing it into rerr.
func (c *Composer) Recover(rerr *error) {
x := recover()
if x == nil {
return
}
if err, ok := x.(error); ok && errors.Is(err, errCompose) {
*rerr = err
} else {
panic(x)
}
}
// Checkf checks err, panicing with sentinel error value.
func (c *Composer) Checkf(err error, format string, args ...any) {
if err != nil {
panic(fmt.Errorf("%w: %s: %v", errCompose, err, fmt.Sprintf(format, args...)))
}
}
// Flush writes any buffered output.
func (c *Composer) Flush() {
err := c.bw.Flush()
c.Checkf(err, "flush")
}
// Header writes a message header.
func (c *Composer) Header(k, v string) {
fmt.Fprintf(c, "%s: %s\r\n", k, v)
}
// HeaderAddrs writes a message header with addresses.
func (c *Composer) HeaderAddrs(k string, l []smtp.Address) {
if len(l) == 0 {
return
}
v := ""
linelen := len(k) + len(": ")
for _, a := range l {
if v != "" {
v += ","
linelen++
}
addr := mail.Address{Address: a.Pack(c.SMTPUTF8)}
s := addr.String()
if v != "" && linelen+1+len(s) > 77 {
v += "\r\n\t"
linelen = 1
} else if v != "" {
v += " "
linelen++
}
v += s
linelen += len(s)
}
fmt.Fprintf(c, "%s: %s\r\n", k, v)
}
// Subject writes a subject message header.
func (c *Composer) Subject(subject string) {
var subjectValue string
subjectLineLen := len("Subject: ")
subjectWord := false
for i, word := range strings.Split(subject, " ") {
if !c.SMTPUTF8 && !isASCII(word) {
word = mime.QEncoding.Encode("utf-8", word)
}
if i > 0 {
subjectValue += " "
subjectLineLen++
}
if subjectWord && subjectLineLen+len(word) > 77 {
subjectValue += "\r\n\t"
subjectLineLen = 1
}
subjectValue += word
subjectLineLen += len(word)
subjectWord = true
}
c.Header("Subject", subjectValue)
}
// Line writes an empty line.
func (c *Composer) Line() {
_, _ = c.Write([]byte("\r\n"))
}
// TextPart prepares a text part to be added. Text should contain lines terminated
// with newlines (lf), which are replaced with crlf. The returned text may be
// quotedprintable, if needed. The returned ct and cte headers are for use with
// Content-Type and Content-Transfer-Encoding headers.
func (c *Composer) TextPart(text string) (textBody []byte, ct, cte string) {
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
text = strings.ReplaceAll(text, "\n", "\r\n")
charset := "us-ascii"
if !isASCII(text) {
charset = "utf-8"
}
if NeedsQuotedPrintable(text) {
var sb strings.Builder
_, err := io.Copy(quotedprintable.NewWriter(&sb), strings.NewReader(text))
c.Checkf(err, "converting text to quoted printable")
text = sb.String()
cte = "quoted-printable"
} else if c.Has8bit || charset == "utf-8" {
cte = "8bit"
} else {
cte = "7bit"
}
ct = mime.FormatMediaType("text/plain", map[string]string{"charset": charset})
return []byte(text), ct, cte
}
func isASCII(s string) bool {
for _, c := range s {
if c >= 0x80 {
return false
}
}
return true
}