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/getsentry/sentry-go v0.13.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/golang/mock v1.6.0
github.com/google/go-cmp v0.5.9
@ -37,7 +38,7 @@ require (
github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.0
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/net 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/universal-translator v0.18.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/gogo/protobuf v1.3.2 // 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/zclconf/go-cty v1.11.0 h1:726SxLdi2SDnjY+BStqB9J1hNp4+2WlzyXLuimibIe0=
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.31.1/go.mod h1:ixp1LUOxOYuB1qf172GdV0ZT8fOomKxVFtIMZeSWg+I=
gitlab.protontech.ch/go/liteapi v0.32.1-0.20221004092920-6b728aed0d4d h1:2CB6po0yWmgb0bVCylvQlQph6a6Hk/Uziq5eHg0ZCfo=
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.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=

View File

@ -2,6 +2,7 @@ package bridge_test
import (
"context"
"net/http"
"os"
"testing"
"time"
@ -127,7 +128,7 @@ func TestBridge_UserAgent(t *testing.T) {
require.NoError(t, err)
// 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)
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)
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.
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.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()
// 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)
// Generate a random vault key.

View File

@ -2,30 +2,36 @@ package bridge
import (
"crypto/subtle"
"strings"
"sync"
"github.com/ProtonMail/proton-bridge/v2/internal/user"
"github.com/bradenaw/juniper/xslices"
"github.com/emersion/go-smtp"
"golang.org/x/exp/slices"
)
type smtpBackend struct {
users []*user.User
users map[string]*user.User
usersLock sync.RWMutex
}
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()
defer backend.usersLock.RUnlock()
for _, user := range backend.users {
if slices.Contains(user.Emails(), username) && subtle.ConstantTimeCompare(user.BridgePass(), []byte(password)) == 1 {
return user.NewSMTPSession(username), nil
if subtle.ConstantTimeCompare(user.BridgePass(), []byte(password)) != 1 {
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.
// 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()
defer backend.usersLock.Unlock()
for _, u := range backend.users {
if u.ID() == user.ID() {
return ErrUserAlreadyExists
}
if _, ok := backend.users[newUser.ID()]; ok {
return ErrUserAlreadyExists
}
backend.users = append(backend.users, user)
backend.users[newUser.ID()] = newUser
return nil
}
@ -59,13 +63,11 @@ func (backend *smtpBackend) removeUser(user *user.User) error {
backend.usersLock.Lock()
defer backend.usersLock.Unlock()
idx := xslices.Index(backend.users, user)
if idx < 0 {
if _, ok := backend.users[user.ID()]; !ok {
return ErrNoSuchUser
}
backend.users = append(backend.users[:idx], backend.users[idx+1:]...)
delete(backend.users, user.ID())
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()
}
func (list *addrList) addrID(email string) (string, bool) {
return list.apiAddrs.getKey(email)
}
func (list *addrList) emails() []string {
return list.apiAddrs.values()
}
func (list *addrList) email(addrID string) string {
return list.apiAddrs.get(addrID)
func (list *addrList) email(addrID string) (string, bool) {
return list.apiAddrs.getVal(addrID)
}
func (list *addrList) addrMap() map[string]string {

View File

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

View File

@ -5,7 +5,7 @@ import (
"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
order []Key
@ -14,7 +14,7 @@ type ordMap[Key comparable, Val, Data any] struct {
isLess func(Data, Data) bool
}
func newOrdMap[Key comparable, Val, Data any](
func newOrdMap[Key, Val comparable, Data any](
key func(Data) Key,
value func(Data) Val,
less func(Data, Data) bool,
@ -64,8 +64,23 @@ func (set *ordMap[Key, Val, Data]) delete(key Key) Val {
return set.toVal(data)
}
func (set *ordMap[Key, Val, Data]) get(key Key) Val {
return set.toVal(set.data[key])
func (set *ordMap[Key, Val, Data]) getVal(key Key) (Val, bool) {
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 {

View File

@ -5,11 +5,16 @@ import (
"encoding/base64"
"fmt"
"io"
"net/mail"
"runtime"
"strings"
"github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-rfc5322"
"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/parser"
"github.com/bradenaw/juniper/parallel"
@ -17,42 +22,67 @@ import (
"github.com/emersion/go-smtp"
"github.com/sirupsen/logrus"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/slices"
)
type smtpSession struct {
// client is the user's API client.
client *liteapi.Client
username string
emails map[string]string
// eventCh allows the session to publish events.
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
userKR *crypto.KeyRing
// userKR holds the user's keyring.
userKR *crypto.KeyRing
// addrKRs holds the keyrings for each address.
addrKRs map[string]*crypto.KeyRing
from string
to map[string]struct{}
// fromAddrID is the ID of the current sending address (taken from the return path).
fromAddrID string
// to holds all to for the current message.
to []string
}
func newSMTPSession(
client *liteapi.Client,
username string,
addresses map[string]string,
eventCh *queue.QueuedChannel[events.Event],
userID, addrID string,
addrMode vault.AddressMode,
emails map[string]string,
settings liteapi.MailSettings,
userKR *crypto.KeyRing,
addrKRs map[string]*crypto.KeyRing,
) *smtpSession {
return &smtpSession{
client: client,
client: client,
eventCh: eventCh,
username: username,
emails: addresses,
userID: userID,
addrID: addrID,
addrMode: addrMode,
emails: emails,
settings: settings,
userKR: userKR,
addrKRs: addrKRs,
from: "",
to: make(map[string]struct{}),
}
}
@ -61,8 +91,8 @@ func (session *smtpSession) Reset() {
logrus.Info("SMTP session reset")
// Clear the from and to fields.
session.from = ""
session.to = make(map[string]struct{})
session.fromAddrID = ""
session.to = nil
}
// 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 {
logrus.Info("SMTP session mail")
if opts.RequireTLS {
switch {
case opts.RequireTLS:
return ErrNotImplemented
}
if opts.UTF8 {
case opts.UTF8:
return ErrNotImplemented
}
if opts.Auth != nil && *opts.Auth != "" && *opts.Auth != session.username {
return ErrNotImplemented
case opts.Auth != nil:
if *opts.Auth != "" && *opts.Auth != session.emails[session.addrID] {
return ErrNotImplemented
}
}
for addrID, email := range session.emails {
if strings.EqualFold(from, email) {
session.from = addrID
session.fromAddrID = addrID
}
}
if session.from == "" {
if session.fromAddrID == "" {
return ErrInvalidReturnPath
}
@ -111,86 +142,240 @@ func (session *smtpSession) Rcpt(to string) error {
return ErrInvalidRecipient
}
session.to[to] = struct{}{}
if !slices.Contains(session.to, to) {
session.to = append(session.to, to)
}
return nil
}
// Set currently processed message contents and send it.
func (session *smtpSession) Data(r io.Reader) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
logrus.Info("SMTP session data")
if session.from == "" {
switch {
case session.fromAddrID == "":
return ErrInvalidReturnPath
}
if len(session.to) == 0 {
case len(session.to) == 0:
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)
if err != nil {
return fmt.Errorf("failed to create parser: %w", err)
}
if session.settings.AttachPublicKey == liteapi.AttachPublicKeyEnabled {
key, err := addrKey.GetKey(0)
if err != nil {
return fmt.Errorf("failed to get user public key: %w", err)
// If the message contains a sender, use it instead of the one from the return path.
if sender, ok := getMessageSender(parser); ok {
for addrID, email := range session.emails {
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 {
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 {
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)
if err != nil {
return fmt.Errorf("failed to get recipients: %w", err)
}
session.eventCh.Enqueue(events.MessageSent{
UserID: session.userID,
AddressID: session.addrID,
MessageID: message.ID,
})
req, err := createSendReq(addrKey, message.MIMEBody, message.RichBody, message.PlainBody, recipients, attKeys)
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")
logrus.WithField("messageID", message.ID).Info("Message sent")
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)
if err != nil {
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)
}
draft, err := session.client.CreateDraft(ctx, liteapi.CreateDraftReq{
draft, err := client.CreateDraft(ctx, liteapi.CreateDraftReq{
Message: liteapi.DraftTemplate{
Subject: message.Subject,
Sender: message.Sender,
ToList: message.ToList,
CCList: message.CCList,
BCCList: message.BCCList,
Body: armBody,
Subject: message.Subject,
Sender: message.Sender,
ToList: message.ToList,
CCList: message.CCList,
BCCList: message.BCCList,
Body: armBody,
MIMEType: message.MIMEType,
ExternalID: message.ExternalID,
},
AttachmentKeyPackets: []string{},
ParentID: parentID,
})
if err != nil {
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 {
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
}
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 {
attID string
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)
}
attachment, err := session.client.UploadAttachment(ctx, liteapi.CreateAttachmentReq{
attachment, err := client.UploadAttachment(ctx, liteapi.CreateAttachmentReq{
Filename: att.Name,
MessageID: draftID,
MIMEType: rfc822.MIMEType(att.MIMEType),
@ -280,12 +475,19 @@ func (session *smtpSession) createAttachments(ctx context.Context, addrKR *crypt
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) {
return session.getSendPrefs(ctx, address, mimeType)
return getSendPrefs(ctx, client, userKR, settings, address, mimeType)
})
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)
@ -297,22 +499,34 @@ func (session *smtpSession) getRecipients(ctx context.Context, addresses []strin
return recipients, nil
}
func (session *smtpSession) getSendPrefs(ctx context.Context, recipient string, mimeType rfc822.MIMEType) (liteapi.SendPreferences, error) {
pubKeys, internal, err := session.client.GetPublicKeys(ctx, recipient)
func getSendPrefs(
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 {
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 {
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) {
contacts, err := session.client.GetAllContactEmails(ctx, recipient)
func getContactSettings(
ctx context.Context,
client *liteapi.Client,
userKR *crypto.KeyRing,
recipient string,
) (liteapi.ContactSettings, error) {
contacts, err := client.GetAllContactEmails(ctx, recipient)
if err != nil {
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
}
contact, err := session.client.GetContact(ctx, contacts[idx].ContactID)
contact, err := client.GetContact(ctx, contacts[idx].ContactID)
if err != nil {
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"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
var (
@ -143,7 +142,11 @@ func (user *User) Match(query string) bool {
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.
@ -289,7 +292,12 @@ func (user *User) NewIMAPConnector(addrID string) (connector.Connector, error) {
emails = user.apiAddrs.emails()
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(
@ -319,8 +327,23 @@ func (user *User) NewIMAPConnectors() (map[string]connector.Connector, error) {
}
// NewSMTPSession returns an SMTP session for the user.
func (user *User) NewSMTPSession(username string) smtp.Session {
return newSMTPSession(user.client, username, user.apiAddrs.addrMap(), user.settings, user.userKR, user.addrKRs)
func (user *User) NewSMTPSession(email string) (smtp.Session, error) {
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.

View File

@ -98,13 +98,13 @@ func withAPI(t *testing.T, ctx context.Context, username, password string, email
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)
addrIDs = append(addrIDs, addrID)
for _, email := range emails[1:] {
addrID, err := server.AddAddress(userID, email, password)
addrID, err := server.CreateAddress(userID, email, password)
require.NoError(t, err)
addrIDs = append(addrIDs, addrID)

View File

@ -30,6 +30,7 @@ import (
"github.com/ProtonMail/go-rfc5322"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/pkg/algo"
"github.com/bradenaw/juniper/xslices"
"github.com/emersion/go-message"
"github.com/emersion/go-message/textproto"
"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...).
if opts.AddMessageIDReference {
if references := hdr.Get("References"); !strings.Contains(references, msg.ID) {
hdr.Set("References", references+" <"+msg.ID+"@"+InternalIDDomain+">")
if refs := hdr.Values("References"); xslices.IndexFunc(refs, func(ref string) bool {
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 Message struct {
Header mail.Header
MIMEBody MIMEBody
RichBody Body
PlainBody Body
Time int64
ExternalID string
MIMEBody MIMEBody
RichBody Body
PlainBody Body
Attachments []Attachment
MIMEType rfc822.MIMEType
Subject string
Sender *mail.Address
@ -57,8 +56,8 @@ type Message struct {
BCCList []*mail.Address
ReplyTos []*mail.Address
MIMEType rfc822.MIMEType
Attachments []Attachment
References []string
ExternalID 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
var m Message
mimeHeader, err := toMailHeader(h)
if err != nil {
return Message{}, err
}
m.Header = mimeHeader
fields := h.Fields()
for fields.Next() {
for fields := h.Fields(); fields.Next(); {
switch strings.ToLower(fields.Key()) {
case "subject":
s, err := fields.Text()
@ -473,6 +463,7 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse from")
}
if len(sender) > 0 {
m.Sender = sender[0]
}
@ -482,6 +473,7 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse to")
}
m.ToList = toList
case "reply-to":
@ -489,6 +481,7 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse reply-to")
}
m.ReplyTos = replyTos
case "cc":
@ -496,6 +489,7 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse cc")
}
m.CCList = ccList
case "bcc":
@ -503,17 +497,16 @@ func parseMessageHeader(h message.Header) (Message, error) { //nolint:funlen
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse bcc")
}
m.BCCList = bccList
case "date":
date, err := rfc5322.ParseDateTime(fields.Value())
if err != nil {
return Message{}, errors.Wrap(err, "failed to parse date")
}
m.Time = date.Unix()
m.BCCList = bccList
case "message-id":
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)
}
type panicReader struct{}
func TestParseMessageReferences(t *testing.T) {
f := getFileReader("references.eml")
func (panicReader) Read(p []byte) (int, error) {
panic("lol")
m, err := Parse(f)
require.NoError(t, err)
assert.Len(t, m.References, 2)
}
func TestParsePanic(t *testing.T) {
@ -603,3 +606,9 @@ func getFileReader(filename string) io.Reader {
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
import (
"net/mail"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon/rfc822"
"gitlab.protontech.ch/go/liteapi"
@ -13,16 +15,16 @@ type API interface {
GetHostURL() string
AddCallWatcher(func(server.Call), ...string)
AddUser(username, password, address string) (string, string, error)
AddAddress(userID, address, password string) (string, error)
CreateUser(username, password, address string) (string, string, error)
CreateAddress(userID, address, password string) (string, error)
RemoveAddress(userID, addrID string) error
RevokeUser(userID string) 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)
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()
}

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 on$`, s.internetIsTurnedOn)
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 ====
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 "([^"]*)" sends MAIL FROM "([^"]*)"$`, s.smtpClientSendsMailFrom)
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 the following message from "([^"]*)" to "([^"]*)":$`, s.smtpClientSendsTheFollowingMessageFromTo)
},
Options: &godog.Options{
Format: "pretty",

View File

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

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
import (
"encoding/json"
"fmt"
"strings"
"github.com/cucumber/godog"
)
func (s *scenario) itSucceeds() error {
@ -52,15 +55,38 @@ func (s *scenario) theUserAgentIs(userAgent string) error {
return nil
}
func (s *scenario) theValueOfTheHeaderInTheRequestToIs(key, path, value string) error {
call, err := s.t.getLastCall(path)
func (s *scenario) theHeaderInTheRequestToHasSetTo(method, path, key, value string) error {
call, err := s.t.getLastCall(method, path)
if err != nil {
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 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"
And IMAP client "1" announces its ID with name "name" and version "version"
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"
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"
Scenario: Reset is the same as without FROM and TO
@ -16,7 +19,10 @@ Feature: SMTP initiation
Then it succeeds
When SMTP client "1" sends RSET
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"
Scenario: Send without FROM
@ -26,7 +32,10 @@ Feature: SMTP initiation
Scenario: Send without TO
When SMTP client "1" sends MAIL FROM "<user@pm.me>"
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"
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 {
return Message{
Sender: msg.Envelope.Sender[0].Address(),
Recipient: msg.Envelope.To[0].Address(),
Subject: msg.Envelope.Subject,
Unread: slices.Contains(msg.Flags, imap.SeenFlag),
message := Message{
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)

View File

@ -5,6 +5,7 @@ import (
"net/smtp"
"github.com/ProtonMail/proton-bridge/v2/internal/constants"
"github.com/cucumber/godog"
)
func (s *scenario) userConnectsSMTPClient(username, clientID string) error {
@ -87,13 +88,13 @@ func (s *scenario) smtpClientSendsRcptTo(clientID, to string) error {
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)
rc, err := client.Data()
if err != nil {
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)
} else if err := rc.Close(); err != nil {
s.t.pushError(err)
@ -109,3 +110,30 @@ func (s *scenario) smtpClientSendsReset(clientID string) error {
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 (
"fmt"
"reflect"
"strconv"
"time"
@ -13,10 +14,24 @@ import (
)
type Message struct {
Sender string
Recipient string
Subject string
Unread bool
Subject string `bdd:"subject"`
From string `bdd:"sender"`
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 {
@ -28,20 +43,27 @@ func matchMessages(have []Message, want *godog.Table) error {
}
func parseMessages(table *godog.Table) []Message {
header := table.Rows[0]
return xslices.Map(table.Rows[1:], func(row *messages.PickleTableRow) Message {
return Message{
Sender: row.Cells[0].Value,
Recipient: row.Cells[1].Value,
Subject: row.Cells[2].Value,
Unread: mustParseBool(row.Cells[3].Value),
}
return newMessageFromRow(header, row)
})
}
type Mailbox struct {
Name string
Total int
Unread int
Name string `bdd:"name"`
Total int `bdd:"total"`
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 {
@ -53,33 +75,13 @@ func matchMailboxes(have []Mailbox, want *godog.Table) error {
}
func parseMailboxes(table *godog.Table) []Mailbox {
mustParseInt := func(s string) int {
i, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return i
}
header := table.Rows[0]
return xslices.Map(table.Rows[1:], func(row *messages.PickleTableRow) Mailbox {
return Mailbox{
Name: row.Cells[0].Value,
Total: mustParseInt(row.Cells[1].Value),
Unread: mustParseInt(row.Cells[2].Value),
}
return newMailboxFromRow(header, row)
})
}
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 {
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"
"errors"
"fmt"
"net/mail"
"time"
"github.com/ProtonMail/gluon/rfc822"
@ -17,7 +18,7 @@ import (
func (s *scenario) thereExistsAnAccountWithUsernameAndPassword(username, password string) error {
// 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 {
return err
}
@ -37,7 +38,7 @@ func (s *scenario) thereExistsAnAccountWithUsernameAndPassword(username, passwor
func (s *scenario) theAccountHasAdditionalAddress(username, address string) error {
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 {
return err
}
@ -62,7 +63,7 @@ func (s *scenario) theAccountNoLongerHasAdditionalAddress(username, address stri
func (s *scenario) theAccountHasCustomFolders(username string, count int) error {
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
}
}
@ -72,7 +73,7 @@ func (s *scenario) theAccountHasCustomFolders(username string, count int) error
func (s *scenario) theAccountHasCustomLabels(username string, count int) error {
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
}
}
@ -103,7 +104,7 @@ func (s *scenario) theAccountHasTheFollowingCustomMailboxes(username string, tab
})
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
}
}
@ -117,13 +118,15 @@ func (s *scenario) theAddressOfAccountHasTheFollowingMessagesInMailbox(address,
mboxID := s.t.getMBoxID(userID, mailbox)
for _, wantMessage := range parseMessages(table) {
if _, err := s.t.api.AddMessage(
if _, err := s.t.api.CreateMessage(
userID,
addrID,
[]string{mboxID},
wantMessage.Sender,
wantMessage.Recipient,
wantMessage.Subject,
&mail.Address{Address: wantMessage.From},
[]*mail.Address{{Address: wantMessage.To}},
[]*mail.Address{},
[]*mail.Address{},
"some body goes here",
rfc822.TextPlain,
wantMessage.Unread,
@ -142,16 +145,18 @@ func (s *scenario) theAddressOfAccountHasMessagesInMailbox(address, username str
mboxID := s.t.getMBoxID(userID, mailbox)
for idx := 0; idx < count; idx++ {
if _, err := s.t.api.AddMessage(
if _, err := s.t.api.CreateMessage(
userID,
addrID,
[]string{mboxID},
fmt.Sprintf("sender%v@pm.me", idx),
fmt.Sprintf("recipient%v@pm.me", idx),
fmt.Sprintf("subject %v", idx),
fmt.Sprintf("body %v", idx),
fmt.Sprintf("subject %d", idx),
&mail.Address{Address: fmt.Sprintf("sender %d", idx)},
[]*mail.Address{{Address: fmt.Sprintf("recipient %d", idx)}},
[]*mail.Address{},
[]*mail.Address{},
fmt.Sprintf("body %d", idx),
rfc822.TextPlain,
false,
idx%2 == 0,
false,
); err != nil {
return err