GODT-1650: Send extras

This commit is contained in:
James Houlahan
2022-10-02 13:28:41 +02:00
parent 2cb739027b
commit ba9368426c
28 changed files with 1248 additions and 236 deletions

4
go.mod
View File

@ -24,6 +24,7 @@ require (
github.com/fatih/color v1.13.0 github.com/fatih/color v1.13.0
github.com/getsentry/sentry-go v0.13.0 github.com/getsentry/sentry-go v0.13.0
github.com/go-resty/resty/v2 v2.7.0 github.com/go-resty/resty/v2 v2.7.0
github.com/goccy/go-json v0.9.11
github.com/godbus/dbus v4.1.0+incompatible github.com/godbus/dbus v4.1.0+incompatible
github.com/golang/mock v1.6.0 github.com/golang/mock v1.6.0
github.com/google/go-cmp v0.5.9 github.com/google/go-cmp v0.5.9
@ -37,7 +38,7 @@ require (
github.com/sirupsen/logrus v1.9.0 github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.0 github.com/stretchr/testify v1.8.0
github.com/urfave/cli/v2 v2.16.3 github.com/urfave/cli/v2 v2.16.3
gitlab.protontech.ch/go/liteapi v0.31.1 gitlab.protontech.ch/go/liteapi v0.32.1-0.20221004092920-6b728aed0d4d
golang.org/x/exp v0.0.0-20220921164117-439092de6870 golang.org/x/exp v0.0.0-20220921164117-439092de6870
golang.org/x/net v0.1.0 golang.org/x/net v0.1.0
golang.org/x/sys v0.1.0 golang.org/x/sys v0.1.0
@ -81,7 +82,6 @@ require (
github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.11.0 // indirect github.com/go-playground/validator/v10 v10.11.0 // indirect
github.com/goccy/go-json v0.9.11 // indirect
github.com/gofrs/uuid v4.3.0+incompatible // indirect github.com/gofrs/uuid v4.3.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.0.0 // indirect github.com/golang/glog v1.0.0 // indirect

4
go.sum
View File

@ -463,8 +463,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zclconf/go-cty v1.11.0 h1:726SxLdi2SDnjY+BStqB9J1hNp4+2WlzyXLuimibIe0= github.com/zclconf/go-cty v1.11.0 h1:726SxLdi2SDnjY+BStqB9J1hNp4+2WlzyXLuimibIe0=
github.com/zclconf/go-cty v1.11.0/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA= github.com/zclconf/go-cty v1.11.0/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA=
gitlab.protontech.ch/go/liteapi v0.31.1 h1:GPGwsFkm4leeaQaRzIXRPnmsn0tiVXh4daM5iYsfMa8= gitlab.protontech.ch/go/liteapi v0.32.1-0.20221004092920-6b728aed0d4d h1:2CB6po0yWmgb0bVCylvQlQph6a6Hk/Uziq5eHg0ZCfo=
gitlab.protontech.ch/go/liteapi v0.31.1/go.mod h1:ixp1LUOxOYuB1qf172GdV0ZT8fOomKxVFtIMZeSWg+I= gitlab.protontech.ch/go/liteapi v0.32.1-0.20221004092920-6b728aed0d4d/go.mod h1:SVxEeF4uYYYpSlfeAj2ZqluVEP95pbZ8LyoieSxU0pM=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=

View File

@ -2,6 +2,7 @@ package bridge_test
import ( import (
"context" "context"
"net/http"
"os" "os"
"testing" "testing"
"time" "time"
@ -127,7 +128,7 @@ func TestBridge_UserAgent(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Assert that the user agent was sent to the API. // Assert that the user agent was sent to the API.
require.Contains(t, calls[len(calls)-1].Request.Header.Get("User-Agent"), bridge.GetCurrentUserAgent()) require.Contains(t, calls[len(calls)-1].Header.Get("User-Agent"), bridge.GetCurrentUserAgent())
}) })
}) })
} }
@ -147,7 +148,7 @@ func TestBridge_Cookies(t *testing.T) {
_, err := bridge.LoginUser(context.Background(), username, password, nil, nil) _, err := bridge.LoginUser(context.Background(), username, password, nil, nil)
require.NoError(t, err) require.NoError(t, err)
cookie, err := calls[len(calls)-1].Request.Cookie("Session-Id") cookie, err := (&http.Request{Header: calls[len(calls)-1].Header}).Cookie("Session-Id")
require.NoError(t, err) require.NoError(t, err)
sessionID = cookie.Value sessionID = cookie.Value
@ -155,7 +156,7 @@ func TestBridge_Cookies(t *testing.T) {
// Start bridge again and check that it uses the same session ID. // Start bridge again and check that it uses the same session ID.
withBridge(t, ctx, s.GetHostURL(), dialer, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) { withBridge(t, ctx, s.GetHostURL(), dialer, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
cookie, err := calls[len(calls)-1].Request.Cookie("Session-Id") cookie, err := (&http.Request{Header: calls[len(calls)-1].Header}).Cookie("Session-Id")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, sessionID, cookie.Value) require.Equal(t, sessionID, cookie.Value)
@ -340,7 +341,7 @@ func withEnv(t *testing.T, tests func(ctx context.Context, server *server.Server
defer server.Close() defer server.Close()
// Add test user. // Add test user.
_, _, err := server.AddUser(username, string(password), username+"@pm.me") _, _, err := server.CreateUser(username, string(password), username+"@pm.me")
require.NoError(t, err) require.NoError(t, err)
// Generate a random vault key. // Generate a random vault key.

View File

@ -2,30 +2,36 @@ package bridge
import ( import (
"crypto/subtle" "crypto/subtle"
"strings"
"sync" "sync"
"github.com/ProtonMail/proton-bridge/v2/internal/user" "github.com/ProtonMail/proton-bridge/v2/internal/user"
"github.com/bradenaw/juniper/xslices"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
type smtpBackend struct { type smtpBackend struct {
users []*user.User users map[string]*user.User
usersLock sync.RWMutex usersLock sync.RWMutex
} }
func newSMTPBackend() (*smtpBackend, error) { func newSMTPBackend() (*smtpBackend, error) {
return &smtpBackend{}, nil return &smtpBackend{
users: make(map[string]*user.User),
}, nil
} }
func (backend *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { func (backend *smtpBackend) Login(state *smtp.ConnectionState, email, password string) (smtp.Session, error) {
backend.usersLock.RLock() backend.usersLock.RLock()
defer backend.usersLock.RUnlock() defer backend.usersLock.RUnlock()
for _, user := range backend.users { for _, user := range backend.users {
if slices.Contains(user.Emails(), username) && subtle.ConstantTimeCompare(user.BridgePass(), []byte(password)) == 1 { if subtle.ConstantTimeCompare(user.BridgePass(), []byte(password)) != 1 {
return user.NewSMTPSession(username), nil continue
}
if email := strings.ToLower(email); slices.Contains(user.Emails(), email) {
return user.NewSMTPSession(email)
} }
} }
@ -38,17 +44,15 @@ func (backend *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Se
// addUser adds the given user to the backend. // addUser adds the given user to the backend.
// It returns an error if a user with the same ID already exists. // It returns an error if a user with the same ID already exists.
func (backend *smtpBackend) addUser(user *user.User) error { func (backend *smtpBackend) addUser(newUser *user.User) error {
backend.usersLock.Lock() backend.usersLock.Lock()
defer backend.usersLock.Unlock() defer backend.usersLock.Unlock()
for _, u := range backend.users { if _, ok := backend.users[newUser.ID()]; ok {
if u.ID() == user.ID() { return ErrUserAlreadyExists
return ErrUserAlreadyExists
}
} }
backend.users = append(backend.users, user) backend.users[newUser.ID()] = newUser
return nil return nil
} }
@ -59,13 +63,11 @@ func (backend *smtpBackend) removeUser(user *user.User) error {
backend.usersLock.Lock() backend.usersLock.Lock()
defer backend.usersLock.Unlock() defer backend.usersLock.Unlock()
idx := xslices.Index(backend.users, user) if _, ok := backend.users[user.ID()]; !ok {
if idx < 0 {
return ErrNoSuchUser return ErrNoSuchUser
} }
backend.users = append(backend.users[:idx], backend.users[idx+1:]...) delete(backend.users, user.ID())
return nil return nil
} }

9
internal/events/send.go Normal file
View File

@ -0,0 +1,9 @@
package events
type MessageSent struct {
eventBase
UserID string
AddressID string
MessageID string
}

View File

@ -33,12 +33,16 @@ func (list *addrList) addrIDs() []string {
return list.apiAddrs.keys() return list.apiAddrs.keys()
} }
func (list *addrList) addrID(email string) (string, bool) {
return list.apiAddrs.getKey(email)
}
func (list *addrList) emails() []string { func (list *addrList) emails() []string {
return list.apiAddrs.values() return list.apiAddrs.values()
} }
func (list *addrList) email(addrID string) string { func (list *addrList) email(addrID string) (string, bool) {
return list.apiAddrs.get(addrID) return list.apiAddrs.getVal(addrID)
} }
func (list *addrList) addrMap() map[string]string { func (list *addrList) addrMap() map[string]string {

View File

@ -25,7 +25,6 @@ const (
) )
type imapConnector struct { type imapConnector struct {
addrID string
client *liteapi.Client client *liteapi.Client
updateCh <-chan imap.Update updateCh <-chan imap.Update

View File

@ -5,7 +5,7 @@ import (
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
type ordMap[Key comparable, Val, Data any] struct { type ordMap[Key, Val comparable, Data any] struct {
data map[Key]Data data map[Key]Data
order []Key order []Key
@ -14,7 +14,7 @@ type ordMap[Key comparable, Val, Data any] struct {
isLess func(Data, Data) bool isLess func(Data, Data) bool
} }
func newOrdMap[Key comparable, Val, Data any]( func newOrdMap[Key, Val comparable, Data any](
key func(Data) Key, key func(Data) Key,
value func(Data) Val, value func(Data) Val,
less func(Data, Data) bool, less func(Data, Data) bool,
@ -64,8 +64,23 @@ func (set *ordMap[Key, Val, Data]) delete(key Key) Val {
return set.toVal(data) return set.toVal(data)
} }
func (set *ordMap[Key, Val, Data]) get(key Key) Val { func (set *ordMap[Key, Val, Data]) getVal(key Key) (Val, bool) {
return set.toVal(set.data[key]) data, ok := set.data[key]
if !ok {
return *new(Val), false
}
return set.toVal(data), true
}
func (set *ordMap[Key, Val, Data]) getKey(wantVal Val) (Key, bool) {
for key, data := range set.data {
if set.toVal(data) == wantVal {
return key, true
}
}
return *new(Key), false
} }
func (set *ordMap[Key, Val, Data]) keys() []Key { func (set *ordMap[Key, Val, Data]) keys() []Key {

View File

@ -5,11 +5,16 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"net/mail"
"runtime" "runtime"
"strings" "strings"
"github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/gluon/rfc822" "github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-rfc5322"
"github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/ProtonMail/proton-bridge/v2/pkg/message" "github.com/ProtonMail/proton-bridge/v2/pkg/message"
"github.com/ProtonMail/proton-bridge/v2/pkg/message/parser" "github.com/ProtonMail/proton-bridge/v2/pkg/message/parser"
"github.com/bradenaw/juniper/parallel" "github.com/bradenaw/juniper/parallel"
@ -17,42 +22,67 @@ import (
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gitlab.protontech.ch/go/liteapi" "gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/slices"
) )
type smtpSession struct { type smtpSession struct {
// client is the user's API client.
client *liteapi.Client client *liteapi.Client
username string // eventCh allows the session to publish events.
emails map[string]string eventCh *queue.QueuedChannel[events.Event]
// userID is the user's ID.
userID string
// addrID holds the ID of the address that is currently being used.
addrID string
// addrMode holds the address mode that is currently being used.
addrMode vault.AddressMode
// emails holds all email addresses associated with the user, by address ID.
emails map[string]string
// settings holds the mail settings for the user.
settings liteapi.MailSettings settings liteapi.MailSettings
userKR *crypto.KeyRing // userKR holds the user's keyring.
userKR *crypto.KeyRing
// addrKRs holds the keyrings for each address.
addrKRs map[string]*crypto.KeyRing addrKRs map[string]*crypto.KeyRing
from string // fromAddrID is the ID of the current sending address (taken from the return path).
to map[string]struct{} fromAddrID string
// to holds all to for the current message.
to []string
} }
func newSMTPSession( func newSMTPSession(
client *liteapi.Client, client *liteapi.Client,
username string, eventCh *queue.QueuedChannel[events.Event],
addresses map[string]string, userID, addrID string,
addrMode vault.AddressMode,
emails map[string]string,
settings liteapi.MailSettings, settings liteapi.MailSettings,
userKR *crypto.KeyRing, userKR *crypto.KeyRing,
addrKRs map[string]*crypto.KeyRing, addrKRs map[string]*crypto.KeyRing,
) *smtpSession { ) *smtpSession {
return &smtpSession{ return &smtpSession{
client: client, client: client,
eventCh: eventCh,
username: username, userID: userID,
emails: addresses, addrID: addrID,
addrMode: addrMode,
emails: emails,
settings: settings, settings: settings,
userKR: userKR, userKR: userKR,
addrKRs: addrKRs, addrKRs: addrKRs,
from: "",
to: make(map[string]struct{}),
} }
} }
@ -61,8 +91,8 @@ func (session *smtpSession) Reset() {
logrus.Info("SMTP session reset") logrus.Info("SMTP session reset")
// Clear the from and to fields. // Clear the from and to fields.
session.from = "" session.fromAddrID = ""
session.to = make(map[string]struct{}) session.to = nil
} }
// Free all resources associated with session. // Free all resources associated with session.
@ -78,25 +108,26 @@ func (session *smtpSession) Logout() error {
func (session *smtpSession) Mail(from string, opts smtp.MailOptions) error { func (session *smtpSession) Mail(from string, opts smtp.MailOptions) error {
logrus.Info("SMTP session mail") logrus.Info("SMTP session mail")
if opts.RequireTLS { switch {
case opts.RequireTLS:
return ErrNotImplemented return ErrNotImplemented
}
if opts.UTF8 { case opts.UTF8:
return ErrNotImplemented return ErrNotImplemented
}
if opts.Auth != nil && *opts.Auth != "" && *opts.Auth != session.username { case opts.Auth != nil:
return ErrNotImplemented if *opts.Auth != "" && *opts.Auth != session.emails[session.addrID] {
return ErrNotImplemented
}
} }
for addrID, email := range session.emails { for addrID, email := range session.emails {
if strings.EqualFold(from, email) { if strings.EqualFold(from, email) {
session.from = addrID session.fromAddrID = addrID
} }
} }
if session.from == "" { if session.fromAddrID == "" {
return ErrInvalidReturnPath return ErrInvalidReturnPath
} }
@ -111,86 +142,240 @@ func (session *smtpSession) Rcpt(to string) error {
return ErrInvalidRecipient return ErrInvalidRecipient
} }
session.to[to] = struct{}{} if !slices.Contains(session.to, to) {
session.to = append(session.to, to)
}
return nil return nil
} }
// Set currently processed message contents and send it. // Set currently processed message contents and send it.
func (session *smtpSession) Data(r io.Reader) error { func (session *smtpSession) Data(r io.Reader) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
logrus.Info("SMTP session data") logrus.Info("SMTP session data")
if session.from == "" { switch {
case session.fromAddrID == "":
return ErrInvalidReturnPath return ErrInvalidReturnPath
}
if len(session.to) == 0 { case len(session.to) == 0:
return ErrInvalidRecipient return ErrInvalidRecipient
} }
addrKR, ok := session.addrKRs[session.from]
if !ok {
return ErrMissingAddrKey
}
addrKey, err := addrKR.FirstKey()
if err != nil {
return fmt.Errorf("failed to get first key: %w", err)
}
parser, err := parser.New(r) parser, err := parser.New(r)
if err != nil { if err != nil {
return fmt.Errorf("failed to create parser: %w", err) return fmt.Errorf("failed to create parser: %w", err)
} }
if session.settings.AttachPublicKey == liteapi.AttachPublicKeyEnabled { // If the message contains a sender, use it instead of the one from the return path.
key, err := addrKey.GetKey(0) if sender, ok := getMessageSender(parser); ok {
if err != nil { for addrID, email := range session.emails {
return fmt.Errorf("failed to get user public key: %w", err) if strings.EqualFold(email, sanitizeEmail(sender)) {
session.fromAddrID = addrID
}
} }
pubKey, err := key.GetArmoredPublicKey()
if err != nil {
return fmt.Errorf("failed to get user public key: %w", err)
}
parser.AttachPublicKey(pubKey, fmt.Sprintf("publickey - %v - %v", addrKey.GetIdentities()[0].Name, key.GetFingerprint()[:8]))
} }
message, err := message.ParseWithParser(parser) addrKR, ok := session.addrKRs[session.fromAddrID]
if !ok {
return ErrMissingAddrKey
}
firstAddrKR, err := addrKR.FirstKey()
if err != nil { if err != nil {
return fmt.Errorf("failed to parse message: %w", err) return fmt.Errorf("failed to get first key: %w", err)
} }
draft, attKeys, err := session.createDraft(ctx, addrKey, message) message, err := sendWithKey(
session.client,
session.addrID,
session.addrMode,
session.userKR,
firstAddrKR,
session.settings,
sanitizeEmail(session.emails[session.fromAddrID]),
session.to,
parser,
)
if err != nil { if err != nil {
return fmt.Errorf("failed to create draft: %w", err) return fmt.Errorf("failed to send message: %w", err)
} }
recipients, err := session.getRecipients(ctx, message.Recipients(), message.MIMEType) session.eventCh.Enqueue(events.MessageSent{
if err != nil { UserID: session.userID,
return fmt.Errorf("failed to get recipients: %w", err) AddressID: session.addrID,
} MessageID: message.ID,
})
req, err := createSendReq(addrKey, message.MIMEBody, message.RichBody, message.PlainBody, recipients, attKeys) logrus.WithField("messageID", message.ID).Info("Message sent")
if err != nil {
return fmt.Errorf("failed to create packages: %w", err)
}
res, err := session.client.SendDraft(ctx, draft.ID, req)
if err != nil {
return fmt.Errorf("failed to send draft: %w", err)
}
logrus.WithField("messageID", res.ID).Info("SMTP message sent")
return nil return nil
} }
func (session *smtpSession) createDraft(ctx context.Context, addrKR *crypto.KeyRing, message message.Message) (liteapi.Message, map[string]*crypto.SessionKey, error) { // sendWithKey sends the message with the given address key.
func sendWithKey(
client *liteapi.Client,
addrID string,
addrMode vault.AddressMode,
userKR, addrKR *crypto.KeyRing,
settings liteapi.MailSettings,
from string,
to []string,
parser *parser.Parser,
) (liteapi.Message, error) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if settings.AttachPublicKey == liteapi.AttachPublicKeyEnabled {
key, err := addrKR.GetKey(0)
if err != nil {
return liteapi.Message{}, fmt.Errorf("failed to get user public key: %w", err)
}
pubKey, err := key.GetArmoredPublicKey()
if err != nil {
return liteapi.Message{}, fmt.Errorf("failed to get user public key: %w", err)
}
parser.AttachPublicKey(pubKey, fmt.Sprintf("publickey - %v - %v", addrKR.GetIdentities()[0].Name, key.GetFingerprint()[:8]))
}
message, err := message.ParseWithParser(parser)
if err != nil {
return liteapi.Message{}, fmt.Errorf("failed to parse message: %w", err)
}
if err := sanitizeParsedMessage(&message, from, to); err != nil {
return liteapi.Message{}, fmt.Errorf("failed to sanitize message: %w", err)
}
parentID, err := getParentID(ctx, client, addrID, addrMode, message.References)
if err != nil {
return liteapi.Message{}, fmt.Errorf("failed to get parent ID: %w", err)
}
draft, attKeys, err := createDraftWithAttachments(ctx, client, addrKR, message, parentID)
if err != nil {
return liteapi.Message{}, fmt.Errorf("failed to create draft: %w", err)
}
recipients, err := getRecipients(ctx, client, userKR, settings, message.Recipients(), message.MIMEType)
if err != nil {
return liteapi.Message{}, fmt.Errorf("failed to get recipients: %w", err)
}
req, err := createSendReq(addrKR, message.MIMEBody, message.RichBody, message.PlainBody, recipients, attKeys)
if err != nil {
return liteapi.Message{}, fmt.Errorf("failed to create packages: %w", err)
}
res, err := client.SendDraft(ctx, draft.ID, req)
if err != nil {
return liteapi.Message{}, fmt.Errorf("failed to send draft: %w", err)
}
return res, nil
}
func sanitizeParsedMessage(message *message.Message, from string, to []string) error {
// Check sender: set the sender in the parsed message if it's missing.
if message.Sender == nil {
message.Sender = &mail.Address{Address: from}
} else if message.Sender.Address == "" {
message.Sender.Address = from
}
// Check ToList: ensure that ToList only contains addresses we actually plan to send to.
message.ToList = xslices.Filter(message.ToList, func(addr *mail.Address) bool {
return slices.Contains(to, addr.Address)
})
// Check BCCList: any recipients not present in the ToList or CCList are BCC recipients.
for _, recipient := range to {
if !slices.Contains(message.Recipients(), recipient) {
message.BCCList = append(message.BCCList, &mail.Address{Address: recipient})
}
}
return nil
}
func getParentID(
ctx context.Context,
client *liteapi.Client,
addrID string,
addrMode vault.AddressMode,
references []string,
) (string, error) {
var (
parentID string
internal []string
external []string
)
// Collect all the internal and external references of the message.
for _, ref := range references {
if strings.Contains(ref, message.InternalIDDomain) {
internal = append(internal, strings.TrimSuffix(ref, "@"+message.InternalIDDomain))
} else {
external = append(external, ref)
}
}
// Try to find a parent ID in the internal references.
for _, internal := range internal {
filter := map[string][]string{
"ID": {internal},
}
if addrMode == vault.SplitMode {
filter["AddressID"] = []string{addrID}
}
metadata, err := client.GetAllMessageMetadata(ctx, filter)
if err != nil {
return "", fmt.Errorf("failed to get message metadata: %w", err)
}
for _, metadata := range metadata {
if !metadata.IsDraft() {
parentID = metadata.ID
} else if err := client.DeleteMessage(ctx, metadata.ID); err != nil {
return "", fmt.Errorf("failed to delete message: %w", err)
}
}
}
// If no parent was found, try to find it in the last external reference.
// There can be multiple messages with the same external ID; in this case, we don't pick any parent.
if parentID == "" && len(external) > 0 {
filter := map[string][]string{
"ExternalID": {external[len(external)-1]},
}
if addrMode == vault.SplitMode {
filter["AddressID"] = []string{addrID}
}
metadata, err := client.GetAllMessageMetadata(ctx, filter)
if err != nil {
return "", fmt.Errorf("failed to get message metadata: %w", err)
}
if len(metadata) == 1 {
parentID = metadata[0].ID
}
}
return parentID, nil
}
func createDraftWithAttachments(
ctx context.Context,
client *liteapi.Client,
addrKR *crypto.KeyRing,
message message.Message,
parentID string,
) (liteapi.Message, map[string]*crypto.SessionKey, error) {
encBody, err := addrKR.Encrypt(crypto.NewPlainMessageFromString(string(message.RichBody)), nil) encBody, err := addrKR.Encrypt(crypto.NewPlainMessageFromString(string(message.RichBody)), nil)
if err != nil { if err != nil {
return liteapi.Message{}, nil, fmt.Errorf("failed to encrypt message body: %w", err) return liteapi.Message{}, nil, fmt.Errorf("failed to encrypt message body: %w", err)
@ -201,22 +386,26 @@ func (session *smtpSession) createDraft(ctx context.Context, addrKR *crypto.KeyR
return liteapi.Message{}, nil, fmt.Errorf("failed to armor message body: %w", err) return liteapi.Message{}, nil, fmt.Errorf("failed to armor message body: %w", err)
} }
draft, err := session.client.CreateDraft(ctx, liteapi.CreateDraftReq{ draft, err := client.CreateDraft(ctx, liteapi.CreateDraftReq{
Message: liteapi.DraftTemplate{ Message: liteapi.DraftTemplate{
Subject: message.Subject, Subject: message.Subject,
Sender: message.Sender, Sender: message.Sender,
ToList: message.ToList, ToList: message.ToList,
CCList: message.CCList, CCList: message.CCList,
BCCList: message.BCCList, BCCList: message.BCCList,
Body: armBody, Body: armBody,
MIMEType: message.MIMEType,
ExternalID: message.ExternalID,
}, },
AttachmentKeyPackets: []string{},
ParentID: parentID,
}) })
if err != nil { if err != nil {
return liteapi.Message{}, nil, fmt.Errorf("failed to create draft: %w", err) return liteapi.Message{}, nil, fmt.Errorf("failed to create draft: %w", err)
} }
attKeys, err := session.createAttachments(ctx, addrKR, draft.ID, message.Attachments) attKeys, err := createAttachments(ctx, client, addrKR, draft.ID, message.Attachments)
if err != nil { if err != nil {
return liteapi.Message{}, nil, fmt.Errorf("failed to create attachments: %w", err) return liteapi.Message{}, nil, fmt.Errorf("failed to create attachments: %w", err)
} }
@ -224,7 +413,13 @@ func (session *smtpSession) createDraft(ctx context.Context, addrKR *crypto.KeyR
return draft, attKeys, nil return draft, attKeys, nil
} }
func (session *smtpSession) createAttachments(ctx context.Context, addrKR *crypto.KeyRing, draftID string, attachments []message.Attachment) (map[string]*crypto.SessionKey, error) { func createAttachments(
ctx context.Context,
client *liteapi.Client,
addrKR *crypto.KeyRing,
draftID string,
attachments []message.Attachment,
) (map[string]*crypto.SessionKey, error) {
type attKey struct { type attKey struct {
attID string attID string
key *crypto.SessionKey key *crypto.SessionKey
@ -241,7 +436,7 @@ func (session *smtpSession) createAttachments(ctx context.Context, addrKR *crypt
return attKey{}, fmt.Errorf("failed to encrypt attachment: %w", err) return attKey{}, fmt.Errorf("failed to encrypt attachment: %w", err)
} }
attachment, err := session.client.UploadAttachment(ctx, liteapi.CreateAttachmentReq{ attachment, err := client.UploadAttachment(ctx, liteapi.CreateAttachmentReq{
Filename: att.Name, Filename: att.Name,
MessageID: draftID, MessageID: draftID,
MIMEType: rfc822.MIMEType(att.MIMEType), MIMEType: rfc822.MIMEType(att.MIMEType),
@ -280,12 +475,19 @@ func (session *smtpSession) createAttachments(ctx context.Context, addrKR *crypt
return attKeys, nil return attKeys, nil
} }
func (session *smtpSession) getRecipients(ctx context.Context, addresses []string, mimeType rfc822.MIMEType) (recipients, error) { func getRecipients(
ctx context.Context,
client *liteapi.Client,
userKR *crypto.KeyRing,
settings liteapi.MailSettings,
addresses []string,
mimeType rfc822.MIMEType,
) (recipients, error) {
prefs, err := parallel.MapContext(ctx, runtime.NumCPU(), addresses, func(ctx context.Context, address string) (liteapi.SendPreferences, error) { prefs, err := parallel.MapContext(ctx, runtime.NumCPU(), addresses, func(ctx context.Context, address string) (liteapi.SendPreferences, error) {
return session.getSendPrefs(ctx, address, mimeType) return getSendPrefs(ctx, client, userKR, settings, address, mimeType)
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get recipients: %w", err) return nil, fmt.Errorf("failed to get send preferences: %w", err)
} }
recipients := make(recipients) recipients := make(recipients)
@ -297,22 +499,34 @@ func (session *smtpSession) getRecipients(ctx context.Context, addresses []strin
return recipients, nil return recipients, nil
} }
func (session *smtpSession) getSendPrefs(ctx context.Context, recipient string, mimeType rfc822.MIMEType) (liteapi.SendPreferences, error) { func getSendPrefs(
pubKeys, internal, err := session.client.GetPublicKeys(ctx, recipient) ctx context.Context,
client *liteapi.Client,
userKR *crypto.KeyRing,
settings liteapi.MailSettings,
recipient string,
mimeType rfc822.MIMEType,
) (liteapi.SendPreferences, error) {
pubKeys, recType, err := client.GetPublicKeys(ctx, recipient)
if err != nil { if err != nil {
return liteapi.SendPreferences{}, fmt.Errorf("failed to get public keys: %w", err) return liteapi.SendPreferences{}, fmt.Errorf("failed to get public keys: %w", err)
} }
settings, err := session.getContactSettings(ctx, recipient) contactSettings, err := getContactSettings(ctx, client, userKR, recipient)
if err != nil { if err != nil {
return liteapi.SendPreferences{}, fmt.Errorf("failed to get contact settings: %w", err) return liteapi.SendPreferences{}, fmt.Errorf("failed to get contact settings: %w", err)
} }
return buildSendPrefs(settings, session.settings, pubKeys, mimeType, internal) return buildSendPrefs(contactSettings, settings, pubKeys, mimeType, recType == liteapi.RecipientTypeInternal)
} }
func (session *smtpSession) getContactSettings(ctx context.Context, recipient string) (liteapi.ContactSettings, error) { func getContactSettings(
contacts, err := session.client.GetAllContactEmails(ctx, recipient) ctx context.Context,
client *liteapi.Client,
userKR *crypto.KeyRing,
recipient string,
) (liteapi.ContactSettings, error) {
contacts, err := client.GetAllContactEmails(ctx, recipient)
if err != nil { if err != nil {
return liteapi.ContactSettings{}, fmt.Errorf("failed to get contact data: %w", err) return liteapi.ContactSettings{}, fmt.Errorf("failed to get contact data: %w", err)
} }
@ -325,10 +539,31 @@ func (session *smtpSession) getContactSettings(ctx context.Context, recipient st
return liteapi.ContactSettings{}, nil return liteapi.ContactSettings{}, nil
} }
contact, err := session.client.GetContact(ctx, contacts[idx].ContactID) contact, err := client.GetContact(ctx, contacts[idx].ContactID)
if err != nil { if err != nil {
return liteapi.ContactSettings{}, fmt.Errorf("failed to get contact: %w", err) return liteapi.ContactSettings{}, fmt.Errorf("failed to get contact: %w", err)
} }
return contact.GetSettings(session.userKR, recipient) return contact.GetSettings(userKR, recipient)
}
func getMessageSender(parser *parser.Parser) (string, bool) {
address, err := rfc5322.ParseAddressList(parser.Root().Header.Get("From"))
if err != nil {
return "", false
} else if len(address) == 0 {
return "", false
}
return address[0].Address, true
}
func sanitizeEmail(email string) string {
splitAt := strings.Split(email, "@")
if len(splitAt) != 2 {
return email
}
splitPlus := strings.Split(splitAt[0], "+")
email = splitPlus[0] + "@" + splitAt[1]
return email
} }

View File

@ -18,7 +18,6 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gitlab.protontech.ch/go/liteapi" "gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
"golang.org/x/exp/slices"
) )
var ( var (
@ -143,7 +142,11 @@ func (user *User) Match(query string) bool {
return true return true
} }
return slices.Contains(user.apiAddrs.emails(), query) if _, ok := user.apiAddrs.addrID(query); ok {
return true
}
return false
} }
// Emails returns all the user's email addresses. // Emails returns all the user's email addresses.
@ -289,7 +292,12 @@ func (user *User) NewIMAPConnector(addrID string) (connector.Connector, error) {
emails = user.apiAddrs.emails() emails = user.apiAddrs.emails()
case vault.SplitMode: case vault.SplitMode:
emails = []string{user.apiAddrs.email(addrID)} email, ok := user.apiAddrs.email(addrID)
if !ok {
return nil, fmt.Errorf("address %s not found", addrID)
}
emails = []string{email}
} }
return newIMAPConnector( return newIMAPConnector(
@ -319,8 +327,23 @@ func (user *User) NewIMAPConnectors() (map[string]connector.Connector, error) {
} }
// NewSMTPSession returns an SMTP session for the user. // NewSMTPSession returns an SMTP session for the user.
func (user *User) NewSMTPSession(username string) smtp.Session { func (user *User) NewSMTPSession(email string) (smtp.Session, error) {
return newSMTPSession(user.client, username, user.apiAddrs.addrMap(), user.settings, user.userKR, user.addrKRs) addrID, ok := user.apiAddrs.addrID(email)
if !ok {
return nil, ErrNoSuchAddress
}
return newSMTPSession(
user.client,
user.eventCh,
user.apiUser.ID,
addrID,
user.vault.AddressMode(),
user.apiAddrs.addrMap(),
user.settings,
user.userKR,
user.addrKRs,
), nil
} }
// Logout logs the user out from the API. // Logout logs the user out from the API.

View File

@ -98,13 +98,13 @@ func withAPI(t *testing.T, ctx context.Context, username, password string, email
var addrIDs []string var addrIDs []string
userID, addrID, err := server.AddUser(username, password, emails[0]) userID, addrID, err := server.CreateUser(username, password, emails[0])
require.NoError(t, err) require.NoError(t, err)
addrIDs = append(addrIDs, addrID) addrIDs = append(addrIDs, addrID)
for _, email := range emails[1:] { for _, email := range emails[1:] {
addrID, err := server.AddAddress(userID, email, password) addrID, err := server.CreateAddress(userID, email, password)
require.NoError(t, err) require.NoError(t, err)
addrIDs = append(addrIDs, addrID) addrIDs = append(addrIDs, addrID)

View File

@ -30,6 +30,7 @@ import (
"github.com/ProtonMail/go-rfc5322" "github.com/ProtonMail/go-rfc5322"
"github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/pkg/algo" "github.com/ProtonMail/proton-bridge/v2/pkg/algo"
"github.com/bradenaw/juniper/xslices"
"github.com/emersion/go-message" "github.com/emersion/go-message"
"github.com/emersion/go-message/textproto" "github.com/emersion/go-message/textproto"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -440,8 +441,10 @@ func getMessageHeader(msg liteapi.Message, opts JobOptions) message.Header { //n
// Include the message ID in the references (supposedly this somehow improves outlook support...). // Include the message ID in the references (supposedly this somehow improves outlook support...).
if opts.AddMessageIDReference { if opts.AddMessageIDReference {
if references := hdr.Get("References"); !strings.Contains(references, msg.ID) { if refs := hdr.Values("References"); xslices.IndexFunc(refs, func(ref string) bool {
hdr.Set("References", references+" <"+msg.ID+"@"+InternalIDDomain+">") return strings.Contains(ref, msg.ID)
}) < 0 {
hdr.Set("References", strings.Join(append(refs, "<"+msg.ID+"@"+InternalIDDomain+">"), " "))
} }
} }

View File

@ -43,12 +43,11 @@ type MIMEBody string
type Body string type Body string
type Message struct { type Message struct {
Header mail.Header MIMEBody MIMEBody
MIMEBody MIMEBody RichBody Body
RichBody Body PlainBody Body
PlainBody Body Attachments []Attachment
Time int64 MIMEType rfc822.MIMEType
ExternalID string
Subject string Subject string
Sender *mail.Address Sender *mail.Address
@ -57,8 +56,8 @@ type Message struct {
BCCList []*mail.Address BCCList []*mail.Address
ReplyTos []*mail.Address ReplyTos []*mail.Address
MIMEType rfc822.MIMEType References []string
Attachments []Attachment ExternalID string
} }
func (m *Message) Recipients() []string { func (m *Message) Recipients() []string {
@ -447,16 +446,7 @@ func getPlainBody(part *parser.Part) []byte {
func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
var m Message var m Message
mimeHeader, err := toMailHeader(h) for fields := h.Fields(); fields.Next(); {
if err != nil {
return Message{}, err
}
m.Header = mimeHeader
fields := h.Fields()
for fields.Next() {
switch strings.ToLower(fields.Key()) { switch strings.ToLower(fields.Key()) {
case "subject": case "subject":
s, err := fields.Text() s, err := fields.Text()
@ -473,6 +463,7 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil { if err != nil {
return Message{}, errors.Wrap(err, "failed to parse from") return Message{}, errors.Wrap(err, "failed to parse from")
} }
if len(sender) > 0 { if len(sender) > 0 {
m.Sender = sender[0] m.Sender = sender[0]
} }
@ -482,6 +473,7 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil { if err != nil {
return Message{}, errors.Wrap(err, "failed to parse to") return Message{}, errors.Wrap(err, "failed to parse to")
} }
m.ToList = toList m.ToList = toList
case "reply-to": case "reply-to":
@ -489,6 +481,7 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil { if err != nil {
return Message{}, errors.Wrap(err, "failed to parse reply-to") return Message{}, errors.Wrap(err, "failed to parse reply-to")
} }
m.ReplyTos = replyTos m.ReplyTos = replyTos
case "cc": case "cc":
@ -496,6 +489,7 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil { if err != nil {
return Message{}, errors.Wrap(err, "failed to parse cc") return Message{}, errors.Wrap(err, "failed to parse cc")
} }
m.CCList = ccList m.CCList = ccList
case "bcc": case "bcc":
@ -503,17 +497,16 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil { if err != nil {
return Message{}, errors.Wrap(err, "failed to parse bcc") return Message{}, errors.Wrap(err, "failed to parse bcc")
} }
m.BCCList = bccList
case "date": m.BCCList = bccList
date, err := rfc5322.ParseDateTime(fields.Value())
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse date")
}
m.Time = date.Unix()
case "message-id": case "message-id":
m.ExternalID = regexp.MustCompile("<(.*)>").ReplaceAllString(fields.Value(), "$1") m.ExternalID = regexp.MustCompile("<(.*)>").ReplaceAllString(fields.Value(), "$1")
case "references":
m.References = append(m.References, xslices.Map(strings.Fields(fields.Value()), func(ref string) string {
return strings.Trim(ref, "<>")
})...)
} }
} }

View File

@ -581,10 +581,13 @@ func TestParseEncodedContentTypeBad(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
type panicReader struct{} func TestParseMessageReferences(t *testing.T) {
f := getFileReader("references.eml")
func (panicReader) Read(p []byte) (int, error) { m, err := Parse(f)
panic("lol") require.NoError(t, err)
assert.Len(t, m.References, 2)
} }
func TestParsePanic(t *testing.T) { func TestParsePanic(t *testing.T) {
@ -603,3 +606,9 @@ func getFileReader(filename string) io.Reader {
return f return f
} }
type panicReader struct{}
func (panicReader) Read(p []byte) (int, error) {
panic("lol")
}

27
pkg/message/testdata/references.eml vendored Normal file
View File

@ -0,0 +1,27 @@
Content-Type: multipart/mixed;
boundary=987c7102dcaf02d01860ce777b465f86d39ec16a3b4e12605eb6b0eb200a
X-Original-To: someone@protonmail.com
Delivered-To: someone@protonmail.com
Date: Sun, 04 Aug 2019 13:03:26 +0000
From: ProtonVPN <support@protonvpn.something.com>
Reply-To: ProtonVPN <support+id493949@protonvpn.something.com>
To: someone <someone@protonmail.com>
Message-Id: <OEUOEUOUOU_5d46d79df036f_1d78b3fd8f42bcf2014719e_sprut@something.com>
In-Reply-To: <OEUOEUEOUOUOU770B9QNZWFVGM@protonmail.ch>
References: <PMZV4VZMRM@something.com> <OEUOEUEOUOUOU770B9QNZWFVGM@protonmail.ch>
Subject: Some test subject
Mime-Version: 1.0
--987c7102dcaf02d01860ce777b465f86d39ec16a3b4e12605eb6b0eb200a
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=utf-8
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.=
w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<body>
test test test
</body>
</html>
--987c7102dcaf02d01860ce777b465f86d39ec16a3b4e12605eb6b0eb200a--

View File

@ -1,6 +1,8 @@
package tests package tests
import ( import (
"net/mail"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon/rfc822" "github.com/ProtonMail/gluon/rfc822"
"gitlab.protontech.ch/go/liteapi" "gitlab.protontech.ch/go/liteapi"
@ -13,16 +15,16 @@ type API interface {
GetHostURL() string GetHostURL() string
AddCallWatcher(func(server.Call), ...string) AddCallWatcher(func(server.Call), ...string)
AddUser(username, password, address string) (string, string, error) CreateUser(username, password, address string) (string, string, error)
AddAddress(userID, address, password string) (string, error) CreateAddress(userID, address, password string) (string, error)
RemoveAddress(userID, addrID string) error RemoveAddress(userID, addrID string) error
RevokeUser(userID string) error RevokeUser(userID string) error
GetLabels(userID string) ([]liteapi.Label, error) GetLabels(userID string) ([]liteapi.Label, error)
AddLabel(userID, name string, labelType liteapi.LabelType) (string, error) CreateLabel(userID, name string, labelType liteapi.LabelType) (string, error)
GetMessages(userID string) ([]liteapi.Message, error) GetMessages(userID string) ([]liteapi.Message, error)
AddMessage(userID, addrID string, labelIDs []string, sender, recipient, subject, body string, mimeType rfc822.MIMEType, read, starred bool) (string, error) CreateMessage(userID, addrID string, labelIDs []string, subject string, sender *mail.Address, toList, ccList, bccList []*mail.Address, decBody string, mimeType rfc822.MIMEType, read, starred bool) (string, error)
Close() Close()
} }

View File

@ -74,7 +74,8 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^the internet is turned off$`, s.internetIsTurnedOff) ctx.Step(`^the internet is turned off$`, s.internetIsTurnedOff)
ctx.Step(`^the internet is turned on$`, s.internetIsTurnedOn) ctx.Step(`^the internet is turned on$`, s.internetIsTurnedOn)
ctx.Step(`^the user agent is "([^"]*)"$`, s.theUserAgentIs) ctx.Step(`^the user agent is "([^"]*)"$`, s.theUserAgentIs)
ctx.Step(`^the value of the "([^"]*)" header in the request to "([^"]*)" is "([^"]*)"$`, s.theValueOfTheHeaderInTheRequestToIs) ctx.Step(`^the header in the "([^"]*)" request to "([^"]*)" has "([^"]*)" set to "([^"]*)"$`, s.theHeaderInTheRequestToHasSetTo)
ctx.Step(`^the body in the "([^"]*)" request to "([^"]*)" is:$`, s.theBodyInTheRequestToIs)
// ==== SETUP ==== // ==== SETUP ====
ctx.Step(`^there exists an account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPassword) ctx.Step(`^there exists an account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPassword)
@ -167,8 +168,9 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^SMTP client "([^"]*)" cannot authenticate with incorrect password$`, s.smtpClientCannotAuthenticateWithIncorrectPassword) ctx.Step(`^SMTP client "([^"]*)" cannot authenticate with incorrect password$`, s.smtpClientCannotAuthenticateWithIncorrectPassword)
ctx.Step(`^SMTP client "([^"]*)" sends MAIL FROM "([^"]*)"$`, s.smtpClientSendsMailFrom) ctx.Step(`^SMTP client "([^"]*)" sends MAIL FROM "([^"]*)"$`, s.smtpClientSendsMailFrom)
ctx.Step(`^SMTP client "([^"]*)" sends RCPT TO "([^"]*)"$`, s.smtpClientSendsRcptTo) ctx.Step(`^SMTP client "([^"]*)" sends RCPT TO "([^"]*)"$`, s.smtpClientSendsRcptTo)
ctx.Step(`^SMTP client "([^"]*)" sends DATA "([^"]*)"$`, s.smtpClientSendsData) ctx.Step(`^SMTP client "([^"]*)" sends DATA:$`, s.smtpClientSendsData)
ctx.Step(`^SMTP client "([^"]*)" sends RSET$`, s.smtpClientSendsReset) ctx.Step(`^SMTP client "([^"]*)" sends RSET$`, s.smtpClientSendsReset)
ctx.Step(`^SMTP client "([^"]*)" sends the following message from "([^"]*)" to "([^"]*)":$`, s.smtpClientSendsTheFollowingMessageFromTo)
}, },
Options: &godog.Options{ Options: &godog.Options{
Format: "pretty", Format: "pretty",

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/smtp" "net/smtp"
"regexp"
"testing" "testing"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
@ -172,20 +173,24 @@ func (t *testCtx) getMBoxID(userID string, name string) string {
return labels[idx].ID return labels[idx].ID
} }
func (t *testCtx) getLastCall(path string) (server.Call, error) { func (t *testCtx) getLastCall(method, path string) (server.Call, error) {
calls := t.calls[len(t.calls)-2] var allCalls []server.Call
if len(calls) == 0 { for _, calls := range t.calls {
allCalls = append(allCalls, calls...)
}
if len(allCalls) == 0 {
return server.Call{}, fmt.Errorf("no calls made") return server.Call{}, fmt.Errorf("no calls made")
} }
for _, call := range calls { for idx := len(allCalls) - 1; idx >= 0; idx-- {
if call.URL.Path == path { if call := allCalls[idx]; call.Method == method && regexp.MustCompile("^"+path+"$").MatchString(call.URL.Path) {
return call, nil return call, nil
} }
} }
return calls[len(calls)-1], nil return server.Call{}, fmt.Errorf("no call with method %q and path %q was made", method, path)
} }
func (t *testCtx) pushError(err error) { func (t *testCtx) pushError(err error) {

81
tests/diff.go Normal file
View File

@ -0,0 +1,81 @@
package tests
import (
"reflect"
"github.com/bradenaw/juniper/xslices"
)
func IsSub(outer, inner any) bool {
if outer == nil && inner != nil {
return IsSub(reflect.Zero(reflect.TypeOf(inner)).Interface(), inner)
}
if outer != nil && inner == nil {
return IsSub(reflect.Zero(reflect.TypeOf(outer)).Interface(), outer)
}
switch inner := inner.(type) {
case map[string]any:
outer, ok := outer.(map[string]any)
if !ok {
return false
}
return isSubMap(outer, inner)
case []any:
outer, ok := outer.([]any)
if !ok {
return false
}
if len(inner) != len(outer) {
return false
}
return isSubSlice(outer, inner)
default:
if reflect.TypeOf(outer) != reflect.TypeOf(inner) {
return false
}
if reflect.DeepEqual(outer, inner) {
return true
}
return reflect.DeepEqual(reflect.Zero(reflect.TypeOf(inner)).Interface(), inner)
}
}
func isSubMap(outer, inner map[string]any) bool {
for k, v := range inner {
w, ok := outer[k]
if !ok {
for _, w := range outer {
if IsSub(w, inner) {
return true
}
}
}
if !IsSub(w, v) {
return false
}
}
return true
}
func isSubSlice(outer, inner []any) bool {
for _, v := range inner {
if xslices.IndexFunc(outer, func(outer any) bool {
return IsSub(outer, v)
}) < 0 {
return false
}
}
return true
}

125
tests/diff_test.go Normal file
View File

@ -0,0 +1,125 @@
package tests
import (
"fmt"
"testing"
"github.com/goccy/go-json"
)
func Test_IsSub(t *testing.T) {
tests := []struct {
outer string
inner string
want bool
}{
{
outer: `{}`,
inner: `{}`,
want: true,
},
{
outer: `{"a": 1}`,
inner: `{"a": 1}`,
want: true,
},
{
outer: `{"a": 1, "b": 2}`,
inner: `{"a": 1}`,
want: true,
},
{
outer: `{"a": 1, "b": 2}`,
inner: `{"a": 1, "c": 3}`,
want: false,
},
{
outer: `{"a": 1, "b": {"c": 2}}`,
inner: `{"c": 2}`,
want: true,
},
{
outer: `{"a": 1, "b": {"c": 2, "d": 3}}`,
inner: `{"c": 2}`,
want: true,
},
{
outer: `{"a": 1, "b": {"c": 2, "d": 3}}`,
inner: `{"c": 2, "d": 3}`,
want: true,
},
{
outer: `{"a": 1, "b": {"c": 2, "d": 3}}`,
inner: `{"c": 2, "e": 3}`,
want: false,
},
{
outer: `{"a": 1, "b": {"c": 2, "d": "ignore"}}`,
inner: `{"a": 1, "b": {"c": 2, "d": ""}}`,
want: true,
},
{
outer: `{"a": 1, "b": {"c": 2, "d": null}}`,
inner: `{"a": 1, "b": {"c": 2, "d": null}}`,
want: true,
},
{
outer: `{"a": 1, "b": {"c": 2, "d": ["1"]}}`,
inner: `{"a": 1, "b": {"c": 2, "d": []}}`,
want: false,
},
{
outer: `{"a": 1, "b": {"c": 2, "d": []}}`,
inner: `{"a": 1, "b": {"c": 2, "d": null}}`,
want: true,
},
{
outer: `{"a": []}`,
inner: `{"a": []}`,
want: true,
},
{
outer: `{"a": [1, 2]}`,
inner: `{"a": [1, 2]}`,
want: true,
},
{
outer: `{"a": [1, 3]}`,
inner: `{"a": [1, 2]}`,
want: false,
},
{
outer: `{"a": [1, 2, 3]}`,
inner: `{"a": [1, 2]}`,
want: false,
},
{
outer: `{"a": null}`,
inner: `{"a": []}`,
want: true,
},
{
outer: `{"a": []}`,
inner: `{"a": null}`,
want: true,
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%v vs %v", tt.inner, tt.outer), func(t *testing.T) {
var outerMap, innerMap map[string]any
if err := json.Unmarshal([]byte(tt.outer), &outerMap); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(tt.inner), &innerMap); err != nil {
t.Fatal(err)
}
if got := IsSub(outerMap, innerMap); got != tt.want {
t.Errorf("isSub() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -1,8 +1,11 @@
package tests package tests
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"github.com/cucumber/godog"
) )
func (s *scenario) itSucceeds() error { func (s *scenario) itSucceeds() error {
@ -52,15 +55,38 @@ func (s *scenario) theUserAgentIs(userAgent string) error {
return nil return nil
} }
func (s *scenario) theValueOfTheHeaderInTheRequestToIs(key, path, value string) error { func (s *scenario) theHeaderInTheRequestToHasSetTo(method, path, key, value string) error {
call, err := s.t.getLastCall(path) call, err := s.t.getLastCall(method, path)
if err != nil { if err != nil {
return err return err
} }
if haveKey := call.Request.Header.Get(key); haveKey != value { if haveKey := call.Header.Get(key); haveKey != value {
return fmt.Errorf("have header %q, want %q", haveKey, value) return fmt.Errorf("have header %q, want %q", haveKey, value)
} }
return nil return nil
} }
func (s *scenario) theBodyInTheRequestToIs(method, path string, value *godog.DocString) error {
call, err := s.t.getLastCall(method, path)
if err != nil {
return err
}
var body, want map[string]any
if err := json.Unmarshal(call.Body, &body); err != nil {
return err
}
if err := json.Unmarshal([]byte(value.Content), &want); err != nil {
return err
}
if !IsSub(body, want) {
return fmt.Errorf("have body %v, want %v", body, want)
}
return nil
}

View File

@ -17,4 +17,4 @@ Feature: The IMAP ID is propagated to bridge
When user "user@pm.me" connects IMAP client "1" When user "user@pm.me" connects IMAP client "1"
And IMAP client "1" announces its ID with name "name" and version "version" And IMAP client "1" announces its ID with name "name" and version "version"
When the user reports a bug When the user reports a bug
Then the value of the "User-Agent" header in the request to "/core/v4/reports/bug" is "name/version ([GOOS])" Then the header in the "POST" request to "/core/v4/reports/bug" has "User-Agent" set to "name/version ([GOOS])"

View File

@ -6,7 +6,10 @@ Feature: SMTP initiation
When user "user@pm.me" connects and authenticates SMTP client "1" When user "user@pm.me" connects and authenticates SMTP client "1"
Scenario: Send without first announcing FROM and TO Scenario: Send without first announcing FROM and TO
When SMTP client "1" sends DATA "Subject: test" When SMTP client "1" sends DATA:
"""
Subject: test
"""
Then it fails with error "Missing RCPT TO command" Then it fails with error "Missing RCPT TO command"
Scenario: Reset is the same as without FROM and TO Scenario: Reset is the same as without FROM and TO
@ -16,7 +19,10 @@ Feature: SMTP initiation
Then it succeeds Then it succeeds
When SMTP client "1" sends RSET When SMTP client "1" sends RSET
Then it succeeds Then it succeeds
When SMTP client "1" sends DATA "Subject: test" When SMTP client "1" sends DATA:
"""
Subject: test
"""
Then it fails with error "Missing RCPT TO command" Then it fails with error "Missing RCPT TO command"
Scenario: Send without FROM Scenario: Send without FROM
@ -26,7 +32,10 @@ Feature: SMTP initiation
Scenario: Send without TO Scenario: Send without TO
When SMTP client "1" sends MAIL FROM "<user@pm.me>" When SMTP client "1" sends MAIL FROM "<user@pm.me>"
Then it succeeds Then it succeeds
When SMTP client "1" sends DATA "Subject: test" When SMTP client "1" sends DATA:
"""
Subject: test
"""
Then it fails with error "Missing RCPT TO command" Then it fails with error "Missing RCPT TO command"
Scenario: Send with empty FROM Scenario: Send with empty FROM

View File

@ -0,0 +1,332 @@
Feature: SMTP sending of plain messages
Background:
Given there exists an account with username "user@pm.me" and password "password"
And there exists an account with username "bridgetest@protonmail.com" and password "password"
And there exists an account with username "bridgetest2@protonmail.com" and password "password"
And bridge starts
And the user logs in with username "user@pm.me" and password "password"
And user "user@pm.me" connects and authenticates SMTP client "1"
Scenario: Only from and to headers to internal account
When SMTP client "1" sends the following message from "user@pm.me" to "bridgetest@protonmail.com":
"""
From: Bridge Test <user@pm.me>
To: Internal Bridge <bridgetest@protonmail.com>
hello
"""
Then it succeeds
When user "user@pm.me" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| sender | recipient | subject | unread |
| user@pm.me | bridgetest@protonmail.com | | false |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
"Message": {
"Subject": "",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Only from and to headers to external account
When SMTP client "1" sends the following message from "user@pm.me" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <user@pm.me>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it succeeds
When user "user@pm.me" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| sender | recipient | subject | unread |
| user@pm.me | pm.bridge.qa@gmail.com | | false |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
"Message": {
"Subject": "",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Basic message to internal account
When SMTP client "1" sends the following message from "user@pm.me" to "bridgetest@protonmail.com":
"""
From: Bridge Test <user@pm.me>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Plain text internal
Content-Disposition: inline
Content-Type: text/plain; charset=utf-8
This is body of mail 👋
"""
Then it succeeds
When user "user@pm.me" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| sender | recipient | subject | unread |
| user@pm.me | bridgetest@protonmail.com | Plain text internal | false |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
"Message": {
"Subject": "Plain text internal",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Basic message to external account
When SMTP client "1" sends the following message from "user@pm.me" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <user@pm.me>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain text external
Content-Disposition: inline
Content-Type: text/plain; charset=utf-8
This is body of mail 👋
"""
Then it succeeds
When user "user@pm.me" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| sender | recipient | subject | unread |
| user@pm.me | pm.bridge.qa@gmail.com | Plain text external | false |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
"Message": {
"Subject": "Plain text external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Message without charset is utf8
When SMTP client "1" sends the following message from "user@pm.me" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <user@pm.me>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain text no charset external
Content-Disposition: inline
Content-Type: text/plain;
This is body of mail without charset. Please assume utf8
"""
Then it succeeds
When user "user@pm.me" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| sender | recipient | subject | unread |
| user@pm.me | pm.bridge.qa@gmail.com | Plain text no charset external | false |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
"Message": {
"Subject": "Plain text no charset external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Message without charset is base64-encoded latin1
When SMTP client "1" sends the following message from "user@pm.me" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <user@pm.me>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain text no charset external
Content-Disposition: inline
Content-Type: text/plain;
Content-Transfer-Encoding: base64
dGhpcyBpcyBpbiBsYXRpbjEgYW5kIHRoZXJlIGFyZSBsb3RzIG9mIGVzIHdpdGggYWNjZW50czog
6enp6enp6enp6enp6enp
"""
Then it succeeds
When user "user@pm.me" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| sender | recipient | subject | unread |
| user@pm.me | pm.bridge.qa@gmail.com | Plain text no charset external | false |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
"Message": {
"Subject": "Plain text no charset external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Message without charset and content is detected as HTML
When SMTP client "1" sends the following message from "user@pm.me" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <user@pm.me>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain, no charset, no content, external
Content-Disposition: inline
Content-Type: text/plain;
"""
Then it succeeds
When user "user@pm.me" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| sender | recipient | subject | unread |
| user@pm.me | pm.bridge.qa@gmail.com | Plain, no charset, no content, external | false |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
"Message": {
"Subject": "Plain, no charset, no content, external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: RCPT does not contain all CC
When SMTP client "1" sends MAIL FROM "<user@pm.me>"
And SMTP client "1" sends RCPT TO "<bridgetest@protonmail.com>"
And SMTP client "1" sends DATA:
"""
From: Bridge Test <user@pm.me>
To: Internal Bridge <bridgetest@protonmail.com>
CC: Internal Bridge 2 <bridgetest2@protonmail.com>
Content-Type: text/plain
Subject: RCPT-CC test
This is CC missing in RCPT test. Have a nice day!
.
"""
Then it succeeds
When user "user@pm.me" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Sent":
| sender | recipient | cc | subject | unread |
| user@pm.me | bridgetest@protonmail.com | bridgetest2@protonmail.com | RCPT-CC test | false |
And the body in the "POST" request to "/mail/v4/messages" is:
"""
{
"Message": {
"Subject": "RCPT-CC test",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [
{
"Address": "bridgetest2@protonmail.com",
"Name": "Internal Bridge 2"
}
],
"BCCList": []
}
}
"""
And the body in the "POST" request to "/mail/v4/messages/.*" is:
"""
{
"Packages":[
{
"Addresses":{
"bridgetest@protonmail.com":{
"Type":1
},
"bridgetest2@protonmail.com":{
"Type":1
}
},
"Type":1,
"MIMEType":"text/plain"
}
]
}
"""

View File

@ -280,12 +280,28 @@ func (s *scenario) imapClientSeesTheFollowingMessagesInMailbox(clientID, mailbox
} }
haveMessages := xslices.Map(fetch, func(msg *imap.Message) Message { haveMessages := xslices.Map(fetch, func(msg *imap.Message) Message {
return Message{ message := Message{
Sender: msg.Envelope.Sender[0].Address(), Subject: msg.Envelope.Subject,
Recipient: msg.Envelope.To[0].Address(), Unread: slices.Contains(msg.Flags, imap.SeenFlag),
Subject: msg.Envelope.Subject,
Unread: slices.Contains(msg.Flags, imap.SeenFlag),
} }
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()
}
return message
}) })
return matchMessages(haveMessages, table) return matchMessages(haveMessages, table)

View File

@ -5,6 +5,7 @@ import (
"net/smtp" "net/smtp"
"github.com/ProtonMail/proton-bridge/v2/internal/constants" "github.com/ProtonMail/proton-bridge/v2/internal/constants"
"github.com/cucumber/godog"
) )
func (s *scenario) userConnectsSMTPClient(username, clientID string) error { func (s *scenario) userConnectsSMTPClient(username, clientID string) error {
@ -87,13 +88,13 @@ func (s *scenario) smtpClientSendsRcptTo(clientID, to string) error {
return nil return nil
} }
func (s *scenario) smtpClientSendsData(clientID, data string) error { func (s *scenario) smtpClientSendsData(clientID string, data *godog.DocString) error {
_, client := s.t.getSMTPClient(clientID) _, client := s.t.getSMTPClient(clientID)
rc, err := client.Data() rc, err := client.Data()
if err != nil { if err != nil {
s.t.pushError(err) s.t.pushError(err)
} else if _, err := rc.Write([]byte(data)); err != nil { } else if _, err := rc.Write([]byte(data.Content)); err != nil {
s.t.pushError(err) s.t.pushError(err)
} else if err := rc.Close(); err != nil { } else if err := rc.Close(); err != nil {
s.t.pushError(err) s.t.pushError(err)
@ -109,3 +110,30 @@ func (s *scenario) smtpClientSendsReset(clientID string) error {
return nil return nil
} }
func (s *scenario) smtpClientSendsTheFollowingMessageFromTo(clientID, from, to string, message *godog.DocString) error {
_, client := s.t.getSMTPClient(clientID)
s.t.pushError(func() error {
if err := client.Mail(from); err != nil {
return err
}
if err := client.Rcpt(to); err != nil {
return err
}
wc, err := client.Data()
if err != nil {
return err
}
if _, err := wc.Write([]byte(message.Content)); err != nil {
return err
}
return wc.Close()
}())
return nil
}

View File

@ -2,6 +2,7 @@ package tests
import ( import (
"fmt" "fmt"
"reflect"
"strconv" "strconv"
"time" "time"
@ -13,10 +14,24 @@ import (
) )
type Message struct { type Message struct {
Sender string Subject string `bdd:"subject"`
Recipient string
Subject string From string `bdd:"sender"`
Unread bool To string `bdd:"recipient"`
CC string `bdd:"cc"`
BCC string `bdd:"bcc"`
Unread bool `bdd:"unread"`
}
func newMessageFromRow(header, row *messages.PickleTableRow) Message {
var msg Message
if err := unmarshalRow(header, row, &msg); err != nil {
panic(err)
}
return msg
} }
func matchMessages(have []Message, want *godog.Table) error { func matchMessages(have []Message, want *godog.Table) error {
@ -28,20 +43,27 @@ func matchMessages(have []Message, want *godog.Table) error {
} }
func parseMessages(table *godog.Table) []Message { func parseMessages(table *godog.Table) []Message {
header := table.Rows[0]
return xslices.Map(table.Rows[1:], func(row *messages.PickleTableRow) Message { return xslices.Map(table.Rows[1:], func(row *messages.PickleTableRow) Message {
return Message{ return newMessageFromRow(header, row)
Sender: row.Cells[0].Value,
Recipient: row.Cells[1].Value,
Subject: row.Cells[2].Value,
Unread: mustParseBool(row.Cells[3].Value),
}
}) })
} }
type Mailbox struct { type Mailbox struct {
Name string Name string `bdd:"name"`
Total int Total int `bdd:"total"`
Unread int Unread int `bdd:"unread"`
}
func newMailboxFromRow(header, row *messages.PickleTableRow) Mailbox {
var mbox Mailbox
if err := unmarshalRow(header, row, &mbox); err != nil {
panic(err)
}
return mbox
} }
func matchMailboxes(have []Mailbox, want *godog.Table) error { func matchMailboxes(have []Mailbox, want *godog.Table) error {
@ -53,33 +75,13 @@ func matchMailboxes(have []Mailbox, want *godog.Table) error {
} }
func parseMailboxes(table *godog.Table) []Mailbox { func parseMailboxes(table *godog.Table) []Mailbox {
mustParseInt := func(s string) int { header := table.Rows[0]
i, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return i
}
return xslices.Map(table.Rows[1:], func(row *messages.PickleTableRow) Mailbox { return xslices.Map(table.Rows[1:], func(row *messages.PickleTableRow) Mailbox {
return Mailbox{ return newMailboxFromRow(header, row)
Name: row.Cells[0].Value,
Total: mustParseInt(row.Cells[1].Value),
Unread: mustParseInt(row.Cells[2].Value),
}
}) })
} }
func mustParseBool(s string) bool {
v, err := strconv.ParseBool(s)
if err != nil {
panic(err)
}
return v
}
func eventually(condition func() error, waitFor, tick time.Duration) error { func eventually(condition func() error, waitFor, tick time.Duration) error {
ch := make(chan error, 1) ch := make(chan error, 1)
@ -108,3 +110,62 @@ func eventually(condition func() error, waitFor, tick time.Duration) error {
} }
} }
} }
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 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() {
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 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
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/mail"
"time" "time"
"github.com/ProtonMail/gluon/rfc822" "github.com/ProtonMail/gluon/rfc822"
@ -17,7 +18,7 @@ import (
func (s *scenario) thereExistsAnAccountWithUsernameAndPassword(username, password string) error { func (s *scenario) thereExistsAnAccountWithUsernameAndPassword(username, password string) error {
// Create the user. // Create the user.
userID, addrID, err := s.t.api.AddUser(username, password, username) userID, addrID, err := s.t.api.CreateUser(username, password, username)
if err != nil { if err != nil {
return err return err
} }
@ -37,7 +38,7 @@ func (s *scenario) thereExistsAnAccountWithUsernameAndPassword(username, passwor
func (s *scenario) theAccountHasAdditionalAddress(username, address string) error { func (s *scenario) theAccountHasAdditionalAddress(username, address string) error {
userID := s.t.getUserID(username) userID := s.t.getUserID(username)
addrID, err := s.t.api.AddAddress(userID, address, s.t.getUserPass(userID)) addrID, err := s.t.api.CreateAddress(userID, address, s.t.getUserPass(userID))
if err != nil { if err != nil {
return err return err
} }
@ -62,7 +63,7 @@ func (s *scenario) theAccountNoLongerHasAdditionalAddress(username, address stri
func (s *scenario) theAccountHasCustomFolders(username string, count int) error { func (s *scenario) theAccountHasCustomFolders(username string, count int) error {
for idx := 0; idx < count; idx++ { for idx := 0; idx < count; idx++ {
if _, err := s.t.api.AddLabel(s.t.getUserID(username), uuid.NewString(), liteapi.LabelTypeFolder); err != nil { if _, err := s.t.api.CreateLabel(s.t.getUserID(username), uuid.NewString(), liteapi.LabelTypeFolder); err != nil {
return err return err
} }
} }
@ -72,7 +73,7 @@ func (s *scenario) theAccountHasCustomFolders(username string, count int) error
func (s *scenario) theAccountHasCustomLabels(username string, count int) error { func (s *scenario) theAccountHasCustomLabels(username string, count int) error {
for idx := 0; idx < count; idx++ { for idx := 0; idx < count; idx++ {
if _, err := s.t.api.AddLabel(s.t.getUserID(username), uuid.NewString(), liteapi.LabelTypeLabel); err != nil { if _, err := s.t.api.CreateLabel(s.t.getUserID(username), uuid.NewString(), liteapi.LabelTypeLabel); err != nil {
return err return err
} }
} }
@ -103,7 +104,7 @@ func (s *scenario) theAccountHasTheFollowingCustomMailboxes(username string, tab
}) })
for _, wantMailbox := range wantMailboxes { for _, wantMailbox := range wantMailboxes {
if _, err := s.t.api.AddLabel(s.t.getUserID(username), wantMailbox.name, wantMailbox.typ); err != nil { if _, err := s.t.api.CreateLabel(s.t.getUserID(username), wantMailbox.name, wantMailbox.typ); err != nil {
return err return err
} }
} }
@ -117,13 +118,15 @@ func (s *scenario) theAddressOfAccountHasTheFollowingMessagesInMailbox(address,
mboxID := s.t.getMBoxID(userID, mailbox) mboxID := s.t.getMBoxID(userID, mailbox)
for _, wantMessage := range parseMessages(table) { for _, wantMessage := range parseMessages(table) {
if _, err := s.t.api.AddMessage( if _, err := s.t.api.CreateMessage(
userID, userID,
addrID, addrID,
[]string{mboxID}, []string{mboxID},
wantMessage.Sender,
wantMessage.Recipient,
wantMessage.Subject, wantMessage.Subject,
&mail.Address{Address: wantMessage.From},
[]*mail.Address{{Address: wantMessage.To}},
[]*mail.Address{},
[]*mail.Address{},
"some body goes here", "some body goes here",
rfc822.TextPlain, rfc822.TextPlain,
wantMessage.Unread, wantMessage.Unread,
@ -142,16 +145,18 @@ func (s *scenario) theAddressOfAccountHasMessagesInMailbox(address, username str
mboxID := s.t.getMBoxID(userID, mailbox) mboxID := s.t.getMBoxID(userID, mailbox)
for idx := 0; idx < count; idx++ { for idx := 0; idx < count; idx++ {
if _, err := s.t.api.AddMessage( if _, err := s.t.api.CreateMessage(
userID, userID,
addrID, addrID,
[]string{mboxID}, []string{mboxID},
fmt.Sprintf("sender%v@pm.me", idx), fmt.Sprintf("subject %d", idx),
fmt.Sprintf("recipient%v@pm.me", idx), &mail.Address{Address: fmt.Sprintf("sender %d", idx)},
fmt.Sprintf("subject %v", idx), []*mail.Address{{Address: fmt.Sprintf("recipient %d", idx)}},
fmt.Sprintf("body %v", idx), []*mail.Address{},
[]*mail.Address{},
fmt.Sprintf("body %d", idx),
rfc822.TextPlain, rfc822.TextPlain,
false, idx%2 == 0,
false, false,
); err != nil { ); err != nil {
return err return err