1// Package dsn parses and composes Delivery Status Notification messages, see
2// RFC 3464 and RFC 6533.
3package dsn
4
5import (
6 "bufio"
7 "bytes"
8 "encoding/base64"
9 "errors"
10 "fmt"
11 "io"
12 "mime/multipart"
13 "net/textproto"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/mjl-/mox/message"
19 "github.com/mjl-/mox/mlog"
20 "github.com/mjl-/mox/smtp"
21)
22
23// Message represents a DSN message, with basic message headers, human-readable text,
24// machine-parsable data, and optional original message/headers.
25//
26// A DSN represents a delayed, failed or successful delivery. Failing incoming
27// deliveries over SMTP, and failing outgoing deliveries from the message queue,
28// can result in a DSN being sent.
29type Message struct {
30 SMTPUTF8 bool // Whether the original was received with smtputf8.
31
32 // DSN message From header. E.g. postmaster@ourdomain.example. NOTE:
33 // DSNs should be sent with a null reverse path to prevent mail loops.
34 // ../rfc/3464:421
35 From smtp.Path
36
37 // "To" header, and also SMTP RCP TO to deliver DSN to. Should be taken
38 // from original SMTP transaction MAIL FROM.
39 // ../rfc/3464:415
40 To smtp.Path
41
42 // Message subject header, e.g. describing mail delivery failure.
43 Subject string
44
45 MessageID string
46
47 // References header, with Message-ID of original message this DSN is about. So
48 // mail user-agents will thread the DSN with the original message.
49 References string
50
51 // Human-readable text explaining the failure. Line endings should be
52 // bare newlines, not \r\n. They are converted to \r\n when composing.
53 TextBody string
54
55 // Per-message fields.
56 OriginalEnvelopeID string
57 ReportingMTA string // Required.
58 DSNGateway string
59 ReceivedFromMTA smtp.Ehlo // Host from which message was received.
60 ArrivalDate time.Time
61
62 // All per-message fields, including extensions. Only used for parsing,
63 // not composing.
64 MessageHeader textproto.MIMEHeader
65
66 // One or more per-recipient fields.
67 // ../rfc/3464:436
68 Recipients []Recipient
69
70 // Original message or headers to include in DSN as third MIME part.
71 // Optional. Only used for generating DSNs, not set for parsed DNSs.
72 Original []byte
73}
74
75// Action is a field in a DSN.
76type Action string
77
78// ../rfc/3464:890
79
80const (
81 Failed Action = "failed"
82 Delayed Action = "delayed"
83 Delivered Action = "delivered"
84 Relayed Action = "relayed"
85 Expanded Action = "expanded"
86)
87
88// ../rfc/3464:1530 ../rfc/6533:370
89
90// Recipient holds the per-recipient delivery-status lines in a DSN.
91type Recipient struct {
92 // Required fields.
93 FinalRecipient smtp.Path // Final recipient of message.
94 Action Action
95
96 // Enhanced status code. First digit indicates permanent or temporary
97 // error. If the string contains more than just a status, that
98 // additional text is added as comment when composing a DSN.
99 Status string
100
101 // Optional fields.
102 // Original intended recipient of message. Used with the DSN extensions ORCPT
103 // parameter.
104 // ../rfc/3464:1197
105 OriginalRecipient smtp.Path
106
107 // Remote host that returned an error code. Can also be empty for
108 // deliveries.
109 RemoteMTA NameIP
110
111 // If RemoteMTA is present, DiagnosticCode is from remote. When
112 // creating a DSN, additional text in the string will be added to the
113 // DSN as comment.
114 DiagnosticCode string
115 LastAttemptDate time.Time
116 FinalLogID string
117
118 // For delayed deliveries, deliveries may be retried until this time.
119 WillRetryUntil *time.Time
120
121 // All fields, including extensions. Only used for parsing, not
122 // composing.
123 Header textproto.MIMEHeader
124}
125
126// Compose returns a DSN message.
127//
128// smtputf8 indicates whether the remote MTA that is receiving the DSN
129// supports smtputf8. This influences the message media (sub)types used for the
130// DSN.
131//
132// Called may want to add DKIM-Signature headers.
133func (m *Message) Compose(log mlog.Log, smtputf8 bool) ([]byte, error) {
134 // ../rfc/3462:119
135 // ../rfc/3464:377
136 // We'll make a multipart/report with 2 or 3 parts:
137 // - 1. human-readable explanation;
138 // - 2. message/delivery-status;
139 // - 3. (optional) original message (either in full, or only headers).
140
141 // todo future: add option to send full message. but only do so if the message is <100kb.
142 // todo future: possibly write to a file directly, instead of building up message in memory.
143
144 // If message does not require smtputf8, we are never generating a utf-8 DSN.
145 if !m.SMTPUTF8 {
146 smtputf8 = false
147 }
148
149 // We check for errors once after all the writes.
150 msgw := &errWriter{w: &bytes.Buffer{}}
151
152 header := func(k, v string) {
153 fmt.Fprintf(msgw, "%s: %s\r\n", k, v)
154 }
155
156 line := func(w io.Writer) {
157 _, _ = w.Write([]byte("\r\n"))
158 }
159
160 // Outer message headers.
161 header("From", fmt.Sprintf("<%s>", m.From.XString(smtputf8))) // todo: would be good to have a local ascii-only name for this address.
162 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.
163 header("Subject", m.Subject)
164 if m.MessageID == "" {
165 return nil, fmt.Errorf("missing message-id")
166 }
167 header("Message-Id", fmt.Sprintf("<%s>", m.MessageID))
168 if m.References != "" {
169 header("References", m.References)
170 }
171 header("Date", time.Now().Format(message.RFC5322Z))
172 header("MIME-Version", "1.0")
173 mp := multipart.NewWriter(msgw)
174 header("Content-Type", fmt.Sprintf(`multipart/report; report-type="delivery-status"; boundary="%s"`, mp.Boundary()))
175
176 line(msgw)
177
178 // First part, human-readable message.
179 msgHdr := textproto.MIMEHeader{}
180 if smtputf8 {
181 msgHdr.Set("Content-Type", "text/plain; charset=utf-8")
182 msgHdr.Set("Content-Transfer-Encoding", "8BIT")
183 } else {
184 msgHdr.Set("Content-Type", "text/plain")
185 msgHdr.Set("Content-Transfer-Encoding", "7BIT")
186 }
187 msgp, err := mp.CreatePart(msgHdr)
188 if err != nil {
189 return nil, err
190 }
191 if _, err := msgp.Write([]byte(strings.ReplaceAll(m.TextBody, "\n", "\r\n"))); err != nil {
192 return nil, err
193 }
194
195 // Machine-parsable message. ../rfc/3464:455
196 statusHdr := textproto.MIMEHeader{}
197 if smtputf8 {
198 // ../rfc/6533:325
199 statusHdr.Set("Content-Type", "message/global-delivery-status")
200 statusHdr.Set("Content-Transfer-Encoding", "8BIT")
201 } else {
202 statusHdr.Set("Content-Type", "message/delivery-status")
203 statusHdr.Set("Content-Transfer-Encoding", "7BIT")
204 }
205 statusp, err := mp.CreatePart(statusHdr)
206 if err != nil {
207 return nil, err
208 }
209
210 // ../rfc/3464:470
211 // examples: ../rfc/3464:1855
212 // type fields: ../rfc/3464:536 https://www.iana.org/assignments/dsn-types/dsn-types.xhtml
213
214 status := func(k, v string) {
215 fmt.Fprintf(statusp, "%s: %s\r\n", k, v)
216 }
217
218 // Per-message fields first. ../rfc/3464:575
219 // todo future: once we support the smtp dsn extension, the envid should be saved/set as OriginalEnvelopeID. ../rfc/3464:583 ../rfc/3461:1139
220 if m.OriginalEnvelopeID != "" {
221 status("Original-Envelope-ID", m.OriginalEnvelopeID)
222 }
223 status("Reporting-MTA", "dns; "+m.ReportingMTA) // ../rfc/3464:628
224 if m.DSNGateway != "" {
225 // ../rfc/3464:714
226 status("DSN-Gateway", "dns; "+m.DSNGateway)
227 }
228 if !m.ReceivedFromMTA.IsZero() {
229 // ../rfc/3464:735
230 status("Received-From-MTA", fmt.Sprintf("dns;%s (%s)", m.ReceivedFromMTA.Name, smtp.AddressLiteral(m.ReceivedFromMTA.ConnIP)))
231 }
232 status("Arrival-Date", m.ArrivalDate.Format(message.RFC5322Z)) // ../rfc/3464:758
233
234 // Then per-recipient fields. ../rfc/3464:769
235 // todo: should also handle other address types. at least recognize "unknown". Probably just store this field. ../rfc/3464:819
236 addrType := "rfc822;" // ../rfc/3464:514
237 if smtputf8 {
238 addrType = "utf-8;" // ../rfc/6533:250
239 }
240 if len(m.Recipients) == 0 {
241 return nil, fmt.Errorf("missing per-recipient fields")
242 }
243 for _, r := range m.Recipients {
244 line(statusp)
245 if !r.OriginalRecipient.IsZero() {
246 // ../rfc/3464:807
247 status("Original-Recipient", addrType+r.OriginalRecipient.DSNString(smtputf8))
248 }
249 status("Final-Recipient", addrType+r.FinalRecipient.DSNString(smtputf8)) // ../rfc/3464:829
250 status("Action", string(r.Action)) // ../rfc/3464:879
251 st := r.Status
252 if st == "" {
253 // ../rfc/3464:944
254 // Making up a status code is not great, but the field is required. We could simply
255 // require the caller to make one up...
256 switch r.Action {
257 case Delayed:
258 st = "4.0.0"
259 case Failed:
260 st = "5.0.0"
261 default:
262 st = "2.0.0"
263 }
264 }
265 var rest string
266 st, rest = codeLine(st)
267 statusLine := st
268 if rest != "" {
269 statusLine += " (" + rest + ")"
270 }
271 status("Status", statusLine) // ../rfc/3464:975
272 if !r.RemoteMTA.IsZero() {
273 // ../rfc/3464:1015
274 s := "dns;" + r.RemoteMTA.Name
275 if len(r.RemoteMTA.IP) > 0 {
276 s += " (" + smtp.AddressLiteral(r.RemoteMTA.IP) + ")"
277 }
278 status("Remote-MTA", s)
279 }
280 // Presence of Diagnostic-Code indicates the code is from Remote-MTA. ../rfc/3464:1053
281 if r.DiagnosticCode != "" {
282 diagCode, rest := codeLine(r.DiagnosticCode)
283 diagLine := diagCode
284 if rest != "" {
285 diagLine += " (" + rest + ")"
286 }
287 // ../rfc/6533:589
288 status("Diagnostic-Code", "smtp; "+diagLine)
289 }
290 if !r.LastAttemptDate.IsZero() {
291 status("Last-Attempt-Date", r.LastAttemptDate.Format(message.RFC5322Z)) // ../rfc/3464:1076
292 }
293 if r.FinalLogID != "" {
294 // todo future: think about adding cid as "Final-Log-Id"?
295 status("Final-Log-ID", r.FinalLogID) // ../rfc/3464:1098
296 }
297 if r.WillRetryUntil != nil {
298 status("Will-Retry-Until", r.WillRetryUntil.Format(message.RFC5322Z)) // ../rfc/3464:1108
299 }
300 }
301
302 // We include only the header of the original message.
303 // todo: add the textual version of the original message, if it exists and isn't too large.
304 if m.Original != nil {
305 headers, err := message.ReadHeaders(bufio.NewReader(bytes.NewReader(m.Original)))
306 if err != nil && errors.Is(err, message.ErrHeaderSeparator) {
307 // Whole data is a header.
308 headers = m.Original
309 } else if err != nil {
310 return nil, err
311 }
312 // Else, this is a whole message. We still only include the headers. todo: include the whole body.
313
314 origHdr := textproto.MIMEHeader{}
315 if smtputf8 {
316 // ../rfc/6533:431
317 // ../rfc/6533:605
318 origHdr.Set("Content-Type", "message/global-headers") // ../rfc/6533:625
319 origHdr.Set("Content-Transfer-Encoding", "8BIT")
320 } else {
321 // ../rfc/3462:175
322 if m.SMTPUTF8 {
323 // ../rfc/6533:480
324 origHdr.Set("Content-Type", "text/rfc822-headers; charset=utf-8")
325 origHdr.Set("Content-Transfer-Encoding", "BASE64")
326 } else {
327 origHdr.Set("Content-Type", "text/rfc822-headers")
328 origHdr.Set("Content-Transfer-Encoding", "7BIT")
329 }
330 }
331 origp, err := mp.CreatePart(origHdr)
332 if err != nil {
333 return nil, err
334 }
335
336 if !smtputf8 && m.SMTPUTF8 {
337 data := base64.StdEncoding.EncodeToString(headers)
338 for len(data) > 0 {
339 line := data
340 n := len(line)
341 if n > 78 {
342 n = 78
343 }
344 line, data = data[:n], data[n:]
345 if _, err := origp.Write([]byte(line + "\r\n")); err != nil {
346 return nil, err
347 }
348 }
349 } else {
350 if _, err := origp.Write(headers); err != nil {
351 return nil, err
352 }
353 }
354 }
355
356 if err := mp.Close(); err != nil {
357 return nil, err
358 }
359
360 if msgw.err != nil {
361 return nil, err
362 }
363
364 data := msgw.w.Bytes()
365 return data, nil
366}
367
368type errWriter struct {
369 w *bytes.Buffer
370 err error
371}
372
373func (w *errWriter) Write(buf []byte) (int, error) {
374 if w.err != nil {
375 return -1, w.err
376 }
377 n, err := w.w.Write(buf)
378 w.err = err
379 return n, err
380}
381
382// split a line into enhanced status code and rest.
383func codeLine(s string) (string, string) {
384 t := strings.SplitN(s, " ", 2)
385 l := strings.Split(t[0], ".")
386 if len(l) != 3 {
387 return "", s
388 }
389 for i, e := range l {
390 _, err := strconv.ParseInt(e, 10, 32)
391 if err != nil {
392 return "", s
393 }
394 if i == 0 && len(e) != 1 {
395 return "", s
396 }
397 }
398
399 var rest string
400 if len(t) == 2 {
401 rest = t[1]
402 }
403 return t[0], rest
404}
405
406// HasCode returns whether line starts with an enhanced SMTP status code.
407func HasCode(line string) bool {
408 // ../rfc/3464:986
409 ecode, _ := codeLine(line)
410 return ecode != ""
411}
412