From f8e4959352cd6653b8bdb10c445d06196406006a Mon Sep 17 00:00:00 2001 From: Shulhan Date: Sun, 27 Feb 2022 22:24:52 +0700 Subject: lib/email: set the Date and Message-ID on Message Pack Calling Pack now set the Date header if its not exist, using the local time; and the message-id header if its not exist using the following format: .@ The random-8-chars is Seed-ed from Epoch(), so does the boundary. --- lib/email/email.go | 19 ++++++++++--- lib/email/email_test.go | 9 ++++++ lib/email/header.go | 6 ++-- lib/email/message.go | 54 +++++++++++++++++++++++++++++++++-- lib/email/message_test.go | 61 +++++++++++++++++++++++++++++----------- lib/smtp/mail_tx_example_test.go | 26 +++++++++++------ 6 files changed, 139 insertions(+), 36 deletions(-) diff --git a/lib/email/email.go b/lib/email/email.go index 89eb1ffc..00aa692d 100644 --- a/lib/email/email.go +++ b/lib/email/email.go @@ -4,7 +4,12 @@ package email -import "time" +import ( + "math/rand" + "time" + + "github.com/shuLhan/share/lib/ascii" +) const ( contentTypeMultipartAlternative = "multipart/alternative" @@ -20,19 +25,25 @@ const ( lf byte = '\n' ) +var boundSeps = []byte{'-', '-'} + // dateInUtc if set to true, the Date header will be set to UTC instead of // local time. -// This variable is used to make test work on all zones. +// This variable is used to make testing works on all zones. var dateInUtc bool // // Epoch return the UNIX timestamp in seconds. // -// This variable is exported to allow function that use date and/or time can +// This variable is exported to allow function that use time and math/rand can // be tested with fixed, predictable value. // var Epoch = func() int64 { return time.Now().Unix() } -var boundSeps = []byte{'-', '-'} +// randomChars generate n random characters. +func randomChars(n int) []byte { + rand.Seed(Epoch()) + return ascii.Random([]byte(ascii.LettersNumber), n) +} diff --git a/lib/email/email_test.go b/lib/email/email_test.go index c0ff81ea..2c4e5fbd 100644 --- a/lib/email/email_test.go +++ b/lib/email/email_test.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "encoding/pem" "io/ioutil" + "os" "testing" "time" @@ -20,6 +21,14 @@ var ( publicKey *rsa.PublicKey ) +func TestMain(m *testing.M) { + Epoch = func() int64 { + return 1645811431 + } + + os.Exit(m.Run()) +} + func initKeys(t *testing.T) { rsaPrivateRaw, err := ioutil.ReadFile("dkim/testdata/rsa.private.pem") if err != nil { diff --git a/lib/email/header.go b/lib/email/header.go index d16030be..97a2992c 100644 --- a/lib/email/header.go +++ b/lib/email/header.go @@ -9,8 +9,6 @@ import ( "fmt" "io" "log" - - "github.com/shuLhan/share/lib/ascii" ) // @@ -274,7 +272,7 @@ func (hdr *Header) SetMultipart() (err error) { return fmt.Errorf("email.SetMultipart: %w", err) } - boundary := ascii.Random([]byte(ascii.Hexaletters), 32) + boundary := randomChars(32) contentType := hdr.ContentType() contentType.SetBoundary(boundary) @@ -294,6 +292,8 @@ func (hdr *Header) WriteTo(w io.Writer) (n int, err error) { for _, f = range hdr.fields { if f.Type == FieldTypeContentType { m, err = fmt.Fprintf(w, "%s: %s\r\n", f.Name, f.ContentType.String()) + } else if f.Type == FieldTypeMessageID { + m, err = fmt.Fprintf(w, "%s: <%s>\r\n", f.Name, f.oriValue) } else { m, err = fmt.Fprintf(w, "%s: %s", f.Name, f.Value) } diff --git a/lib/email/message.go b/lib/email/message.go index df6baf20..731b8bf8 100644 --- a/lib/email/message.go +++ b/lib/email/message.go @@ -9,6 +9,7 @@ import ( "crypto/rsa" "fmt" "io/ioutil" + "os" "strings" "time" @@ -378,6 +379,18 @@ func (msg *Message) SetFrom(mailbox string) (err error) { return nil } +// +// SetID set or replace the message-id header to id. +// If the id is empty, nothing will changes. +// +func (msg *Message) SetID(id string) { + id = strings.TrimSpace(id) + if len(id) == 0 { + return + } + _ = msg.Header.Set(FieldTypeMessageID, []byte(id)) +} + // // SetSubject set or replace the subject. // It will do nothing if the subject is empty. @@ -489,6 +502,12 @@ func (msg *Message) CanonHeader(subHeader *Header, dkimField *Field) []byte { // // Pack the message for sending. // +// This method will set the Date header if its not exist, using the local +// time; +// and the message-id header if its not exist using the following format: +// +// .@ +// // The message content type is automatically set based on the Body parts. // If the Body only contain text part, the generated content-type will be set // to text/plain. @@ -498,17 +517,46 @@ func (msg *Message) CanonHeader(subHeader *Header, dkimField *Field) []byte { // set to multipart/alternative. // func (msg *Message) Pack() (out []byte, err error) { + // TODO: check from, to, subject. + var ( - logp = "Pack" + logp = "Pack" + timeNow = time.Unix(Epoch(), 0) + + dateValue string + hostname string + id string + fields []*Field ) if len(msg.Body.Parts) == 0 { return nil, fmt.Errorf("%s: empty body", logp) } - // TODO: check date, from, to, subject. + fields = msg.Header.Filter(FieldTypeDate) + if len(fields) == 0 { + if dateInUtc { + dateValue = timeNow.UTC().Format(DateFormat) + } else { + dateValue = timeNow.Format(DateFormat) + } + err = msg.Header.Set(FieldTypeDate, []byte(dateValue)) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + } + + fields = msg.Header.Filter(FieldTypeMessageID) + if len(fields) == 0 { + hostname, err = os.Hostname() + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + id = fmt.Sprintf("%d.%s@%s", timeNow.Unix(), randomChars(8), hostname) + msg.SetID(id) + } - if len(msg.Body.Parts) > 1 { + if len(msg.Body.Parts) >= 2 { out, err = msg.packMultipartAlternative() if err != nil { return nil, fmt.Errorf("%s: %w", logp, err) diff --git a/lib/email/message_test.go b/lib/email/message_test.go index 599f9a98..7a52a0c2 100644 --- a/lib/email/message_test.go +++ b/lib/email/message_test.go @@ -6,7 +6,7 @@ package email import ( "io/ioutil" - "math/rand" + "os" "testing" "github.com/shuLhan/share/lib/email/dkim" @@ -15,16 +15,18 @@ import ( func TestNewMultipart(t *testing.T) { var ( - gotMsg *Message - msgb []byte - err error + gotMsg *Message + hostname string + msgb []byte + err error ) dateInUtc = true - Epoch = func() int64 { - return 1645811431 + + hostname, err = os.Hostname() + if err != nil { + t.Fatal(err) } - rand.Seed(42) cases := []struct { expMsg string @@ -45,21 +47,22 @@ func TestNewMultipart(t *testing.T) { "to: d@e.f\r\n" + "subject: test\r\n" + "mime-version: 1.0\r\n" + - "content-type: multipart/alternative; boundary=1b4df158039f7cce49f0a64b0ea7b7dd\r\n" + + "content-type: multipart/alternative; boundary=bqOnpYF7Yw1N0jDpjM004riRyz7oPxD6\r\n" + + "message-id: <1645811431.bqOnpYF7@" + hostname + ">\r\n" + "\r\n" + - "--1b4df158039f7cce49f0a64b0ea7b7dd\r\n" + + "--bqOnpYF7Yw1N0jDpjM004riRyz7oPxD6\r\n" + "mime-version: 1.0\r\n" + "content-type: text/plain; charset=\"utf-8\"\r\n" + "content-transfer-encoding: quoted-printable\r\n" + "\r\n" + "This is plain text\r\n" + - "--1b4df158039f7cce49f0a64b0ea7b7dd\r\n" + + "--bqOnpYF7Yw1N0jDpjM004riRyz7oPxD6\r\n" + "mime-version: 1.0\r\n" + "content-type: text/html; charset=\"utf-8\"\r\n" + "content-transfer-encoding: quoted-printable\r\n" + "\r\n" + "This is body in HTML\r\n" + - "--1b4df158039f7cce49f0a64b0ea7b7dd--\r\n", + "--bqOnpYF7Yw1N0jDpjM004riRyz7oPxD6--\r\n", }} for _, c := range cases { @@ -326,16 +329,25 @@ func TestMessage_packSingle(t *testing.T) { } var ( - msg Message - err error - cases []testCase - got []byte + msg Message + hostname string + err error + cases []testCase + got []byte ) + dateInUtc = true + hostname, err = os.Hostname() + if err != nil { + t.Fatal(err) + } + cases = []testCase{{ desc: "With body text", bodyText: []byte(`this is a body text`), exp: "" + + "date: Fri, 25 Feb 2022 17:50:31 +0000\r\n" + + "message-id: <1645811431.bqOnpYF7@" + hostname + ">\r\n" + "mime-version: 1.0\r\n" + "content-type: text/plain; charset=\"utf-8\"\r\n" + "content-transfer-encoding: quoted-printable\r\n" + @@ -345,6 +357,8 @@ func TestMessage_packSingle(t *testing.T) { desc: "With body HTML", bodyHtml: []byte(`

this is an HTML body

`), exp: "" + + "date: Fri, 25 Feb 2022 17:50:31 +0000\r\n" + + "message-id: <1645811431.bqOnpYF7@" + hostname + ">\r\n" + "mime-version: 1.0\r\n" + "content-type: text/html; charset=\"utf-8\"\r\n" + "content-transfer-encoding: quoted-printable\r\n" + @@ -377,9 +391,18 @@ func TestMessage_packSingle(t *testing.T) { func TestMessage_SetBodyText(t *testing.T) { var ( - msg Message - err error + msg Message + hostname string + err error ) + + dateInUtc = true + + hostname, err = os.Hostname() + if err != nil { + t.Fatal(err) + } + cases := []struct { desc string expMsg string @@ -388,6 +411,8 @@ func TestMessage_SetBodyText(t *testing.T) { desc: "With empty Body", content: []byte("text body"), expMsg: "" + + "date: Fri, 25 Feb 2022 17:50:31 +0000\r\n" + + "message-id: <1645811431.bqOnpYF7@" + hostname + ">\r\n" + "mime-version: 1.0\r\n" + "content-type: text/plain; charset=\"utf-8\"\r\n" + "content-transfer-encoding: quoted-printable\r\n" + @@ -397,6 +422,8 @@ func TestMessage_SetBodyText(t *testing.T) { desc: "With new text", content: []byte("new text body"), expMsg: "" + + "date: Fri, 25 Feb 2022 17:50:31 +0000\r\n" + + "message-id: <1645811431.bqOnpYF7@" + hostname + ">\r\n" + "mime-version: 1.0\r\n" + "content-type: text/plain; charset=\"utf-8\"\r\n" + "content-transfer-encoding: quoted-printable\r\n" + diff --git a/lib/smtp/mail_tx_example_test.go b/lib/smtp/mail_tx_example_test.go index 33ebbee3..a4b6df0a 100644 --- a/lib/smtp/mail_tx_example_test.go +++ b/lib/smtp/mail_tx_example_test.go @@ -8,7 +8,7 @@ import ( "bytes" "fmt" "log" - "math/rand" + "os" "regexp" "time" @@ -39,12 +39,12 @@ func ExampleNewMailTx() { mboxes []*email.Mailbox msg *email.Message mailtx *MailTx + reDate *regexp.Regexp + hostname string data []byte err error ) - rand.Seed(42) - mboxes, err = email.ParseMailboxes(toAddresses) if err != nil { log.Fatal(err) @@ -75,15 +75,22 @@ func ExampleNewMailTx() { fmt.Printf("Tx Recipients: %s\n", mailtx.Recipients) // In order to make the example Output works, we need to replace all - // CRLF with LF and "date:" with the system timezone. + // CRLF with LF, "date:" with the system timezone, and message-id + // hostname with fixed "hostname". data = bytes.ReplaceAll(mailtx.Data, []byte("\r\n"), []byte("\n")) //fmt.Printf("timeNowUtc: %s\n", timeNowUtc) //fmt.Printf("dateNowUtc: %s\n", dateNowUtc) - reDate := regexp.MustCompile(`^date: Wed(.*) \+....`) + reDate = regexp.MustCompile(`^date: Wed(.*) \+....`) data = reDate.ReplaceAll(data, []byte(`date: `+dateNowUtc)) + hostname, err = os.Hostname() + if err != nil { + log.Fatal(err) + } + data = bytes.Replace(data, []byte(hostname), []byte("hostname"), 1) + fmt.Printf("Tx Data:\n%s", data) //Output: //Tx From: Postmaster @@ -94,19 +101,20 @@ func ExampleNewMailTx() { //to: John , Jane //subject: Example subject //mime-version: 1.0 - //content-type: multipart/alternative; boundary=1b4df158039f7cce49f0a64b0ea7b7dd + //content-type: multipart/alternative; boundary=QoqDPQfzDVkv5R49vrA78GmqPmlfmBHf + //message-id: <1645600000.QoqDPQfz@hostname> // - //--1b4df158039f7cce49f0a64b0ea7b7dd + //--QoqDPQfzDVkv5R49vrA78GmqPmlfmBHf //mime-version: 1.0 //content-type: text/plain; charset="utf-8" //content-transfer-encoding: quoted-printable // //Email body as plain text - //--1b4df158039f7cce49f0a64b0ea7b7dd + //--QoqDPQfzDVkv5R49vrA78GmqPmlfmBHf //mime-version: 1.0 //content-type: text/html; charset="utf-8" //content-transfer-encoding: quoted-printable // //Email body as HTML - //--1b4df158039f7cce49f0a64b0ea7b7dd-- + //--QoqDPQfzDVkv5R49vrA78GmqPmlfmBHf-- } -- cgit v1.3