mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-10 20:56:51 +00:00
301 lines
10 KiB
Go
301 lines
10 KiB
Go
// Copyright (c) 2025 Proton AG
|
|
//
|
|
// This file is part of Proton Mail Bridge.
|
|
//
|
|
// Proton Mail Bridge is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// Proton Mail Bridge is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package imapservice
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/ProtonMail/gluon/db"
|
|
"github.com/ProtonMail/gluon/imap"
|
|
"github.com/ProtonMail/gluon/reporter"
|
|
"github.com/ProtonMail/go-proton-api"
|
|
"github.com/ProtonMail/proton-bridge/v3/internal/unleash"
|
|
"github.com/ProtonMail/proton-bridge/v3/pkg/algo"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type GluonLabelNameProvider interface {
|
|
GetUserMailboxByName(ctx context.Context, addrID string, labelName []string) (imap.MailboxData, error)
|
|
}
|
|
|
|
type gluonIDProvider interface {
|
|
GetGluonID(addrID string) (string, bool)
|
|
}
|
|
|
|
type sentryReporter interface {
|
|
ReportMessageWithContext(string, reporter.Context) error
|
|
}
|
|
|
|
type apiClient interface {
|
|
GetLabel(ctx context.Context, labelID string, labelTypes ...proton.LabelType) (proton.Label, error)
|
|
}
|
|
|
|
type mailboxFetcherFn func(ctx context.Context, label proton.Label) (imap.MailboxData, error)
|
|
|
|
type LabelConflictManager struct {
|
|
gluonLabelNameProvider GluonLabelNameProvider
|
|
gluonIDProvider gluonIDProvider
|
|
client apiClient
|
|
reporter sentryReporter
|
|
featureFlagProvider unleash.FeatureFlagValueProvider
|
|
}
|
|
|
|
func NewLabelConflictManager(
|
|
gluonLabelNameProvider GluonLabelNameProvider,
|
|
gluonIDProvider gluonIDProvider,
|
|
client apiClient,
|
|
reporter sentryReporter,
|
|
featureFlagProvider unleash.FeatureFlagValueProvider) *LabelConflictManager {
|
|
return &LabelConflictManager{
|
|
gluonLabelNameProvider: gluonLabelNameProvider,
|
|
gluonIDProvider: gluonIDProvider,
|
|
client: client,
|
|
reporter: reporter,
|
|
featureFlagProvider: featureFlagProvider,
|
|
}
|
|
}
|
|
|
|
func (m *LabelConflictManager) generateMailboxFetcher(connectors []*Connector) mailboxFetcherFn {
|
|
return func(ctx context.Context, label proton.Label) (imap.MailboxData, error) {
|
|
for _, updateCh := range connectors {
|
|
addrID, ok := m.gluonIDProvider.GetGluonID(updateCh.addrID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
return m.gluonLabelNameProvider.GetUserMailboxByName(ctx, addrID, GetMailboxName(label))
|
|
}
|
|
return imap.MailboxData{}, errors.New("no gluon connectors found")
|
|
}
|
|
}
|
|
|
|
type UserLabelConflictResolver interface {
|
|
ResolveConflict(ctx context.Context, label proton.Label, visited map[string]bool) (func() []imap.Update, error)
|
|
}
|
|
type userLabelConflictResolverImpl struct {
|
|
mailboxFetch mailboxFetcherFn
|
|
client apiClient
|
|
reporter sentryReporter
|
|
log *logrus.Entry
|
|
}
|
|
|
|
type nullUserLabelConflictResolverImpl struct {
|
|
}
|
|
|
|
func (r *nullUserLabelConflictResolverImpl) ResolveConflict(_ context.Context, _ proton.Label, _ map[string]bool) (func() []imap.Update, error) {
|
|
return func() []imap.Update {
|
|
return []imap.Update{}
|
|
}, nil
|
|
}
|
|
|
|
func (m *LabelConflictManager) NewUserConflictResolver(connectors []*Connector) UserLabelConflictResolver {
|
|
if m.featureFlagProvider.GetFlagValue(unleash.LabelConflictResolverDisabled) {
|
|
return &nullUserLabelConflictResolverImpl{}
|
|
}
|
|
|
|
return &userLabelConflictResolverImpl{
|
|
mailboxFetch: m.generateMailboxFetcher(connectors),
|
|
client: m.client,
|
|
reporter: m.reporter,
|
|
log: logrus.WithFields(logrus.Fields{
|
|
"pkg": "imapservice/userLabelConflictResolver",
|
|
"numberOfConnectors": len(connectors),
|
|
}),
|
|
}
|
|
}
|
|
|
|
func (r *userLabelConflictResolverImpl) ResolveConflict(ctx context.Context, label proton.Label, visited map[string]bool) (func() []imap.Update, error) {
|
|
logger := r.log.WithFields(logrus.Fields{
|
|
"labelID": label.ID,
|
|
"labelPath": hashLabelPaths(GetMailboxName(label)),
|
|
})
|
|
|
|
var updateFns []func() []imap.Update
|
|
|
|
// There's a cycle, such as in a label swap operation, we'll need to temporarily rename the label.
|
|
// The change will be overwritten by one of the previous recursive calls.
|
|
if visited[label.ID] {
|
|
logrus.Info("Cycle detected, applying temporary rename")
|
|
fn := func() []imap.Update {
|
|
return []imap.Update{newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), getMailboxNameWithTempPrefix(label))}
|
|
}
|
|
updateFns = append(updateFns, fn)
|
|
return combineIMAPUpdateFns(updateFns), nil
|
|
}
|
|
visited[label.ID] = true
|
|
|
|
// Fetch the gluon mailbox data and verify whether there are conflicts with the name.
|
|
mailboxData, err := r.mailboxFetch(ctx, label)
|
|
if err != nil {
|
|
// Name is free, create the mailbox.
|
|
if db.IsErrNotFound(err) {
|
|
logger.Info("Label not found in DB, creating mailbox.")
|
|
fn := func() []imap.Update {
|
|
return []imap.Update{newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), GetMailboxName(label))}
|
|
}
|
|
updateFns = append(updateFns, fn)
|
|
return combineIMAPUpdateFns(updateFns), nil
|
|
}
|
|
return combineIMAPUpdateFns(updateFns), err
|
|
}
|
|
|
|
// Verify whether the label name corresponds to the same label ID. If true terminate, we don't need to update.
|
|
if mailboxData.RemoteID == label.ID {
|
|
logger.Info("Mailbox name matches label ID, no conflict.")
|
|
return combineIMAPUpdateFns(updateFns), nil
|
|
}
|
|
|
|
// This means we've found a conflict. So let's log it.
|
|
logger = logger.WithFields(logrus.Fields{
|
|
"conflictingLabelID": mailboxData.RemoteID,
|
|
"conflictingLabelPath": hashLabelPaths(mailboxData.BridgeName),
|
|
})
|
|
logger.Info("Label conflict found")
|
|
|
|
// If the label name belongs to some other label ID. Fetch it's state from the remote.
|
|
conflictingLabel, err := r.client.GetLabel(ctx, mailboxData.RemoteID, proton.LabelTypeFolder, proton.LabelTypeLabel)
|
|
if err != nil {
|
|
// If it's not present on the remote we should delete it. And create the new label.
|
|
if errors.Is(err, proton.ErrNoSuchLabel) {
|
|
logger.Info("Conflicting label does not exist on remote. Deleting.")
|
|
fn := func() []imap.Update {
|
|
return []imap.Update{
|
|
imap.NewMailboxDeleted(imap.MailboxID(mailboxData.RemoteID)),
|
|
newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), GetMailboxName(label)),
|
|
}
|
|
}
|
|
updateFns = append(updateFns, fn)
|
|
return combineIMAPUpdateFns(updateFns), nil
|
|
}
|
|
logger.WithError(err).Error("Failed to fetch conflicting label from remote.")
|
|
return combineIMAPUpdateFns(updateFns), err
|
|
}
|
|
|
|
// Check if the conflicting label name has changed. If not, then this is a BE inconsistency.
|
|
if compareLabelNames(GetMailboxName(conflictingLabel), mailboxData.BridgeName) {
|
|
if err := r.reporter.ReportMessageWithContext("Unexpected label conflict", reporter.Context{
|
|
"labelID": label.ID,
|
|
"conflictingLabelID": conflictingLabel.ID,
|
|
}); err != nil {
|
|
logger.WithError(err).Error("Failed to report update error")
|
|
}
|
|
|
|
err := fmt.Errorf("unexpected label conflict: the name of label ID %s is already used by label ID %s", label.ID, conflictingLabel.ID)
|
|
return combineIMAPUpdateFns(updateFns), err
|
|
}
|
|
|
|
// The name of the conflicting label has changed on the remote. We need to verify that the new name does not conflict with anything else.
|
|
// Thus, a recursive check can be performed.
|
|
logger.WithField("conflictingLabelNewPath", hashLabelPaths(conflictingLabel.Path)).
|
|
Info("Conflicting label name has changed. Recursively resolving conflict.")
|
|
childUpdateFns, err := r.ResolveConflict(ctx, conflictingLabel, visited)
|
|
if err != nil {
|
|
return combineIMAPUpdateFns(updateFns), err
|
|
}
|
|
updateFns = append(updateFns, childUpdateFns)
|
|
|
|
fn := func() []imap.Update {
|
|
return []imap.Update{newMailboxUpdatedOrCreated(imap.MailboxID(label.ID), GetMailboxName(label))}
|
|
}
|
|
updateFns = append(updateFns, fn)
|
|
|
|
return combineIMAPUpdateFns(updateFns), nil
|
|
}
|
|
|
|
func combineIMAPUpdateFns(updateFunctions []func() []imap.Update) func() []imap.Update {
|
|
return func() []imap.Update {
|
|
var updates []imap.Update
|
|
for _, fn := range updateFunctions {
|
|
updates = append(updates, fn()...)
|
|
}
|
|
return updates
|
|
}
|
|
}
|
|
|
|
func compareLabelNames(labelName1, labelName2 []string) bool {
|
|
name1 := strings.Join(labelName1, "")
|
|
name2 := strings.Join(labelName2, "")
|
|
return name1 == name2
|
|
}
|
|
|
|
func hashLabelPaths(path []string) string {
|
|
return algo.HashBase64SHA256(strings.Join(path, ""))
|
|
}
|
|
|
|
type InternalLabelConflictResolver interface {
|
|
ResolveConflict(ctx context.Context) (func() []imap.Update, error)
|
|
}
|
|
|
|
type internalLabelConflictResolverImpl struct {
|
|
mailboxFetch mailboxFetcherFn
|
|
client apiClient
|
|
reporter sentryReporter
|
|
log *logrus.Entry
|
|
}
|
|
|
|
type nullInternalLabelConflictResolver struct{}
|
|
|
|
func (r *nullInternalLabelConflictResolver) ResolveConflict(_ context.Context) (func() []imap.Update, error) {
|
|
return func() []imap.Update { return []imap.Update{} }, nil
|
|
}
|
|
|
|
func (m *LabelConflictManager) NewInternalLabelConflictResolver(connectors []*Connector) InternalLabelConflictResolver {
|
|
if m.featureFlagProvider.GetFlagValue(unleash.InternalLabelConflictResolverDisabled) {
|
|
return &nullInternalLabelConflictResolver{}
|
|
}
|
|
|
|
return &internalLabelConflictResolverImpl{
|
|
mailboxFetch: m.generateMailboxFetcher(connectors),
|
|
client: m.client,
|
|
reporter: m.reporter,
|
|
log: logrus.WithFields(logrus.Fields{
|
|
"pkg": "imapservice/internalLabelConflictResolver",
|
|
"numberOfConnectors": len(connectors),
|
|
}),
|
|
}
|
|
}
|
|
|
|
func (r *internalLabelConflictResolverImpl) ResolveConflict(ctx context.Context) (func() []imap.Update, error) {
|
|
var updateFns []func() []imap.Update
|
|
|
|
for _, prefix := range []string{folderPrefix, labelPrefix} {
|
|
label := proton.Label{
|
|
Path: []string{prefix},
|
|
ID: prefix,
|
|
Name: prefix,
|
|
}
|
|
|
|
mbox, err := r.mailboxFetch(ctx, label)
|
|
if err != nil {
|
|
if db.IsErrNotFound(err) {
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if mbox.RemoteID != label.ID {
|
|
// If the ID's don't match we should delete these.
|
|
fn := func() []imap.Update { return []imap.Update{imap.NewMailboxDeleted(imap.MailboxID(prefix))} }
|
|
updateFns = append(updateFns, fn)
|
|
}
|
|
}
|
|
return combineIMAPUpdateFns(updateFns), nil
|
|
}
|