diff --git a/tests/features/imap/message/import.feature b/tests/features/imap/message/import.feature index f264287b..fb0c4046 100644 --- a/tests/features/imap/message/import.feature +++ b/tests/features/imap/message/import.feature @@ -21,9 +21,19 @@ Feature: IMAP import messages Hello """ Then it succeeds - And IMAP client "1" eventually sees the following messages in "INBOX": - | from | to | subject | body | - | bridgetest@pm.test | bridgetest@example.com | Basic text/plain message | Hello | + And IMAP client "1" eventually sees the following message in "INBOX" with this structure: + """ + { + "from": "Bridge Test ", + "date": "01 Jan 80 00:00 +0000", + "to": "Internal Bridge ", + "subject": "Basic text/plain message", + "content": { + "content-type": "text/plain", + "body-is": "Hello" + } + } + """ Scenario: Import message with double charset in content type When IMAP client "1" appends the following message to "INBOX": @@ -39,9 +49,22 @@ Feature: IMAP import messages Hello """ Then it succeeds - And IMAP client "1" eventually sees the following messages in "INBOX": - | from | to | subject | body | - | bridgetest@pm.test | bridgetest@example.com | Message with double charset in content type | Hello | + And IMAP client "1" eventually sees the following message in "INBOX" with this structure: + """ + { + "from": "Bridge Test ", + "date": "01 Jan 80 00:00 +0000", + "to": "Internal Bridge ", + "subject": "Message with double charset in content type", + "content": { + "content-type": "text/plain", + "content-type-charset": "utf-8", + "content-disposition": "", + "transfer-encoding": "quoted-printable", + "body-is": "Hello" + } + } + """ Scenario: Import message with attachment name encoded by RFC 2047 without quoting @@ -69,31 +92,87 @@ Feature: IMAP import messages """ Then it succeeds +# And IMAP client "1" eventually sees the following message in "INBOX" with this structure: +# """ +# { +# "from": "Bridge Test ", +# "date": "01 Jan 80 00:00 +0000", +# "to": "Internal Bridge ", +# "subject": "Message with attachment name encoded by RFC 2047 without quoting", +# "body-contains": "Hello", +# "content": { +# "content-type": "multipart/mixed; boundary=\"boundary\"", +# "sections":[ +# { +# "content-type": "text/plain", +# "body-is": "Hello" +# }, +# { +# "content-type": "application/pdf", +# "content-type-name": "=?US-ASCII?Q?filename?=", +# "content-disposition": "attachment", +# "content-disposition-filename": "=?US-ASCII?Q?filename?=", +# "body-is": "somebytes" +# } +# ] +# } +# } +# """ # The message is imported as UTF-8 and the content type is determined at build time. Scenario: Import message as latin1 without content type When IMAP client "1" appends "plain/text_plain_unknown_latin1.eml" to "INBOX" Then it succeeds - And IMAP client "1" eventually sees the following messages in "INBOX": - | from | to | body | - | sender@pm.me | receiver@pm.me | ééééééé | + And IMAP client "1" eventually sees the following message in "INBOX" with this structure: + """ + { + "from": "Sender ", + "date": "01 Jan 80 00:00 +0000", + "to": "Receiver ", + "content": { + "content-type": "text/plain", + "body-is": "ééééééé" + } + } + """ # The message is imported and the body is converted to UTF-8. Scenario: Import message as latin1 with content type When IMAP client "1" appends "plain/text_plain_latin1.eml" to "INBOX" Then it succeeds - And IMAP client "1" eventually sees the following messages in "INBOX": - | from | to | body | - | sender@pm.me | receiver@pm.me | ééééééé | + And IMAP client "1" eventually sees the following message in "INBOX" with this structure: + """ + { + "from": "Sender ", + "date": "01 Jan 80 00:00 +0000", + "to": "Receiver ", + "content": { + "content-type": "text/plain", + "content-type-charset": "utf-8", + "body-is": "ééééééé" + } + } + """ + # The message is imported anad the body is wrongly converted (body is corrupted). Scenario: Import message as latin1 with wrong content type When IMAP client "1" appends "plain/text_plain_wrong_latin1.eml" to "INBOX" Then it succeeds - And IMAP client "1" eventually sees the following messages in "INBOX": - | from | to | - | sender@pm.me | receiver@pm.me | + And IMAP client "1" eventually sees the following message in "INBOX" with this structure: + """ + { + "from": "Sender ", + "date": "01 Jan 80 00:00 +0000", + "to": "Receiver ", + "content": { + "content-type": "text/plain", + "content-type-charset": "utf-8", + "body-is": "" + } + } + """ Scenario: Import received message to Sent When IMAP client "1" appends the following message to "Sent": @@ -107,9 +186,19 @@ Feature: IMAP import messages Hello """ Then it succeeds - And IMAP client "1" eventually sees the following messages in "Sent": - | from | to | subject | body | - | foo@example.com | bridgetest@pm.test | Hello | Hello | + And IMAP client "1" eventually sees the following message in "Sent" with this structure: + """ + { + "from": "Foo ", + "date": "01 Jan 80 00:00 +0000", + "to": "Bridge Test ", + "subject": "Hello", + "content": { + "content-type": "text/plain", + "body-is": "Hello" + } + } + """ And IMAP client "1" eventually sees 0 messages in "Inbox" Scenario: Import non-received message to Inbox @@ -123,11 +212,22 @@ Feature: IMAP import messages Hello """ Then it succeeds - And IMAP client "1" eventually sees the following messages in "INBOX": - | from | to | subject | body | - | foo@example.com | bridgetest@pm.test | Hello | Hello | + And IMAP client "1" eventually sees the following message in "INBOX" with this structure: + """ + { + "from": "Foo ", + "date": "01 Jan 80 00:00 +0000", + "to": "Bridge Test ", + "subject": "Hello", + "content": { + "content-type": "text/plain", + "body-is": "Hello" + } + } + """ And IMAP client "1" eventually sees 0 messages in "Sent" + Scenario: Import non-received message to Sent When IMAP client "1" appends the following message to "Sent": """ @@ -139,10 +239,20 @@ Feature: IMAP import messages Hello """ Then it succeeds - And IMAP client "1" eventually sees the following messages in "Sent": - | from | to | subject | body | - | foo@example.com | bridgetest@pm.test | Hello | Hello | And IMAP client "1" eventually sees 0 messages in "Inbox" + And IMAP client "1" eventually sees the following message in "Sent" with this structure: + """ + { + "from": "Foo ", + "date": "01 Jan 80 00:00 +0000", + "to": "Bridge Test ", + "subject": "Hello", + "content": { + "content-type": "text/plain", + "body-is": "Hello" + } + } + """ Scenario Outline: Import message without sender to When IMAP client "1" appends the following message to "": @@ -155,16 +265,53 @@ Feature: IMAP import messages Nope. """ Then it succeeds - And IMAP client "1" eventually sees the following messages in "": - | to | subject | body | - | lionel@richie.com | RE: Hello, is it me you looking for? | Nope. | - + And IMAP client "1" eventually sees the following message in "" with this structure: + """ + { + "from": "Somebody@somewhere.org", + "date": "01 Jan 80 00:00 +0000", + "to": "Lionel Richie ", + "subject": "RE: Hello, is it me you looking for?", + "content": { + "content-type": "text/plain", + "content-type-charset":"utf-8", + "transfer-encoding":"quoted-printable", + "body-is": "Nope." + } + } + """ Examples: | mailbox | - | Drafts | | Archive | | Sent | + Scenario: Import message without sender to Drafts + When IMAP client "1" appends the following message to "Drafts": + """ + From: Somebody@somewhere.org + Date: 01 Jan 1980 00:00:00 +0000 + To: Lionel Richie + Subject: RE: Hello, is it me you looking for? + + Nope. + """ + Then it succeeds + And IMAP client "1" eventually sees the following message in "Drafts" with this structure: + """ + { + "date": "01 Jan 01 00:00 +0000", + "to": "Lionel Richie ", + "subject": "RE: Hello, is it me you looking for?", + "content": { + "content-type": "text/plain", + "content-type-charset":"utf-8", + "transfer-encoding":"quoted-printable", + "body-is": "Nope." + } + } + """ + + Scenario: Import embedded message When IMAP client "1" appends the following message to "INBOX": """ @@ -198,3 +345,33 @@ Feature: IMAP import messages """ Then it succeeds +# And IMAP client "1" eventually sees the following message in "INBOX" with this structure: +# """ +# { +# "from": "Foo ", +# "date": "01 Jan 80 00:00 +0000", +# "to": "Bridge Test ", +# "subject": "Embedded message", +# "body-contains": "Hello", +# "content": { +# "content-type": "multipart/mixed", +# "body-contains": "This is a multi-part message in MIME format.", +# "sections":[ +# { +# "content-type": "text/plain", +# "content-type-charset": "utf-8", +# "transfer-encoding": "7bit", +# "body-is": "" +# }, +# { +# "content-type": "message/rfc822", +# "content-type-name": "embedded.eml", +# "transfer-encoding": "7bit", +# "content-disposition": "attachment", +# "content-disposition-filename": "embedded.eml", +# "body-is": "From: Bar \n\rTo: Bridge Test \n\rSubject: (No Subject)\n\rContent-Type: text/plain; charset=utf-8\n\rContent-Transfer-Encoding: quoted-printable\n\r\n\rhello" +# } +# ] +# } +# } +# """ diff --git a/tests/imap_test.go b/tests/imap_test.go index b11e7813..4165984f 100644 --- a/tests/imap_test.go +++ b/tests/imap_test.go @@ -19,6 +19,7 @@ package tests import ( "bytes" + "encoding/json" "fmt" "io" "os" @@ -342,6 +343,26 @@ func (s *scenario) imapClientEventuallySeesTheFollowingMessagesInMailbox(clientI }) } +func (s *scenario) imapClientSeesMessageInMailboxWithStructure(clientID, mailbox string, message *godog.DocString) error { + return eventually(func() error { + _, client := s.t.getIMAPClient(clientID) + + var msgStruct MessageStruct + if err := json.Unmarshal([]byte(message.Content), &msgStruct); err != nil { + return err + } + + fetch, err := clientFetch(client, mailbox) + if err != nil { + return err + } + + haveMessages := xslices.Map(fetch, newMessageStructFromIMAP) + + return matchStructure(haveMessages, msgStruct) + }) +} + func (s *scenario) imapClientSeesMessagesInMailbox(clientID string, count int, mailbox string) error { _, client := s.t.getIMAPClient(clientID) diff --git a/tests/steps_test.go b/tests/steps_test.go index f987c70a..5df03455 100644 --- a/tests/steps_test.go +++ b/tests/steps_test.go @@ -143,6 +143,7 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) { ctx.Step(`^IMAP client "([^"]*)" moves the message with subject "([^"]*)" from "([^"]*)" to "([^"]*)"$`, s.imapClientMovesTheMessageWithSubjectFromTo) ctx.Step(`^IMAP client "([^"]*)" moves all messages from "([^"]*)" to "([^"]*)"$`, s.imapClientMovesAllMessagesFromTo) ctx.Step(`^IMAP client "([^"]*)" eventually sees the following messages in "([^"]*)":$`, s.imapClientEventuallySeesTheFollowingMessagesInMailbox) + ctx.Step(`^IMAP client "([^"]*)" eventually sees the following message in "([^"]*)" with this structure:$`, s.imapClientSeesMessageInMailboxWithStructure) ctx.Step(`^IMAP client "([^"]*)" eventually sees (\d+) messages in "([^"]*)"$`, s.imapClientEventuallySeesMessagesInMailbox) ctx.Step(`^IMAP client "([^"]*)" marks message (\d+) as deleted$`, s.imapClientMarksMessageAsDeleted) ctx.Step(`^IMAP client "([^"]*)" marks the message with subject "([^"]*)" as deleted$`, s.imapClientMarksTheMessageWithSubjectAsDeleted) diff --git a/tests/types_test.go b/tests/types_test.go index 27b7e982..f7d4526d 100644 --- a/tests/types_test.go +++ b/tests/types_test.go @@ -56,6 +56,31 @@ type Message struct { References string `bdd:"references"` } +type MessageStruct struct { + From string `json:"from"` + To string `json:"to"` + CC string `json:"cc"` + BCC string `json:"bcc"` + Subject string `json:"subject"` + Date string `json:"date"` + + Content MessageSection `json:"content"` +} + +type MessageSection struct { + ContentType string `json:"content-type"` + ContentTypeBoundary string `json:"content-type-boundary"` + ContentTypeCharset string `json:"content-type-charset"` + ContentTypeName string `json:"content-type-name"` + ContentDisposition string `json:"content-disposition"` + ContentDispositionFilename string `json:"content-disposition-filename"` + Sections []MessageSection `json:"sections"` + + TransferEncoding string `json:"transfer-encoding"` + BodyContains string `json:"body-contains"` + BodyIs string `json:"body-is"` +} + func (msg Message) Build() []byte { var b []byte @@ -166,6 +191,116 @@ func newMessageFromIMAP(msg *imap.Message) Message { return message } +func newMessageStructFromIMAP(msg *imap.Message) MessageStruct { + section, err := imap.ParseBodySectionName("BODY[]") + if err != nil { + panic(err) + } + + literal, err := io.ReadAll(msg.GetBody(section)) + if err != nil { + panic(err) + } + + m, err := message.Parse(bytes.NewReader(literal)) + if err != nil { + panic(err) + } + var body string + if m.MIMEType == rfc822.TextPlain { + body = strings.TrimSpace(string(m.PlainBody)) + } else { + body = strings.TrimSpace(string(m.RichBody)) + } + + message := MessageStruct{ + Subject: msg.Envelope.Subject, + Date: msg.Envelope.Date.Format(time.RFC822Z), + From: formatAddressList(msg.Envelope.From), + To: formatAddressList(msg.Envelope.To), + CC: formatAddressList(msg.Envelope.Cc), + BCC: formatAddressList(msg.Envelope.Bcc), + + Content: parseMessageSection(literal, body), + } + return message +} + +func formatAddressList(list []*imap.Address) string { + var res string + for idx, address := range list { + if address.PersonalName != "" { + res += address.PersonalName + " <" + address.Address() + ">" + } else { + res += address.Address() + } + if idx < len(list)-1 { + res += "; " + } + } + return res +} + +func parseMessageSection(literal []byte, body string) MessageSection { + mimeType, boundary, charset, name := parseContentType(literal) + + headers, err := rfc822.Parse(literal).ParseHeader() + if err != nil { + panic(err) + } + + msgSect := MessageSection{ + ContentType: string(mimeType), + ContentTypeBoundary: boundary, + ContentTypeCharset: charset, + ContentTypeName: name, + TransferEncoding: headers.Get("content-transfer-encoding"), + BodyIs: body, + } + + contentDisposition := bytes.Split([]byte(headers.Get("content-disposition")), []byte(";")) + for id, value := range contentDisposition { + if id == 0 { + msgSect.ContentDisposition = strings.TrimSpace(string(value)) + } + param := bytes.Split(value, []byte("=")) + if strings.TrimSpace(string(param[0])) == "filename" && len(param) >= 2 { + filename := strings.TrimPrefix(string(value), "filename=") + msgSect.ContentDispositionFilename = strings.TrimSpace(filename) + } + } + + if msgSect.ContentTypeBoundary != "" { + sections := bytes.Split([]byte(msgSect.BodyIs), []byte("--"+msgSect.ContentTypeBoundary)) + // Remove last element that will be the -- from finale boundary + sections = sections[:len(sections)-1] + for _, v := range sections { + msgSect.Sections = append(msgSect.Sections, parseMessageSection(v, string(v))) + } + } + return msgSect +} + +func parseContentType(literal []byte) (rfc822.MIMEType, string, string, string) { + mimeType, params, err := rfc822.Parse(literal).ContentType() + if err != nil { + panic(err) + } + boundary, ok := params["boundary"] + if !ok { + boundary = "" + } + charset, ok := params["charset"] + if !ok { + charset = "" + } + name, ok := params["name"] + if !ok { + name = "" + } + return mimeType, boundary, charset, name +} + func matchMessages(have, want []Message) error { slices.SortFunc(have, func(a, b Message) bool { return a.Subject < b.Subject @@ -182,6 +317,71 @@ func matchMessages(have, want []Message) error { return nil } +func matchStructure(have []MessageStruct, want MessageStruct) error { + for _, msg := range have { + if want.From != "" && msg.From != want.From { + continue + } + if want.To != "" && msg.To != want.To { + continue + } + if want.BCC != "" && msg.BCC != want.BCC { + continue + } + if want.CC != "" && msg.CC != want.CC { + continue + } + if want.Subject != "" && msg.Subject != want.Subject { + continue + } + if want.Date != "" && want.Date != msg.Date { + continue + } + + if matchContent(msg.Content, want.Content) { + return nil + } + } + return fmt.Errorf("missing messages: have %#v, want %#v", have, want) +} + +func matchContent(have MessageSection, want MessageSection) bool { + if want.ContentType != "" && want.ContentType != have.ContentType { + return false + } + if want.ContentTypeBoundary != "" && want.ContentTypeBoundary != have.ContentTypeBoundary { + return false + } + if want.ContentTypeCharset != "" && want.ContentTypeCharset != have.ContentTypeCharset { + return false + } + if want.ContentTypeName != "" && want.ContentTypeName != have.ContentTypeName { + return false + } + if want.ContentDisposition != "" && want.ContentDisposition != have.ContentDisposition { + return false + } + if want.ContentDispositionFilename != "" && want.ContentDispositionFilename != have.ContentDispositionFilename { + return false + } + if want.TransferEncoding != "" && want.TransferEncoding != have.TransferEncoding { + return false + } + if want.BodyContains != "" && strings.Contains(have.BodyIs, want.BodyContains) { + return false + } + if want.BodyIs != "" && want.BodyIs != have.BodyIs { + return false + } + for _, section := range want.Sections { + if !matchContent(have, section) { + return false + } + } + + return true +} + type Mailbox struct { Name string `bdd:"name"` Total int `bdd:"total"` @@ -336,3 +536,7 @@ type Contact struct { Sign string `bdd:"signature"` Encrypt string `bdd:"encryption"` } + +func FullAddress(addr *imap.Address) string { + return addr.PersonalName + " <" + addr.MailboxName + "@" + addr.HostName + ">" +} diff --git a/tests/user_test.go b/tests/user_test.go index afc446fa..7670cb3d 100644 --- a/tests/user_test.go +++ b/tests/user_test.go @@ -558,7 +558,7 @@ func (s *scenario) createUserAccount(username, password string, disabled bool) e if _, err := s.t.runQuarkCmd( context.Background(), "user:create:subscription", - "--planID", "plus", + "--planID", "visionary2022", string(userDecID), ); err != nil { return err