-
Notifications
You must be signed in to change notification settings - Fork 102
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Dmytro Kasianenko
committed
Nov 16, 2022
1 parent
ebd4cb3
commit 695d9d9
Showing
9 changed files
with
692 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
// Package dsn meant to work with Delivery Status Notification (DSN) per rfc3464: | ||
// https://datatracker.ietf.org/doc/html/rfc3464 | ||
package dsn | ||
|
||
import ( | ||
"net/textproto" | ||
"strings" | ||
) | ||
|
||
// Report represents delivery status report as per https://datatracker.ietf.org/doc/html/rfc6522. | ||
// It is container of the MIME type multipart/report and contains 3 parts. | ||
type Report struct { | ||
// Explanation contains a human-readable description of the condition(s) that caused the report to be generated. | ||
Explanation Explanation | ||
// DeliveryStatus provides a machine-readable description of the condition(s) that caused the report to be generated. | ||
DeliveryStatus DeliveryStatus | ||
// OriginalMessage is optional original message or its portion. | ||
OriginalMessage []byte | ||
} | ||
|
||
// Explanation contains a human-readable description of the condition(s) that caused the report to be generated. | ||
// Where a description of the error is desired in several languages or several media, a multipart/alternative construct MAY be used. | ||
type Explanation struct { | ||
// Text is a text/plain part of the explanation. | ||
Text string | ||
// HTML is a text/html part of the explanation. | ||
HTML string | ||
} | ||
|
||
// DeliveryStatus provides a machine-readable description of the condition(s) that caused the report to be generated. | ||
type DeliveryStatus struct { | ||
// RecipientDSN is Delivery Status Notification per message. | ||
MessageDSNs []textproto.MIMEHeader | ||
// RecipientDSN is Delivery Status Notification per recipient. | ||
RecipientDSNs []textproto.MIMEHeader | ||
} | ||
|
||
// IsFailed returns true if Action field is "failed", meaning message could not be delivered to the recipient. | ||
func IsFailed(n textproto.MIMEHeader) bool { | ||
return strings.EqualFold(n.Get("Action"), "failed") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
package dsn | ||
|
||
import ( | ||
"bufio" | ||
"bytes" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"net/textproto" | ||
|
||
"github.com/jhillyerd/enmime" | ||
) | ||
|
||
// ParseReport parses p as a "container" for delivery status report (per rfc6522) if p is "multipart/report". | ||
// Otherwise returns nil. | ||
func ParseReport(p *enmime.Part) (*Report, error) { | ||
if !isMultipartReport(p.ContentType) { | ||
return nil, nil | ||
} | ||
|
||
var report Report | ||
// first part is explanation | ||
explanation := p.FirstChild | ||
if explanation == nil || !setExplanation(&report.Explanation, explanation) { | ||
return &report, nil | ||
} | ||
|
||
// second part is delivery-status | ||
deliveryStatus := explanation.NextSibling | ||
if deliveryStatus == nil || !isDeliveryStatus(deliveryStatus.ContentType) { | ||
return &report, nil | ||
} | ||
|
||
ds, err := parseDeliveryStatus(deliveryStatus.Content) | ||
if err != nil { | ||
return nil, err | ||
} | ||
report.DeliveryStatus = ds | ||
|
||
// third part is original email | ||
originalEmail := deliveryStatus.NextSibling | ||
if originalEmail == nil || !isEmail(originalEmail.ContentType) { | ||
return &report, nil | ||
} | ||
|
||
report.OriginalMessage = originalEmail.Content | ||
|
||
return &report, nil | ||
} | ||
|
||
func isMultipartReport(ct string) bool { | ||
return ct == "multipart/report" | ||
} | ||
|
||
func isDeliveryStatus(ct string) bool { | ||
return ct == "message/delivery-status" || ct == "message/global-delivery-status" | ||
} | ||
|
||
func isEmail(ct string) bool { | ||
return ct == "message/rfc822" | ||
} | ||
|
||
func setExplanation(e *Explanation, p *enmime.Part) bool { | ||
if p == nil { | ||
return false | ||
} | ||
|
||
switch p.ContentType { | ||
case "text/plain", "": // treat no content-type as text | ||
e.Text = string(p.Content) | ||
return true | ||
case "text/html": | ||
e.HTML = string(p.Content) | ||
return true | ||
case "multipart/alternative": | ||
// the structure is next: | ||
// multipart/alternative | ||
// - text/plain (FirstChild) | ||
// - text/html (FirstChild.NextSibling) | ||
if setExplanation(e, p.FirstChild) { | ||
return setExplanation(e, p.FirstChild.NextSibling) | ||
} | ||
default: | ||
return false | ||
} | ||
|
||
return false | ||
} | ||
|
||
func parseDeliveryStatus(date []byte) (DeliveryStatus, error) { | ||
fields, err := parseDeliveryStatusMessage(date) | ||
if err != nil { | ||
return DeliveryStatus{}, fmt.Errorf("parse delivey status: %w", err) | ||
} | ||
|
||
perMessage, perRecipient := splitDSNFields(fields) | ||
|
||
return DeliveryStatus{ | ||
MessageDSNs: perMessage, | ||
RecipientDSNs: perRecipient, | ||
}, nil | ||
} | ||
|
||
// parseDeliveryStatusMessage parses delivery-status message per https://www.rfc-editor.org/rfc/rfc3464#section-2.1 | ||
// The body of a message/delivery-status consists of one or more "fields" formatted according to the ABNF of | ||
// RFC 822 header "fields". In other words, body of delivery status is multiple headers separated by blank line. | ||
// First part is per-message fields, following by per-recipient fields. | ||
func parseDeliveryStatusMessage(data []byte) ([]textproto.MIMEHeader, error) { | ||
if len(data) > 0 && data[len(data)-1] != '\n' { // additional new line if missing | ||
data = append(data, byte('\n')) | ||
} | ||
data = append(data, byte('\n')) // fix for https://github.com/golang/go/issues/47897 - can't read messages with headers only | ||
|
||
r := textproto.NewReader(bufio.NewReader(bytes.NewReader(data))) | ||
var ( | ||
fields []textproto.MIMEHeader | ||
err error | ||
) | ||
// parse body as multiple header fields separated by blank lines | ||
for err == nil { | ||
h, err := r.ReadMIMEHeader() | ||
if err != nil { | ||
if errors.Is(err, io.EOF) { | ||
break | ||
} | ||
|
||
return nil, fmt.Errorf("read MIME header fields: %w", err) | ||
} | ||
|
||
fields = append(fields, h) | ||
} | ||
|
||
return fields, nil | ||
} | ||
|
||
// splitDSNFields splits into per-message and per-recipient fields. | ||
// First part is per-message fields, following by per-recipient fields. | ||
func splitDSNFields(fields []textproto.MIMEHeader) (perMessage, perRecipient []textproto.MIMEHeader) { | ||
for i, f := range fields { | ||
if isPerMessageDSN(f) { | ||
perMessage = append(perMessage, f) | ||
continue | ||
} | ||
|
||
perRecipient = fields[i:] | ||
break | ||
} | ||
|
||
return | ||
} | ||
|
||
// isPerMessageDSN returns true if field is per-message DSN field. | ||
// According to https://datatracker.ietf.org/doc/html/rfc3464#section-3, minimal per-message DSN must have Reporting-MTA field. | ||
func isPerMessageDSN(header textproto.MIMEHeader) bool { | ||
return header.Get("Reporting-MTA") != "" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
package dsn | ||
|
||
import ( | ||
"net/textproto" | ||
"testing" | ||
|
||
"github.com/jhillyerd/enmime" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func TestParseDeliveryStatusMessage(t *testing.T) { | ||
tests := map[string]struct { | ||
status string | ||
want []textproto.MIMEHeader | ||
}{ | ||
"regular": { | ||
status: `Reporting-MTA: dns; cs.utk.edu | ||
Action: failed | ||
Status: 4.0.0 | ||
Diagnostic-Code: smtp; 426 connection timed out | ||
`, | ||
want: []textproto.MIMEHeader{ | ||
{ | ||
"Reporting-Mta": []string{"dns; cs.utk.edu"}, | ||
}, | ||
{ | ||
"Action": []string{"failed"}, | ||
"Status": []string{"4.0.0"}, | ||
"Diagnostic-Code": []string{"smtp; 426 connection timed out"}, | ||
}, | ||
}, | ||
}, | ||
"without per-message DSN": { | ||
status: `Action: failed | ||
Status: 4.0.0 | ||
Diagnostic-Code: smtp; 426 connection timed out | ||
`, | ||
want: []textproto.MIMEHeader{ | ||
{ | ||
"Action": []string{"failed"}, | ||
"Status": []string{"4.0.0"}, | ||
"Diagnostic-Code": []string{"smtp; 426 connection timed out"}, | ||
}, | ||
}, | ||
}, | ||
"without new line": { | ||
status: `Action: failed | ||
Status: 4.0.0 | ||
Diagnostic-Code: smtp; 426 connection timed out`, | ||
want: []textproto.MIMEHeader{ | ||
{ | ||
"Action": []string{"failed"}, | ||
"Status": []string{"4.0.0"}, | ||
"Diagnostic-Code": []string{"smtp; 426 connection timed out"}, | ||
}, | ||
}, | ||
}, | ||
"empty": { | ||
status: "", | ||
want: []textproto.MIMEHeader{{}}, | ||
}, | ||
} | ||
|
||
for name, tt := range tests { | ||
t.Run(name, func(t *testing.T) { | ||
got, err := parseDeliveryStatusMessage([]byte(tt.status)) | ||
if err != nil { | ||
t.Fatal(err) | ||
} | ||
assert.Equal(t, tt.want, got, "should correctly parse delivery/status message") | ||
}) | ||
} | ||
} | ||
|
||
func TestSetExplanation(t *testing.T) { | ||
tests := map[string]struct { | ||
part *enmime.Part | ||
want Explanation | ||
}{ | ||
"text/plain": { | ||
part: &enmime.Part{ | ||
ContentType: "text/plain", | ||
Content: []byte("text content"), | ||
}, | ||
want: Explanation{Text: "text content"}, | ||
}, | ||
"text/html": { | ||
part: &enmime.Part{ | ||
ContentType: "text/html", | ||
Content: []byte("HTML content"), | ||
}, | ||
want: Explanation{HTML: "HTML content"}, | ||
}, | ||
"multipart/alternative": { | ||
part: &enmime.Part{ | ||
ContentType: "multipart/alternative", | ||
FirstChild: &enmime.Part{ | ||
ContentType: "text/plain", | ||
Content: []byte("text content"), | ||
NextSibling: &enmime.Part{ | ||
ContentType: "text/html", | ||
Content: []byte("HTML content"), | ||
}, | ||
}, | ||
}, | ||
want: Explanation{Text: "text content", HTML: "HTML content"}, | ||
}, | ||
"no content-type": { | ||
part: &enmime.Part{ | ||
ContentType: "", | ||
Content: []byte("text content"), | ||
}, | ||
want: Explanation{Text: "text content"}, | ||
}, | ||
"other": { | ||
part: &enmime.Part{ | ||
ContentType: "other", | ||
Content: []byte("some content"), | ||
}, | ||
want: Explanation{}, | ||
}, | ||
} | ||
|
||
for name, tt := range tests { | ||
t.Run(name, func(t *testing.T) { | ||
var e Explanation | ||
setExplanation(&e, tt.part) | ||
assert.Equal(t, tt.want, e) | ||
}) | ||
} | ||
} |
Oops, something went wrong.