GODT-1815: Combined/Split mode

This commit is contained in:
James Houlahan
2022-09-28 11:29:33 +02:00
parent 9670e29d9f
commit e9672e6bba
55 changed files with 1909 additions and 705 deletions

4
go.mod
View File

@ -5,7 +5,7 @@ go 1.18
require (
github.com/0xAX/notificator v0.0.0-20220220101646-ee9b8921e557
github.com/Masterminds/semver/v3 v3.1.1
github.com/ProtonMail/gluon v0.11.1-0.20220922143913-ef3617264557
github.com/ProtonMail/gluon v0.11.1-0.20221001180052-2e11f5804b8a
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-rfc5322 v0.11.0
github.com/ProtonMail/gopenpgp/v2 v2.4.10
@ -37,7 +37,7 @@ require (
github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.0
github.com/urfave/cli/v2 v2.16.3
gitlab.protontech.ch/go/liteapi v0.31.0
gitlab.protontech.ch/go/liteapi v0.31.1-0.20221001204216-b781c54ca2a6
golang.org/x/exp v0.0.0-20220921164117-439092de6870
golang.org/x/net v0.1.0
golang.org/x/sys v0.1.0

8
go.sum
View File

@ -29,8 +29,8 @@ github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/docker-credential-helpers v1.1.0 h1:+kvUIpwWcbtP3WFv5sSvkFn/XLzSqPOB5AAthuk9xPk=
github.com/ProtonMail/docker-credential-helpers v1.1.0/go.mod h1:mK0aBveCxhnQ756AmaTfXMZDeULvheYVhF/MWMErN5g=
github.com/ProtonMail/gluon v0.11.1-0.20220922143913-ef3617264557 h1:uyiHq7jDgn1p2TeMKRPnVCVs2bHoNL9AYs26UzLYr4I=
github.com/ProtonMail/gluon v0.11.1-0.20220922143913-ef3617264557/go.mod h1:9k3URQEASX9XSA+JEcukjIiK3S6aR9GzhLhwccy8AnI=
github.com/ProtonMail/gluon v0.11.1-0.20221001180052-2e11f5804b8a h1:JUjaQ7bUifpYdnLKviBPrVKOPfW6r4Mm8xCL1fdevaA=
github.com/ProtonMail/gluon v0.11.1-0.20221001180052-2e11f5804b8a/go.mod h1:9k3URQEASX9XSA+JEcukjIiK3S6aR9GzhLhwccy8AnI=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a h1:D+aZah+k14Gn6kmL7eKxoo/4Dr/lK3ChBcwce2+SQP4=
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a/go.mod h1:oTGdE7/DlWIr23G0IKW3OXK9wZ5Hw1GGiaJFccTvZi4=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
@ -463,8 +463,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zclconf/go-cty v1.11.0 h1:726SxLdi2SDnjY+BStqB9J1hNp4+2WlzyXLuimibIe0=
github.com/zclconf/go-cty v1.11.0/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA=
gitlab.protontech.ch/go/liteapi v0.31.0 h1:Et3P2EyTySldgBunFJqaa5W5Fap8yLvuOaLUkZX/Kn0=
gitlab.protontech.ch/go/liteapi v0.31.0/go.mod h1:ixp1LUOxOYuB1qf172GdV0ZT8fOomKxVFtIMZeSWg+I=
gitlab.protontech.ch/go/liteapi v0.31.1-0.20221001204216-b781c54ca2a6 h1:N9Wzm4pNhIjR4aBmP9AzVGy+G8XQCDlkLy9GGEONbYM=
gitlab.protontech.ch/go/liteapi v0.31.1-0.20221001204216-b781c54ca2a6/go.mod h1:ixp1LUOxOYuB1qf172GdV0ZT8fOomKxVFtIMZeSWg+I=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=

View File

@ -232,6 +232,13 @@ func (bridge *Bridge) GetErrors() []error {
}
func (bridge *Bridge) Close(ctx context.Context) error {
// Abort any ongoing syncs.
for _, user := range bridge.users {
if err := user.AbortSync(ctx); err != nil {
return fmt.Errorf("failed to abort sync: %w", err)
}
}
// Close the IMAP server.
if err := bridge.closeIMAP(ctx); err != nil {
logrus.WithError(err).Error("Failed to close IMAP server")

View File

@ -4,19 +4,24 @@ import (
"context"
"os"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/internal/bridge"
"github.com/ProtonMail/proton-bridge/v2/internal/certs"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/focus"
"github.com/ProtonMail/proton-bridge/v2/internal/locations"
"github.com/ProtonMail/proton-bridge/v2/internal/updater"
"github.com/ProtonMail/proton-bridge/v2/internal/user"
"github.com/ProtonMail/proton-bridge/v2/internal/useragent"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/ProtonMail/proton-bridge/v2/tests"
"github.com/bradenaw/juniper/xslices"
"github.com/stretchr/testify/require"
"gitlab.protontech.ch/go/liteapi/server"
"gitlab.protontech.ch/go/liteapi/server/account"
)
const (
@ -29,6 +34,13 @@ var (
v2_4_0 = semver.MustParse("2.4.0")
)
func init() {
user.DefaultEventPeriod = 100 * time.Millisecond
user.DefaultEventJitter = 0
account.GenerateKey = tests.FastGenerateKey
certs.GenerateCert = tests.FastGenerateCert
}
func TestBridge_ConnStatus(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, dialer *bridge.TestDialer, locator bridge.Locator, vaultKey []byte) {
withBridge(t, ctx, s.GetHostURL(), dialer, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
@ -156,19 +168,27 @@ func TestBridge_CheckUpdate(t *testing.T) {
// Disable autoupdate for this test.
require.NoError(t, bridge.SetAutoUpdate(false))
// Get a stream of update events.
updateCh, done := bridge.GetEvents(events.UpdateNotAvailable{}, events.UpdateAvailable{})
// Get a stream of update not available events.
noUpdateCh, done := bridge.GetEvents(events.UpdateNotAvailable{})
defer done()
// We are currently on the latest version.
bridge.CheckForUpdates()
require.Equal(t, events.UpdateNotAvailable{}, <-updateCh)
// we should receive an event indicating that no update is available.
require.Equal(t, events.UpdateNotAvailable{}, <-noUpdateCh)
// Simulate a new version being available.
mocks.Updater.SetLatestVersion(v2_4_0, v2_3_0)
// Get a stream of update available events.
updateCh, done := bridge.GetEvents(events.UpdateAvailable{})
defer done()
// Check for updates.
bridge.CheckForUpdates()
// We should receive an event indicating that an update is available.
require.Equal(t, events.UpdateAvailable{
Version: updater.VersionInfo{
Version: v2_4_0,
@ -188,7 +208,7 @@ func TestBridge_AutoUpdate(t *testing.T) {
require.NoError(t, bridge.SetAutoUpdate(true))
// Get a stream of update events.
updateCh, done := bridge.GetEvents(events.UpdateNotAvailable{}, events.UpdateInstalled{})
updateCh, done := bridge.GetEvents(events.UpdateInstalled{})
defer done()
// Simulate a new version being available.
@ -196,6 +216,8 @@ func TestBridge_AutoUpdate(t *testing.T) {
// Check for updates.
bridge.CheckForUpdates()
// We should receive an event indicating that the update was installed.
require.Equal(t, events.UpdateInstalled{
Version: updater.VersionInfo{
Version: v2_4_0,
@ -213,8 +235,8 @@ func TestBridge_ManualUpdate(t *testing.T) {
// Disable autoupdate for this test.
require.NoError(t, bridge.SetAutoUpdate(false))
// Get a stream of update events.
updateCh, done := bridge.GetEvents(events.UpdateNotAvailable{}, events.UpdateAvailable{})
// Get a stream of update available events.
updateCh, done := bridge.GetEvents(events.UpdateAvailable{})
defer done()
// Simulate a new version being available, but it's too new for us.
@ -222,6 +244,8 @@ func TestBridge_ManualUpdate(t *testing.T) {
// Check for updates.
bridge.CheckForUpdates()
// We should receive an event indicating an update is available, but we can't install it.
require.Equal(t, events.UpdateAvailable{
Version: updater.VersionInfo{
Version: v2_4_0,

View File

@ -14,8 +14,9 @@ func (bridge *Bridge) ConfigureAppleMail(userID, address string) error {
return ErrNoSuchUser
}
// TODO: Handle split mode!
if address == "" {
address = user.Addresses()[0]
address = user.Emails()[0]
}
// If configuring apple mail for Catalina or newer, users should use SSL.
@ -32,7 +33,7 @@ func (bridge *Bridge) ConfigureAppleMail(userID, address string) error {
bridge.vault.GetIMAPSSL(),
bridge.vault.GetSMTPSSL(),
address,
strings.Join(user.Addresses(), ","),
strings.Join(user.Emails(), ","),
user.BridgePass(),
)
}

View File

@ -2,6 +2,7 @@ package bridge
import (
"context"
"fmt"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v2/internal/updater"
@ -96,40 +97,39 @@ func (bridge *Bridge) GetGluonDir() string {
func (bridge *Bridge) SetGluonDir(ctx context.Context, newGluonDir string) error {
if newGluonDir == bridge.GetGluonDir() {
return nil
return fmt.Errorf("new gluon dir is the same as the old one")
}
if err := bridge.closeIMAP(context.Background()); err != nil {
return err
return fmt.Errorf("failed to close IMAP: %w", err)
}
if err := moveDir(bridge.GetGluonDir(), newGluonDir); err != nil {
return err
return fmt.Errorf("failed to move gluon dir: %w", err)
}
if err := bridge.vault.SetGluonDir(newGluonDir); err != nil {
return err
return fmt.Errorf("failed to set new gluon dir: %w", err)
}
imapServer, err := newIMAPServer(bridge.vault.GetGluonDir(), bridge.curVersion, bridge.tlsConfig)
if err != nil {
return err
}
for _, user := range bridge.users {
imapConn, err := user.NewGluonConnector(ctx)
if err != nil {
return err
}
if err := imapServer.LoadUser(context.Background(), imapConn, user.GluonID(), user.GluonKey()); err != nil {
return err
}
return fmt.Errorf("failed to create new IMAP server: %w", err)
}
bridge.imapServer = imapServer
return bridge.serveIMAP()
for _, user := range bridge.users {
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
}
if err := bridge.serveIMAP(); err != nil {
return fmt.Errorf("failed to serve IMAP: %w", err)
}
return nil
}
func (bridge *Bridge) GetProxyAllowed() bool {

View File

@ -23,8 +23,8 @@ func (backend *smtpBackend) Login(state *smtp.ConnectionState, username string,
defer backend.usersLock.RUnlock()
for _, user := range backend.users {
if slices.Contains(user.Addresses(), username) && user.BridgePass() == password {
return user.NewSMTPSession(username)
if slices.Contains(user.Emails(), username) && user.BridgePass() == password {
return user.NewSMTPSession(username), nil
}
}

View File

@ -29,7 +29,7 @@ type UserInfo struct {
Addresses []string
// AddressMode is the user's address mode.
AddressMode AddressMode
AddressMode vault.AddressMode
// BridgePass is the user's bridge password.
BridgePass string
@ -41,13 +41,6 @@ type UserInfo struct {
MaxSpace int
}
type AddressMode int
const (
SplitMode AddressMode = iota
CombinedMode
)
// GetUserIDs returns the IDs of all known users (authorized or not).
func (bridge *Bridge) GetUserIDs() []string {
return bridge.vault.GetUserIDs()
@ -62,7 +55,7 @@ func (bridge *Bridge) GetUserInfo(userID string) (UserInfo, error) {
user, ok := bridge.users[userID]
if !ok {
return getUserInfo(vaultUser.UserID(), vaultUser.Username()), nil
return getUserInfo(vaultUser.UserID(), vaultUser.Username(), vaultUser.AddressMode()), nil
}
return getConnUserInfo(user), nil
@ -153,12 +146,43 @@ func (bridge *Bridge) DeleteUser(ctx context.Context, userID string) error {
return nil
}
func (bridge *Bridge) GetAddressMode(userID string) (AddressMode, error) {
panic("TODO")
}
// SetAddressMode sets the address mode for the given user.
func (bridge *Bridge) SetAddressMode(ctx context.Context, userID string, mode vault.AddressMode) error {
user, ok := bridge.users[userID]
if !ok {
return ErrNoSuchUser
}
func (bridge *Bridge) SetAddressMode(userID string, mode AddressMode) error {
panic("TODO")
if user.GetAddressMode() == mode {
return fmt.Errorf("address mode is already %q", mode)
}
if err := user.AbortSync(ctx); err != nil {
return fmt.Errorf("failed to abort sync: %w", err)
}
for _, gluonID := range user.GetGluonIDs() {
if err := bridge.imapServer.RemoveUser(ctx, gluonID, true); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
}
if err := user.SetAddressMode(ctx, mode); err != nil {
return fmt.Errorf("failed to set address mode: %w", err)
}
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
bridge.publish(events.AddressModeChanged{
UserID: userID,
AddressMode: mode,
})
user.DoSync(ctx)
return nil
}
// loadUsers loads authorized users from the vault.
@ -177,7 +201,7 @@ func (bridge *Bridge) loadUsers(ctx context.Context) error {
logrus.WithError(err).Error("Failed to load connected user")
if _, ok := err.(*resty.ResponseError); ok {
if err := user.Clear(); err != nil {
if err := bridge.vault.ClearUser(userID); err != nil {
logrus.WithError(err).Error("Failed to clear user")
}
}
@ -231,33 +255,41 @@ func (bridge *Bridge) addUser(
if slices.Contains(bridge.vault.GetUserIDs(), apiUser.ID) {
existingUser, err := bridge.addExistingUser(ctx, client, apiUser, apiAddrs, userKR, addrKRs, authUID, authRef, saltedKeyPass)
if err != nil {
return err
return fmt.Errorf("failed to add existing user: %w", err)
}
user = existingUser
} else {
newUser, err := bridge.addNewUser(ctx, client, apiUser, apiAddrs, userKR, addrKRs, authUID, authRef, saltedKeyPass)
if err != nil {
return err
return fmt.Errorf("failed to add new user: %w", err)
}
user = newUser
}
go func() {
for event := range user.GetNotifyCh() {
switch event := event.(type) {
case events.UserDeauth:
if err := bridge.logoutUser(context.Background(), event.UserID, false, false); err != nil {
logrus.WithError(err).Error("Failed to logout user")
}
}
// Connects the user's address(es) to gluon.
if err := bridge.addIMAPUser(ctx, user); err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
bridge.publish(event)
// Handle events coming from the user before forwarding them to the bridge.
// For example, if the user's addresses change, we need to update them in gluon.
go func() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for event := range user.GetEventCh() {
if err := bridge.handleUserEvent(ctx, user, event); err != nil {
logrus.WithError(err).Error("Failed to handle user event")
} else {
bridge.publish(event)
}
}
}()
// Gluon will set the IMAP ID in the context, if known, before making requests on behalf of this user.
// As such, if we find this ID in the context, we should use it to update our user agent.
client.AddPreRequestHook(func(ctx context.Context, req *resty.Request) error {
if imapID, ok := imap.GetIMAPIDFromContext(ctx); ok {
bridge.identifier.SetClient(imapID.Name, imapID.Version)
@ -266,6 +298,11 @@ func (bridge *Bridge) addUser(
return nil
})
// TODO: Replace this with proper sync manager.
if !user.HasSync() {
user.DoSync(ctx)
}
bridge.publish(events.UserLoggedIn{
UserID: user.ID(),
})
@ -293,25 +330,6 @@ func (bridge *Bridge) addNewUser(
return nil, err
}
gluonKey, err := crypto.RandomToken(32)
if err != nil {
return nil, err
}
imapConn, err := user.NewGluonConnector(ctx)
if err != nil {
return nil, err
}
gluonID, err := bridge.imapServer.AddUser(ctx, imapConn, gluonKey)
if err != nil {
return nil, err
}
if err := vaultUser.SetGluonAuth(gluonID, gluonKey); err != nil {
return nil, err
}
if err := bridge.smtpBackend.addUser(user); err != nil {
return nil, err
}
@ -349,15 +367,6 @@ func (bridge *Bridge) addExistingUser(
return nil, err
}
imapConn, err := user.NewGluonConnector(ctx)
if err != nil {
return nil, err
}
if err := bridge.imapServer.LoadUser(ctx, imapConn, user.GluonID(), user.GluonKey()); err != nil {
return nil, err
}
if err := bridge.smtpBackend.addUser(user); err != nil {
return nil, err
}
@ -376,31 +385,39 @@ func (bridge *Bridge) logoutUser(ctx context.Context, userID string, withAPI, wi
return ErrNoSuchUser
}
vaultUser, err := bridge.vault.GetUser(userID)
if err != nil {
return err
}
if err := bridge.imapServer.RemoveUser(ctx, vaultUser.GluonID(), withFiles); err != nil {
return err
// TODO: The sync should be canceled by the sync manager.
if err := user.AbortSync(ctx); err != nil {
return fmt.Errorf("failed to abort user sync: %w", err)
}
if err := bridge.smtpBackend.removeUser(user); err != nil {
return err
return fmt.Errorf("failed to remove SMTP user: %w", err)
}
for _, gluonID := range user.GetGluonIDs() {
if err := bridge.imapServer.RemoveUser(ctx, gluonID, withFiles); err != nil {
return fmt.Errorf("failed to remove IMAP user: %w", err)
}
}
if withAPI {
if err := user.Logout(ctx); err != nil {
return err
return fmt.Errorf("failed to logout user: %w", err)
}
}
if err := user.Close(ctx); err != nil {
return err
return fmt.Errorf("failed to close user: %w", err)
}
if err := vaultUser.Clear(); err != nil {
return err
if err := bridge.vault.ClearUser(userID); err != nil {
return fmt.Errorf("failed to clear user: %w", err)
}
if withFiles {
if err := bridge.vault.DeleteUser(userID); err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
}
delete(bridge.users, userID)
@ -412,12 +429,39 @@ func (bridge *Bridge) logoutUser(ctx context.Context, userID string, withAPI, wi
return nil
}
// addIMAPUser connects the given user to gluon.
func (bridge *Bridge) addIMAPUser(ctx context.Context, user *user.User) error {
imapConn, err := user.NewIMAPConnectors()
if err != nil {
return fmt.Errorf("failed to create IMAP connectors: %w", err)
}
for addrID, imapConn := range imapConn {
if gluonID, ok := user.GetGluonID(addrID); ok {
if err := bridge.imapServer.LoadUser(ctx, imapConn, gluonID, user.GluonKey()); err != nil {
return fmt.Errorf("failed to load IMAP user: %w", err)
}
} else {
gluonID, err := bridge.imapServer.AddUser(ctx, imapConn, user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add IMAP user: %w", err)
}
if err := user.SetGluonID(addrID, gluonID); err != nil {
return fmt.Errorf("failed to set IMAP user ID: %w", err)
}
}
}
return nil
}
// getUserInfo returns information about a disconnected user.
func getUserInfo(userID, username string) UserInfo {
func getUserInfo(userID, username string, addressMode vault.AddressMode) UserInfo {
return UserInfo{
UserID: userID,
Username: username,
AddressMode: CombinedMode,
AddressMode: addressMode,
}
}
@ -427,8 +471,8 @@ func getConnUserInfo(user *user.User) UserInfo {
Connected: true,
UserID: user.ID(),
Username: user.Name(),
Addresses: user.Addresses(),
AddressMode: CombinedMode,
Addresses: user.Emails(),
AddressMode: user.GetAddressMode(),
BridgePass: user.BridgePass(),
UsedSpace: user.UsedSpace(),
MaxSpace: user.MaxSpace(),

View File

@ -0,0 +1,118 @@
package bridge
import (
"context"
"fmt"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/user"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
)
func (bridge *Bridge) handleUserEvent(ctx context.Context, user *user.User, event events.Event) error {
switch event := event.(type) {
case events.UserAddressCreated:
if err := bridge.handleUserAddressCreated(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address created event: %w", err)
}
case events.UserAddressUpdated:
if err := bridge.handleUserAddressUpdated(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address updated event: %w", err)
}
case events.UserAddressDeleted:
if err := bridge.handleUserAddressDeleted(ctx, user, event); err != nil {
return fmt.Errorf("failed to handle user address deleted event: %w", err)
}
case events.UserDeauth:
if err := bridge.logoutUser(context.Background(), event.UserID, false, false); err != nil {
return fmt.Errorf("failed to logout user: %w", err)
}
}
return nil
}
func (bridge *Bridge) handleUserAddressCreated(ctx context.Context, user *user.User, event events.UserAddressCreated) error {
switch user.GetAddressMode() {
case vault.CombinedMode:
for addrID, gluonID := range user.GetGluonIDs() {
if err := bridge.imapServer.RemoveUser(ctx, gluonID, false); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
imapConn, err := user.NewIMAPConnector(addrID)
if err != nil {
return fmt.Errorf("failed to create IMAP connector: %w", err)
}
if err := bridge.imapServer.LoadUser(ctx, imapConn, gluonID, user.GluonKey()); err != nil {
return fmt.Errorf("failed to add user to IMAP server: %w", err)
}
}
case vault.SplitMode:
imapConn, err := user.NewIMAPConnector(event.AddressID)
if err != nil {
return fmt.Errorf("failed to create IMAP connector: %w", err)
}
gluonID, err := bridge.imapServer.AddUser(ctx, imapConn, user.GluonKey())
if err != nil {
return fmt.Errorf("failed to add user to IMAP server: %w", err)
}
if err := user.SetGluonID(event.AddressID, gluonID); err != nil {
return fmt.Errorf("failed to set gluon ID: %w", err)
}
}
return nil
}
// TODO: Handle addresses that have been disabled!
func (bridge *Bridge) handleUserAddressUpdated(ctx context.Context, user *user.User, event events.UserAddressUpdated) error {
switch user.GetAddressMode() {
case vault.CombinedMode:
return fmt.Errorf("not implemented")
case vault.SplitMode:
return fmt.Errorf("not implemented")
}
return nil
}
func (bridge *Bridge) handleUserAddressDeleted(ctx context.Context, user *user.User, event events.UserAddressDeleted) error {
switch user.GetAddressMode() {
case vault.CombinedMode:
for addrID, gluonID := range user.GetGluonIDs() {
if err := bridge.imapServer.RemoveUser(ctx, gluonID, false); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
imapConn, err := user.NewIMAPConnector(addrID)
if err != nil {
return fmt.Errorf("failed to create IMAP connector: %w", err)
}
if err := bridge.imapServer.LoadUser(ctx, imapConn, gluonID, user.GluonKey()); err != nil {
return fmt.Errorf("failed to add user to IMAP server: %w", err)
}
}
case vault.SplitMode:
gluonID, ok := user.GetGluonID(event.AddressID)
if !ok {
return fmt.Errorf("gluon ID not found for address %s", event.AddressID)
}
if err := bridge.imapServer.RemoveUser(ctx, gluonID, true); err != nil {
return fmt.Errorf("failed to remove user from IMAP server: %w", err)
}
}
return nil
}

View File

@ -7,6 +7,7 @@ import (
"github.com/ProtonMail/proton-bridge/v2/internal/bridge"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/stretchr/testify/require"
"gitlab.protontech.ch/go/liteapi/server"
)
@ -283,3 +284,30 @@ func TestBridge_BridgePass(t *testing.T) {
})
})
}
func TestBridge_AddressMode(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, dialer *bridge.TestDialer, locator bridge.Locator, storeKey []byte) {
withBridge(t, ctx, s.GetHostURL(), dialer, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
// Login the user.
userID, err := bridge.LoginUser(ctx, username, password, nil, nil)
require.NoError(t, err)
// Get the user's info.
info, err := bridge.GetUserInfo(userID)
require.NoError(t, err)
// The user is in combined mode by default.
require.Equal(t, vault.CombinedMode, info.AddressMode)
// Put the user in split mode.
require.NoError(t, bridge.SetAddressMode(ctx, userID, vault.SplitMode))
// Get the user's info.
info, err = bridge.GetUserInfo(userID)
require.NoError(t, err)
// The user is in split mode.
require.Equal(t, vault.SplitMode, info.AddressMode)
})
})
}

View File

@ -1,5 +1,7 @@
package events
import "github.com/ProtonMail/proton-bridge/v2/internal/vault"
type UserLoggedIn struct {
eventBase
@ -33,20 +35,30 @@ type UserChanged struct {
type UserAddressCreated struct {
eventBase
UserID string
Address string
UserID string
AddressID string
Email string
}
type UserAddressChanged struct {
type UserAddressUpdated struct {
eventBase
UserID string
Address string
UserID string
AddressID string
Email string
}
type UserAddressDeleted struct {
eventBase
UserID string
Address string
UserID string
AddressID string
Email string
}
type AddressModeChanged struct {
eventBase
UserID string
AddressMode vault.AddressMode
}

View File

@ -23,6 +23,7 @@ import (
"github.com/ProtonMail/proton-bridge/v2/internal/bridge"
"github.com/ProtonMail/proton-bridge/v2/internal/constants"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/abiosoft/ishell"
)
@ -39,7 +40,7 @@ func (f *frontendCLI) listAccounts(c *ishell.Context) {
connected = "connected"
}
mode := "split"
if user.AddressMode == bridge.CombinedMode {
if user.AddressMode == vault.CombinedMode {
mode = "combined"
}
f.Printf(spacing, idx, user.Username, connected, mode)
@ -58,7 +59,7 @@ func (f *frontendCLI) showAccountInfo(c *ishell.Context) {
return
}
if user.AddressMode == bridge.CombinedMode {
if user.AddressMode == vault.CombinedMode {
f.showAccountAddressInfo(user, user.Addresses[0])
} else {
for _, address := range user.Addresses {
@ -225,19 +226,19 @@ func (f *frontendCLI) changeMode(c *ishell.Context) {
return
}
var targetMode bridge.AddressMode
var targetMode vault.AddressMode
if user.AddressMode == bridge.CombinedMode {
targetMode = bridge.SplitMode
if user.AddressMode == vault.CombinedMode {
targetMode = vault.SplitMode
} else {
targetMode = bridge.CombinedMode
targetMode = vault.CombinedMode
}
if !f.yesNoQuestion("Are you sure you want to change the mode for account " + bold(user.Username) + " to " + bold(targetMode)) {
return
}
if err := f.bridge.SetAddressMode(user.UserID, targetMode); err != nil {
if err := f.bridge.SetAddressMode(context.Background(), user.UserID, targetMode); err != nil {
f.printAndLogError("Cannot switch address mode:", err)
}

View File

@ -296,7 +296,7 @@ func (f *frontendCLI) watchEvents() {
f.notifyLogout(user.Username)
case events.UserAddressChanged:
case events.UserAddressUpdated:
user, err := f.bridge.GetUserInfo(event.UserID)
if err != nil {
return
@ -305,7 +305,7 @@ func (f *frontendCLI) watchEvents() {
f.Printf("Address changed for %s. You may need to reconfigure your email client.\n", user.Username)
case events.UserAddressDeleted:
f.notifyLogout(event.Address)
f.notifyLogout(event.Email)
case events.SyncStarted:
user, err := f.bridge.GetUserInfo(event.UserID)

View File

@ -228,13 +228,13 @@ func (s *Service) watchEvents() {
_ = s.SendEvent(NewShowMainWindowEvent())
case events.UserAddressCreated:
_ = s.SendEvent(NewMailAddressChangeEvent(event.Address))
_ = s.SendEvent(NewMailAddressChangeEvent(event.Email))
case events.UserAddressChanged:
_ = s.SendEvent(NewMailAddressChangeEvent(event.Address))
case events.UserAddressUpdated:
_ = s.SendEvent(NewMailAddressChangeEvent(event.Email))
case events.UserAddressDeleted:
_ = s.SendEvent(NewMailAddressChangeLogoutEvent(event.Address))
_ = s.SendEvent(NewMailAddressChangeLogoutEvent(event.Email))
case events.UserChanged:
_ = s.SendEvent(NewUserChangedEvent(event.UserID))

View File

@ -20,7 +20,7 @@ package grpc
import (
"context"
"github.com/ProtonMail/proton-bridge/v2/internal/bridge"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@ -74,15 +74,15 @@ func (s *Service) SetUserSplitMode(ctx context.Context, splitMode *UserSplitMode
defer s.panicHandler.HandlePanic()
defer func() { _ = s.SendEvent(NewUserToggleSplitModeFinishedEvent(splitMode.UserID)) }()
var targetMode bridge.AddressMode
var targetMode vault.AddressMode
if splitMode.Active && user.AddressMode == bridge.CombinedMode {
targetMode = bridge.SplitMode
} else if !splitMode.Active && user.AddressMode == bridge.SplitMode {
targetMode = bridge.CombinedMode
if splitMode.Active && user.AddressMode == vault.CombinedMode {
targetMode = vault.SplitMode
} else if !splitMode.Active && user.AddressMode == vault.SplitMode {
targetMode = vault.CombinedMode
}
if err := s.bridge.SetAddressMode(user.UserID, targetMode); err != nil {
if err := s.bridge.SetAddressMode(context.Background(), user.UserID, targetMode); err != nil {
logrus.WithError(err).Error("Failed to set address mode")
}
}()

View File

@ -22,6 +22,7 @@ import (
"strings"
"github.com/ProtonMail/proton-bridge/v2/internal/bridge"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/sirupsen/logrus"
)
@ -64,7 +65,7 @@ func grpcUserFromInfo(user bridge.UserInfo) *User {
Username: user.Username,
AvatarText: getInitials(user.Username),
LoggedIn: user.Connected,
SplitMode: user.AddressMode == bridge.SplitMode,
SplitMode: user.AddressMode == vault.SplitMode,
SetupGuideSeen: true, // users listed have already seen the setup guide.
UsedBytes: int64(user.UsedSpace),
TotalBytes: int64(user.MaxSpace),

41
internal/pool/job.go Normal file
View File

@ -0,0 +1,41 @@
package pool
import "context"
type job[In, Out any] struct {
ctx context.Context
req In
res chan Out
err chan error
done chan struct{}
}
func newJob[In, Out any](ctx context.Context, req In) *job[In, Out] {
return &job[In, Out]{
ctx: ctx,
req: req,
res: make(chan Out),
err: make(chan error),
done: make(chan struct{}),
}
}
func (job *job[In, Out]) result() (Out, error) {
return <-job.res, <-job.err
}
func (job *job[In, Out]) postSuccess(res Out) {
close(job.err)
job.res <- res
}
func (job *job[In, Out]) postFailure(err error) {
close(job.res)
job.err <- err
}
func (job *job[In, Out]) waitDone() {
<-job.done
}

View File

@ -13,16 +13,16 @@ var ErrJobCancelled = errors.New("Job cancelled by surrounding context")
// Pool is a worker pool that handles input of type In and returns results of type Out.
type Pool[In comparable, Out any] struct {
queue *queue.QueuedChannel[*Job[In, Out]]
queue *queue.QueuedChannel[*job[In, Out]]
size int
}
// DoneFunc must be called to free up pool resources.
type DoneFunc func()
// doneFunc must be called to free up pool resources.
type doneFunc func()
// New returns a new pool.
func New[In comparable, Out any](size int, work func(context.Context, In) (Out, error)) *Pool[In, Out] {
queue := queue.NewQueuedChannel[*Job[In, Out]](0, 0)
queue := queue.NewQueuedChannel[*job[In, Out]](0, 0)
for i := 0; i < size; i++ {
go func() {
@ -51,17 +51,6 @@ func New[In comparable, Out any](size int, work func(context.Context, In) (Out,
}
}
// NewJob submits a job to the pool. It returns a job handle and a DoneFunc.
// The job handle allows the job result to be obtained. The DoneFunc is used to mark the job as done,
// which frees up the worker in the pool for reuse.
func (pool *Pool[In, Out]) NewJob(ctx context.Context, req In) (*Job[In, Out], DoneFunc) {
job := newJob[In, Out](ctx, req)
pool.queue.Enqueue(job)
return job, func() { close(job.done) }
}
// Process submits jobs to the pool. The callback provides access to the result, or an error if one occurred.
func (pool *Pool[In, Out]) Process(ctx context.Context, reqs []In, fn func(In, Out, error) error) error {
ctx, cancel := context.WithCancel(ctx)
@ -81,10 +70,10 @@ func (pool *Pool[In, Out]) Process(ctx context.Context, reqs []In, fn func(In, O
go func() {
defer wg.Done()
job, done := pool.NewJob(ctx, req)
job, done := pool.newJob(ctx, req)
defer done()
res, err := job.Result()
res, err := job.result()
if err := fn(req, res, err); err != nil {
lock.Lock()
@ -134,44 +123,25 @@ func (pool *Pool[In, Out]) ProcessAll(ctx context.Context, reqs []In) (map[In]Ou
return data, nil
}
// ProcessOne submits one job to the pool and returns the result.
func (pool *Pool[In, Out]) ProcessOne(ctx context.Context, req In) (Out, error) {
job, done := pool.newJob(ctx, req)
defer done()
return job.result()
}
func (pool *Pool[In, Out]) Done() {
pool.queue.Close()
}
type Job[In, Out any] struct {
ctx context.Context
req In
// newJob submits a job to the pool. It returns a job handle and a DoneFunc.
// The job handle allows the job result to be obtained. The DoneFunc is used to mark the job as done,
// which frees up the worker in the pool for reuse.
func (pool *Pool[In, Out]) newJob(ctx context.Context, req In) (*job[In, Out], doneFunc) {
job := newJob[In, Out](ctx, req)
res chan Out
err chan error
pool.queue.Enqueue(job)
done chan struct{}
}
func newJob[In, Out any](ctx context.Context, req In) *Job[In, Out] {
return &Job[In, Out]{
ctx: ctx,
req: req,
res: make(chan Out),
err: make(chan error),
done: make(chan struct{}),
}
}
func (job *Job[In, Out]) Result() (Out, error) {
return <-job.res, <-job.err
}
func (job *Job[In, Out]) postSuccess(res Out) {
close(job.err)
job.res <- res
}
func (job *Job[In, Out]) postFailure(err error) {
close(job.res)
job.err <- err
}
func (job *Job[In, Out]) waitDone() {
<-job.done
return job, func() { close(job.done) }
}

View File

@ -15,16 +15,16 @@ import (
func TestPool_NewJob(t *testing.T) {
doubler := newDoubler(runtime.NumCPU())
job1, done1 := doubler.NewJob(context.Background(), 1)
job1, done1 := doubler.newJob(context.Background(), 1)
defer done1()
job2, done2 := doubler.NewJob(context.Background(), 2)
job2, done2 := doubler.newJob(context.Background(), 2)
defer done2()
res2, err := job2.Result()
res2, err := job2.result()
require.NoError(t, err)
res1, err := job1.Result()
res1, err := job1.result()
require.NoError(t, err)
assert.Equal(t, 2, res1)
@ -36,31 +36,31 @@ func TestPool_NewJob_Done(t *testing.T) {
doubler := newDoubler(2)
// Start two jobs. Don't mark the jobs as done yet.
job1, done1 := doubler.NewJob(context.Background(), 1)
job2, done2 := doubler.NewJob(context.Background(), 2)
job1, done1 := doubler.newJob(context.Background(), 1)
job2, done2 := doubler.newJob(context.Background(), 2)
// Get the first result.
res1, _ := job1.Result()
res1, _ := job1.result()
assert.Equal(t, 2, res1)
// Get the first result.
res2, _ := job2.Result()
res2, _ := job2.result()
assert.Equal(t, 4, res2)
// Additional jobs will wait.
job3, _ := doubler.NewJob(context.Background(), 3)
job4, _ := doubler.NewJob(context.Background(), 4)
job3, _ := doubler.newJob(context.Background(), 3)
job4, _ := doubler.newJob(context.Background(), 4)
// Channel to collect results from jobs 3 and 4.
resCh := make(chan int, 2)
go func() {
res, _ := job3.Result()
res, _ := job3.result()
resCh <- res
}()
go func() {
res, _ := job4.Result()
res, _ := job4.result()
resCh <- res
}()

View File

@ -0,0 +1,46 @@
package user
import "gitlab.protontech.ch/go/liteapi"
type addrList struct {
apiAddrs ordMap[string, string, liteapi.Address]
}
func newAddrList(apiAddrs []liteapi.Address) *addrList {
return &addrList{
apiAddrs: newOrdMap(
func(addr liteapi.Address) string { return addr.ID },
func(addr liteapi.Address) string { return addr.Email },
func(a, b liteapi.Address) bool { return a.Order < b.Order },
apiAddrs...,
),
}
}
func (list *addrList) insert(address liteapi.Address) {
list.apiAddrs.insert(address)
}
func (list *addrList) delete(addrID string) string {
return list.apiAddrs.delete(addrID)
}
func (list *addrList) primary() string {
return list.apiAddrs.keys()[0]
}
func (list *addrList) addrIDs() []string {
return list.apiAddrs.keys()
}
func (list *addrList) emails() []string {
return list.apiAddrs.values()
}
func (list *addrList) email(addrID string) string {
return list.apiAddrs.get(addrID)
}
func (list *addrList) addrMap() map[string]string {
return list.apiAddrs.toMap()
}

View File

@ -2,16 +2,20 @@ package user
import (
"context"
"time"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/internal/pool"
"github.com/ProtonMail/proton-bridge/v2/pkg/message"
"github.com/bradenaw/juniper/xslices"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/slices"
)
type request struct {
messageID string
addressID string
addrKR *crypto.KeyRing
}
@ -54,8 +58,38 @@ func newBuilder(f fetcher, msgWorkers, attWorkers int) *pool.Pool[request, *imap
return nil, err
}
return getMessageCreatedUpdate(msg, literal)
return newMessageCreatedUpdate(msg, literal)
})
return msgPool
}
func newMessageCreatedUpdate(message liteapi.Message, literal []byte) (*imap.MessageCreated, error) {
parsedMessage, err := imap.NewParsedMessage(literal)
if err != nil {
return nil, err
}
flags := imap.NewFlagSet()
if !message.Unread {
flags = flags.Add(imap.FlagSeen)
}
if slices.Contains(message.LabelIDs, liteapi.StarredLabel) {
flags = flags.Add(imap.FlagFlagged)
}
imapMessage := imap.Message{
ID: imap.MessageID(message.ID),
Flags: flags,
Date: time.Unix(message.Time, 0),
}
return &imap.MessageCreated{
Message: imapMessage,
Literal: literal,
LabelIDs: mapTo[string, imap.LabelID](xslices.Filter(message.LabelIDs, wantLabelID)),
ParsedMessage: parsedMessage,
}, nil
}

View File

@ -8,5 +8,5 @@ var (
ErrNotSupported = errors.New("not supported")
ErrInvalidReturnPath = errors.New("invalid return path")
ErrInvalidRecipient = errors.New("invalid recipient")
ErrMissingAddressKey = errors.New("missing address key")
ErrMissingAddrKey = errors.New("missing address key")
)

View File

@ -2,43 +2,44 @@ package user
import (
"context"
"fmt"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/bradenaw/juniper/xslices"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
// handleAPIEvent handles the given liteapi.Event.
func (user *User) handleAPIEvent(event liteapi.Event) error {
func (user *User) handleAPIEvent(ctx context.Context, event liteapi.Event) error {
if event.User != nil {
if err := user.handleUserEvent(*event.User); err != nil {
if err := user.handleUserEvent(ctx, *event.User); err != nil {
return err
}
}
if len(event.Addresses) > 0 {
if err := user.handleAddressEvents(event.Addresses); err != nil {
if err := user.handleAddressEvents(ctx, event.Addresses); err != nil {
return err
}
}
if event.MailSettings != nil {
if err := user.handleMailSettingsEvent(*event.MailSettings); err != nil {
if err := user.handleMailSettingsEvent(ctx, *event.MailSettings); err != nil {
return err
}
}
if len(event.Labels) > 0 {
if err := user.handleLabelEvents(event.Labels); err != nil {
if err := user.handleLabelEvents(ctx, event.Labels); err != nil {
return err
}
}
if len(event.Messages) > 0 {
if err := user.handleMessageEvents(event.Messages); err != nil {
if err := user.handleMessageEvents(ctx, event.Messages); err != nil {
return err
}
}
@ -47,7 +48,7 @@ func (user *User) handleAPIEvent(event liteapi.Event) error {
}
// handleUserEvent handles the given user event.
func (user *User) handleUserEvent(userEvent liteapi.User) error {
func (user *User) handleUserEvent(ctx context.Context, userEvent liteapi.User) error {
userKR, err := userEvent.Keys.Unlock(user.vault.KeyPass(), nil)
if err != nil {
return err
@ -57,49 +58,31 @@ func (user *User) handleUserEvent(userEvent liteapi.User) error {
user.userKR = userKR
user.notifyCh <- events.UserChanged{
user.eventCh.Enqueue(events.UserChanged{
UserID: user.ID(),
}
})
return nil
}
// handleAddressEvents handles the given address events.
// TODO: If split address mode, need to signal back to bridge to update the addresses!
func (user *User) handleAddressEvents(addressEvents []liteapi.AddressEvent) error {
func (user *User) handleAddressEvents(ctx context.Context, addressEvents []liteapi.AddressEvent) error {
for _, event := range addressEvents {
switch event.Action {
case liteapi.EventDelete:
address, err := user.deleteAddress(event.ID)
if err != nil {
return err
}
// TODO: This is not the same as addressChangedLogout event!
// That was only relevant in split mode. This is used differently now.
user.notifyCh <- events.UserAddressDeleted{
UserID: user.ID(),
Address: address.Email,
}
case liteapi.EventCreate:
if err := user.createAddress(event.Address); err != nil {
return err
}
user.notifyCh <- events.UserAddressCreated{
UserID: user.ID(),
Address: event.Address.Email,
if err := user.handleCreateAddressEvent(ctx, event); err != nil {
return fmt.Errorf("failed to handle create address event: %w", err)
}
case liteapi.EventUpdate:
if err := user.updateAddress(event.Address); err != nil {
return err
if err := user.handleUpdateAddressEvent(ctx, event); err != nil {
return fmt.Errorf("failed to handle update address event: %w", err)
}
user.notifyCh <- events.UserAddressChanged{
UserID: user.ID(),
Address: event.Address.Email,
case liteapi.EventDelete:
if err := user.handleDeleteAddressEvent(ctx, event); err != nil {
return fmt.Errorf("failed to delete address: %w", err)
}
}
}
@ -107,111 +90,189 @@ func (user *User) handleAddressEvents(addressEvents []liteapi.AddressEvent) erro
return nil
}
// createAddress creates the given address.
func (user *User) createAddress(address liteapi.Address) error {
addrKR, err := address.Keys.Unlock(user.vault.KeyPass(), user.userKR)
func (user *User) handleCreateAddressEvent(ctx context.Context, event liteapi.AddressEvent) error {
addrKR, err := event.Address.Keys.Unlock(user.vault.KeyPass(), user.userKR)
if err != nil {
return err
return fmt.Errorf("failed to unlock address keys: %w", err)
}
if user.imapConn != nil {
user.imapConn.addAddress(address.Email)
user.apiAddrs.insert(event.Address)
user.addrKRs[event.Address.ID] = addrKR
if user.vault.AddressMode() == vault.SplitMode {
user.updateCh[event.Address.ID] = queue.NewQueuedChannel[imap.Update](0, 0)
if err := user.syncLabels(ctx, event.Address.ID); err != nil {
return fmt.Errorf("failed to sync labels to new address: %w", err)
}
}
user.addresses = append(user.addresses, address)
user.addrKRs[address.ID] = addrKR
user.eventCh.Enqueue(events.UserAddressCreated{
UserID: user.ID(),
AddressID: event.Address.ID,
Email: event.Address.Email,
})
return nil
}
// updateAddress updates the given address.
func (user *User) updateAddress(address liteapi.Address) error {
if _, err := user.deleteAddress(address.ID); err != nil {
return err
func (user *User) handleUpdateAddressEvent(ctx context.Context, event liteapi.AddressEvent) error {
addrKR, err := event.Address.Keys.Unlock(user.vault.KeyPass(), user.userKR)
if err != nil {
return fmt.Errorf("failed to unlock address keys: %w", err)
}
return user.createAddress(address)
}
user.apiAddrs.insert(event.Address)
// deleteAddress deletes the given address.
func (user *User) deleteAddress(addressID string) (liteapi.Address, error) {
idx := xslices.IndexFunc(user.addresses, func(address liteapi.Address) bool {
return address.ID == addressID
user.addrKRs[event.Address.ID] = addrKR
user.eventCh.Enqueue(events.UserAddressUpdated{
UserID: user.ID(),
AddressID: event.Address.ID,
Email: event.Address.Email,
})
if idx < 0 {
return liteapi.Address{}, ErrNoSuchAddress
return nil
}
func (user *User) handleDeleteAddressEvent(ctx context.Context, event liteapi.AddressEvent) error {
email := user.apiAddrs.delete(event.ID)
if user.vault.AddressMode() == vault.SplitMode {
user.updateCh[event.ID].Close()
delete(user.updateCh, event.ID)
}
if user.imapConn != nil {
user.imapConn.remAddress(user.addresses[idx].Email)
}
user.eventCh.Enqueue(events.UserAddressDeleted{
UserID: user.ID(),
AddressID: event.ID,
Email: email,
})
var address liteapi.Address
address, user.addresses = user.addresses[idx], append(user.addresses[:idx], user.addresses[idx+1:]...)
delete(user.addrKRs, addressID)
return address, nil
return nil
}
// handleMailSettingsEvent handles the given mail settings event.
func (user *User) handleMailSettingsEvent(mailSettingsEvent liteapi.MailSettings) error {
func (user *User) handleMailSettingsEvent(ctx context.Context, mailSettingsEvent liteapi.MailSettings) error {
user.settings = mailSettingsEvent
return nil
}
// handleLabelEvents handles the given label events.
func (user *User) handleLabelEvents(labelEvents []liteapi.LabelEvent) error {
func (user *User) handleLabelEvents(ctx context.Context, labelEvents []liteapi.LabelEvent) error {
for _, event := range labelEvents {
switch event.Action {
case liteapi.EventDelete:
user.updateCh <- imap.NewMailboxDeleted(imap.LabelID(event.ID))
case liteapi.EventCreate:
user.updateCh <- newMailboxCreatedUpdate(imap.LabelID(event.ID), getMailboxName(event.Label))
if err := user.handleCreateLabelEvent(ctx, event); err != nil {
return fmt.Errorf("failed to handle create label event: %w", err)
}
case liteapi.EventUpdate, liteapi.EventUpdateFlags:
user.updateCh <- imap.NewMailboxUpdated(imap.LabelID(event.ID), getMailboxName(event.Label))
if err := user.handleUpdateLabelEvent(ctx, event); err != nil {
return fmt.Errorf("failed to handle update label event: %w", err)
}
case liteapi.EventDelete:
if err := user.handleDeleteLabelEvent(ctx, event); err != nil {
return fmt.Errorf("failed to handle delete label event: %w", err)
}
}
}
return nil
}
func (user *User) handleCreateLabelEvent(ctx context.Context, event liteapi.LabelEvent) error {
for _, updateCh := range user.updateCh {
updateCh.Enqueue(newMailboxCreatedUpdate(imap.LabelID(event.ID), getMailboxName(event.Label)))
}
return nil
}
func (user *User) handleUpdateLabelEvent(ctx context.Context, event liteapi.LabelEvent) error {
for _, updateCh := range user.updateCh {
updateCh.Enqueue(imap.NewMailboxUpdated(imap.LabelID(event.ID), getMailboxName(event.Label)))
}
return nil
}
func (user *User) handleDeleteLabelEvent(ctx context.Context, event liteapi.LabelEvent) error {
for _, updateCh := range user.updateCh {
updateCh.Enqueue(imap.NewMailboxDeleted(imap.LabelID(event.ID)))
}
return nil
}
// handleMessageEvents handles the given message events.
func (user *User) handleMessageEvents(messageEvents []liteapi.MessageEvent) error {
ctx, cancel := context.WithCancel(context.Background())
func (user *User) handleMessageEvents(ctx context.Context, messageEvents []liteapi.MessageEvent) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for _, event := range messageEvents {
switch event.Action {
case liteapi.EventDelete:
return ErrNotImplemented
case liteapi.EventCreate:
messages, err := user.builder.ProcessAll(ctx, []request{{event.ID, user.addrKRs[event.Message.AddressID]}})
if err != nil {
return err
if err := user.handleCreateMessageEvent(ctx, event); err != nil {
return fmt.Errorf("failed to handle create message event: %w", err)
}
user.updateCh <- imap.NewMessagesCreated(maps.Values(messages)...)
case liteapi.EventUpdate, liteapi.EventUpdateFlags:
user.updateCh <- imap.NewMessageLabelsUpdated(
imap.MessageID(event.ID),
imapLabelIDs(filterLabelIDs(event.Message.LabelIDs)),
bool(!event.Message.Unread),
slices.Contains(event.Message.LabelIDs, liteapi.StarredLabel),
)
if err := user.handleUpdateMessageEvent(ctx, event); err != nil {
return fmt.Errorf("failed to handle update message event: %w", err)
}
case liteapi.EventDelete:
return ErrNotImplemented
}
}
return nil
}
func (user *User) handleCreateMessageEvent(ctx context.Context, event liteapi.MessageEvent) error {
var addressID string
if user.GetAddressMode() == vault.CombinedMode {
addressID = user.apiAddrs.primary()
} else {
addressID = event.Message.AddressID
}
message, err := user.builder.ProcessOne(ctx, request{
messageID: event.ID,
addressID: addressID,
addrKR: user.addrKRs[event.Message.AddressID],
})
if err != nil {
return err
}
user.updateCh[addressID].Enqueue(imap.NewMessagesCreated(message))
return nil
}
func (user *User) handleUpdateMessageEvent(ctx context.Context, event liteapi.MessageEvent) error {
update := imap.NewMessageLabelsUpdated(
imap.MessageID(event.ID),
mapTo[string, imap.LabelID](xslices.Filter(event.Message.LabelIDs, wantLabelID)),
event.Message.Seen(),
event.Message.Starred(),
)
if user.GetAddressMode() == vault.CombinedMode {
user.updateCh[user.apiAddrs.primary()].Enqueue(update)
} else {
user.updateCh[event.Message.AddressID].Enqueue(update)
}
return nil
}
func getMailboxName(label liteapi.Label) []string {
var name []string

76
internal/user/flusher.go Normal file
View File

@ -0,0 +1,76 @@
package user
import (
"sync"
"time"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
)
type flusher struct {
userID string
updateCh *queue.QueuedChannel[imap.Update]
eventCh *queue.QueuedChannel[events.Event]
updates []*imap.MessageCreated
maxChunkSize int
curChunkSize int
count int
total int
start time.Time
pushLock sync.Mutex
}
func newFlusher(
userID string,
updateCh *queue.QueuedChannel[imap.Update],
eventCh *queue.QueuedChannel[events.Event],
total, maxChunkSize int,
) *flusher {
return &flusher{
userID: userID,
updateCh: updateCh,
eventCh: eventCh,
maxChunkSize: maxChunkSize,
total: total,
start: time.Now(),
}
}
func (f *flusher) push(update *imap.MessageCreated) {
f.pushLock.Lock()
defer f.pushLock.Unlock()
f.updates = append(f.updates, update)
if f.curChunkSize += len(update.Literal); f.curChunkSize >= f.maxChunkSize {
f.flush()
}
}
func (f *flusher) flush() {
if len(f.updates) == 0 {
return
}
f.count += len(f.updates)
f.updateCh.Enqueue(imap.NewMessagesCreated(f.updates...))
f.eventCh.Enqueue(newSyncProgress(f.userID, f.count, f.total, f.start))
f.updates = nil
f.curChunkSize = 0
}
func newSyncProgress(userID string, count, total int, start time.Time) events.SyncProgress {
return events.SyncProgress{
UserID: userID,
Progress: float64(count) / float64(total),
Elapsed: time.Since(start),
Remaining: time.Since(start) * time.Duration(total-count) / time.Duration(count),
}
}

View File

@ -25,11 +25,12 @@ const (
)
type imapConnector struct {
addrID string
client *liteapi.Client
updateCh <-chan imap.Update
addresses []string
password string
emails []string
password string
flags, permFlags, attrs imap.FlagSet
}
@ -37,15 +38,15 @@ type imapConnector struct {
func newIMAPConnector(
client *liteapi.Client,
updateCh <-chan imap.Update,
addresses []string,
password string,
emails ...string,
) *imapConnector {
return &imapConnector{
client: client,
updateCh: updateCh,
addresses: addresses,
password: password,
emails: emails,
password: password,
flags: defaultFlags,
permFlags: defaultPermanentFlags,
@ -59,7 +60,7 @@ func (conn *imapConnector) Authorize(username string, password string) bool {
return false
}
return xslices.IndexFunc(conn.addresses, func(address string) bool {
return xslices.IndexFunc(conn.emails, func(address string) bool {
return strings.EqualFold(address, username)
}) >= 0
}
@ -187,7 +188,7 @@ func (conn *imapConnector) GetMessage(ctx context.Context, messageID imap.Messag
ID: imap.MessageID(message.ID),
Flags: flags,
Date: time.Unix(message.Time, 0),
}, imapLabelIDs(message.LabelIDs), nil
}, mapTo[string, imap.LabelID](message.LabelIDs), nil
}
// CreateMessage creates a new message on the remote.
@ -204,21 +205,21 @@ func (conn *imapConnector) CreateMessage(
// LabelMessages labels the given messages with the given label ID.
func (conn *imapConnector) LabelMessages(ctx context.Context, messageIDs []imap.MessageID, labelID imap.LabelID) error {
return conn.client.LabelMessages(ctx, strMessageIDs(messageIDs), string(labelID))
return conn.client.LabelMessages(ctx, mapTo[imap.MessageID, string](messageIDs), string(labelID))
}
// UnlabelMessages unlabels the given messages with the given label ID.
func (conn *imapConnector) UnlabelMessages(ctx context.Context, messageIDs []imap.MessageID, labelID imap.LabelID) error {
return conn.client.UnlabelMessages(ctx, strMessageIDs(messageIDs), string(labelID))
return conn.client.UnlabelMessages(ctx, mapTo[imap.MessageID, string](messageIDs), string(labelID))
}
// MoveMessages removes the given messages from one label and adds them to the other label.
func (conn *imapConnector) MoveMessages(ctx context.Context, messageIDs []imap.MessageID, labelFromID imap.LabelID, labelToID imap.LabelID) error {
if err := conn.client.LabelMessages(ctx, strMessageIDs(messageIDs), string(labelToID)); err != nil {
if err := conn.client.LabelMessages(ctx, mapTo[imap.MessageID, string](messageIDs), string(labelToID)); err != nil {
return fmt.Errorf("labeling messages: %w", err)
}
if err := conn.client.UnlabelMessages(ctx, strMessageIDs(messageIDs), string(labelFromID)); err != nil {
if err := conn.client.UnlabelMessages(ctx, mapTo[imap.MessageID, string](messageIDs), string(labelFromID)); err != nil {
return fmt.Errorf("unlabeling messages: %w", err)
}
@ -228,18 +229,18 @@ func (conn *imapConnector) MoveMessages(ctx context.Context, messageIDs []imap.M
// MarkMessagesSeen sets the seen value of the given messages.
func (conn *imapConnector) MarkMessagesSeen(ctx context.Context, messageIDs []imap.MessageID, seen bool) error {
if seen {
return conn.client.MarkMessagesRead(ctx, strMessageIDs(messageIDs)...)
return conn.client.MarkMessagesRead(ctx, mapTo[imap.MessageID, string](messageIDs)...)
} else {
return conn.client.MarkMessagesUnread(ctx, strMessageIDs(messageIDs)...)
return conn.client.MarkMessagesUnread(ctx, mapTo[imap.MessageID, string](messageIDs)...)
}
}
// MarkMessagesFlagged sets the flagged value of the given messages.
func (conn *imapConnector) MarkMessagesFlagged(ctx context.Context, messageIDs []imap.MessageID, flagged bool) error {
if flagged {
return conn.client.LabelMessages(ctx, strMessageIDs(messageIDs), liteapi.StarredLabel)
return conn.client.LabelMessages(ctx, mapTo[imap.MessageID, string](messageIDs), liteapi.StarredLabel)
} else {
return conn.client.UnlabelMessages(ctx, strMessageIDs(messageIDs), liteapi.StarredLabel)
return conn.client.UnlabelMessages(ctx, mapTo[imap.MessageID, string](messageIDs), liteapi.StarredLabel)
}
}
@ -249,45 +250,17 @@ func (conn *imapConnector) GetUpdates() <-chan imap.Update {
return conn.updateCh
}
// Close the connector when it will no longer be used and all resources should be closed/released.
func (conn *imapConnector) Close(ctx context.Context) error {
// GetUIDValidity returns the default UID validity for this user.
func (conn *imapConnector) GetUIDValidity() imap.UID {
return imap.UID(1)
}
// SetUIDValidity sets the default UID validity for this user.
func (conn *imapConnector) SetUIDValidity(uidValidity imap.UID) error {
return nil
}
func (conn *imapConnector) addAddress(address string) {
conn.addresses = append(conn.addresses, address)
}
func (conn *imapConnector) remAddress(address string) {
idx := slices.Index(conn.addresses, address)
if idx < 0 {
return
}
conn.addresses = append(conn.addresses[:idx], conn.addresses[idx+1:]...)
}
func strLabelIDs(imapLabelIDs []imap.LabelID) []string {
return xslices.Map(imapLabelIDs, func(labelID imap.LabelID) string {
return string(labelID)
})
}
func imapLabelIDs(labelIDs []string) []imap.LabelID {
return xslices.Map(labelIDs, func(labelID string) imap.LabelID {
return imap.LabelID(labelID)
})
}
func strMessageIDs(imapMessageIDs []imap.MessageID) []string {
return xslices.Map(imapMessageIDs, func(messageID imap.MessageID) string {
return string(messageID)
})
}
func imapMessageIDs(messageIDs []string) []imap.MessageID {
return xslices.Map(messageIDs, func(messageID string) imap.MessageID {
return imap.MessageID(messageID)
})
// Close the connector will no longer be used and all resources should be closed/released.
func (conn *imapConnector) Close(ctx context.Context) error {
return nil
}

89
internal/user/map.go Normal file
View File

@ -0,0 +1,89 @@
package user
import (
"github.com/bradenaw/juniper/xslices"
"golang.org/x/exp/slices"
)
type ordMap[Key comparable, Val, Data any] struct {
data map[Key]Data
order []Key
toKey func(Data) Key
toVal func(Data) Val
isLess func(Data, Data) bool
}
func newOrdMap[Key comparable, Val, Data any](
key func(Data) Key,
value func(Data) Val,
less func(Data, Data) bool,
data ...Data,
) ordMap[Key, Val, Data] {
m := ordMap[Key, Val, Data]{
data: make(map[Key]Data),
toKey: key,
toVal: value,
isLess: less,
}
for _, d := range data {
m.insert(d)
}
return m
}
func (set *ordMap[Key, Val, Data]) insert(data Data) {
if _, ok := set.data[set.toKey(data)]; ok {
set.delete(set.toKey(data))
}
set.data[set.toKey(data)] = data
set.order = append(set.order, set.toKey(data))
slices.SortFunc(set.order, func(a, b Key) bool {
return set.isLess(set.data[a], set.data[b])
})
}
func (set *ordMap[Key, Val, Data]) delete(key Key) Val {
data, ok := set.data[key]
if !ok {
return *new(Val)
}
delete(set.data, key)
set.order = xslices.Filter(set.order, func(otherKey Key) bool {
return otherKey != key
})
return set.toVal(data)
}
func (set *ordMap[Key, Val, Data]) get(key Key) Val {
return set.toVal(set.data[key])
}
func (set *ordMap[Key, Val, Data]) keys() []Key {
return set.order
}
func (set *ordMap[Key, Val, Data]) values() []Val {
return xslices.Map(set.order, func(key Key) Val {
return set.toVal(set.data[key])
})
}
func (set *ordMap[Key, Val, Data]) toMap() map[Key]Val {
m := make(map[Key]Val)
for _, key := range set.order {
m[key] = set.toVal(set.data[key])
}
return m
}

48
internal/user/map_test.go Normal file
View File

@ -0,0 +1,48 @@
package user
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMap(t *testing.T) {
type Key int
type Value string
type Data struct {
key Key
value Value
}
m := newOrdMap(
func(d Data) Key { return d.key },
func(d Data) Value { return d.value },
func(a, b Data) bool { return a.key < b.key },
Data{key: 1, value: "a"},
Data{key: 2, value: "b"},
Data{key: 3, value: "c"},
)
// Insert some new data.
m.insert(Data{key: 4, value: "d"})
m.insert(Data{key: 5, value: "e"})
// Delete some data.
require.Equal(t, Value("c"), m.delete(3))
require.Equal(t, Value("a"), m.delete(1))
require.Equal(t, Value("e"), m.delete(5))
// Check the remaining keys and values are correct.
require.Equal(t, []Key{2, 4}, m.keys())
require.Equal(t, []Value{"b", "d"}, m.values())
// Overwrite some data.
m.insert(Data{key: 2, value: "two"})
m.insert(Data{key: 4, value: "four"})
// Check the remaining keys and values are correct.
require.Equal(t, []Key{2, 4}, m.keys())
require.Equal(t, []Value{"two", "four"}, m.values())
}

View File

@ -20,12 +20,14 @@ import (
)
type smtpSession struct {
client *liteapi.Client
username string
addresses []liteapi.Address
userKR *crypto.KeyRing
addrKRs map[string]*crypto.KeyRing
settings liteapi.MailSettings
client *liteapi.Client
username string
emails map[string]string
settings liteapi.MailSettings
userKR *crypto.KeyRing
addrKRs map[string]*crypto.KeyRing
from string
to map[string]struct{}
@ -34,18 +36,20 @@ type smtpSession struct {
func newSMTPSession(
client *liteapi.Client,
username string,
addresses []liteapi.Address,
addresses map[string]string,
settings liteapi.MailSettings,
userKR *crypto.KeyRing,
addrKRs map[string]*crypto.KeyRing,
settings liteapi.MailSettings,
) *smtpSession {
return &smtpSession{
client: client,
username: username,
addresses: addresses,
userKR: userKR,
addrKRs: addrKRs,
settings: settings,
client: client,
username: username,
emails: addresses,
settings: settings,
userKR: userKR,
addrKRs: addrKRs,
from: "",
to: make(map[string]struct{}),
@ -86,15 +90,15 @@ func (session *smtpSession) Mail(from string, opts smtp.MailOptions) error {
return ErrNotImplemented
}
idx := xslices.IndexFunc(session.addresses, func(address liteapi.Address) bool {
return strings.EqualFold(address.Email, from)
})
if idx < 0 {
return ErrInvalidReturnPath
for addrID, email := range session.emails {
if strings.EqualFold(from, email) {
session.from = addrID
}
}
session.from = session.addresses[idx].ID
if session.from == "" {
return ErrInvalidReturnPath
}
return nil
}
@ -129,10 +133,10 @@ func (session *smtpSession) Data(r io.Reader) error {
addrKR, ok := session.addrKRs[session.from]
if !ok {
return ErrMissingAddressKey
return ErrMissingAddrKey
}
addrKR, err := addrKR.FirstKey()
addrKey, err := addrKR.FirstKey()
if err != nil {
return fmt.Errorf("failed to get first key: %w", err)
}
@ -143,7 +147,7 @@ func (session *smtpSession) Data(r io.Reader) error {
}
if session.settings.AttachPublicKey == liteapi.AttachPublicKeyEnabled {
key, err := addrKR.GetKey(0)
key, err := addrKey.GetKey(0)
if err != nil {
return fmt.Errorf("failed to get user public key: %w", err)
}
@ -153,7 +157,7 @@ func (session *smtpSession) Data(r io.Reader) error {
return 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]))
parser.AttachPublicKey(pubKey, fmt.Sprintf("publickey - %v - %v", addrKey.GetIdentities()[0].Name, key.GetFingerprint()[:8]))
}
message, err := message.ParseWithParser(parser)
@ -161,7 +165,7 @@ func (session *smtpSession) Data(r io.Reader) error {
return fmt.Errorf("failed to parse message: %w", err)
}
draft, attKeys, err := session.createDraft(ctx, addrKR, message)
draft, attKeys, err := session.createDraft(ctx, addrKey, message)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
@ -171,7 +175,7 @@ func (session *smtpSession) Data(r io.Reader) error {
return fmt.Errorf("failed to get recipients: %w", err)
}
req, err := createSendReq(addrKR, message.MIMEBody, message.RichBody, message.PlainBody, recipients, attKeys)
req, err := createSendReq(addrKey, message.MIMEBody, message.RichBody, message.PlainBody, recipients, attKeys)
if err != nil {
return fmt.Errorf("failed to create packages: %w", err)
}

View File

@ -4,57 +4,34 @@ import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/bradenaw/juniper/xslices"
"github.com/google/uuid"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/slices"
)
const chunkSize = 1 << 20
func (user *User) sync(ctx context.Context) error {
user.notifyCh <- events.SyncStarted{
UserID: user.ID(),
}
if err := user.syncLabels(ctx); err != nil {
return fmt.Errorf("failed to sync labels: %w", err)
}
if err := user.syncMessages(ctx); err != nil {
return fmt.Errorf("failed to sync messages: %w", err)
}
user.notifyCh <- events.SyncFinished{
UserID: user.ID(),
}
if err := user.vault.SetSync(true); err != nil {
return fmt.Errorf("failed to update sync status: %w", err)
}
return nil
}
func (user *User) syncLabels(ctx context.Context) error {
func (user *User) syncLabels(ctx context.Context, addrIDs ...string) error {
// Sync the system folders.
system, err := user.client.GetLabels(ctx, liteapi.LabelTypeSystem)
if err != nil {
return err
}
for _, label := range system {
user.updateCh <- newSystemMailboxCreatedUpdate(imap.LabelID(label.ID), label.Name)
for _, label := range xslices.Filter(system, func(label liteapi.Label) bool { return wantLabelID(label.ID) }) {
for _, addrID := range addrIDs {
user.updateCh[addrID].Enqueue(newSystemMailboxCreatedUpdate(imap.LabelID(label.ID), label.Name))
}
}
// Create Folders/Labels mailboxes with a random ID and with the \Noselect attribute.
for _, prefix := range []string{folderPrefix, labelPrefix} {
user.updateCh <- newPlaceHolderMailboxCreatedUpdate(prefix)
for _, addrID := range addrIDs {
user.updateCh[addrID].Enqueue(newPlaceHolderMailboxCreatedUpdate(prefix))
}
}
// Sync the API folders.
@ -64,7 +41,9 @@ func (user *User) syncLabels(ctx context.Context) error {
}
for _, folder := range folders {
user.updateCh <- newMailboxCreatedUpdate(imap.LabelID(folder.ID), []string{folderPrefix, folder.Path})
for _, addrID := range addrIDs {
user.updateCh[addrID].Enqueue(newMailboxCreatedUpdate(imap.LabelID(folder.ID), []string{folderPrefix, folder.Path}))
}
}
// Sync the API labels.
@ -74,7 +53,9 @@ func (user *User) syncLabels(ctx context.Context) error {
}
for _, label := range labels {
user.updateCh <- newMailboxCreatedUpdate(imap.LabelID(label.ID), []string{labelPrefix, label.Path})
for _, addrID := range addrIDs {
user.updateCh[addrID].Enqueue(newMailboxCreatedUpdate(imap.LabelID(label.ID), []string{labelPrefix, label.Path}))
}
}
return nil
@ -84,27 +65,53 @@ func (user *User) syncMessages(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Determine which messages to sync.
// TODO: This needs to be done better using the new API route to retrieve just the message IDs.
metadata, err := user.client.GetAllMessageMetadata(ctx)
if err != nil {
return err
}
// If in split mode, we need to send each message to a different IMAP connector.
isSplitMode := user.vault.AddressMode() == vault.SplitMode
// Collect the build requests -- we need:
// - the message ID to build,
// - the keyring to decrypt the message,
// - and the address to send the message to (for split mode).
requests := xslices.Map(metadata, func(metadata liteapi.MessageMetadata) request {
var addressID string
if isSplitMode {
addressID = metadata.AddressID
} else {
addressID = user.apiAddrs.primary()
}
return request{
messageID: metadata.ID,
addressID: addressID,
addrKR: user.addrKRs[metadata.AddressID],
}
})
flusher := newFlusher(user.ID(), user.updateCh, user.notifyCh, len(metadata), chunkSize)
defer flusher.flush()
// Create the flushers, one per update channel.
flushers := make(map[string]*flusher)
for addrID, updateCh := range user.updateCh {
flusher := newFlusher(user.ID(), updateCh, user.eventCh, len(requests), chunkSize)
defer flusher.flush()
flushers[addrID] = flusher
}
// Build the messages and send them to the correct flusher.
if err := user.builder.Process(ctx, requests, func(req request, res *imap.MessageCreated, err error) error {
if err != nil {
return fmt.Errorf("failed to build message %s: %w", req.messageID, err)
}
flusher.push(res)
flushers[req.addressID].push(res)
return nil
}); err != nil {
@ -114,95 +121,15 @@ func (user *User) syncMessages(ctx context.Context) error {
return nil
}
type flusher struct {
userID string
func (user *User) syncWait() {
for _, updateCh := range user.updateCh {
waiter := imap.NewNoop()
defer waiter.Wait()
updates []*imap.MessageCreated
updateCh chan<- imap.Update
notifyCh chan<- events.Event
maxChunkSize int
curChunkSize int
count int
total int
start time.Time
pushLock sync.Mutex
}
func newFlusher(userID string, updateCh chan<- imap.Update, notifyCh chan<- events.Event, total, maxChunkSize int) *flusher {
return &flusher{
userID: userID,
updateCh: updateCh,
notifyCh: notifyCh,
maxChunkSize: maxChunkSize,
total: total,
start: time.Now(),
updateCh.Enqueue(waiter)
}
}
func (f *flusher) push(update *imap.MessageCreated) {
f.pushLock.Lock()
defer f.pushLock.Unlock()
f.updates = append(f.updates, update)
if f.curChunkSize += len(update.Literal); f.curChunkSize >= f.maxChunkSize {
f.flush()
}
}
func (f *flusher) flush() {
if len(f.updates) == 0 {
return
}
f.count += len(f.updates)
f.updateCh <- imap.NewMessagesCreated(f.updates...)
f.notifyCh <- newSyncProgress(f.userID, f.count, f.total, f.start)
f.updates = nil
f.curChunkSize = 0
}
func newSyncProgress(userID string, count, total int, start time.Time) events.SyncProgress {
return events.SyncProgress{
UserID: userID,
Progress: float64(count) / float64(total),
Elapsed: time.Since(start),
Remaining: time.Since(start) * time.Duration(total-count) / time.Duration(count),
}
}
func getMessageCreatedUpdate(message liteapi.Message, literal []byte) (*imap.MessageCreated, error) {
parsedMessage, err := imap.NewParsedMessage(literal)
if err != nil {
return nil, err
}
flags := imap.NewFlagSet()
if !message.Unread {
flags = flags.Add(imap.FlagSeen)
}
if slices.Contains(message.LabelIDs, liteapi.StarredLabel) {
flags = flags.Add(imap.FlagFlagged)
}
imapMessage := imap.Message{
ID: imap.MessageID(message.ID),
Flags: flags,
Date: time.Unix(message.Time, 0),
}
return &imap.MessageCreated{
Message: imapMessage,
Literal: literal,
LabelIDs: imapLabelIDs(filterLabelIDs(message.LabelIDs)),
ParsedMessage: parsedMessage,
}, nil
}
func newSystemMailboxCreatedUpdate(labelID imap.LabelID, labelName string) *imap.MailboxCreated {
if strings.EqualFold(labelName, imap.Inbox) {
labelName = imap.Inbox
@ -237,18 +164,12 @@ func newMailboxCreatedUpdate(labelID imap.LabelID, labelName []string) *imap.Mai
})
}
func filterLabelIDs(labelIDs []string) []string {
var filteredLabelIDs []string
func wantLabelID(labelID string) bool {
switch labelID {
case liteapi.AllDraftsLabel, liteapi.AllSentLabel, liteapi.OutboxLabel:
return false
for _, labelID := range labelIDs {
switch labelID {
case liteapi.AllDraftsLabel, liteapi.AllSentLabel, liteapi.OutboxLabel:
// ... skip ...
default:
filteredLabelIDs = append(filteredLabelIDs, labelID)
}
default:
return true
}
return filteredLabelIDs
}

13
internal/user/types.go Normal file
View File

@ -0,0 +1,13 @@
package user
import "reflect"
func mapTo[From, To any](from []From) []To {
to := make([]To, 0, len(from))
for _, from := range from {
to = append(to, reflect.ValueOf(from).Convert(reflect.TypeOf(to).Elem()).Interface().(To))
}
return to
}

View File

@ -0,0 +1,20 @@
package user
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestToType(t *testing.T) {
type myString string
// Slices of different types are not equal.
require.NotEqual(t, []myString{"a", "b", "c"}, []string{"a", "b", "c"})
// But converting them to the same type makes them equal.
require.Equal(t, []myString{"a", "b", "c"}, mapTo[string, myString]([]string{"a", "b", "c"}))
// The conversion can happen in the other direction too.
require.Equal(t, []string{"a", "b", "c"}, mapTo[myString, string]([]myString{"a", "b", "c"}))
}

View File

@ -2,19 +2,22 @@ package user
import (
"context"
"fmt"
"runtime"
"time"
"github.com/ProtonMail/gluon"
"github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/queue"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/pool"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/bradenaw/juniper/xslices"
"github.com/emersion/go-smtp"
"github.com/sirupsen/logrus"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
@ -23,40 +26,38 @@ var (
DefaultEventJitter = 20 * time.Second
)
// TODO: Is it bad to store the key pass in the user? Any worse than storing private keys?
type User struct {
vault *vault.User
client *liteapi.Client
builder *pool.Pool[request, *imap.MessageCreated]
eventCh *queue.QueuedChannel[events.Event]
apiUser liteapi.User
addresses []liteapi.Address
settings liteapi.MailSettings
notifyCh chan events.Event
updateCh chan imap.Update
apiUser liteapi.User
apiAddrs *addrList
userKR *crypto.KeyRing
addrKRs map[string]*crypto.KeyRing
imapConn *imapConnector
settings liteapi.MailSettings
updateCh map[string]*queue.QueuedChannel[imap.Update]
syncWG gluon.WaitGroup
}
func New(
ctx context.Context,
vault *vault.User,
encVault *vault.User,
client *liteapi.Client,
apiUser liteapi.User,
apiAddrs []liteapi.Address,
userKR *crypto.KeyRing,
addrKRs map[string]*crypto.KeyRing,
) (*User, error) {
if vault.EventID() == "" {
if encVault.EventID() == "" {
eventID, err := client.GetLatestEventID(ctx)
if err != nil {
return nil, err
}
if err := vault.SetEventID(eventID); err != nil {
if err := encVault.SetEventID(eventID); err != nil {
return nil, err
}
}
@ -67,19 +68,29 @@ func New(
}
user := &User{
apiUser: apiUser,
addresses: apiAddrs,
settings: settings,
vault: vault,
vault: encVault,
client: client,
builder: newBuilder(client, runtime.NumCPU()*runtime.NumCPU(), runtime.NumCPU()*runtime.NumCPU()),
eventCh: queue.NewQueuedChannel[events.Event](0, 0),
notifyCh: make(chan events.Event),
updateCh: make(chan imap.Update),
apiUser: apiUser,
apiAddrs: newAddrList(apiAddrs),
userKR: userKR,
addrKRs: addrKRs,
userKR: userKR,
addrKRs: addrKRs,
settings: settings,
updateCh: make(map[string]*queue.QueuedChannel[imap.Update]),
}
// Initialize update channels for each of the user's addresses.
for _, addrID := range user.apiAddrs.addrIDs() {
user.updateCh[addrID] = queue.NewQueuedChannel[imap.Update](0, 0)
// If in combined mode, we only need one update channel.
if encVault.AddressMode() == vault.CombinedMode {
break
}
}
// When we receive an auth object, we update it in the store.
@ -93,111 +104,234 @@ func New(
// When we are deauthorized, we send a deauth event to the notify channel.
// Bridge will catch this and log the user out.
client.AddDeauthHandler(func() {
user.notifyCh <- events.UserDeauth{
user.eventCh.Enqueue(events.UserDeauth{
UserID: user.ID(),
}
})
})
// When we receive an API event, we attempt to handle it. If successful, we send the event to the event channel.
// When we receive an API event, we attempt to handle it.
// If successful, we update the event ID in the vault.
go func() {
for event := range user.client.NewEventStreamer(DefaultEventPeriod, DefaultEventJitter, vault.EventID()).Subscribe() {
if err := user.handleAPIEvent(event); err != nil {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for event := range user.client.NewEventStreamer(DefaultEventPeriod, DefaultEventJitter, encVault.EventID()).Subscribe() {
if err := user.handleAPIEvent(ctx, event); err != nil {
logrus.WithError(err).Error("Failed to handle event")
} else {
if err := user.vault.SetEventID(event.EventID); err != nil {
logrus.WithError(err).Error("Failed to update event ID")
}
} else if err := user.vault.SetEventID(event.EventID); err != nil {
logrus.WithError(err).Error("Failed to update event ID")
}
}
}()
// TODO: Use a proper sync manager! (if partial sync, pickup from where we last stopped)
if !vault.HasSync() {
go user.sync(context.Background())
}
return user, nil
}
// ID returns the user's ID.
func (user *User) ID() string {
return user.apiUser.ID
}
// Name returns the user's username.
func (user *User) Name() string {
return user.apiUser.Name
}
// Match matches the given query against the user's username and email addresses.
func (user *User) Match(query string) bool {
if query == user.Name() {
if query == user.apiUser.Name {
return true
}
return slices.Contains(user.Addresses(), query)
return slices.Contains(user.apiAddrs.emails(), query)
}
func (user *User) Addresses() []string {
return xslices.Map(
sort(user.addresses, func(a, b liteapi.Address) bool {
return a.Order < b.Order
}),
func(address liteapi.Address) string {
return address.Email
},
)
// Emails returns all the user's email addresses.
func (user *User) Emails() []string {
return user.apiAddrs.emails()
}
func (user *User) GluonID() string {
return user.vault.GluonID()
// GetAddressMode returns the user's current address mode.
func (user *User) GetAddressMode() vault.AddressMode {
return user.vault.AddressMode()
}
// SetAddressMode sets the user's address mode.
func (user *User) SetAddressMode(ctx context.Context, mode vault.AddressMode) error {
for _, updateCh := range user.updateCh {
updateCh.Close()
}
user.updateCh = make(map[string]*queue.QueuedChannel[imap.Update])
for _, addrID := range user.apiAddrs.addrIDs() {
user.updateCh[addrID] = queue.NewQueuedChannel[imap.Update](0, 0)
if mode == vault.CombinedMode {
break
}
}
if err := user.vault.SetAddressMode(mode); err != nil {
return fmt.Errorf("failed to set address mode: %w", err)
}
return nil
}
// GetGluonIDs returns the users gluon IDs.
func (user *User) GetGluonIDs() map[string]string {
return user.vault.GetGluonIDs()
}
// GetGluonID returns the gluon ID for the given address, if present.
func (user *User) GetGluonID(addrID string) (string, bool) {
gluonID, ok := user.vault.GetGluonIDs()[addrID]
if !ok {
return "", false
}
return gluonID, true
}
// SetGluonID sets the gluon ID for the given address.
func (user *User) SetGluonID(addrID, gluonID string) error {
return user.vault.SetGluonID(addrID, gluonID)
}
// GluonKey returns the user's gluon key from the vault.
func (user *User) GluonKey() []byte {
return user.vault.GluonKey()
}
// BridgePass returns the user's bridge password, used for authentication over SMTP and IMAP.
func (user *User) BridgePass() string {
return user.vault.BridgePass()
}
// UsedSpace returns the total space used by the user on the API.
func (user *User) UsedSpace() int {
return user.apiUser.UsedSpace
}
// MaxSpace returns the amount of space the user can use on the API.
func (user *User) MaxSpace() int {
return user.apiUser.MaxSpace
}
// GetNotifyCh returns a channel which notifies of events happening to the user (such as deauth, address change)
func (user *User) GetNotifyCh() <-chan events.Event {
return user.notifyCh
// HasSync returns whether the user has finished syncing.
func (user *User) HasSync() bool {
return user.vault.HasSync()
}
func (user *User) NewGluonConnector(ctx context.Context) (connector.Connector, error) {
if user.imapConn != nil {
if err := user.imapConn.Close(ctx); err != nil {
return nil, err
// AbortSync aborts any ongoing sync.
// TODO: This should abort the sync rather than just waiting.
// Should probably be done automatically when one of the user's IMAP connectors is closed.
func (user *User) AbortSync(ctx context.Context) error {
user.syncWG.Wait()
return nil
}
// DoSync performs a sync for the user.
func (user *User) DoSync(ctx context.Context) <-chan error {
errCh := queue.NewQueuedChannel[error](0, 0)
user.syncWG.Go(func() {
defer errCh.Close()
user.eventCh.Enqueue(events.SyncStarted{
UserID: user.ID(),
})
errCh.Enqueue(func() error {
if err := user.syncLabels(ctx, maps.Keys(user.updateCh)...); err != nil {
return fmt.Errorf("failed to sync labels: %w", err)
}
if err := user.syncMessages(ctx); err != nil {
return fmt.Errorf("failed to sync messages: %w", err)
}
user.syncWait()
if err := user.vault.SetSync(true); err != nil {
return fmt.Errorf("failed to set sync status: %w", err)
}
return nil
}())
user.eventCh.Enqueue(events.SyncFinished{
UserID: user.ID(),
})
})
return errCh.GetChannel()
}
// GetEventCh returns a channel which notifies of events happening to the user (such as deauth, address change)
func (user *User) GetEventCh() <-chan events.Event {
return user.eventCh.GetChannel()
}
// NewIMAPConnector returns an IMAP connector for the given address.
// If not in split mode, this function returns an error.
func (user *User) NewIMAPConnector(addrID string) (connector.Connector, error) {
var emails []string
switch user.vault.AddressMode() {
case vault.CombinedMode:
if addrID != user.apiAddrs.primary() {
return nil, fmt.Errorf("cannot create IMAP connector for non-primary address in combined mode")
}
emails = user.apiAddrs.emails()
case vault.SplitMode:
emails = []string{user.apiAddrs.email(addrID)}
}
user.imapConn = newIMAPConnector(user.client, user.updateCh, user.Addresses(), user.vault.BridgePass())
return user.imapConn, nil
return newIMAPConnector(
user.client,
user.updateCh[addrID].GetChannel(),
user.vault.BridgePass(),
emails...,
), nil
}
func (user *User) NewSMTPSession(username string) (smtp.Session, error) {
return newSMTPSession(user.client, username, user.addresses, user.userKR, user.addrKRs, user.settings), nil
// NewIMAPConnectors returns IMAP connectors for each of the user's addresses.
// In combined mode, this is just the user's primary address.
// In split mode, this is all the user's addresses.
func (user *User) NewIMAPConnectors() (map[string]connector.Connector, error) {
imapConn := make(map[string]connector.Connector)
for addrID := range user.updateCh {
conn, err := user.NewIMAPConnector(addrID)
if err != nil {
return nil, fmt.Errorf("failed to create IMAP connector: %w", err)
}
imapConn[addrID] = conn
}
return imapConn, nil
}
// NewSMTPSession returns an SMTP session for the user.
func (user *User) NewSMTPSession(username string) smtp.Session {
return newSMTPSession(user.client, username, user.apiAddrs.addrMap(), user.settings, user.userKR, user.addrKRs)
}
// Logout logs the user out from the API.
func (user *User) Logout(ctx context.Context) error {
return user.client.AuthDelete(ctx)
}
// Close closes ongoing connections and cleans up resources.
func (user *User) Close(ctx context.Context) error {
// Close the user's IMAP connectors.
if user.imapConn != nil {
if err := user.imapConn.Close(ctx); err != nil {
return err
}
}
// Wait for ongoing syncs to finish.
user.syncWG.Wait()
// Close the user's message builder.
user.builder.Done()
@ -205,15 +339,13 @@ func (user *User) Close(ctx context.Context) error {
// Close the user's API client.
user.client.Close()
// Close the user's update channels.
for _, updateCh := range user.updateCh {
updateCh.Close()
}
// Close the user's notify channel.
close(user.notifyCh)
user.eventCh.Close()
return nil
}
// sort returns the slice, sorted by the given callback.
func sort[T any](slice []T, less func(a, b T) bool) []T {
slices.SortFunc(slice, less)
return slice
}

162
internal/user/user_test.go Normal file
View File

@ -0,0 +1,162 @@
package user_test
import (
"context"
"testing"
"time"
"github.com/ProtonMail/proton-bridge/v2/internal/certs"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/user"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/ProtonMail/proton-bridge/v2/tests"
"github.com/bradenaw/juniper/iterator"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"github.com/stretchr/testify/require"
"gitlab.protontech.ch/go/liteapi"
"gitlab.protontech.ch/go/liteapi/server"
"gitlab.protontech.ch/go/liteapi/server/account"
)
func init() {
user.DefaultEventPeriod = 100 * time.Millisecond
user.DefaultEventJitter = 0
account.GenerateKey = tests.FastGenerateKey
certs.GenerateCert = tests.FastGenerateCert
}
func TestUser_Data(t *testing.T) {
withAPI(t, context.Background(), "username", "password", []string{"email@pm.me", "alias@pm.me"}, func(ctx context.Context, s *server.Server, userID string, addrIDs []string) {
withUser(t, ctx, s.GetHostURL(), "username", "password", func(user *user.User) {
// User's ID should be correct.
require.Equal(t, userID, user.ID())
// User's name should be correct.
require.Equal(t, "username", user.Name())
// User's email should be correct.
require.ElementsMatch(t, []string{"email@pm.me", "alias@pm.me"}, user.Emails())
// By default, user should be in combined mode.
require.Equal(t, vault.CombinedMode, user.GetAddressMode())
// By default, user should have a non-empty bridge password.
require.NotEmpty(t, user.BridgePass())
})
})
}
func TestUser_Sync(t *testing.T) {
withAPI(t, context.Background(), "username", "password", []string{"email@pm.me"}, func(ctx context.Context, s *server.Server, userID string, addrIDs []string) {
withUser(t, ctx, s.GetHostURL(), "username", "password", func(user *user.User) {
// Get the user's IMAP connectors.
imapConn, err := user.NewIMAPConnectors()
require.NoError(t, err)
// Pretend to be gluon applying all the updates.
go func() {
for _, imapConn := range imapConn {
for update := range imapConn.GetUpdates() {
update.Done()
}
}
}()
// Trigger a user sync.
errCh := user.DoSync(ctx)
// User starts a sync at startup.
require.IsType(t, events.SyncStarted{}, <-user.GetEventCh())
// User finishes a sync at startup.
require.IsType(t, events.SyncFinished{}, <-user.GetEventCh())
// The sync completes without error.
require.NoError(t, <-errCh)
})
})
}
func TestUser_Deauth(t *testing.T) {
withAPI(t, context.Background(), "username", "password", []string{"email@pm.me"}, func(ctx context.Context, s *server.Server, userID string, addrIDs []string) {
withUser(t, ctx, s.GetHostURL(), "username", "password", func(user *user.User) {
eventCh := user.GetEventCh()
// Revoke the user's auth token.
require.NoError(t, s.RevokeUser(userID))
// The user should eventually be logged out.
require.Eventually(t, func() bool { _, ok := (<-eventCh).(events.UserDeauth); return ok }, 5*time.Second, 100*time.Millisecond)
})
})
}
func withAPI(t *testing.T, ctx context.Context, username, password string, emails []string, fn func(context.Context, *server.Server, string, []string)) {
server := server.New()
defer server.Close()
var addrIDs []string
userID, addrID, err := server.AddUser(username, password, emails[0])
require.NoError(t, err)
addrIDs = append(addrIDs, addrID)
for _, email := range emails[1:] {
addrID, err := server.AddAddress(userID, email, password)
require.NoError(t, err)
addrIDs = append(addrIDs, addrID)
}
fn(ctx, server, userID, addrIDs)
}
func withUser(t *testing.T, ctx context.Context, apiURL, username, password string, fn func(*user.User)) {
c, apiAuth, err := liteapi.New(liteapi.WithHostURL(apiURL)).NewClientWithLogin(ctx, username, password)
require.NoError(t, err)
defer func() { require.NoError(t, c.Close()) }()
apiUser, apiAddrs, userKR, addrKRs, passphrase, err := c.Unlock(ctx, []byte(password))
require.NoError(t, err)
vault, corrupt, err := vault.New(t.TempDir(), t.TempDir(), []byte("my secret key"))
require.NoError(t, err)
require.False(t, corrupt)
vaultUser, err := vault.AddUser(apiUser.ID, username, apiAuth.UID, apiAuth.RefreshToken, passphrase)
require.NoError(t, err)
user, err := user.New(ctx, vaultUser, c, apiUser, apiAddrs, userKR, addrKRs)
require.NoError(t, err)
defer func() { require.NoError(t, user.Close(ctx)) }()
fn(user)
}
func withIMAPClient(t *testing.T, addr string, fn func(*client.Client)) {
c, err := client.Dial(addr)
require.NoError(t, err)
defer c.Close()
fn(c)
}
func fetch(t *testing.T, c *client.Client, seqset string, items ...imap.FetchItem) []*imap.Message {
msgCh := make(chan *imap.Message)
go func() {
require.NoError(t, c.Fetch(must(imap.ParseSeqSet(seqset)), items, msgCh))
}()
return iterator.Collect(iterator.Chan(msgCh))
}
func must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}

View File

@ -5,9 +5,14 @@ import (
)
// RandomToken is a function that returns a random token.
var RandomToken func(size int) ([]byte, error)
// By default, we use crypto.RandomToken to generate tokens.
func init() {
RandomToken = crypto.RandomToken
var RandomToken = crypto.RandomToken
func newRandomToken(size int) []byte {
token, err := RandomToken(size)
if err != nil {
panic(err)
}
return token
}

View File

@ -4,6 +4,7 @@ import (
"math/rand"
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/proton-bridge/v2/internal/updater"
)
@ -45,15 +46,24 @@ type Settings struct {
FirstStartGUI bool
}
type AddressMode int
const (
CombinedMode AddressMode = iota
SplitMode
)
// UserData holds information about a single bridge user.
// The user may or may not be logged in.
type UserData struct {
UserID string
Username string
GluonID string
GluonKey []byte
BridgePass string
GluonKey []byte
GluonIDs map[string]string
UIDValidity map[string]imap.UID
BridgePass []byte
AddressMode AddressMode
AuthUID string
AuthRef string

View File

@ -1,5 +1,11 @@
package vault
import (
"encoding/hex"
"github.com/ProtonMail/gluon/imap"
)
type User struct {
vault *Vault
userID string
@ -13,16 +19,41 @@ func (user *User) Username() string {
return user.vault.getUser(user.userID).Username
}
func (user *User) GluonID() string {
return user.vault.getUser(user.userID).GluonID
func (user *User) GetGluonIDs() map[string]string {
return user.vault.getUser(user.userID).GluonIDs
}
func (user *User) SetGluonID(addrID, gluonID string) error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.GluonIDs[addrID] = gluonID
})
}
func (user *User) GetUIDValidity(addrID string) (imap.UID, bool) {
validity, ok := user.vault.getUser(user.userID).UIDValidity[addrID]
if !ok {
return imap.UID(0), false
}
return validity, true
}
func (user *User) SetUIDValidity(addrID string, validity imap.UID) error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.UIDValidity[addrID] = validity
})
}
func (user *User) GluonKey() []byte {
return user.vault.getUser(user.userID).GluonKey
}
func (user *User) AddressMode() AddressMode {
return user.vault.getUser(user.userID).AddressMode
}
func (user *User) BridgePass() string {
return user.vault.getUser(user.userID).BridgePass
return hex.EncodeToString(user.vault.getUser(user.userID).BridgePass)
}
func (user *User) AuthUID() string {
@ -51,7 +82,7 @@ func (user *User) SetKeyPass(keyPass []byte) error {
})
}
// SetAuth updates the auth secrets for the given user.
// SetAuth sets the auth secrets for the given user.
func (user *User) SetAuth(authUID, authRef string) error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.AuthUID = authUID
@ -59,33 +90,23 @@ func (user *User) SetAuth(authUID, authRef string) error {
})
}
// SetGluonAuth updates the gluon ID and key for the given user.
func (user *User) SetGluonAuth(gluonID string, gluonKey []byte) error {
// SetAddressMode sets the address mode for the given user.
func (user *User) SetAddressMode(mode AddressMode) error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.GluonID = gluonID
data.GluonKey = gluonKey
data.AddressMode = mode
})
}
// SetEventID updates the event ID for the given user.
// SetEventID sets the event ID for the given user.
func (user *User) SetEventID(eventID string) error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.EventID = eventID
})
}
// SetSync updates the sync state for the given user.
// SetSync sets the sync state for the given user.
func (user *User) SetSync(hasSync bool) error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.HasSync = hasSync
})
}
// Clear clears the secrets for the given user.
func (user *User) Clear() error {
return user.vault.modUser(user.userID, func(data *UserData) {
data.AuthUID = ""
data.AuthRef = ""
data.KeyPass = nil
})
}

View File

@ -4,6 +4,7 @@ import (
"encoding/hex"
"testing"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
"github.com/stretchr/testify/require"
)
@ -32,30 +33,48 @@ func TestUser(t *testing.T) {
require.NoError(t, user2.SetSync(false))
// Set gluon data for user 1 and 2.
require.NoError(t, user1.SetGluonAuth("gluonID1", []byte("gluonKey1")))
require.NoError(t, user2.SetGluonAuth("gluonID2", []byte("gluonKey2")))
require.NoError(t, user1.SetGluonID("addrID1", "gluonID1"))
require.NoError(t, user2.SetGluonID("addrID2", "gluonID2"))
require.NoError(t, user1.SetUIDValidity("addrID1", imap.UID(1)))
require.NoError(t, user2.SetUIDValidity("addrID2", imap.UID(2)))
// List available users.
require.ElementsMatch(t, []string{"userID1", "userID2"}, s.GetUserIDs())
// Check gluon information for user 1.
gluonID1, ok := user1.GetGluonIDs()["addrID1"]
require.True(t, ok)
require.Equal(t, "gluonID1", gluonID1)
uidValidity1, ok := user1.GetUIDValidity("addrID1")
require.True(t, ok)
require.Equal(t, imap.UID(1), uidValidity1)
require.NotEmpty(t, user1.GluonKey())
// Get auth information for user 1.
require.Equal(t, "userID1", user1.UserID())
require.Equal(t, "user1", user1.Username())
require.Equal(t, "gluonID1", user1.GluonID())
require.Equal(t, []byte("gluonKey1"), user1.GluonKey())
require.Equal(t, hex.EncodeToString([]byte("token")), user1.BridgePass())
require.Equal(t, vault.CombinedMode, user1.AddressMode())
require.Equal(t, "authUID1", user1.AuthUID())
require.Equal(t, "authRef1", user1.AuthRef())
require.Equal(t, []byte("keyPass1"), user1.KeyPass())
require.Equal(t, "eventID1", user1.EventID())
require.Equal(t, true, user1.HasSync())
// Check gluon information for user 1.
gluonID2, ok := user2.GetGluonIDs()["addrID2"]
require.True(t, ok)
require.Equal(t, "gluonID2", gluonID2)
uidValidity2, ok := user2.GetUIDValidity("addrID2")
require.True(t, ok)
require.Equal(t, imap.UID(2), uidValidity2)
require.NotEmpty(t, user2.GluonKey())
// Get auth information for user 2.
require.Equal(t, "userID2", user2.UserID())
require.Equal(t, "user2", user2.Username())
require.Equal(t, "gluonID2", user2.GluonID())
require.Equal(t, []byte("gluonKey2"), user2.GluonKey())
require.Equal(t, hex.EncodeToString([]byte("token")), user2.BridgePass())
require.Equal(t, vault.CombinedMode, user2.AddressMode())
require.Equal(t, "authUID2", user2.AuthUID())
require.Equal(t, "authRef2", user2.AuthRef())
require.Equal(t, []byte("keyPass2"), user2.KeyPass())
@ -63,8 +82,8 @@ func TestUser(t *testing.T) {
require.Equal(t, false, user2.HasSync())
// Clear the users.
require.NoError(t, user1.Clear())
require.NoError(t, user2.Clear())
require.NoError(t, s.ClearUser("userID1"))
require.NoError(t, s.ClearUser("userID2"))
// Their secrets should now be cleared.
require.Equal(t, "", user1.AuthUID())

View File

@ -4,7 +4,6 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io/fs"
@ -12,6 +11,7 @@ import (
"os"
"path/filepath"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/proton-bridge/v2/internal/certs"
"github.com/bradenaw/juniper/xslices"
)
@ -99,16 +99,16 @@ func (vault *Vault) AddUser(userID, username, authUID, authRef string, keyPass [
return nil, errors.New("user already exists")
}
tok, err := RandomToken(16)
if err != nil {
return nil, err
}
if err := vault.mod(func(data *Data) {
data.Users = append(data.Users, UserData{
UserID: userID,
Username: username,
BridgePass: hex.EncodeToString(tok),
UserID: userID,
Username: username,
GluonKey: newRandomToken(32),
GluonIDs: make(map[string]string),
UIDValidity: make(map[string]imap.UID),
BridgePass: newRandomToken(16),
AddressMode: CombinedMode,
AuthUID: authUID,
AuthRef: authRef,
@ -121,6 +121,14 @@ func (vault *Vault) AddUser(userID, username, authUID, authRef string, keyPass [
return vault.GetUser(userID)
}
func (vault *Vault) ClearUser(userID string) error {
return vault.modUser(userID, func(data *UserData) {
data.AuthUID = ""
data.AuthRef = ""
data.KeyPass = nil
})
}
// DeleteUser removes the given user from the vault.
func (vault *Vault) DeleteUser(userID string) error {
return vault.mod(func(data *Data) {

View File

@ -13,7 +13,9 @@ type API interface {
GetHostURL() string
AddCallWatcher(func(server.Call), ...string)
AddUser(username, password, address string) (userID, addrID string, err error)
AddUser(username, password, address string) (string, string, error)
AddAddress(userID, address, password string) (string, error)
RemoveAddress(userID, addrID string) error
RevokeUser(userID string) error
GetLabels(userID string) ([]liteapi.Label, error)

View File

@ -30,8 +30,8 @@ import (
)
func init() {
user.DefaultEventPeriod = time.Second
user.DefaultEventJitter = time.Second
user.DefaultEventPeriod = 100 * time.Millisecond
user.DefaultEventJitter = 0
}
type scenario struct {
@ -76,6 +76,16 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^the user agent is "([^"]*)"$`, s.theUserAgentIs)
ctx.Step(`^the value of the "([^"]*)" header in the request to "([^"]*)" is "([^"]*)"$`, s.theValueOfTheHeaderInTheRequestToIs)
// ==== SETUP ====
ctx.Step(`^there exists an account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPassword)
ctx.Step(`^the account "([^"]*)" has additional address "([^"]*)"$`, s.theAccountHasAdditionalAddress)
ctx.Step(`^the account "([^"]*)" no longer has additional address "([^"]*)"$`, s.theAccountNoLongerHasAdditionalAddress)
ctx.Step(`^the account "([^"]*)" has (\d+) custom folders$`, s.theAccountHasCustomFolders)
ctx.Step(`^the account "([^"]*)" has (\d+) custom labels$`, s.theAccountHasCustomLabels)
ctx.Step(`^the account "([^"]*)" has the following custom mailboxes:$`, s.theAccountHasTheFollowingCustomMailboxes)
ctx.Step(`^the address "([^"]*)" of account "([^"]*)" has the following messages in "([^"]*)":$`, s.theAddressOfAccountHasTheFollowingMessagesInMailbox)
ctx.Step(`^the address "([^"]*)" of account "([^"]*)" has (\d+) messages in "([^"]*)"$`, s.theAddressOfAccountHasMessagesInMailbox)
// ==== BRIDGE ====
ctx.Step(`^bridge starts$`, s.bridgeStarts)
ctx.Step(`^bridge restarts$`, s.bridgeRestarts)
@ -85,12 +95,15 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^the user has disabled automatic updates$`, s.theUserHasDisabledAutomaticUpdates)
ctx.Step(`^the user changes the IMAP port to (\d+)$`, s.theUserChangesTheIMAPPortTo)
ctx.Step(`^the user changes the SMTP port to (\d+)$`, s.theUserChangesTheSMTPPortTo)
ctx.Step(`^the user sets the address mode of "([^"]*)" to "([^"]*)"$`, s.theUserSetsTheAddressModeOfTo)
ctx.Step(`^the user changes the gluon path$`, s.theUserChangesTheGluonPath)
ctx.Step(`^the user deletes the gluon files$`, s.theUserDeletesTheGluonFiles)
ctx.Step(`^the user reports a bug$`, s.theUserReportsABug)
ctx.Step(`^bridge sends a connection up event$`, s.bridgeSendsAConnectionUpEvent)
ctx.Step(`^bridge sends a connection down event$`, s.bridgeSendsAConnectionDownEvent)
ctx.Step(`^bridge sends a deauth event for user "([^"]*)"$`, s.bridgeSendsADeauthEventForUser)
ctx.Step(`^bridge sends an address created event for user "([^"]*)"$`, s.bridgeSendsAnAddressCreatedEventForUser)
ctx.Step(`^bridge sends an address deleted event for user "([^"]*)"$`, s.bridgeSendsAnAddressDeletedEventForUser)
ctx.Step(`^bridge sends sync started and finished events for user "([^"]*)"$`, s.bridgeSendsSyncStartedAndFinishedEventsForUser)
ctx.Step(`^bridge sends an update available event for version "([^"]*)"$`, s.bridgeSendsAnUpdateAvailableEventForVersion)
ctx.Step(`^bridge sends a manual update event for version "([^"]*)"$`, s.bridgeSendsAManualUpdateEventForVersion)
@ -99,12 +112,6 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^bridge sends a forced update event$`, s.bridgeSendsAForcedUpdateEvent)
// ==== USER ====
ctx.Step(`^there exists an account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPassword)
ctx.Step(`^the account "([^"]*)" has (\d+) custom folders$`, s.theAccountHasCustomFolders)
ctx.Step(`^the account "([^"]*)" has (\d+) custom labels$`, s.theAccountHasCustomLabels)
ctx.Step(`^the account "([^"]*)" has the following custom mailboxes:$`, s.theAccountHasTheFollowingCustomMailboxes)
ctx.Step(`^the account "([^"]*)" has the following messages in "([^"]*)":$`, s.theAccountHasTheFollowingMessagesInMailbox)
ctx.Step(`^the account "([^"]*)" has (\d+) messages in "([^"]*)"$`, s.theAccountHasMessagesInMailbox)
ctx.Step(`^the user logs in with username "([^"]*)" and password "([^"]*)"$`, s.userLogsInWithUsernameAndPassword)
ctx.Step(`^user "([^"]*)" logs out$`, s.userLogsOut)
ctx.Step(`^user "([^"]*)" is deleted$`, s.userIsDeleted)
@ -119,8 +126,10 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^user "([^"]*)" connects IMAP client "([^"]*)"$`, s.userConnectsIMAPClient)
ctx.Step(`^user "([^"]*)" connects IMAP client "([^"]*)" on port (\d+)$`, s.userConnectsIMAPClientOnPort)
ctx.Step(`^user "([^"]*)" connects and authenticates IMAP client "([^"]*)"$`, s.userConnectsAndAuthenticatesIMAPClient)
ctx.Step(`^user "([^"]*)" connects and authenticates IMAP client "([^"]*)" with address "([^"]*)"$`, s.userConnectsAndAuthenticatesIMAPClientWithAddress)
ctx.Step(`^IMAP client "([^"]*)" can authenticate$`, s.imapClientCanAuthenticate)
ctx.Step(`^IMAP client "([^"]*)" cannot authenticate$`, s.imapClientCannotAuthenticate)
ctx.Step(`^IMAP client "([^"]*)" cannot authenticate with address "([^"]*)"$`, s.imapClientCannotAuthenticateWithAddress)
ctx.Step(`^IMAP client "([^"]*)" cannot authenticate with incorrect username$`, s.imapClientCannotAuthenticateWithIncorrectUsername)
ctx.Step(`^IMAP client "([^"]*)" cannot authenticate with incorrect password$`, s.imapClientCannotAuthenticateWithIncorrectPassword)
ctx.Step(`^IMAP client "([^"]*)" announces its ID with name "([^"]*)" and version "([^"]*)"$`, s.imapClientAnnouncesItsIDWithNameAndVersion)
@ -151,6 +160,7 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^user "([^"]*)" connects SMTP client "([^"]*)"$`, s.userConnectsSMTPClient)
ctx.Step(`^user "([^"]*)" connects SMTP client "([^"]*)" on port (\d+)$`, s.userConnectsSMTPClientOnPort)
ctx.Step(`^user "([^"]*)" connects and authenticates SMTP client "([^"]*)"$`, s.userConnectsAndAuthenticatesSMTPClient)
ctx.Step(`^user "([^"]*)" connects and authenticates SMTP client "([^"]*)" with address "([^"]*)"$`, s.userConnectsAndAuthenticatesSMTPClientWithAddress)
ctx.Step(`^SMTP client "([^"]*)" can authenticate$`, s.smtpClientCanAuthenticate)
ctx.Step(`^SMTP client "([^"]*)" cannot authenticate$`, s.smtpClientCannotAuthenticate)
ctx.Step(`^SMTP client "([^"]*)" cannot authenticate with incorrect username$`, s.smtpClientCannotAuthenticateWithIncorrectUsername)

View File

@ -9,6 +9,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"github.com/ProtonMail/proton-bridge/v2/internal/vault"
)
func (s *scenario) bridgeStarts() error {
@ -46,6 +47,19 @@ func (s *scenario) theUserChangesTheSMTPPortTo(port int) error {
return s.t.bridge.SetSMTPPort(port)
}
func (s *scenario) theUserSetsTheAddressModeOfTo(user, mode string) error {
switch mode {
case "split":
return s.t.bridge.SetAddressMode(context.Background(), s.t.getUserID(user), vault.SplitMode)
case "combined":
return s.t.bridge.SetAddressMode(context.Background(), s.t.getUserID(user), vault.CombinedMode)
default:
return fmt.Errorf("unknown address mode %q", mode)
}
}
func (s *scenario) theUserChangesTheGluonPath() error {
gluonDir, err := os.MkdirTemp(s.t.dir, "gluon")
if err != nil {
@ -113,7 +127,7 @@ func (s *scenario) bridgeSendsAConnectionDownEvent() error {
}
func (s *scenario) bridgeSendsADeauthEventForUser(username string) error {
return try(s.t.userDeauthCh, 5*time.Second, func(event events.UserDeauth) error {
return try(s.t.deauthCh, 5*time.Second, func(event events.UserDeauth) error {
if wantUserID := s.t.getUserID(username); wantUserID != event.UserID {
return fmt.Errorf("expected deauth event for user with ID %s, got %s", wantUserID, event.UserID)
}
@ -122,6 +136,26 @@ func (s *scenario) bridgeSendsADeauthEventForUser(username string) error {
})
}
func (s *scenario) bridgeSendsAnAddressCreatedEventForUser(username string) error {
return try(s.t.addrCreatedCh, 5*time.Second, func(event events.UserAddressCreated) error {
if wantUserID := s.t.getUserID(username); wantUserID != event.UserID {
return fmt.Errorf("expected user address created event for user with ID %s, got %s", wantUserID, event.UserID)
}
return nil
})
}
func (s *scenario) bridgeSendsAnAddressDeletedEventForUser(username string) error {
return try(s.t.addrDeletedCh, 5*time.Second, func(event events.UserAddressDeleted) error {
if wantUserID := s.t.getUserID(username); wantUserID != event.UserID {
return fmt.Errorf("expected user address deleted event for user with ID %s, got %s", wantUserID, event.UserID)
}
return nil
})
}
func (s *scenario) bridgeSendsSyncStartedAndFinishedEventsForUser(username string) error {
if err := get(s.t.syncStartedCh, func(event events.SyncStarted) error {
if wantUserID := s.t.getUserID(username); wantUserID != event.UserID {

View File

@ -54,10 +54,12 @@ func (t *testCtx) startBridge() error {
t.bridge = bridge
// Connect the event channels.
t.userLoginCh = chToType[events.Event, events.UserLoggedIn](bridge.GetEvents(events.UserLoggedIn{}))
t.userLogoutCh = chToType[events.Event, events.UserLoggedOut](bridge.GetEvents(events.UserLoggedOut{}))
t.userDeletedCh = chToType[events.Event, events.UserDeleted](bridge.GetEvents(events.UserDeleted{}))
t.userDeauthCh = chToType[events.Event, events.UserDeauth](bridge.GetEvents(events.UserDeauth{}))
t.loginCh = chToType[events.Event, events.UserLoggedIn](bridge.GetEvents(events.UserLoggedIn{}))
t.logoutCh = chToType[events.Event, events.UserLoggedOut](bridge.GetEvents(events.UserLoggedOut{}))
t.deletedCh = chToType[events.Event, events.UserDeleted](bridge.GetEvents(events.UserDeleted{}))
t.deauthCh = chToType[events.Event, events.UserDeauth](bridge.GetEvents(events.UserDeauth{}))
t.addrCreatedCh = chToType[events.Event, events.UserAddressCreated](bridge.GetEvents(events.UserAddressCreated{}))
t.addrDeletedCh = chToType[events.Event, events.UserAddressDeleted](bridge.GetEvents(events.UserAddressDeleted{}))
t.syncStartedCh = chToType[events.Event, events.SyncStarted](bridge.GetEvents(events.SyncStarted{}))
t.syncFinishedCh = chToType[events.Event, events.SyncFinished](bridge.GetEvents(events.SyncFinished{}))
t.forcedUpdateCh = chToType[events.Event, events.UpdateForced](bridge.GetEvents(events.UpdateForced{}))

View File

@ -14,6 +14,7 @@ import (
"github.com/emersion/go-imap/client"
"gitlab.protontech.ch/go/liteapi"
"gitlab.protontech.ch/go/liteapi/server"
"golang.org/x/exp/maps"
)
var defaultVersion = semver.MustParse("1.0.0")
@ -32,10 +33,12 @@ type testCtx struct {
bridge *bridge.Bridge
// These channels hold events of various types coming from bridge.
userLoginCh <-chan events.UserLoggedIn
userLogoutCh <-chan events.UserLoggedOut
userDeletedCh <-chan events.UserDeleted
userDeauthCh <-chan events.UserDeauth
loginCh <-chan events.UserLoggedIn
logoutCh <-chan events.UserLoggedOut
deletedCh <-chan events.UserDeleted
deauthCh <-chan events.UserDeauth
addrCreatedCh <-chan events.UserAddressCreated
addrDeletedCh <-chan events.UserAddressDeleted
syncStartedCh <-chan events.SyncStarted
syncFinishedCh <-chan events.SyncFinished
forcedUpdateCh <-chan events.UpdateForced
@ -43,10 +46,10 @@ type testCtx struct {
updateCh <-chan events.Event
// These maps hold expected userIDByName, their primary addresses and bridge passwords.
userIDByName map[string]string
userAddrByID map[string]string
userPassByID map[string]string
addrIDByID map[string]string
userIDByName map[string]string
userAddrByEmail map[string]map[string]string
userPassByID map[string]string
userBridgePassByID map[string]string
// These are the IMAP and SMTP clients used to connect to bridge.
imapClients map[string]*imapClient
@ -83,10 +86,10 @@ func newTestCtx(tb testing.TB) *testCtx {
mocks: bridge.NewMocks(tb, dialer, defaultVersion, defaultVersion),
version: defaultVersion,
userIDByName: make(map[string]string),
userAddrByID: make(map[string]string),
userPassByID: make(map[string]string),
addrIDByID: make(map[string]string),
userIDByName: make(map[string]string),
userAddrByEmail: make(map[string]map[string]string),
userPassByID: make(map[string]string),
userBridgePassByID: make(map[string]string),
imapClients: make(map[string]*imapClient),
smtpClients: make(map[string]*smtpClient),
@ -112,12 +115,28 @@ func (t *testCtx) setUserID(username, userID string) {
t.userIDByName[username] = userID
}
func (t *testCtx) getUserAddr(userID string) string {
return t.userAddrByID[userID]
func (t *testCtx) getUserAddrID(userID, email string) string {
return t.userAddrByEmail[userID][email]
}
func (t *testCtx) setUserAddr(userID, addr string) {
t.userAddrByID[userID] = addr
func (t *testCtx) getUserAddrs(userID string) []string {
return maps.Keys(t.userAddrByEmail[userID])
}
func (t *testCtx) setUserAddr(userID, addrID, email string) {
if _, ok := t.userAddrByEmail[userID]; !ok {
t.userAddrByEmail[userID] = make(map[string]string)
}
t.userAddrByEmail[userID][email] = addrID
}
func (t *testCtx) unsetUserAddr(userID, wantAddrID string) {
for email, addrID := range t.userAddrByEmail[userID] {
if addrID == wantAddrID {
delete(t.userAddrByEmail[userID], email)
}
}
}
func (t *testCtx) getUserPass(userID string) string {
@ -128,12 +147,12 @@ func (t *testCtx) setUserPass(userID, pass string) {
t.userPassByID[userID] = pass
}
func (t *testCtx) getAddrID(userID string) string {
return t.addrIDByID[userID]
func (t *testCtx) getUserBridgePass(userID string) string {
return t.userBridgePassByID[userID]
}
func (t *testCtx) setAddrID(userID, addrID string) {
t.addrIDByID[userID] = addrID
func (t *testCtx) setUserBridgePass(userID, pass string) {
t.userBridgePassByID[userID] = pass
}
func (t *testCtx) getMBoxID(userID string, name string) string {

48
tests/fast.go Normal file
View File

@ -0,0 +1,48 @@
package tests
import (
"crypto/x509"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/internal/certs"
)
var (
preCompPGPKey *crypto.Key
preCompCertPEM []byte
preCompKeyPEM []byte
)
func FastGenerateKey(name, email string, passphrase []byte, keyType string, bits int) (string, error) {
encKey, err := preCompPGPKey.Lock(passphrase)
if err != nil {
return "", err
}
return encKey.Armor()
}
func FastGenerateCert(template *x509.Certificate) ([]byte, []byte, error) {
return preCompCertPEM, preCompKeyPEM, nil
}
func init() {
key, err := crypto.GenerateKey("name", "email", "rsa", 1024)
if err != nil {
panic(err)
}
template, err := certs.NewTLSTemplate()
if err != nil {
panic(err)
}
certPEM, keyPEM, err := certs.GenerateCert(template)
if err != nil {
panic(err)
}
preCompPGPKey = key
preCompCertPEM = certPEM
preCompKeyPEM = keyPEM
}

View File

@ -4,7 +4,7 @@ Feature: IMAP get mailbox info
And the account "user@pm.me" has the following custom mailboxes:
| name | type |
| one | folder |
And the account "user@pm.me" has the following messages in "one":
And the address "user@pm.me" of account "user@pm.me" has the following messages in "one":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |

View File

@ -5,7 +5,7 @@ Feature: IMAP copy messages
| name | type |
| mbox | folder |
| label | label |
And the account "user@pm.me" has the following messages in "Inbox":
And the address "user@pm.me" of account "user@pm.me" has the following messages in "Inbox":
| sender | recipient | subject | unread |
| john.doe@mail.com | user@pm.me | foo | false |
| jane.doe@mail.com | name@pm.me | bar | true |

View File

@ -5,7 +5,7 @@ Feature: IMAP remove messages from mailbox
| name | type |
| mbox | folder |
| label | label |
And the account "user@pm.me" has 10 messages in "mbox"
And the address "user@pm.me" of account "user@pm.me" has 10 messages in "mbox"
And bridge starts
And the user logs in with username "user@pm.me" and password "password"
And user "user@pm.me" finishes syncing

View File

@ -0,0 +1,180 @@
Feature: Address mode
Background:
Given there exists an account with username "user@pm.me" and password "password"
And the account "user@pm.me" has additional address "alias@pm.me"
And the account "user@pm.me" has the following custom mailboxes:
| name | type |
| one | folder |
| two | folder |
And the address "user@pm.me" of account "user@pm.me" has the following messages in "one":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
And the address "alias@pm.me" of account "user@pm.me" has the following messages in "two":
| sender | recipient | subject | unread |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
And bridge starts
And the user logs in with username "user@pm.me" and password "password"
And user "user@pm.me" finishes syncing
Scenario: The user is in combined mode
When user "user@pm.me" connects and authenticates IMAP client "1" with address "user@pm.me"
Then IMAP client "1" sees the following messages in "Folders/one":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
And IMAP client "1" sees the following messages in "Folders/two":
| sender | recipient | subject | unread |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
And IMAP client "1" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
When user "user@pm.me" connects and authenticates IMAP client "2" with address "alias@pm.me"
Then IMAP client "2" sees the following messages in "Folders/one":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
And IMAP client "2" sees the following messages in "Folders/two":
| sender | recipient | subject | unread |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
And IMAP client "2" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
Scenario: The user is in split mode
Given the user sets the address mode of "user@pm.me" to "split"
And user "user@pm.me" finishes syncing
When user "user@pm.me" connects and authenticates IMAP client "1" with address "user@pm.me"
Then IMAP client "1" sees the following messages in "Folders/one":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
And IMAP client "1" sees 0 messages in "Folders/two"
And IMAP client "1" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
When user "user@pm.me" connects and authenticates IMAP client "2" with address "alias@pm.me"
Then IMAP client "2" sees 0 messages in "Folders/one"
And IMAP client "2" sees the following messages in "Folders/two":
| sender | recipient | subject | unread |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
And IMAP client "2" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
Scenario: The user switches from combined to split mode and back
Given the user sets the address mode of "user@pm.me" to "split"
And user "user@pm.me" finishes syncing
And the user sets the address mode of "user@pm.me" to "combined"
And user "user@pm.me" finishes syncing
When user "user@pm.me" connects and authenticates IMAP client "1" with address "user@pm.me"
Then IMAP client "1" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
When user "user@pm.me" connects and authenticates IMAP client "2" with address "alias@pm.me"
Then IMAP client "2" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
Scenario: The user adds an address while in combined mode
When user "user@pm.me" connects and authenticates IMAP client "1" with address "user@pm.me"
Then IMAP client "1" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
When user "user@pm.me" connects and authenticates IMAP client "2" with address "alias@pm.me"
Then IMAP client "2" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
Given the account "user@pm.me" has additional address "other@pm.me"
And bridge sends an address created event for user "user@pm.me"
When user "user@pm.me" connects and authenticates IMAP client "3" with address "other@pm.me"
Then IMAP client "3" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
Scenario: The user adds an address while in split mode
Given the user sets the address mode of "user@pm.me" to "split"
And user "user@pm.me" finishes syncing
When user "user@pm.me" connects and authenticates IMAP client "1" with address "user@pm.me"
And IMAP client "1" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
When user "user@pm.me" connects and authenticates IMAP client "2" with address "alias@pm.me"
And IMAP client "2" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
Given the account "user@pm.me" has additional address "other@pm.me"
And bridge sends an address created event for user "user@pm.me"
When user "user@pm.me" connects and authenticates IMAP client "3" with address "other@pm.me"
Then IMAP client "3" eventually sees 0 messages in "All Mail"
Scenario: The user deletes an address while in combined mode
When user "user@pm.me" connects and authenticates IMAP client "1" with address "user@pm.me"
Then IMAP client "1" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
When user "user@pm.me" connects and authenticates IMAP client "2" with address "alias@pm.me"
Then IMAP client "2" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
Given the account "user@pm.me" no longer has additional address "alias@pm.me"
And bridge sends an address deleted event for user "user@pm.me"
When user "user@pm.me" connects IMAP client "3"
Then IMAP client "3" cannot authenticate with address "alias@pm.me"
Scenario: The user deletes an address while in split mode
Given the user sets the address mode of "user@pm.me" to "split"
And user "user@pm.me" finishes syncing
When user "user@pm.me" connects and authenticates IMAP client "1" with address "user@pm.me"
And IMAP client "1" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
When user "user@pm.me" connects and authenticates IMAP client "2" with address "alias@pm.me"
And IMAP client "2" sees the following messages in "All Mail":
| sender | recipient | subject | unread |
| c@pm.me | c@pm.me | three | true |
| d@pm.me | d@pm.me | four | false |
Given the account "user@pm.me" no longer has additional address "alias@pm.me"
And bridge sends an address deleted event for user "user@pm.me"
When user "user@pm.me" connects IMAP client "3"
Then IMAP client "3" cannot authenticate with address "alias@pm.me"
Scenario: The user makes an alias the primary address while in combined mode
Scenario: The user makes an alias the primary address while in split mode

View File

@ -6,11 +6,11 @@ Feature: Bridge can fully sync an account
| one | folder |
| two | folder |
| three | label |
And the account "user@pm.me" has the following messages in "one":
And the address "user@pm.me" of account "user@pm.me" has the following messages in "one":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |
And the account "user@pm.me" has the following messages in "two":
And the address "user@pm.me" of account "user@pm.me" has the following messages in "two":
| sender | recipient | subject | unread |
| a@pm.me | a@pm.me | one | true |
| b@pm.me | b@pm.me | two | false |

View File

@ -26,25 +26,39 @@ func (s *scenario) userConnectsIMAPClientOnPort(username, clientID string, port
}
func (s *scenario) userConnectsAndAuthenticatesIMAPClient(username, clientID string) error {
return s.userConnectsAndAuthenticatesIMAPClientWithAddress(username, clientID, s.t.getUserAddrs(s.t.getUserID(username))[0])
}
func (s *scenario) userConnectsAndAuthenticatesIMAPClientWithAddress(username, clientID, address string) error {
if err := s.t.newIMAPClient(s.t.getUserID(username), clientID); err != nil {
return err
}
userID, client := s.t.getIMAPClient(clientID)
return client.Login(s.t.getUserAddr(userID), s.t.getUserPass(userID))
return client.Login(address, s.t.getUserBridgePass(userID))
}
func (s *scenario) imapClientCanAuthenticate(clientID string) error {
userID, client := s.t.getIMAPClient(clientID)
return client.Login(s.t.getUserAddr(userID), s.t.getUserPass(userID))
return client.Login(s.t.getUserAddrs(userID)[0], s.t.getUserBridgePass(userID))
}
func (s *scenario) imapClientCannotAuthenticate(clientID string) error {
userID, client := s.t.getIMAPClient(clientID)
if err := client.Login(s.t.getUserAddr(userID), s.t.getUserPass(userID)); err == nil {
if err := client.Login(s.t.getUserAddrs(userID)[0], s.t.getUserBridgePass(userID)); err == nil {
return fmt.Errorf("expected error, got nil")
}
return nil
}
func (s *scenario) imapClientCannotAuthenticateWithAddress(clientID, address string) error {
userID, client := s.t.getIMAPClient(clientID)
if err := client.Login(address, s.t.getUserBridgePass(userID)); err == nil {
return fmt.Errorf("expected error, got nil")
}
@ -54,7 +68,7 @@ func (s *scenario) imapClientCannotAuthenticate(clientID string) error {
func (s *scenario) imapClientCannotAuthenticateWithIncorrectUsername(clientID string) error {
userID, client := s.t.getIMAPClient(clientID)
if err := client.Login(s.t.getUserAddr(userID)+"bad", s.t.getUserPass(userID)); err == nil {
if err := client.Login(s.t.getUserAddrs(userID)[0]+"bad", s.t.getUserBridgePass(userID)); err == nil {
return fmt.Errorf("expected error, got nil")
}
@ -64,7 +78,7 @@ func (s *scenario) imapClientCannotAuthenticateWithIncorrectUsername(clientID st
func (s *scenario) imapClientCannotAuthenticateWithIncorrectPassword(clientID string) error {
userID, client := s.t.getIMAPClient(clientID)
if err := client.Login(s.t.getUserAddr(userID), s.t.getUserPass(userID)+"bad"); err == nil {
if err := client.Login(s.t.getUserAddrs(userID)[0], s.t.getUserBridgePass(userID)+"bad"); err == nil {
return fmt.Errorf("expected error, got nil")
}

View File

@ -1,39 +1,14 @@
package tests
import (
"crypto/x509"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/internal/certs"
"gitlab.protontech.ch/go/liteapi/server/account"
)
func init() {
key, err := crypto.GenerateKey("name", "email", "rsa", 1024)
if err != nil {
panic(err)
}
// Use the fast key generation for tests.
account.GenerateKey = FastGenerateKey
account.GenerateKey = func(name, email string, passphrase []byte, keyType string, bits int) (string, error) {
encKey, err := key.Lock(passphrase)
if err != nil {
return "", err
}
return encKey.Armor()
}
template, err := certs.NewTLSTemplate()
if err != nil {
panic(err)
}
certPEM, keyPEM, err := certs.GenerateCert(template)
if err != nil {
panic(err)
}
certs.GenerateCert = func(template *x509.Certificate) ([]byte, []byte, error) {
return certPEM, keyPEM, nil
}
// Use the fast cert generation for tests.
certs.GenerateCert = FastGenerateCert
}

View File

@ -16,13 +16,17 @@ func (s *scenario) userConnectsSMTPClientOnPort(username, clientID string, port
}
func (s *scenario) userConnectsAndAuthenticatesSMTPClient(username, clientID string) error {
return s.userConnectsAndAuthenticatesSMTPClientWithAddress(username, clientID, s.t.getUserAddrs(s.t.getUserID(username))[0])
}
func (s *scenario) userConnectsAndAuthenticatesSMTPClientWithAddress(username, clientID, address string) error {
if err := s.t.newSMTPClient(s.t.getUserID(username), clientID); err != nil {
return err
}
userID, client := s.t.getSMTPClient(clientID)
s.t.pushError(client.Auth(smtp.PlainAuth("", s.t.getUserAddr(userID), s.t.getUserPass(userID), constants.Host)))
s.t.pushError(client.Auth(smtp.PlainAuth("", address, s.t.getUserBridgePass(userID), constants.Host)))
return nil
}
@ -30,7 +34,7 @@ func (s *scenario) userConnectsAndAuthenticatesSMTPClient(username, clientID str
func (s *scenario) smtpClientCanAuthenticate(clientID string) error {
userID, client := s.t.getSMTPClient(clientID)
if err := client.Auth(smtp.PlainAuth("", s.t.getUserAddr(userID), s.t.getUserPass(userID), constants.Host)); err != nil {
if err := client.Auth(smtp.PlainAuth("", s.t.getUserAddrs(userID)[0], s.t.getUserBridgePass(userID), constants.Host)); err != nil {
return fmt.Errorf("expected no error, got %v", err)
}
@ -40,7 +44,7 @@ func (s *scenario) smtpClientCanAuthenticate(clientID string) error {
func (s *scenario) smtpClientCannotAuthenticate(clientID string) error {
userID, client := s.t.getSMTPClient(clientID)
if err := client.Auth(smtp.PlainAuth("", s.t.getUserAddr(userID), s.t.getUserPass(userID), constants.Host)); err == nil {
if err := client.Auth(smtp.PlainAuth("", s.t.getUserAddrs(userID)[0], s.t.getUserBridgePass(userID), constants.Host)); err == nil {
return fmt.Errorf("expected error, got nil")
}
@ -50,7 +54,7 @@ func (s *scenario) smtpClientCannotAuthenticate(clientID string) error {
func (s *scenario) smtpClientCannotAuthenticateWithIncorrectUsername(clientID string) error {
userID, client := s.t.getSMTPClient(clientID)
if err := client.Auth(smtp.PlainAuth("", s.t.getUserAddr(userID)+"bad", s.t.getUserPass(userID), constants.Host)); err == nil {
if err := client.Auth(smtp.PlainAuth("", s.t.getUserAddrs(userID)[0]+"bad", s.t.getUserBridgePass(userID), constants.Host)); err == nil {
return fmt.Errorf("expected error, got nil")
}
@ -60,7 +64,7 @@ func (s *scenario) smtpClientCannotAuthenticateWithIncorrectUsername(clientID st
func (s *scenario) smtpClientCannotAuthenticateWithIncorrectPassword(clientID string) error {
userID, client := s.t.getSMTPClient(clientID)
if err := client.Auth(smtp.PlainAuth("", s.t.getUserAddr(userID), s.t.getUserPass(userID)+"bad", constants.Host)); err == nil {
if err := client.Auth(smtp.PlainAuth("", s.t.getUserAddrs(userID)[0], s.t.getUserBridgePass(userID)+"bad", constants.Host)); err == nil {
return fmt.Errorf("expected error, got nil")
}

View File

@ -16,6 +16,7 @@ import (
)
func (s *scenario) thereExistsAnAccountWithUsernameAndPassword(username, password string) error {
// Create the user.
userID, addrID, err := s.t.api.AddUser(username, password, username)
if err != nil {
return err
@ -24,11 +25,37 @@ func (s *scenario) thereExistsAnAccountWithUsernameAndPassword(username, passwor
// Set the ID of this user.
s.t.setUserID(username, userID)
// Set the address ID of this user.
s.t.setAddrID(userID, addrID)
// Set the password of this user.
s.t.setUserPass(userID, password)
// Set the address of this user (right now just the same as the username, but let's stay flexible).
s.t.setUserAddr(userID, username)
s.t.setUserAddr(userID, addrID, username)
return nil
}
func (s *scenario) theAccountHasAdditionalAddress(username, address string) error {
userID := s.t.getUserID(username)
addrID, err := s.t.api.AddAddress(userID, address, s.t.getUserPass(userID))
if err != nil {
return err
}
s.t.setUserAddr(userID, addrID, address)
return nil
}
func (s *scenario) theAccountNoLongerHasAdditionalAddress(username, address string) error {
userID := s.t.getUserID(username)
addrID := s.t.getUserAddrID(userID, address)
if err := s.t.api.RemoveAddress(userID, addrID); err != nil {
return err
}
s.t.unsetUserAddr(userID, addrID)
return nil
}
@ -84,9 +111,9 @@ func (s *scenario) theAccountHasTheFollowingCustomMailboxes(username string, tab
return nil
}
func (s *scenario) theAccountHasTheFollowingMessagesInMailbox(username, mailbox string, table *godog.Table) error {
func (s *scenario) theAddressOfAccountHasTheFollowingMessagesInMailbox(address, username, mailbox string, table *godog.Table) error {
userID := s.t.getUserID(username)
addrID := s.t.getAddrID(userID)
addrID := s.t.getUserAddrID(userID, address)
mboxID := s.t.getMBoxID(userID, mailbox)
for _, wantMessage := range parseMessages(table) {
@ -109,9 +136,9 @@ func (s *scenario) theAccountHasTheFollowingMessagesInMailbox(username, mailbox
return nil
}
func (s *scenario) theAccountHasMessagesInMailbox(username string, count int, mailbox string) error {
func (s *scenario) theAddressOfAccountHasMessagesInMailbox(address, username string, count int, mailbox string) error {
userID := s.t.getUserID(username)
addrID := s.t.getAddrID(userID)
addrID := s.t.getUserAddrID(userID, address)
mboxID := s.t.getMBoxID(userID, mailbox)
for idx := 0; idx < count; idx++ {
@ -148,7 +175,7 @@ func (s *scenario) userLogsInWithUsernameAndPassword(username, password string)
return err
}
s.t.setUserPass(userID, info.BridgePass)
s.t.setUserBridgePass(userID, info.BridgePass)
}
return nil