575 lines
14 KiB
Go
575 lines
14 KiB
Go
// Copyright (c) 2024 Proton AG
|
|
//
|
|
// This file is part of Proton Mail Bridge.
|
|
//
|
|
// Proton Mail Bridge is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// Proton Mail Bridge is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package tests
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ProtonMail/gluon/rfc822"
|
|
"github.com/ProtonMail/go-proton-api"
|
|
"github.com/ProtonMail/proton-bridge/v3/pkg/message"
|
|
"github.com/ProtonMail/proton-bridge/v3/pkg/message/parser"
|
|
pmmime "github.com/ProtonMail/proton-bridge/v3/pkg/mime"
|
|
"github.com/bradenaw/juniper/xslices"
|
|
"github.com/cucumber/messages-go/v16"
|
|
"github.com/emersion/go-imap"
|
|
"golang.org/x/exp/slices"
|
|
)
|
|
|
|
type Message struct {
|
|
Subject string `bdd:"subject"`
|
|
Body string `bdd:"body"`
|
|
MIMEType string `bdd:"mime-type"`
|
|
Attachments string `bdd:"attachments"`
|
|
MessageID string `bdd:"message-id"`
|
|
Date string `bdd:"date"`
|
|
|
|
From string `bdd:"from"`
|
|
To string `bdd:"to"`
|
|
CC string `bdd:"cc"`
|
|
BCC string `bdd:"bcc"`
|
|
ReplyTo string `bdd:"reply-to"`
|
|
|
|
Unread bool `bdd:"unread"`
|
|
Deleted bool `bdd:"deleted"`
|
|
|
|
InReplyTo string `bdd:"in-reply-to"`
|
|
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
|
|
|
|
if msg.From != "" {
|
|
b = append(b, "From: "+msg.From+"\r\n"...)
|
|
}
|
|
|
|
if msg.To != "" {
|
|
b = append(b, "To: "+msg.To+"\r\n"...)
|
|
}
|
|
|
|
if msg.CC != "" {
|
|
b = append(b, "Cc: "+msg.CC+"\r\n"...)
|
|
}
|
|
|
|
if msg.BCC != "" {
|
|
b = append(b, "Bcc: "+msg.BCC+"\r\n"...)
|
|
}
|
|
|
|
if msg.Subject != "" {
|
|
b = append(b, "Subject: "+msg.Subject+"\r\n"...)
|
|
}
|
|
|
|
if msg.InReplyTo != "" {
|
|
b = append(b, "In-Reply-To: "+msg.InReplyTo+"\r\n"...)
|
|
}
|
|
|
|
if msg.References != "" {
|
|
b = append(b, "References: "+msg.References+"\r\n"...)
|
|
}
|
|
|
|
if msg.Date != "" {
|
|
date, err := time.Parse(time.RFC822, msg.Date)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
b = append(b, "Date: "+date.Format(time.RFC822Z)+"\r\n"...)
|
|
}
|
|
|
|
b = append(b, "\r\n"+msg.Body+"\r\n"...)
|
|
|
|
return b
|
|
}
|
|
|
|
func newMessageFromIMAP(msg *imap.Message) Message {
|
|
section, err := imap.ParseBodySectionName("BODY[]")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
literal, err := io.ReadAll(msg.GetBody(section))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
mimeType, _, err := rfc822.Parse(literal).ContentType()
|
|
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 := Message{
|
|
Subject: msg.Envelope.Subject,
|
|
Body: body,
|
|
MIMEType: string(mimeType),
|
|
Attachments: strings.Join(xslices.Map(m.Attachments, func(att message.Attachment) string { return att.Name }), ", "),
|
|
MessageID: msg.Envelope.MessageId,
|
|
Unread: !slices.Contains(msg.Flags, imap.SeenFlag),
|
|
Deleted: slices.Contains(msg.Flags, imap.DeletedFlag),
|
|
Date: msg.Envelope.Date.Format(time.RFC822Z),
|
|
InReplyTo: msg.Envelope.InReplyTo,
|
|
// Go-imap only supports in-reply-to so we have to mimic other client by using it as references.
|
|
References: msg.Envelope.InReplyTo,
|
|
}
|
|
|
|
if len(msg.Envelope.From) > 0 {
|
|
message.From = msg.Envelope.From[0].Address()
|
|
}
|
|
|
|
if len(msg.Envelope.To) > 0 {
|
|
message.To = msg.Envelope.To[0].Address()
|
|
}
|
|
|
|
if len(msg.Envelope.Cc) > 0 {
|
|
message.CC = msg.Envelope.Cc[0].Address()
|
|
}
|
|
|
|
if len(msg.Envelope.Bcc) > 0 {
|
|
message.BCC = msg.Envelope.Bcc[0].Address()
|
|
}
|
|
|
|
if len(msg.Envelope.ReplyTo) > 0 {
|
|
message.ReplyTo = msg.Envelope.ReplyTo[0].Address()
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
parser, err := parser.New(bytes.NewReader(literal))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
m, err := message.ParseWithParser(parser, true)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
var body string
|
|
switch {
|
|
case m.MIMEType == rfc822.TextPlain:
|
|
body = strings.TrimSpace(string(m.PlainBody))
|
|
case m.MIMEType == rfc822.MultipartMixed:
|
|
_, body, _ = strings.Cut(string(m.MIMEBody), "\r\n\r\n")
|
|
default:
|
|
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([]byte(strings.TrimSpace(string(literal))), strings.TrimSpace(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 {
|
|
headers, err := rfc822.Parse(literal).ParseHeader()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
mimeType, boundary, charset, name := parseContentType(headers.Get("Content-Type"))
|
|
disp, filename := parseContentDisposition(headers.Get("Content-Disposition"))
|
|
|
|
msgSect := MessageSection{
|
|
ContentType: mimeType,
|
|
ContentTypeBoundary: boundary,
|
|
ContentTypeCharset: charset,
|
|
ContentTypeName: name,
|
|
ContentDisposition: disp,
|
|
ContentDispositionFilename: filename,
|
|
TransferEncoding: headers.Get("content-transfer-encoding"),
|
|
BodyIs: body,
|
|
}
|
|
|
|
if msgSect.ContentTypeBoundary != "" {
|
|
sections := bytes.Split(literal, []byte("--"+msgSect.ContentTypeBoundary))
|
|
// Remove last element that will be the -- from finale boundary
|
|
sections = sections[:len(sections)-1]
|
|
sections = sections[1:]
|
|
for _, v := range sections {
|
|
str := strings.TrimSpace(string(v))
|
|
_, sectionBody, found := strings.Cut(str, "\r\n\r\n")
|
|
if !found {
|
|
if _, sectionBody, found = strings.Cut(str, "\n\n"); !found {
|
|
sectionBody = str
|
|
}
|
|
}
|
|
msgSect.Sections = append(msgSect.Sections, parseMessageSection([]byte(str), strings.TrimSpace(sectionBody)))
|
|
}
|
|
}
|
|
return msgSect
|
|
}
|
|
|
|
func parseContentType(contentType string) (string, string, string, string) {
|
|
mimeType, params, err := pmmime.ParseMediaType(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 parseContentDisposition(contentDisp string) (string, string) {
|
|
disp, params, _ := pmmime.ParseMediaType(contentDisp)
|
|
name, ok := params["filename"]
|
|
if !ok {
|
|
name = ""
|
|
}
|
|
return disp, name
|
|
}
|
|
|
|
func matchMessages(have, want []Message) error {
|
|
slices.SortFunc(have, func(a, b Message) bool {
|
|
return a.Subject < b.Subject
|
|
})
|
|
|
|
slices.SortFunc(want, func(a, b Message) bool {
|
|
return a.Subject < b.Subject
|
|
})
|
|
|
|
if !IsSub(ToAny(have), ToAny(want)) {
|
|
return fmt.Errorf("missing messages: have %#v, want %#v", have, want)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func matchStructure(have []MessageStruct, want MessageStruct) error {
|
|
mismatches := make([]string, 0)
|
|
for _, msg := range have {
|
|
if want.From != "" && msg.From != want.From {
|
|
mismatches = append(mismatches, "From")
|
|
continue
|
|
}
|
|
if want.To != "" && msg.To != want.To {
|
|
mismatches = append(mismatches, "To")
|
|
continue
|
|
}
|
|
if want.BCC != "" && msg.BCC != want.BCC {
|
|
mismatches = append(mismatches, "BCC")
|
|
continue
|
|
}
|
|
if want.CC != "" && msg.CC != want.CC {
|
|
mismatches = append(mismatches, "CC")
|
|
continue
|
|
}
|
|
if want.Subject != "" && msg.Subject != want.Subject {
|
|
mismatches = append(mismatches, "Subject")
|
|
continue
|
|
}
|
|
if want.Date != "" && want.Date != msg.Date {
|
|
mismatches = append(mismatches, "Date")
|
|
continue
|
|
}
|
|
|
|
if ok, mismatch := matchContent(msg.Content, want.Content); !ok {
|
|
mismatches = append(mismatches, "Content: "+mismatch)
|
|
continue
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("missing messages: have %#v, want %#v with mismatch list %#v", have, want, mismatches)
|
|
}
|
|
|
|
func matchContent(have MessageSection, want MessageSection) (bool, string) {
|
|
if want.ContentType != "" && want.ContentType != have.ContentType {
|
|
return false, "ContentType"
|
|
}
|
|
if want.ContentTypeBoundary != "" && want.ContentTypeBoundary != have.ContentTypeBoundary {
|
|
return false, "ContentTypeBoundary"
|
|
}
|
|
if want.ContentTypeCharset != "" && want.ContentTypeCharset != have.ContentTypeCharset {
|
|
return false, "ContentTypeCharset"
|
|
}
|
|
if want.ContentTypeName != "" && want.ContentTypeName != have.ContentTypeName {
|
|
return false, "ContentTypeName"
|
|
}
|
|
if want.ContentDisposition != "" && want.ContentDisposition != have.ContentDisposition {
|
|
return false, "ContentDisposition"
|
|
}
|
|
if want.ContentDispositionFilename != "" && want.ContentDispositionFilename != have.ContentDispositionFilename {
|
|
return false, "ContentDispositionFilename"
|
|
}
|
|
if want.TransferEncoding != "" && want.TransferEncoding != have.TransferEncoding {
|
|
return false, "TransferEncoding"
|
|
}
|
|
if want.BodyContains != "" && !strings.Contains(strings.TrimSpace(have.BodyIs), strings.TrimSpace(want.BodyContains)) {
|
|
return false, "BodyContains"
|
|
}
|
|
if want.BodyIs != "" && strings.TrimSpace(have.BodyIs) != strings.TrimSpace(want.BodyIs) {
|
|
return false, "BodyIs"
|
|
}
|
|
|
|
for i, section := range want.Sections {
|
|
if ok, mismatch := matchContent(have.Sections[i], section); !ok {
|
|
return false, fmt.Sprintf("section %#v - %#v", i, mismatch)
|
|
}
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
type Mailbox struct {
|
|
Name string `bdd:"name"`
|
|
Total int `bdd:"total"`
|
|
Unread int `bdd:"unread"`
|
|
}
|
|
|
|
func newMailboxFromIMAP(status *imap.MailboxStatus) Mailbox {
|
|
return Mailbox{
|
|
Name: status.Name,
|
|
Total: int(status.Messages),
|
|
Unread: int(status.Unseen),
|
|
}
|
|
}
|
|
|
|
func matchMailboxes(have, want []Mailbox) error {
|
|
slices.SortFunc(have, func(a, b Mailbox) bool {
|
|
return a.Name < b.Name
|
|
})
|
|
|
|
slices.SortFunc(want, func(a, b Mailbox) bool {
|
|
return a.Name < b.Name
|
|
})
|
|
|
|
if !IsSub(want, have) {
|
|
return fmt.Errorf("missing mailboxes: %v", want)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func eventually(condition func() error) error {
|
|
ch := make(chan error, 1)
|
|
var lastErr error
|
|
|
|
var timerDuration = 30 * time.Second
|
|
// Extend to 5min for live API.
|
|
if hostURL := os.Getenv("FEATURE_TEST_HOST_URL"); hostURL != "" {
|
|
timerDuration = 300 * time.Second
|
|
}
|
|
|
|
timer := time.NewTimer(timerDuration)
|
|
defer timer.Stop()
|
|
|
|
ticker := time.NewTicker(timerDuration / 300)
|
|
defer ticker.Stop()
|
|
|
|
for tick := ticker.C; ; {
|
|
select {
|
|
case <-timer.C:
|
|
return fmt.Errorf("eventually timed out: %w", lastErr)
|
|
|
|
case <-tick:
|
|
tick = nil
|
|
|
|
go func() { ch <- condition() }()
|
|
|
|
case err := <-ch:
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
lastErr = err
|
|
tick = ticker.C
|
|
}
|
|
}
|
|
}
|
|
|
|
func unmarshalTable[T any](table *messages.PickleTable) ([]T, error) {
|
|
if len(table.Rows) == 0 {
|
|
return nil, fmt.Errorf("empty table")
|
|
}
|
|
|
|
res := make([]T, 0, len(table.Rows))
|
|
|
|
for _, row := range table.Rows[1:] {
|
|
var v T
|
|
|
|
if err := unmarshalRow(table.Rows[0], row, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res = append(res, v)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func unmarshalRow(header, row *messages.PickleTableRow, v any) error {
|
|
typ := reflect.TypeOf(v).Elem()
|
|
|
|
for idx := 0; idx < typ.NumField(); idx++ {
|
|
field := typ.Field(idx)
|
|
|
|
if tag, ok := field.Tag.Lookup("bdd"); ok {
|
|
cell, ok := getCellValue(header, row, tag)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
switch field.Type.Kind() { //nolint:exhaustive
|
|
case reflect.String:
|
|
reflect.ValueOf(v).Elem().Field(idx).SetString(cell)
|
|
|
|
case reflect.Int:
|
|
reflect.ValueOf(v).Elem().Field(idx).SetInt(int64(mustParseInt(cell)))
|
|
|
|
case reflect.Bool:
|
|
reflect.ValueOf(v).Elem().Field(idx).SetBool(mustParseBool(cell))
|
|
|
|
default:
|
|
return fmt.Errorf("unsupported type %q", field.Type.Kind())
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func getCellValue(header, row *messages.PickleTableRow, name string) (string, bool) {
|
|
for idx, cell := range header.Cells {
|
|
if cell.Value == name {
|
|
return row.Cells[idx].Value, true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func mustParseInt(s string) int {
|
|
i, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return i
|
|
}
|
|
|
|
func mustParseBool(s string) bool {
|
|
v, err := strconv.ParseBool(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
type Contact struct {
|
|
Name string `bdd:"name"`
|
|
Email string `bdd:"email"`
|
|
Format string `bdd:"format"`
|
|
Scheme string `bdd:"scheme"`
|
|
Sign string `bdd:"signature"`
|
|
Encrypt string `bdd:"encryption"`
|
|
}
|
|
|
|
type MailSettings struct {
|
|
DraftMIMEType rfc822.MIMEType `bdd:"DraftMIMEType"`
|
|
AttachPublicKey proton.Bool `bdd:"AttachPublicKey"`
|
|
Sign proton.SignExternalMessages `bdd:"Sign"`
|
|
PGPScheme proton.EncryptionScheme `bdd:"PGPScheme"`
|
|
}
|