diff options
| author | Shulhan <ms@kilabit.info> | 2022-02-26 00:26:49 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2022-02-27 02:05:41 +0700 |
| commit | 835edbbb599fea680c20dd7c74969d334db369a8 (patch) | |
| tree | afffde39b218483f8e6ef2f0edaab4dd4f26c787 | |
| parent | 5242d7a436444300e89ac7994ffa92ba24bcdcea (diff) | |
| download | pakakeh.go-835edbbb599fea680c20dd7c74969d334db369a8.tar.xz | |
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.
| -rw-r--r-- | lib/email/body.go | 18 | ||||
| -rw-r--r-- | lib/email/contenttype.go | 11 | ||||
| -rw-r--r-- | lib/email/field.go | 84 | ||||
| -rw-r--r-- | lib/email/header.go | 55 | ||||
| -rw-r--r-- | lib/email/message.go | 123 | ||||
| -rw-r--r-- | 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 @@ -102,6 +102,24 @@ func (body *Body) Add(mime *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. // func (body *Body) String() string { 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 @@ -165,6 +165,63 @@ invalid: } // +// 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 @@ -133,6 +133,36 @@ func ParseMessage(raw []byte) (msg *Message, rest []byte, err error) { } // +// 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 // required fields that are empty will be initialized with default values. @@ -287,6 +317,96 @@ func (msg *Message) DKIMVerify() (*dkim.Status, error) { } // +// 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. // func (msg *Message) String() string { @@ -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 <a@b.c>", + expMsg: "cc:one <a@b.c>\r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "cc:one <a@b.c>\r\n\r\n", + }, { + desc: "Invalid mailbox", + mailboxes: "a", + expError: `AddCC: ParseMailboxes "a": empty or invalid address`, + expMsg: "cc:one <a@b.c>\r\n\r\n", + }, { + desc: "Multiple mailboxes", + mailboxes: "two <a@b.c>, three <a@b.c> ", + expMsg: "cc:one <a@b.c>, two <a@b.c>, three <a@b.c>\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 <a@b.c>", + expMsg: "to:one <a@b.c>\r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "to:one <a@b.c>\r\n\r\n", + }, { + desc: "Invalid mailbox", + mailboxes: "a", + expError: `AddTo: ParseMailboxes "a": empty or invalid address`, + expMsg: "to:one <a@b.c>\r\n\r\n", + }, { + desc: "Multiple mailboxes", + mailboxes: "two <a@b.c>, three <a@b.c> ", + expMsg: "to:one <a@b.c>, two <a@b.c>, three <a@b.c>\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 <a@b.c>", + expMsg: "cc:test <a@b.c>\r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "cc:test <a@b.c>\r\n\r\n", + }, { + desc: "Invalid mailbox", + mailboxes: "a", + expError: `SetCC: Set: ParseMailboxes "a": empty or invalid address`, + expMsg: "cc:test <a@b.c>\r\n\r\n", + }, { + desc: "Multiple mailboxes", + mailboxes: "new <a@b.c>, from <a@b.c>", + expMsg: "cc:new <a@b.c>, from <a@b.c>\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 <a@b.c>", + expMsg: "from:test <a@b.c>\r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "from:test <a@b.c>\r\n\r\n", + }, { + desc: "Invalid mailbox", + mailbox: "a", + expError: `SetFrom: Set: ParseMailboxes "a": empty or invalid address`, + expMsg: "from:test <a@b.c>\r\n\r\n", + }, { + desc: "New mailbox", + mailbox: "new <a@b.c>", + expMsg: "from:new <a@b.c>\r\n\r\n", + }, { + desc: "Multiple mailboxes", + mailbox: "two <a@b.c>, three <a@b.c>", + expMsg: "from:two <a@b.c>, three <a@b.c>\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 <a@b.c>", + expMsg: "to:test <a@b.c>\r\n\r\n", + }, { + desc: "Empty mailbox", + expMsg: "to:test <a@b.c>\r\n\r\n", + }, { + desc: "Invalid mailbox", + mailboxes: "a", + expMsg: "to:test <a@b.c>\r\n\r\n", + expError: `SetTo: Set: ParseMailboxes "a": empty or invalid address`, + }, { + desc: "Multiple mailboxes", + mailboxes: "new <a@b.c>, from <a@b.c>", + expMsg: "to:new <a@b.c>, from <a@b.c>\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()) + } +} |
