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