From 835edbbb599fea680c20dd7c74969d334db369a8 Mon Sep 17 00:00:00 2001 From: Shulhan Date: Sat, 26 Feb 2022 00:26:49 +0700 Subject: lib/email: add methods to modify Message Previously, a Message can be created only using NewMultipart, which generate message with text and HTML. This changes add methods to compose a Message: AddCC, AddTo, SetBodyHtml, SetBodyText, SetCC, SetFrom, SetSubject, and SetTo. --- lib/email/body.go | 18 ++++ lib/email/contenttype.go | 11 ++ lib/email/field.go | 84 ++++++++++----- lib/email/header.go | 55 +++++++--- lib/email/message.go | 123 +++++++++++++++++++++- lib/email/message_test.go | 257 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 508 insertions(+), 40 deletions(-) diff --git a/lib/email/body.go b/lib/email/body.go index 475ef3bf..eda591fc 100644 --- a/lib/email/body.go +++ b/lib/email/body.go @@ -101,6 +101,24 @@ func (body *Body) Add(mime *MIME) { body.Parts = append(body.Parts, mime) } +// +// Set replace the MIME content-type with new one, if its exist; otherwise +// append it. +// +func (body *Body) Set(mime *MIME) { + var ( + part *MIME + ) + for _, part = range body.Parts { + if part.contentType.isEqual(mime.contentType) { + part.Header = mime.Header + part.Content = mime.Content + return + } + } + body.Parts = append(body.Parts, mime) +} + // // String return text representation of Body. // diff --git a/lib/email/contenttype.go b/lib/email/contenttype.go index c80aaf79..05c80ca8 100644 --- a/lib/email/contenttype.go +++ b/lib/email/contenttype.go @@ -137,6 +137,17 @@ func (ct *ContentType) GetParamValue(name []byte) []byte { return nil } +// isEqual will return true if the ct's Top and Sub matched with other. +func (ct *ContentType) isEqual(other *ContentType) bool { + if other == nil { + return false + } + if !bytes.Equal(ct.Top, other.Top) { + return false + } + return bytes.Equal(ct.Sub, other.Sub) +} + // // SetBoundary set the parameter boundary in content-type header's value. // diff --git a/lib/email/field.go b/lib/email/field.go index b0a94d47..24bd4e2f 100644 --- a/lib/email/field.go +++ b/lib/email/field.go @@ -164,6 +164,63 @@ invalid: return nil, rest, err } +// +// addMailboxes append zero or more mailboxes to current mboxes. +// +func (field *Field) addMailboxes(mailboxes []byte) (err error) { + var ( + mboxes []*Mailbox + ) + + mailboxes = bytes.TrimSpace(mailboxes) + + mboxes, err = ParseMailboxes(mailboxes) + if err != nil { + return err + } + field.mboxes = append(field.mboxes, mboxes...) + + if len(field.Value) > 0 { + field.Value = bytes.TrimSpace(field.Value) + field.Value = append(field.Value, ',', ' ') + } + field.appendValue(mailboxes) + + return nil +} + +func (field *Field) appendValue(raw []byte) { + var ( + x int + spaces int + ) + + // Skip leading spaces. + for ; x < len(raw); x++ { + if !ascii.IsSpace(raw[x]) { + break + } + } + + for ; x < len(raw); x++ { + if ascii.IsSpace(raw[x]) { + spaces++ + continue + } + if spaces > 0 { + field.Value = append(field.Value, ' ') + spaces = 0 + } + field.Value = append(field.Value, raw[x]) + } + if len(field.Value) > 0 { + field.Value = append(field.Value, cr) + field.Value = append(field.Value, lf) + } + field.unpacked = false + +} + // // setName set field Name by canonicalizing raw field name using "simple" and // "relaxed" algorithms. @@ -201,32 +258,7 @@ func (field *Field) setName(raw []byte) { func (field *Field) setValue(raw []byte) { field.oriValue = raw field.Value = make([]byte, 0, len(raw)) - - x := 0 - // Skip leading spaces. - for ; x < len(raw); x++ { - if !ascii.IsSpace(raw[x]) { - break - } - } - - spaces := 0 - for ; x < len(raw); x++ { - if ascii.IsSpace(raw[x]) { - spaces++ - continue - } - if spaces > 0 { - field.Value = append(field.Value, ' ') - spaces = 0 - } - field.Value = append(field.Value, raw[x]) - } - if len(field.Value) > 0 { - field.Value = append(field.Value, cr) - field.Value = append(field.Value, lf) - } - field.unpacked = false + field.appendValue(raw) } // diff --git a/lib/email/header.go b/lib/email/header.go index 236137e9..39ceda45 100644 --- a/lib/email/header.go +++ b/lib/email/header.go @@ -72,6 +72,30 @@ func ParseHeader(raw []byte) (hdr *Header, rest []byte, err error) { return nil, rest, err } +func (hdr *Header) addMailboxes(ft FieldType, mailboxes []byte) (err error) { + var ( + f, field *Field + ) + + for _, f = range hdr.fields { + if f.Type == ft { + field = f + break + } + } + if field == nil { + field = &Field{ + Type: ft, + } + hdr.fields = append(hdr.fields, field) + field.setName(fieldNames[ft]) + field.setValue(mailboxes) + return field.unpack() + } + + return field.addMailboxes(mailboxes) +} + // // Boundary return the message body boundary defined in Content-Type. // If no field Content-Type or no boundary it will return nil. @@ -179,23 +203,30 @@ func (hdr *Header) Relaxed() []byte { // If no field type found, the new field will be added to the list. // func (hdr *Header) Set(ft FieldType, value []byte) (err error) { - var field *Field - - for _, f := range hdr.fields { - if f.Type == ft { - field = f - break - } - } - if field == nil { + var ( field = &Field{ Type: ft, } - hdr.fields = append(hdr.fields, field) - } + + f *Field + x int + ) + field.setName(fieldNames[ft]) field.setValue(value) - return field.unpack() + err = field.unpack() + if err != nil { + return fmt.Errorf("Set: %w", err) + } + + for x, f = range hdr.fields { + if f.Type == ft { + hdr.fields[x] = field + return nil + } + } + hdr.fields = append(hdr.fields, field) + return nil } // diff --git a/lib/email/message.go b/lib/email/message.go index c03672b5..77faaf6f 100644 --- a/lib/email/message.go +++ b/lib/email/message.go @@ -132,6 +132,36 @@ func ParseMessage(raw []byte) (msg *Message, rest []byte, err error) { return msg, rest, nil } +// +// AddCC add one or more recipients to the message header CC. +// +func (msg *Message) AddCC(mailboxes string) (err error) { + err = msg.addMailboxes(FieldTypeCC, []byte(mailboxes)) + if err != nil { + return fmt.Errorf("AddCC: %w", err) + } + return nil +} + +// +// AddTo add one or more recipients to the mesage header To. +// +func (msg *Message) AddTo(mailboxes string) (err error) { + err = msg.addMailboxes(FieldTypeTo, []byte(mailboxes)) + if err != nil { + return fmt.Errorf("AddTo: %w", err) + } + return nil +} + +func (msg *Message) addMailboxes(ft FieldType, mailboxes []byte) error { + mailboxes = bytes.TrimSpace(mailboxes) + if len(mailboxes) == 0 { + return nil + } + return msg.Header.addMailboxes(ft, mailboxes) +} + // // DKIMSign sign the message using the private key and signature. // The only required fields in signature is SDID and Selector, any other @@ -286,6 +316,96 @@ func (msg *Message) DKIMVerify() (*dkim.Status, error) { return msg.dkimStatus, nil } +// +// SetBodyHtml set or replace the message's body HTML content. +// +func (msg *Message) SetBodyHtml(content []byte) (err error) { + err = msg.setBody([]byte(contentTypeTextHTML), content) + if err != nil { + return fmt.Errorf("SetBodyHtml: %w", err) + } + return nil +} + +// +// SetBodyText set or replace the message body text content. +// +func (msg *Message) SetBodyText(content []byte) (err error) { + err = msg.setBody([]byte(contentTypeTextPlain), content) + if err != nil { + return fmt.Errorf("SetBodyText: %w", err) + } + return nil +} + +func (msg *Message) setBody(contentType, content []byte) (err error) { + var ( + mime *MIME + ) + mime, err = newMIME(contentType, content) + if err != nil { + return err + } + msg.Body.Set(mime) + return nil +} + +// +// SetCC set or replace the message header CC with one or more mailboxes. +// See AddCC to add another recipient to the CC header. +// +func (msg *Message) SetCC(mailboxes string) (err error) { + err = msg.setMailboxes(FieldTypeCC, []byte(mailboxes)) + if err != nil { + return fmt.Errorf("SetCC: %w", err) + } + return nil +} + +// +// SetFrom set or replace the message header From with mailbox. +// If the mailbox parameter is empty, nothing will changes. +// +func (msg *Message) SetFrom(mailbox string) (err error) { + err = msg.setMailboxes(FieldTypeFrom, []byte(mailbox)) + if err != nil { + return fmt.Errorf("SetFrom: %w", err) + } + return nil +} + +// +// SetSubject set or replace the subject. +// It will do nothing if the subject is empty. +// +func (msg *Message) SetSubject(subject string) { + subject = strings.TrimSpace(subject) + if len(subject) == 0 { + return + } + _ = msg.Header.Set(FieldTypeSubject, []byte(subject)) +} + +// +// SetTo set or replace the message header To with one or more mailboxes. +// See AddTo to add another recipient to the To header. +// +func (msg *Message) SetTo(mailboxes string) (err error) { + err = msg.setMailboxes(FieldTypeTo, []byte(mailboxes)) + if err != nil { + return fmt.Errorf("SetTo: %w", err) + } + return nil +} + +func (msg *Message) setMailboxes(ft FieldType, mailboxes []byte) error { + mailboxes = bytes.TrimSpace(mailboxes) + if len(mailboxes) == 0 { + return nil + } + return msg.Header.Set(ft, mailboxes) +} + // // String return the text representation of Message object. // @@ -372,8 +492,7 @@ func (msg *Message) Pack() (out []byte) { for _, f := range msg.Header.fields { if f.Type == FieldTypeContentType { - fmt.Fprintf(&buf, "%s: %s\r\n", f.Name, - f.ContentType.String()) + fmt.Fprintf(&buf, "%s: %s\r\n", f.Name, f.ContentType.String()) } else { fmt.Fprintf(&buf, "%s: %s", f.Name, f.Value) } diff --git a/lib/email/message_test.go b/lib/email/message_test.go index a3d716e5..bbc38345 100644 --- a/lib/email/message_test.go +++ b/lib/email/message_test.go @@ -107,6 +107,82 @@ func TestMessageParseMessage(t *testing.T) { } } +func TestMessage_AddCC(t *testing.T) { + var ( + msg Message + err error + ) + + cases := []struct { + desc string + mailboxes string + expMsg string + expError string + }{{ + desc: "One mailbox", + mailboxes: "one ", + expMsg: "cc:one \r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "cc:one \r\n\r\n", + }, { + desc: "Invalid mailbox", + mailboxes: "a", + expError: `AddCC: ParseMailboxes "a": empty or invalid address`, + expMsg: "cc:one \r\n\r\n", + }, { + desc: "Multiple mailboxes", + mailboxes: "two , three ", + expMsg: "cc:one , two , three \r\n\r\n", + }} + + for _, c := range cases { + err = msg.AddCC(c.mailboxes) + if err != nil { + test.Assert(t, c.desc, c.expError, err.Error()) + } + test.Assert(t, c.desc, c.expMsg, msg.String()) + } +} + +func TestMessage_AddTo(t *testing.T) { + var ( + msg Message + err error + ) + + cases := []struct { + desc string + mailboxes string + expMsg string + expError string + }{{ + desc: "One mailbox", + mailboxes: "one ", + expMsg: "to:one \r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "to:one \r\n\r\n", + }, { + desc: "Invalid mailbox", + mailboxes: "a", + expError: `AddTo: ParseMailboxes "a": empty or invalid address`, + expMsg: "to:one \r\n\r\n", + }, { + desc: "Multiple mailboxes", + mailboxes: "two , three ", + expMsg: "to:one , two , three \r\n\r\n", + }} + + for _, c := range cases { + err = msg.AddTo(c.mailboxes) + if err != nil { + test.Assert(t, c.desc, c.expError, err.Error()) + } + test.Assert(t, c.desc, c.expMsg, msg.String()) + } +} + // // NOTE: this test require call to DNS to get the public key. // @@ -205,3 +281,184 @@ func TestMessageDKIMSign(t *testing.T) { test.Assert(t, "dkim.Status", c.expStatus, gotStatus) } } + +func TestMessage_SetBodyText(t *testing.T) { + var ( + msg Message + err error + ) + cases := []struct { + desc string + content []byte + expMsg string + }{{ + desc: "With empty Body", + content: []byte("text body"), + expMsg: "\r\n" + + "content-type: text/plain; charset=\"utf-8\"\r\n" + + "mime-version: 1.0\r\n" + + "content-transfer-encoding: quoted-printable\r\n" + + "\r\n" + + "text body\r\n", + }, { + desc: "With new text", + content: []byte("new text body"), + expMsg: "\r\n" + + "content-type: text/plain; charset=\"utf-8\"\r\n" + + "mime-version: 1.0\r\n" + + "content-transfer-encoding: quoted-printable\r\n" + + "\r\n" + + "new text body\r\n", + }} + + for _, c := range cases { + err = msg.SetBodyText(c.content) + if err != nil { + t.Fatal(err) + } + + test.Assert(t, c.desc, string(c.expMsg), string(msg.Pack())) + } +} + +func TestMessage_SetCC(t *testing.T) { + var ( + msg Message + err error + ) + + cases := []struct { + desc string + mailboxes string + expMsg string + expError string + }{{ + desc: "One mailbox", + mailboxes: "test ", + expMsg: "cc:test \r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "cc:test \r\n\r\n", + }, { + desc: "Invalid mailbox", + mailboxes: "a", + expError: `SetCC: Set: ParseMailboxes "a": empty or invalid address`, + expMsg: "cc:test \r\n\r\n", + }, { + desc: "Multiple mailboxes", + mailboxes: "new , from ", + expMsg: "cc:new , from \r\n\r\n", + }} + + for _, c := range cases { + err = msg.SetCC(c.mailboxes) + if err != nil { + test.Assert(t, c.desc, c.expError, err.Error()) + } + test.Assert(t, c.desc, c.expMsg, msg.String()) + } +} + +func TestMessage_SetFrom(t *testing.T) { + var ( + msg Message + err error + ) + + cases := []struct { + desc string + mailbox string + expMsg string + expError string + }{{ + desc: "Valid mailbox", + mailbox: "test ", + expMsg: "from:test \r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "from:test \r\n\r\n", + }, { + desc: "Invalid mailbox", + mailbox: "a", + expError: `SetFrom: Set: ParseMailboxes "a": empty or invalid address`, + expMsg: "from:test \r\n\r\n", + }, { + desc: "New mailbox", + mailbox: "new ", + expMsg: "from:new \r\n\r\n", + }, { + desc: "Multiple mailboxes", + mailbox: "two , three ", + expMsg: "from:two , three \r\n\r\n", + }} + + for _, c := range cases { + err = msg.SetFrom(c.mailbox) + if err != nil { + test.Assert(t, c.desc, c.expError, err.Error()) + } + test.Assert(t, c.desc, c.expMsg, msg.String()) + } +} + +func TestMessage_SetSubject(t *testing.T) { + var ( + msg Message + ) + cases := []struct { + subject string + expMsg string + }{{ + subject: "a subject", + expMsg: "subject: a subject\r\n\r\n", + }, { + expMsg: "subject: a subject\r\n\r\n", + }, { + subject: "new subject", + expMsg: "subject: new subject\r\n\r\n", + }} + + for _, c := range cases { + msg.SetSubject(c.subject) + + test.Assert(t, "SetSubject", c.expMsg, string(msg.Pack())) + } +} + +func TestMessage_SetTo(t *testing.T) { + var ( + msg Message + err error + ) + + cases := []struct { + desc string + mailboxes string + expMsg string + expError string + }{{ + desc: "One mailbox", + mailboxes: "test ", + expMsg: "to:test \r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "to:test \r\n\r\n", + }, { + desc: "Invalid mailbox", + mailboxes: "a", + expMsg: "to:test \r\n\r\n", + expError: `SetTo: Set: ParseMailboxes "a": empty or invalid address`, + }, { + desc: "Multiple mailboxes", + mailboxes: "new , from ", + expMsg: "to:new , from \r\n\r\n", + }} + + for _, c := range cases { + err = msg.SetTo(c.mailboxes) + if err != nil { + test.Assert(t, c.desc, c.expError, err.Error()) + } + test.Assert(t, c.desc, c.expMsg, msg.String()) + } +} -- cgit v1.3