2023-11-09 19:26:19 +03:00
|
|
|
package message
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"mime"
|
|
|
|
"mime/quotedprintable"
|
|
|
|
"net/mail"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"github.com/mjl-/mox/smtp"
|
|
|
|
)
|
|
|
|
|
2023-11-09 23:15:27 +03:00
|
|
|
var (
|
|
|
|
ErrMessageSize = errors.New("message too large")
|
|
|
|
ErrCompose = errors.New("compose")
|
|
|
|
)
|
2023-11-09 19:26:19 +03:00
|
|
|
|
2023-11-09 23:15:27 +03:00
|
|
|
// Composer helps compose a message. Operations that fail call panic, which should
|
|
|
|
// be caught with recover(), checking for ErrCompose and optionally ErrMessageSize.
|
|
|
|
// Writes are buffered.
|
2023-11-09 19:26:19 +03:00
|
|
|
type Composer struct {
|
2023-11-09 23:15:27 +03:00
|
|
|
Has8bit bool // Whether message contains 8bit data.
|
|
|
|
SMTPUTF8 bool // Whether message needs to be sent with SMTPUTF8 extension.
|
|
|
|
Size int64 // Total bytes written.
|
2023-11-09 19:26:19 +03:00
|
|
|
|
2023-11-09 23:15:27 +03:00
|
|
|
bw *bufio.Writer
|
|
|
|
maxSize int64 // If greater than zero, writes beyond maximum size raise ErrMessageSize.
|
2023-11-09 19:26:19 +03:00
|
|
|
}
|
|
|
|
|
2023-11-09 23:15:27 +03:00
|
|
|
// NewComposer initializes a new composer with a buffered writer around w, and
|
|
|
|
// with a maximum message size if maxSize is greater than zero.
|
2024-03-16 22:54:10 +03:00
|
|
|
//
|
|
|
|
// smtputf8 must be set when the message must be delivered with smtputf8: if any
|
|
|
|
// email address localpart has non-ascii (utf-8).
|
|
|
|
//
|
2023-11-09 23:15:27 +03:00
|
|
|
// Operations on a Composer do not return an error. Caller must use recover() to
|
|
|
|
// catch ErrCompose and optionally ErrMessageSize errors.
|
2024-03-16 22:54:10 +03:00
|
|
|
func NewComposer(w io.Writer, maxSize int64, smtputf8 bool) *Composer {
|
|
|
|
return &Composer{bw: bufio.NewWriter(w), maxSize: maxSize, SMTPUTF8: smtputf8, Has8bit: smtputf8}
|
2023-11-09 19:26:19 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// Write implements io.Writer, but calls panic (that is handled higher up) on
|
|
|
|
// i/o errors.
|
|
|
|
func (c *Composer) Write(buf []byte) (int, error) {
|
2023-11-09 23:15:27 +03:00
|
|
|
if c.maxSize > 0 && c.Size+int64(len(buf)) > c.maxSize {
|
|
|
|
c.Checkf(ErrMessageSize, "writing message")
|
|
|
|
}
|
2023-11-09 19:26:19 +03:00
|
|
|
n, err := c.bw.Write(buf)
|
2023-11-09 23:15:27 +03:00
|
|
|
if n > 0 {
|
|
|
|
c.Size += int64(n)
|
|
|
|
}
|
2023-11-09 19:26:19 +03:00
|
|
|
c.Checkf(err, "write")
|
|
|
|
return n, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Checkf checks err, panicing with sentinel error value.
|
|
|
|
func (c *Composer) Checkf(err error, format string, args ...any) {
|
|
|
|
if err != nil {
|
2023-11-09 23:15:27 +03:00
|
|
|
// We expose the original error too, needed at least for ErrMessageSize.
|
|
|
|
panic(fmt.Errorf("%w: %w: %v", ErrCompose, err, fmt.Sprintf(format, args...)))
|
2023-11-09 19:26:19 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2023-11-09 23:15:27 +03:00
|
|
|
// NameAddress holds both an address display name, and an SMTP path address.
|
|
|
|
type NameAddress struct {
|
|
|
|
DisplayName string
|
|
|
|
Address smtp.Address
|
|
|
|
}
|
|
|
|
|
2023-11-09 19:26:19 +03:00
|
|
|
// HeaderAddrs writes a message header with addresses.
|
2023-11-09 23:15:27 +03:00
|
|
|
func (c *Composer) HeaderAddrs(k string, l []NameAddress) {
|
2023-11-09 19:26:19 +03:00
|
|
|
if len(l) == 0 {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
v := ""
|
|
|
|
linelen := len(k) + len(": ")
|
|
|
|
for _, a := range l {
|
|
|
|
if v != "" {
|
|
|
|
v += ","
|
|
|
|
linelen++
|
|
|
|
}
|
2023-11-09 23:15:27 +03:00
|
|
|
addr := mail.Address{Name: a.DisplayName, Address: a.Address.Pack(c.SMTPUTF8)}
|
2023-11-09 19:26:19 +03:00
|
|
|
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
|
|
|
|
}
|