forked from Silverfish/proton-bridge
GODT-1650: Send extras
This commit is contained in:
4
go.mod
4
go.mod
@ -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
4
go.sum
@ -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=
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
9
internal/events/send.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
type MessageSent struct {
|
||||||
|
eventBase
|
||||||
|
|
||||||
|
UserID string
|
||||||
|
AddressID string
|
||||||
|
MessageID string
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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+">"), " "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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, "<>")
|
||||||
|
})...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
27
pkg/message/testdata/references.eml
vendored
Normal 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--
|
||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
81
tests/diff.go
Normal 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
125
tests/diff_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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])"
|
||||||
@ -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
|
||||||
|
|||||||
332
tests/features/smtp/send/plain.feature
Normal file
332
tests/features/smtp/send/plain.feature
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user