1// Package dsn parses and composes Delivery Status Notification messages, see
2// RFC 3464 and RFC 6533.
17 "github.com/mjl-/mox/message"
18 "github.com/mjl-/mox/mlog"
19 "github.com/mjl-/mox/smtp"
22// Message represents a DSN message, with basic message headers, human-readable text,
23// machine-parsable data, and optional original message/headers.
25// A DSN represents a delayed, failed or successful delivery. Failing incoming
26// deliveries over SMTP, and failing outgoing deliveries from the message queue,
27// can result in a DSN being sent.
29 SMTPUTF8 bool // Whether the original was received with smtputf8.
31 // DSN message From header. E.g. postmaster@ourdomain.example. NOTE:
32 // DSNs should be sent with a null reverse path to prevent mail loops.
36 // "To" header, and also SMTP RCP TO to deliver DSN to. Should be taken
37 // from original SMTP transaction MAIL FROM.
41 // Message subject header, e.g. describing mail delivery failure.
46 // References header, with Message-ID of original message this DSN is about. So
47 // mail user-agents will thread the DSN with the original message.
50 // For message submitted with FUTURERELEASE SMTP extension. Value is either "for;"
51 // plus original interval in seconds or "until;" plus original UTC RFC3339
53 FutureReleaseRequest string
56 // Human-readable text explaining the failure. Line endings should be
57 // bare newlines, not \r\n. They are converted to \r\n when composing.
60 // Per-message fields.
61 OriginalEnvelopeID string
62 ReportingMTA string // Required.
64 ReceivedFromMTA smtp.Ehlo // Host from which message was received.
67 // All per-message fields, including extensions. Only used for parsing,
69 MessageHeader textproto.MIMEHeader
71 // One or more per-recipient fields.
73 Recipients []Recipient
75 // Original message or headers to include in DSN as third MIME part.
76 // Optional. Only used for generating DSNs, not set for parsed DNSs.
80// Action is a field in a DSN.
86 Failed Action = "failed"
87 Delayed Action = "delayed"
88 Delivered Action = "delivered"
89 Relayed Action = "relayed"
90 Expanded Action = "expanded"
95// Recipient holds the per-recipient delivery-status lines in a DSN.
96type Recipient struct {
98 FinalRecipient smtp.Path // Final recipient of message.
101 // Enhanced status code. First digit indicates permanent or temporary
104 // For additional details, included in comment.
108 // Original intended recipient of message. Used with the DSN extensions ORCPT
111 OriginalRecipient smtp.Path
113 // Remote host that returned an error code. Can also be empty for
117 // DiagnosticCodeSMTP are the full SMTP response lines, space separated. The marshaled
118 // form starts with "smtp; ", this value does not.
119 DiagnosticCodeSMTP string
121 LastAttemptDate time.Time
124 // For delayed deliveries, deliveries may be retried until this time.
125 WillRetryUntil *time.Time
127 // All fields, including extensions. Only used for parsing, not
129 Header textproto.MIMEHeader
132// Compose returns a DSN message.
134// smtputf8 indicates whether the remote MTA that is receiving the DSN
135// supports smtputf8. This influences the message media (sub)types used for the
138// Called may want to add DKIM-Signature headers.
139func (m *Message) Compose(log mlog.Log, smtputf8 bool) ([]byte, error) {
142 // We'll make a multipart/report with 2 or 3 parts:
143 // - 1. human-readable explanation;
144 // - 2. message/delivery-status;
145 // - 3. (optional) original message (either in full, or only headers).
147 // todo future: add option to send full message. but only do so if the message is <100kb.
148 // todo future: possibly write to a file directly, instead of building up message in memory.
150 // If message does not require smtputf8, we are never generating a utf-8 DSN.
155 // We check for errors once after all the writes.
156 msgw := &errWriter{w: &bytes.Buffer{}}
158 header := func(k, v string) {
159 fmt.Fprintf(msgw, "%s: %s\r\n", k, v)
162 line := func(w io.Writer) {
163 _, _ = w.Write([]byte("\r\n"))
166 // Outer message headers.
167 header("From", fmt.Sprintf("<%s>", m.From.XString(smtputf8))) // todo: would be good to have a local ascii-only name for this address.
168 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.
169 header("Subject", m.Subject)
170 if m.MessageID == "" {
171 return nil, fmt.Errorf("missing message-id")
173 header("Message-Id", fmt.Sprintf("<%s>", m.MessageID))
174 if m.References != "" {
175 header("References", m.References)
177 header("Date", time.Now().Format(message.RFC5322Z))
178 header("MIME-Version", "1.0")
179 mp := multipart.NewWriter(msgw)
180 header("Content-Type", fmt.Sprintf(`multipart/report; report-type="delivery-status"; boundary="%s"`, mp.Boundary()))
184 // First part, human-readable message.
185 msgHdr := textproto.MIMEHeader{}
187 msgHdr.Set("Content-Type", "text/plain; charset=utf-8")
188 msgHdr.Set("Content-Transfer-Encoding", "8BIT")
190 msgHdr.Set("Content-Type", "text/plain")
191 msgHdr.Set("Content-Transfer-Encoding", "7BIT")
193 msgp, err := mp.CreatePart(msgHdr)
197 if _, err := msgp.Write([]byte(strings.ReplaceAll(m.TextBody, "\n", "\r\n"))); err != nil {
202 statusHdr := textproto.MIMEHeader{}
205 statusHdr.Set("Content-Type", "message/global-delivery-status")
206 statusHdr.Set("Content-Transfer-Encoding", "8BIT")
208 statusHdr.Set("Content-Type", "message/delivery-status")
209 statusHdr.Set("Content-Transfer-Encoding", "7BIT")
211 statusp, err := mp.CreatePart(statusHdr)
218 // type fields:
../rfc/3464:536 https://www.iana.org/assignments/dsn-types/dsn-types.xhtml
220 status := func(k, v string) {
221 fmt.Fprintf(statusp, "%s: %s\r\n", k, v)
226 if m.OriginalEnvelopeID != "" {
227 status("Original-Envelope-ID", m.OriginalEnvelopeID)
230 if m.DSNGateway != "" {
232 status("DSN-Gateway", "dns; "+m.DSNGateway)
234 if !m.ReceivedFromMTA.IsZero() {
236 status("Received-From-MTA", fmt.Sprintf("dns;%s (%s)", m.ReceivedFromMTA.Name, smtp.AddressLiteral(m.ReceivedFromMTA.ConnIP)))
239 if m.FutureReleaseRequest != "" {
241 status("Future-Release-Request", m.FutureReleaseRequest)
245 // todo: should also handle other address types. at least recognize "unknown". Probably just store this field.
../rfc/3464:819
250 if len(m.Recipients) == 0 {
251 return nil, fmt.Errorf("missing per-recipient fields")
253 for _, r := range m.Recipients {
255 if !r.OriginalRecipient.IsZero() {
257 status("Original-Recipient", addrType+r.OriginalRecipient.DSNString(smtputf8))
259 status("Final-Recipient", addrType+r.FinalRecipient.DSNString(smtputf8)) //
../rfc/3464:829
264 // Making up a status code is not great, but the field is required. We could simply
265 // require the caller to make one up...
276 if r.StatusComment != "" {
277 statusLine += " (" + r.StatusComment + ")"
280 if !r.RemoteMTA.IsZero() {
282 s := "dns;" + r.RemoteMTA.Name
283 if len(r.RemoteMTA.IP) > 0 {
284 s += " (" + smtp.AddressLiteral(r.RemoteMTA.IP) + ")"
286 status("Remote-MTA", s)
289 if r.DiagnosticCodeSMTP != "" {
291 status("Diagnostic-Code", "smtp; "+r.DiagnosticCodeSMTP)
293 if !r.LastAttemptDate.IsZero() {
294 status("Last-Attempt-Date", r.LastAttemptDate.Format(message.RFC5322Z)) //
../rfc/3464:1076
296 if r.FinalLogID != "" {
297 // todo future: think about adding cid as "Final-Log-Id"?
300 if r.WillRetryUntil != nil {
305 // We include only the header of the original message.
306 // todo: add the textual version of the original message, if it exists and isn't too large.
307 if m.Original != nil {
308 headers, err := message.ReadHeaders(bufio.NewReader(bytes.NewReader(m.Original)))
309 if err != nil && errors.Is(err, message.ErrHeaderSeparator) {
310 // Whole data is a header.
312 } else if err != nil {
315 // Else, this is a whole message. We still only include the headers. todo: include the whole body.
317 origHdr := textproto.MIMEHeader{}
322 origHdr.Set("Content-Transfer-Encoding", "8BIT")
327 origHdr.Set("Content-Type", "text/rfc822-headers; charset=utf-8")
328 origHdr.Set("Content-Transfer-Encoding", "BASE64")
330 origHdr.Set("Content-Type", "text/rfc822-headers")
331 origHdr.Set("Content-Transfer-Encoding", "7BIT")
334 origp, err := mp.CreatePart(origHdr)
339 if !smtputf8 && m.SMTPUTF8 {
340 data := base64.StdEncoding.EncodeToString(headers)
347 line, data = data[:n], data[n:]
348 if _, err := origp.Write([]byte(line + "\r\n")); err != nil {
353 if _, err := origp.Write(headers); err != nil {
359 if err := mp.Close(); err != nil {
367 data := msgw.w.Bytes()
371type errWriter struct {
376func (w *errWriter) Write(buf []byte) (int, error) {
380 n, err := w.w.Write(buf)