12 "github.com/mjl-/mox/dns"
13 "github.com/mjl-/mox/message"
14 "github.com/mjl-/mox/mlog"
15 "github.com/mjl-/mox/smtp"
18// Parse reads a DSN message.
20// A DSN is a multipart internet mail message with 2 or 3 parts: human-readable
21// text, machine-parsable text, and optional original message or headers.
23// The first return value is the machine-parsed DSN message. The second value is
24// the entire MIME multipart message. Use its Parts field to access the
25// human-readable text and optional original message/headers.
26func Parse(log *mlog.Log, r io.ReaderAt) (*Message, *message.Part, error) {
29 part, err := message.Parse(log, false, r)
31 return nil, nil, fmt.Errorf("parsing message: %v", err)
33 if part.MediaType != "MULTIPART" || part.MediaSubType != "REPORT" {
34 return nil, nil, fmt.Errorf(`message has content-type %q, must have "message/report"`, strings.ToLower(part.MediaType+"/"+part.MediaSubType))
36 err = part.Walk(log, nil)
38 return nil, nil, fmt.Errorf("parsing message parts: %v", err)
40 nparts := len(part.Parts)
41 if nparts != 2 && nparts != 3 {
42 return nil, nil, fmt.Errorf("invalid dsn, got %d multipart parts, 2 or 3 required", nparts)
45 if !(p0.MediaType == "" && p0.MediaSubType == "") && !(p0.MediaType == "TEXT" && p0.MediaSubType == "PLAIN") {
46 return nil, nil, fmt.Errorf(`invalid dsn, first part has content-type %q, must have "text/plain"`, strings.ToLower(p0.MediaType+"/"+p0.MediaSubType))
51 if !(p1.MediaType == "MESSAGE" && (p1.MediaSubType == "DELIVERY-STATUS" || p1.MediaSubType == "GLOBAL-DELIVERY-STATUS")) {
52 return nil, nil, fmt.Errorf(`invalid dsn, second part has content-type %q, must have "message/delivery-status" or "message/global-delivery-status"`, strings.ToLower(p1.MediaType+"/"+p1.MediaSubType))
54 utf8 := p1.MediaSubType == "GLOBAL-DELIVERY-STATUS"
55 m, err = Decode(p1.Reader(), utf8)
57 return nil, nil, fmt.Errorf("parsing dsn delivery-status part: %v", err)
60 addressPath := func(a message.Address) (smtp.Path, error) {
61 d, err := dns.ParseDomain(a.Host)
63 return smtp.Path{}, fmt.Errorf("parsing domain: %v", err)
65 return smtp.Path{Localpart: smtp.Localpart(a.User), IPDomain: dns.IPDomain{Domain: d}}, nil
67 if len(part.Envelope.From) == 1 {
68 m.From, err = addressPath(part.Envelope.From[0])
70 return nil, nil, fmt.Errorf("parsing From-header: %v", err)
73 if len(part.Envelope.To) == 1 {
74 m.To, err = addressPath(part.Envelope.To[0])
76 return nil, nil, fmt.Errorf("parsing To-header: %v", err)
79 m.Subject = part.Envelope.Subject
80 buf, err := io.ReadAll(p0.ReaderUTF8OrBinary())
82 return nil, nil, fmt.Errorf("reading human-readable text part: %v", err)
84 m.TextBody = strings.ReplaceAll(string(buf), "\r\n", "\n")
91 ct := strings.ToLower(p2.MediaType + "/" + p2.MediaSubType)
93 case "text/rfc822-headers":
94 case "message/global-headers":
95 case "message/rfc822":
96 case "message/global":
98 return nil, nil, fmt.Errorf("invalid content-type %q for optional third part with original message/headers", ct)
104// Decode parses the (global) delivery-status part of a DSN.
106// utf8 indicates if UTF-8 is allowed for this message, if used by the media
107// subtype of the message parts.
108func Decode(r io.Reader, utf8 bool) (*Message, error) {
109 m := Message{SMTPUTF8: utf8}
111 // We are using textproto.Reader to read mime headers. It requires a header section ending in \r\n.
113 b := bufio.NewReader(io.MultiReader(r, strings.NewReader("\r\n")))
114 mr := textproto.NewReader(b)
116 // Read per-message lines.
118 msgh, err := mr.ReadMIMEHeader()
120 return nil, fmt.Errorf("reading per-message lines: %v", err)
122 for k, l := range msgh {
124 return nil, fmt.Errorf("multiple values for %q: %v", k, l)
127 // note: headers are in canonical form, as parsed by textproto.
129 case "Original-Envelope-Id":
130 m.OriginalEnvelopeID = v
131 case "Reporting-Mta":
132 mta, err := parseMTA(v, utf8)
134 return nil, fmt.Errorf("parsing reporting-mta: %v", err)
138 mta, err := parseMTA(v, utf8)
140 return nil, fmt.Errorf("parsing dsn-gateway: %v", err)
143 case "Received-From-Mta":
144 mta, err := parseMTA(v, utf8)
146 return nil, fmt.Errorf("parsing received-from-mta: %v", err)
148 d, err := dns.ParseDomain(mta)
150 return nil, fmt.Errorf("parsing received-from-mta domain %q: %v", mta, err)
152 m.ReceivedFromMTA = smtp.Ehlo{Name: dns.IPDomain{Domain: d}}
154 tm, err := parseDateTime(v)
156 return nil, fmt.Errorf("parsing arrival-date: %v", err)
160 // We'll assume it is an extension field, we'll ignore it for now.
163 m.MessageHeader = msgh
165 required := []string{"Reporting-Mta"}
166 for _, req := range required {
167 if _, ok := msgh[req]; !ok {
168 return nil, fmt.Errorf("missing required recipient field %q", req)
172 rh, err := parseRecipientHeader(mr, utf8)
174 return nil, fmt.Errorf("reading per-recipient header: %v", err)
176 m.Recipients = []Recipient{rh}
178 if _, err := b.Peek(1); err == io.EOF {
181 rh, err := parseRecipientHeader(mr, utf8)
183 return nil, fmt.Errorf("reading another per-recipient header: %v", err)
185 m.Recipients = append(m.Recipients, rh)
191func parseRecipientHeader(mr *textproto.Reader, utf8 bool) (Recipient, error) {
193 h, err := mr.ReadMIMEHeader()
195 return Recipient{}, err
198 for k, l := range h {
200 return Recipient{}, fmt.Errorf("multiple values for %q: %v", k, l)
203 // note: headers are in canonical form, as parsed by textproto.
206 case "Original-Recipient":
207 r.OriginalRecipient, err = parseAddress(v, utf8)
208 case "Final-Recipient":
209 r.FinalRecipient, err = parseAddress(v, utf8)
211 a := Action(strings.ToLower(v))
212 actions := []Action{Failed, Delayed, Delivered, Relayed, Expanded}
214 for _, x := range actions {
221 err = fmt.Errorf("unrecognized action %q", v)
224 // todo: parse the enhanced status code?
227 r.RemoteMTA = NameIP{Name: v}
228 case "Diagnostic-Code":
230 t := strings.SplitN(v, ";", 2)
231 dt := strings.TrimSpace(t[0])
232 if strings.ToLower(dt) != "smtp" {
233 err = fmt.Errorf("unknown diagnostic-type %q, expected smtp", dt)
234 } else if len(t) != 2 {
235 err = fmt.Errorf("missing semicolon to separate diagnostic-type from code")
237 r.DiagnosticCode = strings.TrimSpace(t[1])
239 case "Last-Attempt-Date":
240 r.LastAttemptDate, err = parseDateTime(v)
243 case "Will-Retry-Until":
244 tm, err := parseDateTime(v)
246 r.WillRetryUntil = &tm
249 // todo future: parse localized diagnostic text field?
250 // We'll assume it is an extension field, we'll ignore it for now.
253 return Recipient{}, fmt.Errorf("parsing field %q %q: %v", k, v, err)
257 required := []string{"Final-Recipient", "Action", "Status"}
258 for _, req := range required {
259 if _, ok := h[req]; !ok {
260 return Recipient{}, fmt.Errorf("missing required recipient field %q", req)
269func parseMTA(s string, utf8 bool) (string, error) {
270 s = removeComments(s)
271 t := strings.SplitN(s, ";", 2)
273 return "", fmt.Errorf("missing semicolon that splits type and name")
275 k := strings.TrimSpace(t[0])
276 if !strings.EqualFold(k, "dns") {
277 return "", fmt.Errorf("unknown type %q, expected dns", k)
279 return strings.TrimSpace(t[1]), nil
282func parseDateTime(s string) (time.Time, error) {
283 s = removeComments(s)
284 return time.Parse(message.RFC5322Z, s)
287func parseAddress(s string, utf8 bool) (smtp.Path, error) {
288 s = removeComments(s)
289 t := strings.SplitN(s, ";", 2)
291 addrType := strings.ToLower(strings.TrimSpace(t[0]))
293 return smtp.Path{}, fmt.Errorf("missing semicolon that splits address type and address")
294 } else if addrType == "utf-8" {
296 return smtp.Path{}, fmt.Errorf("utf-8 address type for non-utf-8 dsn")
298 } else if addrType != "rfc822" {
299 return smtp.Path{}, fmt.Errorf("unrecognized address type %q, expected rfc822", addrType)
301 s = strings.TrimSpace(t[1])
303 for _, c := range s {
305 return smtp.Path{}, fmt.Errorf("non-ascii without utf-8 enabled")
309 // todo: more proper parser
310 t = strings.SplitN(s, "@", 2)
311 if len(t) != 2 || t[0] == "" || t[1] == "" {
312 return smtp.Path{}, fmt.Errorf("invalid email address")
314 d, err := dns.ParseDomain(t[1])
316 return smtp.Path{}, fmt.Errorf("parsing domain: %v", err)
320 for _, c := range t[0] {
321 if esc == "" && c == '\\' || esc == `\` && (c == 'x' || c == 'X') || esc == `\x` && c == '{' {
326 } else if strings.HasPrefix(esc, `\x{`) {
328 c, err := strconv.ParseInt(esc[3:], 16, 32)
330 return smtp.Path{}, fmt.Errorf("parsing localpart with hexpoint: %v", err)
332 lp += string(rune(c))
342 return smtp.Path{}, fmt.Errorf("parsing localpart: unfinished embedded unicode char")
344 p := smtp.Path{Localpart: smtp.Localpart(lp), IPDomain: dns.IPDomain{Domain: d}}
348func removeComments(s string) string {
351 for _, c := range s {
354 } else if c == ')' && n > 0 {