mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
405 lines
12 KiB
Go
405 lines
12 KiB
Go
// Package dsn parses and composes Delivery Status Notification messages, see
|
|
// RFC 3464 and RFC 6533.
|
|
package dsn
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/textproto"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mjl-/mox/dkim"
|
|
"github.com/mjl-/mox/message"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/mox-"
|
|
"github.com/mjl-/mox/smtp"
|
|
)
|
|
|
|
// Message represents a DSN message, with basic message headers, human-readable text,
|
|
// machine-parsable data, and optional original message/headers.
|
|
//
|
|
// A DSN represents a delayed, failed or successful delivery. Failing incoming
|
|
// deliveries over SMTP, and failing outgoing deliveries from the message queue,
|
|
// can result in a DSN being sent.
|
|
type Message struct {
|
|
SMTPUTF8 bool // Whether the original was received with smtputf8.
|
|
|
|
// DSN message From header. E.g. postmaster@ourdomain.example. NOTE:
|
|
// DSNs should be sent with a null reverse path to prevent mail loops.
|
|
// ../rfc/3464:421
|
|
From smtp.Path
|
|
|
|
// "To" header, and also SMTP RCP TO to deliver DSN to. Should be taken
|
|
// from original SMTP transaction MAIL FROM.
|
|
// ../rfc/3464:415
|
|
To smtp.Path
|
|
|
|
// Message subject header, e.g. describing mail delivery failure.
|
|
Subject string
|
|
|
|
// Human-readable text explaining the failure. Line endings should be
|
|
// bare newlines, not \r\n. They are converted to \r\n when composing.
|
|
TextBody string
|
|
|
|
// Per-message fields.
|
|
OriginalEnvelopeID string
|
|
ReportingMTA string // Required.
|
|
DSNGateway string
|
|
ReceivedFromMTA smtp.Ehlo // Host from which message was received.
|
|
ArrivalDate time.Time
|
|
|
|
// All per-message fields, including extensions. Only used for parsing,
|
|
// not composing.
|
|
MessageHeader textproto.MIMEHeader
|
|
|
|
// One or more per-recipient fields.
|
|
// ../rfc/3464:436
|
|
Recipients []Recipient
|
|
|
|
// Original message or headers to include in DSN as third MIME part.
|
|
// Optional. Only used for generating DSNs, not set for parsed DNSs.
|
|
Original []byte
|
|
}
|
|
|
|
// Action is a field in a DSN.
|
|
type Action string
|
|
|
|
// ../rfc/3464:890
|
|
|
|
const (
|
|
Failed Action = "failed"
|
|
Delayed Action = "delayed"
|
|
Delivered Action = "delivered"
|
|
Relayed Action = "relayed"
|
|
Expanded Action = "expanded"
|
|
)
|
|
|
|
// ../rfc/3464:1530 ../rfc/6533:370
|
|
|
|
// Recipient holds the per-recipient delivery-status lines in a DSN.
|
|
type Recipient struct {
|
|
// Required fields.
|
|
FinalRecipient smtp.Path // Final recipient of message.
|
|
Action Action
|
|
|
|
// Enhanced status code. First digit indicates permanent or temporary
|
|
// error. If the string contains more than just a status, that
|
|
// additional text is added as comment when composing a DSN.
|
|
Status string
|
|
|
|
// Optional fields.
|
|
// Original intended recipient of message. Used with the DSN extensions ORCPT
|
|
// parameter.
|
|
// ../rfc/3464:1197
|
|
OriginalRecipient smtp.Path
|
|
|
|
// Remote host that returned an error code. Can also be empty for
|
|
// deliveries.
|
|
RemoteMTA NameIP
|
|
|
|
// If RemoteMTA is present, DiagnosticCode is from remote. When
|
|
// creating a DSN, additional text in the string will be added to the
|
|
// DSN as comment.
|
|
DiagnosticCode string
|
|
LastAttemptDate time.Time
|
|
FinalLogID string
|
|
|
|
// For delayed deliveries, deliveries may be retried until this time.
|
|
WillRetryUntil *time.Time
|
|
|
|
// All fields, including extensions. Only used for parsing, not
|
|
// composing.
|
|
Header textproto.MIMEHeader
|
|
}
|
|
|
|
// Compose returns a DSN message.
|
|
//
|
|
// smtputf8 indicates whether the remote MTA that is receiving the DSN
|
|
// supports smtputf8. This influences the message media (sub)types used for the
|
|
// DSN.
|
|
//
|
|
// DKIM signatures are added if DKIM signing is configured for the "from" domain.
|
|
func (m *Message) Compose(log *mlog.Log, smtputf8 bool) ([]byte, error) {
|
|
// ../rfc/3462:119
|
|
// ../rfc/3464:377
|
|
// We'll make a multipart/report with 2 or 3 parts:
|
|
// - 1. human-readable explanation;
|
|
// - 2. message/delivery-status;
|
|
// - 3. (optional) original message (either in full, or only headers).
|
|
|
|
// todo future: add option to send full message. but only do so if the message is <100kb.
|
|
// todo future: possibly write to a file directly, instead of building up message in memory.
|
|
|
|
// If message does not require smtputf8, we are never generating a utf-8 DSN.
|
|
if !m.SMTPUTF8 {
|
|
smtputf8 = false
|
|
}
|
|
|
|
// We check for errors once after all the writes.
|
|
msgw := &errWriter{w: &bytes.Buffer{}}
|
|
|
|
header := func(k, v string) {
|
|
fmt.Fprintf(msgw, "%s: %s\r\n", k, v)
|
|
}
|
|
|
|
line := func(w io.Writer) {
|
|
w.Write([]byte("\r\n"))
|
|
}
|
|
|
|
// Outer message headers.
|
|
header("From", fmt.Sprintf("<%s>", m.From.XString(smtputf8))) // todo: would be good to have a local ascii-only name for this address.
|
|
header("To", fmt.Sprintf("<%s>", m.To.XString(smtputf8))) // todo: we could just leave this out if it has utf-8 and remote does not support utf-8.
|
|
header("Subject", m.Subject)
|
|
header("Message-Id", fmt.Sprintf("<%s>", mox.MessageIDGen(smtputf8)))
|
|
header("Date", time.Now().Format(message.RFC5322Z))
|
|
header("MIME-Version", "1.0")
|
|
mp := multipart.NewWriter(msgw)
|
|
header("Content-Type", fmt.Sprintf(`multipart/report; report-type="delivery-status"; boundary="%s"`, mp.Boundary()))
|
|
|
|
line(msgw)
|
|
|
|
// First part, human-readable message.
|
|
msgHdr := textproto.MIMEHeader{}
|
|
if smtputf8 {
|
|
msgHdr.Set("Content-Type", "text/plain; charset=utf-8")
|
|
msgHdr.Set("Content-Transfer-Encoding", "8BIT")
|
|
} else {
|
|
msgHdr.Set("Content-Type", "text/plain")
|
|
msgHdr.Set("Content-Transfer-Encoding", "7BIT")
|
|
}
|
|
msgp, err := mp.CreatePart(msgHdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
msgp.Write([]byte(strings.ReplaceAll(m.TextBody, "\n", "\r\n")))
|
|
|
|
// Machine-parsable message. ../rfc/3464:455
|
|
statusHdr := textproto.MIMEHeader{}
|
|
if smtputf8 {
|
|
// ../rfc/6533:325
|
|
statusHdr.Set("Content-Type", "message/global-delivery-status")
|
|
statusHdr.Set("Content-Transfer-Encoding", "8BIT")
|
|
} else {
|
|
statusHdr.Set("Content-Type", "message/delivery-status")
|
|
statusHdr.Set("Content-Transfer-Encoding", "7BIT")
|
|
}
|
|
statusp, err := mp.CreatePart(statusHdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// ../rfc/3464:470
|
|
// examples: ../rfc/3464:1855
|
|
// type fields: ../rfc/3464:536 https://www.iana.org/assignments/dsn-types/dsn-types.xhtml
|
|
|
|
status := func(k, v string) {
|
|
fmt.Fprintf(statusp, "%s: %s\r\n", k, v)
|
|
}
|
|
|
|
// Per-message fields first. ../rfc/3464:575
|
|
// todo future: once we support the smtp dsn extension, the envid should be saved/set as OriginalEnvelopeID. ../rfc/3464:583 ../rfc/3461:1139
|
|
if m.OriginalEnvelopeID != "" {
|
|
status("Original-Envelope-ID", m.OriginalEnvelopeID)
|
|
}
|
|
status("Reporting-MTA", "dns; "+m.ReportingMTA) // ../rfc/3464:628
|
|
if m.DSNGateway != "" {
|
|
// ../rfc/3464:714
|
|
status("DSN-Gateway", "dns; "+m.DSNGateway)
|
|
}
|
|
if !m.ReceivedFromMTA.IsZero() {
|
|
// ../rfc/3464:735
|
|
status("Received-From-MTA", fmt.Sprintf("dns;%s (%s)", m.ReceivedFromMTA.Name, smtp.AddressLiteral(m.ReceivedFromMTA.ConnIP)))
|
|
}
|
|
status("Arrival-Date", m.ArrivalDate.Format(message.RFC5322Z)) // ../rfc/3464:758
|
|
|
|
// Then per-recipient fields. ../rfc/3464:769
|
|
// todo: should also handle other address types. at least recognize "unknown". Probably just store this field. ../rfc/3464:819
|
|
addrType := "rfc822;" // ../rfc/3464:514
|
|
if smtputf8 {
|
|
addrType = "utf-8;" // ../rfc/6533:250
|
|
}
|
|
if len(m.Recipients) == 0 {
|
|
return nil, fmt.Errorf("missing per-recipient fields")
|
|
}
|
|
for _, r := range m.Recipients {
|
|
line(statusp)
|
|
if !r.OriginalRecipient.IsZero() {
|
|
// ../rfc/3464:807
|
|
status("Original-Recipient", addrType+r.OriginalRecipient.DSNString(smtputf8))
|
|
}
|
|
status("Final-Recipient", addrType+r.FinalRecipient.DSNString(smtputf8)) // ../rfc/3464:829
|
|
status("Action", string(r.Action)) // ../rfc/3464:879
|
|
st := r.Status
|
|
if st == "" {
|
|
// ../rfc/3464:944
|
|
// Making up a status code is not great, but the field is required. We could simply
|
|
// require the caller to make one up...
|
|
switch r.Action {
|
|
case Delayed:
|
|
st = "4.0.0"
|
|
case Failed:
|
|
st = "5.0.0"
|
|
default:
|
|
st = "2.0.0"
|
|
}
|
|
}
|
|
var rest string
|
|
st, rest = codeLine(st)
|
|
statusLine := st
|
|
if rest != "" {
|
|
statusLine += " (" + rest + ")"
|
|
}
|
|
status("Status", statusLine) // ../rfc/3464:975
|
|
if !r.RemoteMTA.IsZero() {
|
|
// ../rfc/3464:1015
|
|
status("Remote-MTA", fmt.Sprintf("dns;%s (%s)", r.RemoteMTA.Name, smtp.AddressLiteral(r.RemoteMTA.IP)))
|
|
}
|
|
// Presence of Diagnostic-Code indicates the code is from Remote-MTA. ../rfc/3464:1053
|
|
if r.DiagnosticCode != "" {
|
|
diagCode, rest := codeLine(r.DiagnosticCode)
|
|
diagLine := diagCode
|
|
if rest != "" {
|
|
diagLine += " (" + rest + ")"
|
|
}
|
|
// ../rfc/6533:589
|
|
status("Diagnostic-Code", "smtp; "+diagLine)
|
|
}
|
|
if !r.LastAttemptDate.IsZero() {
|
|
status("Last-Attempt-Date", r.LastAttemptDate.Format(message.RFC5322Z)) // ../rfc/3464:1076
|
|
}
|
|
if r.FinalLogID != "" {
|
|
// todo future: think about adding cid as "Final-Log-Id"?
|
|
status("Final-Log-ID", r.FinalLogID) // ../rfc/3464:1098
|
|
}
|
|
if r.WillRetryUntil != nil {
|
|
status("Will-Retry-Until", r.WillRetryUntil.Format(message.RFC5322Z)) // ../rfc/3464:1108
|
|
}
|
|
}
|
|
|
|
// We include only the header of the original message.
|
|
// todo: add the textual version of the original message, if it exists and isn't too large.
|
|
if m.Original != nil {
|
|
headers, err := message.ReadHeaders(bufio.NewReader(bytes.NewReader(m.Original)))
|
|
if err != nil && errors.Is(err, message.ErrHeaderSeparator) {
|
|
// Whole data is a header.
|
|
headers = m.Original
|
|
} else if err != nil {
|
|
return nil, err
|
|
} else {
|
|
// This is a whole message. We still only include the headers.
|
|
// todo: include the whole body.
|
|
}
|
|
|
|
origHdr := textproto.MIMEHeader{}
|
|
if smtputf8 {
|
|
// ../rfc/6533:431
|
|
// ../rfc/6533:605
|
|
origHdr.Set("Content-Type", "message/global-headers") // ../rfc/6533:625
|
|
origHdr.Set("Content-Transfer-Encoding", "8BIT")
|
|
} else {
|
|
// ../rfc/3462:175
|
|
if m.SMTPUTF8 {
|
|
// ../rfc/6533:480
|
|
origHdr.Set("Content-Type", "text/rfc822-headers; charset=utf-8")
|
|
origHdr.Set("Content-Transfer-Encoding", "BASE64")
|
|
} else {
|
|
origHdr.Set("Content-Type", "text/rfc822-headers")
|
|
origHdr.Set("Content-Transfer-Encoding", "7BIT")
|
|
}
|
|
}
|
|
origp, err := mp.CreatePart(origHdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !smtputf8 && m.SMTPUTF8 {
|
|
data := base64.StdEncoding.EncodeToString(headers)
|
|
for len(data) > 0 {
|
|
line := data
|
|
n := len(line)
|
|
if n > 78 {
|
|
n = 78
|
|
}
|
|
line, data = data[:n], data[n:]
|
|
origp.Write([]byte(line + "\r\n"))
|
|
}
|
|
} else {
|
|
origp.Write(headers)
|
|
}
|
|
}
|
|
|
|
if err := mp.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if msgw.err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data := msgw.w.Bytes()
|
|
|
|
fd := m.From.IPDomain.Domain
|
|
confDom, _ := mox.Conf.Domain(fd)
|
|
if len(confDom.DKIM.Sign) > 0 {
|
|
if dkimHeaders, err := dkim.Sign(context.Background(), m.From.Localpart, fd, confDom.DKIM, smtputf8, bytes.NewReader(data)); err != nil {
|
|
log.Errorx("dsn: dkim sign for domain, returning unsigned dsn", err, mlog.Field("domain", fd))
|
|
} else {
|
|
data = append([]byte(dkimHeaders), data...)
|
|
}
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
type errWriter struct {
|
|
w *bytes.Buffer
|
|
err error
|
|
}
|
|
|
|
func (w *errWriter) Write(buf []byte) (int, error) {
|
|
if w.err != nil {
|
|
return -1, w.err
|
|
}
|
|
n, err := w.w.Write(buf)
|
|
w.err = err
|
|
return n, err
|
|
}
|
|
|
|
// split a line into enhanced status code and rest.
|
|
func codeLine(s string) (string, string) {
|
|
t := strings.SplitN(s, " ", 2)
|
|
l := strings.Split(t[0], ".")
|
|
if len(l) != 3 {
|
|
return "", s
|
|
}
|
|
for i, e := range l {
|
|
_, err := strconv.ParseInt(e, 10, 32)
|
|
if err != nil {
|
|
return "", s
|
|
}
|
|
if i == 0 && len(e) != 1 {
|
|
return "", s
|
|
}
|
|
}
|
|
|
|
var rest string
|
|
if len(t) == 2 {
|
|
rest = t[1]
|
|
}
|
|
return t[0], rest
|
|
}
|
|
|
|
// HasCode returns whether line starts with an enhanced SMTP status code.
|
|
func HasCode(line string) bool {
|
|
// ../rfc/3464:986
|
|
ecode, _ := codeLine(line)
|
|
return ecode != ""
|
|
}
|