summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShulhan <ms@kilabit.info>2022-02-26 00:26:49 +0700
committerShulhan <ms@kilabit.info>2022-02-27 02:05:41 +0700
commit835edbbb599fea680c20dd7c74969d334db369a8 (patch)
treeafffde39b218483f8e6ef2f0edaab4dd4f26c787
parent5242d7a436444300e89ac7994ffa92ba24bcdcea (diff)
downloadpakakeh.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.go18
-rw-r--r--lib/email/contenttype.go11
-rw-r--r--lib/email/field.go84
-rw-r--r--lib/email/header.go55
-rw-r--r--lib/email/message.go123
-rw-r--r--lib/email/message_test.go257
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())
+ }
+}