This commit is contained in:
Mechiel Lukkien
2023-01-30 14:27:06 +01:00
commit cb229cb6cf
1256 changed files with 491723 additions and 0 deletions

405
dsn/dsn.go Normal file
View File

@ -0,0 +1,405 @@
// 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 != ""
}

243
dsn/dsn_test.go Normal file
View File

@ -0,0 +1,243 @@
package dsn
import (
"bytes"
"context"
"fmt"
"io"
"net"
"reflect"
"strings"
"testing"
"time"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/smtp"
)
func xparseDomain(s string) dns.Domain {
d, err := dns.ParseDomain(s)
if err != nil {
panic(fmt.Sprintf("parsing domain %q: %v", s, err))
}
return d
}
func xparseIPDomain(s string) dns.IPDomain {
return dns.IPDomain{Domain: xparseDomain(s)}
}
func tparseMessage(t *testing.T, data []byte, nparts int) (*Message, *message.Part) {
t.Helper()
m, p, err := Parse(bytes.NewReader(data))
if err != nil {
t.Fatalf("parsing dsn: %v", err)
}
if len(p.Parts) != nparts {
t.Fatalf("got %d parts, expected %d", len(p.Parts), nparts)
}
return m, p
}
func tcheckType(t *testing.T, p *message.Part, mt, mst, cte string) {
t.Helper()
if !strings.EqualFold(p.MediaType, mt) {
t.Fatalf("got mediatype %q, expected %q", p.MediaType, mt)
}
if !strings.EqualFold(p.MediaSubType, mst) {
t.Fatalf("got mediasubtype %q, expected %q", p.MediaSubType, mst)
}
if !strings.EqualFold(p.ContentTransferEncoding, cte) {
t.Fatalf("got content-transfer-encoding %q, expected %q", p.ContentTransferEncoding, cte)
}
}
func tcompare(t *testing.T, got, exp any) {
t.Helper()
if !reflect.DeepEqual(got, exp) {
t.Fatalf("got %#v, expected %#v", got, exp)
}
}
func tcompareReader(t *testing.T, r io.Reader, exp []byte) {
t.Helper()
buf, err := io.ReadAll(r)
if err != nil {
t.Fatalf("data read, got %q, expected %q", buf, exp)
}
}
func TestDSN(t *testing.T) {
log := mlog.New("dsn")
now := time.Now()
// An ascii-only message.
m := Message{
SMTPUTF8: false,
From: smtp.Path{Localpart: "postmaster", IPDomain: xparseIPDomain("mox.example")},
To: smtp.Path{Localpart: "mjl", IPDomain: xparseIPDomain("remote.example")},
Subject: "dsn",
TextBody: "delivery failure\n",
ReportingMTA: "mox.example",
ReceivedFromMTA: smtp.Ehlo{Name: xparseIPDomain("relay.example"), ConnIP: net.ParseIP("10.10.10.10")},
ArrivalDate: now,
Recipients: []Recipient{
{
FinalRecipient: smtp.Path{Localpart: "mjl", IPDomain: xparseIPDomain("remote.example")},
Action: Failed,
Status: "5.0.0",
LastAttemptDate: now,
},
},
Original: []byte("Subject: test\r\n"),
}
msgbuf, err := m.Compose(log, false)
if err != nil {
t.Fatalf("composing dsn: %v", err)
}
pmsg, part := tparseMessage(t, msgbuf, 3)
tcheckType(t, part, "multipart", "report", "")
tcheckType(t, &part.Parts[0], "text", "plain", "7bit")
tcheckType(t, &part.Parts[1], "message", "delivery-status", "7bit")
tcheckType(t, &part.Parts[2], "text", "rfc822-headers", "7bit")
tcompare(t, part.Parts[2].ContentTypeParams["charset"], "")
tcompareReader(t, part.Parts[2].Reader(), m.Original)
tcompare(t, pmsg.Recipients[0].FinalRecipient, m.Recipients[0].FinalRecipient)
// todo: test more fields
msgbufutf8, err := m.Compose(log, true)
if err != nil {
t.Fatalf("composing dsn with utf-8: %v", err)
}
pmsg, part = tparseMessage(t, msgbufutf8, 3)
tcheckType(t, part, "multipart", "report", "")
tcheckType(t, &part.Parts[0], "text", "plain", "7bit")
tcheckType(t, &part.Parts[1], "message", "delivery-status", "7bit")
tcheckType(t, &part.Parts[2], "text", "rfc822-headers", "7bit")
tcompare(t, part.Parts[2].ContentTypeParams["charset"], "")
tcompareReader(t, part.Parts[2].Reader(), m.Original)
tcompare(t, pmsg.Recipients[0].FinalRecipient, m.Recipients[0].FinalRecipient)
// Test for valid DKIM signature.
mox.Context = context.Background()
mox.ConfigStaticPath = "../testdata/dsn/mox.conf"
mox.MustLoadConfig()
msgbuf, err = m.Compose(log, false)
if err != nil {
t.Fatalf("composing utf-8 dsn with utf-8 support: %v", err)
}
resolver := &dns.MockResolver{
TXT: map[string][]string{
"testsel._domainkey.mox.example.": {"v=DKIM1;h=sha256;t=s;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3ZId3ys70VFspp/VMFaxMOrNjHNPg04NOE1iShih16b3Ex7hHBOgC1UvTGSmrMlbCB1OxTXkvf6jW6S4oYRnZYVNygH6zKUwYYhaSaGIg1xA/fDn+IgcTRyLoXizMUgUgpTGyxhNrwIIWv+i7jjbs3TKpP3NU4owQ/rxowmSNqg+fHIF1likSvXvljYS" + "jaFXXnWfYibW7TdDCFFpN4sB5o13+as0u4vLw6MvOi59B1tLype1LcHpi1b9PfxNtznTTdet3kL0paxIcWtKHT0LDPUos8YYmiPa5nGbUqlC7d+4YT2jQPvwGxCws1oo2Tw6nj1UaihneYGAyvEky49FBwIDAQAB"},
},
}
results, err := dkim.Verify(context.Background(), resolver, false, func(*dkim.Sig) error { return nil }, bytes.NewReader(msgbuf), false)
if err != nil {
t.Fatalf("dkim verify: %v", err)
}
if len(results) != 1 || results[0].Status != dkim.StatusPass {
t.Fatalf("dkim result not pass, %#v", results)
}
// An utf-8 message.
m = Message{
SMTPUTF8: true,
From: smtp.Path{Localpart: "postmæster", IPDomain: xparseIPDomain("møx.example")},
To: smtp.Path{Localpart: "møx", IPDomain: xparseIPDomain("remøte.example")},
Subject: "dsn¡",
TextBody: "delivery failure¿\n",
ReportingMTA: "mox.example",
ReceivedFromMTA: smtp.Ehlo{Name: xparseIPDomain("reläy.example"), ConnIP: net.ParseIP("10.10.10.10")},
ArrivalDate: now,
Recipients: []Recipient{
{
Action: Failed,
FinalRecipient: smtp.Path{Localpart: "møx", IPDomain: xparseIPDomain("remøte.example")},
Status: "5.0.0",
LastAttemptDate: now,
},
},
Original: []byte("Subject: tést\r\n"),
}
msgbuf, err = m.Compose(log, false)
if err != nil {
t.Fatalf("composing utf-8 dsn without utf-8 support: %v", err)
}
pmsg, part = tparseMessage(t, msgbuf, 3)
tcheckType(t, part, "multipart", "report", "")
tcheckType(t, &part.Parts[0], "text", "plain", "7bit")
tcheckType(t, &part.Parts[1], "message", "delivery-status", "7bit")
tcheckType(t, &part.Parts[2], "text", "rfc822-headers", "base64")
tcompare(t, part.Parts[2].ContentTypeParams["charset"], "utf-8")
tcompareReader(t, part.Parts[2].Reader(), m.Original)
tcompare(t, pmsg.Recipients[0].FinalRecipient, m.Recipients[0].FinalRecipient)
msgbufutf8, err = m.Compose(log, true)
if err != nil {
t.Fatalf("composing utf-8 dsn with utf-8 support: %v", err)
}
pmsg, part = tparseMessage(t, msgbufutf8, 3)
tcheckType(t, part, "multipart", "report", "")
tcheckType(t, &part.Parts[0], "text", "plain", "8bit")
tcheckType(t, &part.Parts[1], "message", "global-delivery-status", "8bit")
tcheckType(t, &part.Parts[2], "message", "global-headers", "8bit")
tcompare(t, part.Parts[2].ContentTypeParams["charset"], "")
tcompareReader(t, part.Parts[2].Reader(), m.Original)
tcompare(t, pmsg.Recipients[0].FinalRecipient, m.Recipients[0].FinalRecipient)
// Now a message without 3rd multipart.
m.Original = nil
msgbufutf8, err = m.Compose(log, true)
if err != nil {
t.Fatalf("composing utf-8 dsn with utf-8 support: %v", err)
}
pmsg, part = tparseMessage(t, msgbufutf8, 2)
tcheckType(t, part, "multipart", "report", "")
tcheckType(t, &part.Parts[0], "text", "plain", "8bit")
tcheckType(t, &part.Parts[1], "message", "global-delivery-status", "8bit")
tcompare(t, pmsg.Recipients[0].FinalRecipient, m.Recipients[0].FinalRecipient)
}
func TestCode(t *testing.T) {
testCodeLine := func(line, ecode, rest string) {
t.Helper()
e, r := codeLine(line)
if e != ecode || r != rest {
t.Fatalf("codeLine %q: got %q %q, expected %q %q", line, e, r, ecode, rest)
}
}
testCodeLine("4.0.0", "4.0.0", "")
testCodeLine("4.0.0 more", "4.0.0", "more")
testCodeLine("other", "", "other")
testCodeLine("other more", "", "other more")
testHasCode := func(line string, exp bool) {
t.Helper()
got := HasCode(line)
if got != exp {
t.Fatalf("HasCode %q: got %v, expected %v", line, got, exp)
}
}
testHasCode("4.0.0", true)
testHasCode("5.7.28", true)
testHasCode("10.0.0", false) // first number must be single digit.
testHasCode("4.1.1 more", true)
testHasCode("other ", false)
testHasCode("4.2.", false)
testHasCode("4.2. ", false)
testHasCode(" 4.2.4", false)
testHasCode(" 4.2.4 ", false)
}

15
dsn/nameip.go Normal file
View File

@ -0,0 +1,15 @@
package dsn
import (
"net"
)
// NameIP represents a name and possibly IP, e.g. representing a connection destination.
type NameIP struct {
Name string
IP net.IP
}
func (n NameIP) IsZero() bool {
return n.Name == "" && n.IP == nil
}

360
dsn/parse.go Normal file
View File

@ -0,0 +1,360 @@
package dsn
import (
"bufio"
"fmt"
"io"
"net/textproto"
"strconv"
"strings"
"time"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/smtp"
)
// Parse reads a DSN message.
//
// A DSN is a multipart internet mail message with 2 or 3 parts: human-readable
// text, machine-parsable text, and optional original message or headers.
//
// The first return value is the machine-parsed DSN message. The second value is
// the entire MIME multipart message. Use its Parts field to access the
// human-readable text and optional original message/headers.
func Parse(r io.ReaderAt) (*Message, *message.Part, error) {
// DSNs can mix and match subtypes with and without utf-8. ../rfc/6533:441
part, err := message.Parse(r)
if err != nil {
return nil, nil, fmt.Errorf("parsing message: %v", err)
}
if part.MediaType != "MULTIPART" || part.MediaSubType != "REPORT" {
return nil, nil, fmt.Errorf(`message has content-type %q, must have "message/report"`, strings.ToLower(part.MediaType+"/"+part.MediaSubType))
}
err = part.Walk()
if err != nil {
return nil, nil, fmt.Errorf("parsing message parts: %v", err)
}
nparts := len(part.Parts)
if nparts != 2 && nparts != 3 {
return nil, nil, fmt.Errorf("invalid dsn, got %d multipart parts, 2 or 3 required", nparts)
}
p0 := part.Parts[0]
if !(p0.MediaType == "" && p0.MediaSubType == "") && !(p0.MediaType == "TEXT" && p0.MediaSubType == "PLAIN") {
return nil, nil, fmt.Errorf(`invalid dsn, first part has content-type %q, must have "text/plain"`, strings.ToLower(p0.MediaType+"/"+p0.MediaSubType))
}
p1 := part.Parts[1]
var m *Message
if !(p1.MediaType == "MESSAGE" && (p1.MediaSubType == "DELIVERY-STATUS" || p1.MediaSubType == "GLOBAL-DELIVERY-STATUS")) {
return nil, nil, fmt.Errorf(`invalid dsn, second part has content-type %q, must have "message/delivery-status" or "message/global-delivery-status"`, strings.ToLower(p1.MediaType+"/"+p1.MediaSubType))
}
utf8 := p1.MediaSubType == "GLOBAL-DELIVERY-STATUS"
m, err = Decode(p1.Reader(), utf8)
if err != nil {
return nil, nil, fmt.Errorf("parsing dsn delivery-status part: %v", err)
}
addressPath := func(a message.Address) (smtp.Path, error) {
d, err := dns.ParseDomain(a.Host)
if err != nil {
return smtp.Path{}, fmt.Errorf("parsing domain: %v", err)
}
return smtp.Path{Localpart: smtp.Localpart(a.User), IPDomain: dns.IPDomain{Domain: d}}, nil
}
if len(part.Envelope.From) == 1 {
m.From, err = addressPath(part.Envelope.From[0])
if err != nil {
return nil, nil, fmt.Errorf("parsing From-header: %v", err)
}
}
if len(part.Envelope.To) == 1 {
m.To, err = addressPath(part.Envelope.To[0])
if err != nil {
return nil, nil, fmt.Errorf("parsing To-header: %v", err)
}
}
m.Subject = part.Envelope.Subject
buf, err := io.ReadAll(p0.Reader())
if err != nil {
return nil, nil, fmt.Errorf("reading human-readable text part: %v", err)
}
m.TextBody = strings.ReplaceAll(string(buf), "\r\n", "\n")
if nparts == 2 {
return m, &part, nil
}
p2 := part.Parts[2]
ct := strings.ToLower(p2.MediaType + "/" + p2.MediaSubType)
switch ct {
case "text/rfc822-headers":
case "message/global-headers":
case "message/rfc822":
case "message/global":
default:
return nil, nil, fmt.Errorf("invalid content-type %q for optional third part with original message/headers", ct)
}
return m, &part, nil
}
// Decode parses the (global) delivery-status part of a DSN.
//
// utf8 indicates if UTF-8 is allowed for this message, if used by the media
// subtype of the message parts.
func Decode(r io.Reader, utf8 bool) (*Message, error) {
m := Message{SMTPUTF8: utf8}
// We are using textproto.Reader to read mime headers. It requires a header section ending in \r\n.
// ../rfc/3464:486
b := bufio.NewReader(io.MultiReader(r, strings.NewReader("\r\n")))
mr := textproto.NewReader(b)
// Read per-message lines.
// ../rfc/3464:1522 ../rfc/6533:366
msgh, err := mr.ReadMIMEHeader()
if err != nil {
return nil, fmt.Errorf("reading per-message lines: %v", err)
}
for k, l := range msgh {
if len(l) != 1 {
return nil, fmt.Errorf("multiple values for %q: %v", k, l)
}
v := l[0]
// note: headers are in canonical form, as parsed by textproto.
switch k {
case "Original-Envelope-Id":
m.OriginalEnvelopeID = v
case "Reporting-Mta":
mta, err := parseMTA(v, utf8)
if err != nil {
return nil, fmt.Errorf("parsing reporting-mta: %v", err)
}
m.ReportingMTA = mta
case "Dsn-Gateway":
mta, err := parseMTA(v, utf8)
if err != nil {
return nil, fmt.Errorf("parsing dsn-gateway: %v", err)
}
m.DSNGateway = mta
case "Received-From-Mta":
mta, err := parseMTA(v, utf8)
if err != nil {
return nil, fmt.Errorf("parsing received-from-mta: %v", err)
}
d, err := dns.ParseDomain(mta)
if err != nil {
return nil, fmt.Errorf("parsing received-from-mta domain %q: %v", mta, err)
}
m.ReceivedFromMTA = smtp.Ehlo{Name: dns.IPDomain{Domain: d}}
case "Arrival-Date":
tm, err := parseDateTime(v)
if err != nil {
return nil, fmt.Errorf("parsing arrival-date: %v", err)
}
m.ArrivalDate = tm
default:
// We'll assume it is an extension field, we'll ignore it for now.
}
}
m.MessageHeader = msgh
required := []string{"Reporting-Mta"}
for _, req := range required {
if _, ok := msgh[req]; !ok {
return nil, fmt.Errorf("missing required recipient field %q", req)
}
}
rh, err := parseRecipientHeader(mr, utf8)
if err != nil {
return nil, fmt.Errorf("reading per-recipient header: %v", err)
}
m.Recipients = []Recipient{rh}
for {
if _, err := b.Peek(1); err == io.EOF {
break
}
rh, err := parseRecipientHeader(mr, utf8)
if err != nil {
return nil, fmt.Errorf("reading another per-recipient header: %v", err)
}
m.Recipients = append(m.Recipients, rh)
}
return &m, nil
}
// ../rfc/3464:1530 ../rfc/6533:370
func parseRecipientHeader(mr *textproto.Reader, utf8 bool) (Recipient, error) {
var r Recipient
h, err := mr.ReadMIMEHeader()
if err != nil {
return Recipient{}, err
}
for k, l := range h {
if len(l) != 1 {
return Recipient{}, fmt.Errorf("multiple values for %q: %v", k, l)
}
v := l[0]
// note: headers are in canonical form, as parsed by textproto.
var err error
switch k {
case "Original-Recipient":
r.OriginalRecipient, err = parseAddress(v, utf8)
case "Final-Recipient":
r.FinalRecipient, err = parseAddress(v, utf8)
case "Action":
a := Action(strings.ToLower(v))
actions := []Action{Failed, Delayed, Delivered, Relayed, Expanded}
var ok bool
for _, x := range actions {
if a == x {
ok = true
break
}
}
if !ok {
err = fmt.Errorf("unrecognized action %q", v)
}
case "Status":
// todo: parse the enhanced status code?
r.Status = v
case "Remote-Mta":
r.RemoteMTA = NameIP{Name: v}
case "Diagnostic-Code":
// ../rfc/3464:518
t := strings.SplitN(v, ";", 2)
dt := strings.TrimSpace(t[0])
if strings.ToLower(dt) != "smtp" {
err = fmt.Errorf("unknown diagnostic-type %q, expected smtp", dt)
} else if len(t) != 2 {
err = fmt.Errorf("missing semicolon to separate diagnostic-type from code")
} else {
r.DiagnosticCode = strings.TrimSpace(t[1])
}
case "Last-Attempt-Date":
r.LastAttemptDate, err = parseDateTime(v)
case "Final-Log-Id":
r.FinalLogID = v
case "Will-Retry-Until":
tm, err := parseDateTime(v)
if err == nil {
r.WillRetryUntil = &tm
}
default:
// todo future: parse localized diagnostic text field?
// We'll assume it is an extension field, we'll ignore it for now.
}
if err != nil {
return Recipient{}, fmt.Errorf("parsing field %q %q: %v", k, v, err)
}
}
required := []string{"Final-Recipient", "Action", "Status"}
for _, req := range required {
if _, ok := h[req]; !ok {
return Recipient{}, fmt.Errorf("missing required recipient field %q", req)
}
}
r.Header = h
return r, nil
}
// ../rfc/3464:525
func parseMTA(s string, utf8 bool) (string, error) {
s = removeComments(s)
t := strings.SplitN(s, ";", 2)
if len(t) != 2 {
return "", fmt.Errorf("missing semicolon that splits type and name")
}
k := strings.TrimSpace(t[0])
if !strings.EqualFold(k, "dns") {
return "", fmt.Errorf("unknown type %q, expected dns", k)
}
return strings.TrimSpace(t[1]), nil
}
func parseDateTime(s string) (time.Time, error) {
s = removeComments(s)
return time.Parse(message.RFC5322Z, s)
}
func parseAddress(s string, utf8 bool) (smtp.Path, error) {
s = removeComments(s)
t := strings.SplitN(s, ";", 2)
// ../rfc/3464:513 ../rfc/6533:250
addrType := strings.ToLower(strings.TrimSpace(t[0]))
if len(t) != 2 {
return smtp.Path{}, fmt.Errorf("missing semicolon that splits address type and address")
} else if addrType == "utf-8" {
if !utf8 {
return smtp.Path{}, fmt.Errorf("utf-8 address type for non-utf-8 dsn")
}
} else if addrType != "rfc822" {
return smtp.Path{}, fmt.Errorf("unrecognized address type %q, expected rfc822", addrType)
}
s = strings.TrimSpace(t[1])
if !utf8 {
for _, c := range s {
if c > 0x7f {
return smtp.Path{}, fmt.Errorf("non-ascii without utf-8 enabled")
}
}
}
// todo: more proper parser
t = strings.SplitN(s, "@", 2)
if len(t) != 2 || t[0] == "" || t[1] == "" {
return smtp.Path{}, fmt.Errorf("invalid email address")
}
d, err := dns.ParseDomain(t[1])
if err != nil {
return smtp.Path{}, fmt.Errorf("parsing domain: %v", err)
}
var lp string
var esc string
for _, c := range t[0] {
if esc == "" && c == '\\' || esc == `\` && (c == 'x' || c == 'X') || esc == `\x` && c == '{' {
if c == 'X' {
c = 'x'
}
esc += string(c)
} else if strings.HasPrefix(esc, `\x{`) {
if c == '}' {
c, err := strconv.ParseInt(esc[3:], 16, 32)
if err != nil {
return smtp.Path{}, fmt.Errorf("parsing localpart with hexpoint: %v", err)
}
lp += string(rune(c))
esc = ""
} else {
esc += string(c)
}
} else {
lp += string(c)
}
}
if esc != "" {
return smtp.Path{}, fmt.Errorf("parsing localpart: unfinished embedded unicode char")
}
p := smtp.Path{Localpart: smtp.Localpart(lp), IPDomain: dns.IPDomain{Domain: d}}
return p, nil
}
func removeComments(s string) string {
n := 0
r := ""
for _, c := range s {
if c == '(' {
n++
} else if c == ')' && n > 0 {
n--
} else if n == 0 {
r += string(c)
}
}
return r
}