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