mox/dsn/dsn.go

384 lines
11 KiB
Go
Raw Permalink Normal View History

2023-01-30 16:27:06 +03:00
// Package dsn parses and composes Delivery Status Notification messages, see
// RFC 3464 and RFC 6533.
package dsn
import (
"bufio"
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"strings"
"time"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/mlog"
"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
MessageID string
// References header, with Message-ID of original message this DSN is about. So
// mail user-agents will thread the DSN with the original message.
References string
// For message submitted with FUTURERELEASE SMTP extension. Value is either "for;"
// plus original interval in seconds or "until;" plus original UTC RFC3339
// date-time.
FutureReleaseRequest string
// ../rfc/4865:315
2023-01-30 16:27:06 +03:00
// 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.
2023-01-30 16:27:06 +03:00
Status string
// For additional details, included in comment.
StatusComment string
2023-01-30 16:27:06 +03:00
// 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
add a webapi and webhooks for a simple http/json-based api for applications to compose/send messages, receive delivery feedback, and maintain suppression lists. this is an alternative to applications using a library to compose messages, submitting those messages using smtp, and monitoring a mailbox with imap for DSNs, which can be processed into the equivalent of suppression lists. but you need to know about all these standards/protocols and find libraries. by using the webapi & webhooks, you just need a http & json library. unfortunately, there is no standard for these kinds of api, so mox has made up yet another one... matching incoming DSNs about deliveries to original outgoing messages requires keeping history of "retired" messages (delivered from the queue, either successfully or failed). this can be enabled per account. history is also useful for debugging deliveries. we now also keep history of each delivery attempt, accessible while still in the queue, and kept when a message is retired. the queue webadmin pages now also have pagination, to show potentially large history. a queue of webhook calls is now managed too. failures are retried similar to message deliveries. webhooks can also be saved to the retired list after completing. also configurable per account. messages can be sent with a "unique smtp mail from" address. this can only be used if the domain is configured with a localpart catchall separator such as "+". when enabled, a queued message gets assigned a random "fromid", which is added after the separator when sending. when DSNs are returned, they can be related to previously sent messages based on this fromid. in the future, we can implement matching on the "envid" used in the smtp dsn extension, or on the "message-id" of the message. using a fromid can be triggered by authenticating with a login email address that is configured as enabling fromid. suppression lists are automatically managed per account. if a delivery attempt results in certain smtp errors, the destination address is added to the suppression list. future messages queued for that recipient will immediately fail without a delivery attempt. suppression lists protect your mail server reputation. submitted messages can carry "extra" data through the queue and webhooks for outgoing deliveries. through webapi as a json object, through smtp submission as message headers of the form "x-mox-extra-<key>: value". to make it easy to test webapi/webhooks locally, the "localserve" mode actually puts messages in the queue. when it's time to deliver, it still won't do a full delivery attempt, but just delivers to the sender account. unless the recipient address has a special form, simulating a failure to deliver. admins now have more control over the queue. "hold rules" can be added to mark newly queued messages as "on hold", pausing delivery. rules can be about certain sender or recipient domains/addresses, or apply to all messages pausing the entire queue. also useful for (local) testing. new config options have been introduced. they are editable through the admin and/or account web interfaces. the webapi http endpoints are enabled for newly generated configs with the quickstart, and in localserve. existing configurations must explicitly enable the webapi in mox.conf. gopherwatch.org was created to dogfood this code. it initially used just the compose/smtpclient/imapclient mox packages to send messages and process delivery feedback. it will get a config option to use the mox webapi/webhooks instead. the gopherwatch code to use webapi/webhook is smaller and simpler, and developing that shaped development of the mox webapi/webhooks. for issue #31 by cuu508
2024-04-15 22:49:02 +03:00
// DiagnosticCodeSMTP are the full SMTP response lines, space separated. The marshaled
// form starts with "smtp; ", this value does not.
DiagnosticCodeSMTP string
2023-01-30 16:27:06 +03:00
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.
//
// Called may want to add DKIM-Signature headers.
func (m *Message) Compose(log mlog.Log, smtputf8 bool) ([]byte, error) {
2023-01-30 16:27:06 +03:00
// ../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"))
2023-01-30 16:27:06 +03:00
}
// 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)
if m.MessageID == "" {
return nil, fmt.Errorf("missing message-id")
}
header("Message-Id", fmt.Sprintf("<%s>", m.MessageID))
if m.References != "" {
header("References", m.References)
}
2023-01-30 16:27:06 +03:00
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
}
if _, err := msgp.Write([]byte(strings.ReplaceAll(m.TextBody, "\n", "\r\n"))); err != nil {
return nil, err
}
2023-01-30 16:27:06 +03:00
// 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
if m.FutureReleaseRequest != "" {
// ../rfc/4865:320
status("Future-Release-Request", m.FutureReleaseRequest)
}
2023-01-30 16:27:06 +03:00
// 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"
}
}
statusLine := st
if r.StatusComment != "" {
statusLine += " (" + r.StatusComment + ")"
2023-01-30 16:27:06 +03:00
}
status("Status", statusLine) // ../rfc/3464:975
if !r.RemoteMTA.IsZero() {
// ../rfc/3464:1015
s := "dns;" + r.RemoteMTA.Name
if len(r.RemoteMTA.IP) > 0 {
s += " (" + smtp.AddressLiteral(r.RemoteMTA.IP) + ")"
}
status("Remote-MTA", s)
2023-01-30 16:27:06 +03:00
}
// Presence of Diagnostic-Code indicates the code is from Remote-MTA. ../rfc/3464:1053
add a webapi and webhooks for a simple http/json-based api for applications to compose/send messages, receive delivery feedback, and maintain suppression lists. this is an alternative to applications using a library to compose messages, submitting those messages using smtp, and monitoring a mailbox with imap for DSNs, which can be processed into the equivalent of suppression lists. but you need to know about all these standards/protocols and find libraries. by using the webapi & webhooks, you just need a http & json library. unfortunately, there is no standard for these kinds of api, so mox has made up yet another one... matching incoming DSNs about deliveries to original outgoing messages requires keeping history of "retired" messages (delivered from the queue, either successfully or failed). this can be enabled per account. history is also useful for debugging deliveries. we now also keep history of each delivery attempt, accessible while still in the queue, and kept when a message is retired. the queue webadmin pages now also have pagination, to show potentially large history. a queue of webhook calls is now managed too. failures are retried similar to message deliveries. webhooks can also be saved to the retired list after completing. also configurable per account. messages can be sent with a "unique smtp mail from" address. this can only be used if the domain is configured with a localpart catchall separator such as "+". when enabled, a queued message gets assigned a random "fromid", which is added after the separator when sending. when DSNs are returned, they can be related to previously sent messages based on this fromid. in the future, we can implement matching on the "envid" used in the smtp dsn extension, or on the "message-id" of the message. using a fromid can be triggered by authenticating with a login email address that is configured as enabling fromid. suppression lists are automatically managed per account. if a delivery attempt results in certain smtp errors, the destination address is added to the suppression list. future messages queued for that recipient will immediately fail without a delivery attempt. suppression lists protect your mail server reputation. submitted messages can carry "extra" data through the queue and webhooks for outgoing deliveries. through webapi as a json object, through smtp submission as message headers of the form "x-mox-extra-<key>: value". to make it easy to test webapi/webhooks locally, the "localserve" mode actually puts messages in the queue. when it's time to deliver, it still won't do a full delivery attempt, but just delivers to the sender account. unless the recipient address has a special form, simulating a failure to deliver. admins now have more control over the queue. "hold rules" can be added to mark newly queued messages as "on hold", pausing delivery. rules can be about certain sender or recipient domains/addresses, or apply to all messages pausing the entire queue. also useful for (local) testing. new config options have been introduced. they are editable through the admin and/or account web interfaces. the webapi http endpoints are enabled for newly generated configs with the quickstart, and in localserve. existing configurations must explicitly enable the webapi in mox.conf. gopherwatch.org was created to dogfood this code. it initially used just the compose/smtpclient/imapclient mox packages to send messages and process delivery feedback. it will get a config option to use the mox webapi/webhooks instead. the gopherwatch code to use webapi/webhook is smaller and simpler, and developing that shaped development of the mox webapi/webhooks. for issue #31 by cuu508
2024-04-15 22:49:02 +03:00
if r.DiagnosticCodeSMTP != "" {
// ../rfc/3461:1342 ../rfc/6533:589
add a webapi and webhooks for a simple http/json-based api for applications to compose/send messages, receive delivery feedback, and maintain suppression lists. this is an alternative to applications using a library to compose messages, submitting those messages using smtp, and monitoring a mailbox with imap for DSNs, which can be processed into the equivalent of suppression lists. but you need to know about all these standards/protocols and find libraries. by using the webapi & webhooks, you just need a http & json library. unfortunately, there is no standard for these kinds of api, so mox has made up yet another one... matching incoming DSNs about deliveries to original outgoing messages requires keeping history of "retired" messages (delivered from the queue, either successfully or failed). this can be enabled per account. history is also useful for debugging deliveries. we now also keep history of each delivery attempt, accessible while still in the queue, and kept when a message is retired. the queue webadmin pages now also have pagination, to show potentially large history. a queue of webhook calls is now managed too. failures are retried similar to message deliveries. webhooks can also be saved to the retired list after completing. also configurable per account. messages can be sent with a "unique smtp mail from" address. this can only be used if the domain is configured with a localpart catchall separator such as "+". when enabled, a queued message gets assigned a random "fromid", which is added after the separator when sending. when DSNs are returned, they can be related to previously sent messages based on this fromid. in the future, we can implement matching on the "envid" used in the smtp dsn extension, or on the "message-id" of the message. using a fromid can be triggered by authenticating with a login email address that is configured as enabling fromid. suppression lists are automatically managed per account. if a delivery attempt results in certain smtp errors, the destination address is added to the suppression list. future messages queued for that recipient will immediately fail without a delivery attempt. suppression lists protect your mail server reputation. submitted messages can carry "extra" data through the queue and webhooks for outgoing deliveries. through webapi as a json object, through smtp submission as message headers of the form "x-mox-extra-<key>: value". to make it easy to test webapi/webhooks locally, the "localserve" mode actually puts messages in the queue. when it's time to deliver, it still won't do a full delivery attempt, but just delivers to the sender account. unless the recipient address has a special form, simulating a failure to deliver. admins now have more control over the queue. "hold rules" can be added to mark newly queued messages as "on hold", pausing delivery. rules can be about certain sender or recipient domains/addresses, or apply to all messages pausing the entire queue. also useful for (local) testing. new config options have been introduced. they are editable through the admin and/or account web interfaces. the webapi http endpoints are enabled for newly generated configs with the quickstart, and in localserve. existing configurations must explicitly enable the webapi in mox.conf. gopherwatch.org was created to dogfood this code. it initially used just the compose/smtpclient/imapclient mox packages to send messages and process delivery feedback. it will get a config option to use the mox webapi/webhooks instead. the gopherwatch code to use webapi/webhook is smaller and simpler, and developing that shaped development of the mox webapi/webhooks. for issue #31 by cuu508
2024-04-15 22:49:02 +03:00
status("Diagnostic-Code", "smtp; "+r.DiagnosticCodeSMTP)
2023-01-30 16:27:06 +03:00
}
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.
2023-01-30 16:27:06 +03:00
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:]
if _, err := origp.Write([]byte(line + "\r\n")); err != nil {
return nil, err
}
2023-01-30 16:27:06 +03:00
}
} else {
if _, err := origp.Write(headers); err != nil {
return nil, err
}
2023-01-30 16:27:06 +03:00
}
}
if err := mp.Close(); err != nil {
return nil, err
}
if msgw.err != nil {
return nil, err
}
data := msgw.w.Bytes()
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
}