// Copyright (c) 2024 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 . package bridge import ( "context" "fmt" "io" "os" "path/filepath" "github.com/ProtonMail/gluon/imap" "github.com/ProtonMail/gluon/rfc822" "github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/proton-bridge/v3/internal/user" "github.com/bradenaw/juniper/iterator" "github.com/bradenaw/juniper/xslices" goimap "github.com/emersion/go-imap" goimapclient "github.com/emersion/go-imap/client" "github.com/sirupsen/logrus" "golang.org/x/exp/maps" ) type CheckClientStateResult struct { MissingMessages map[string]map[string]user.DiagMailboxMessage } func (c *CheckClientStateResult) AddMissingMessage(userID string, message user.DiagMailboxMessage) { v, ok := c.MissingMessages[userID] if !ok { c.MissingMessages[userID] = map[string]user.DiagMailboxMessage{message.ID: message} } else { v[message.ID] = message } } // CheckClientState checks the current IMAP client reported state against the proton server state and reports // anything that is out of place. func (bridge *Bridge) CheckClientState(ctx context.Context, checkFlags bool, progressCB func(string)) (CheckClientStateResult, error) { bridge.usersLock.RLock() defer bridge.usersLock.RUnlock() users := maps.Values(bridge.users) result := CheckClientStateResult{ MissingMessages: make(map[string]map[string]user.DiagMailboxMessage), } for _, usr := range users { if progressCB != nil { progressCB(fmt.Sprintf("Checking state for user %v", usr.Name())) } log := logrus.WithFields(logrus.Fields{ "pkg": "bridge/debug", "user": usr.Name(), "diag": "state-check", }) log.Debug("Retrieving all server metadata") meta, err := usr.GetDiagnosticMetadata(ctx) if err != nil { return result, err } success := true if len(meta.Metadata) != len(meta.MessageIDs) { log.Errorf("Metadata (%v) and message(%v) list sizes do not match", len(meta.Metadata), len(meta.MessageIDs)) } log.Debug("Building state") state, err := meta.BuildMailboxToMessageMap(ctx, usr) if err != nil { log.WithError(err).Error("Failed to build state") return result, err } info, err := bridge.GetUserInfo(usr.ID()) if err != nil { log.WithError(err).Error("Failed to get user info") return result, err } addr := fmt.Sprintf("127.0.0.1:%v", bridge.GetIMAPPort()) for account, mboxMap := range state { if progressCB != nil { progressCB(fmt.Sprintf("Checking state for user %v's account '%v'", usr.Name(), account)) } if err := func(account string, mboxMap user.AccountMailboxMap) error { client, err := goimapclient.Dial(addr) if err != nil { log.WithError(err).Error("Failed to connect to imap client") return err } defer func() { _ = client.Logout() }() if err := client.Login(account, string(info.BridgePass)); err != nil { return fmt.Errorf("failed to login for user %v:%w", usr.Name(), err) } log := log.WithField("account", account) for mboxName, messageList := range mboxMap { log := log.WithField("mbox", mboxName) status, err := client.Select(mboxName, true) if err != nil { log.WithError(err).Errorf("Failed to select mailbox %v", messageList) return fmt.Errorf("failed to select '%v':%w", mboxName, err) } log.Debug("Checking message count") if int(status.Messages) != len(messageList) { success = false log.Errorf("Message count doesn't match, got '%v' expected '%v'", status.Messages, len(messageList)) } ids, err := clientGetMessageIDs(client, mboxName) if err != nil { return fmt.Errorf("failed to get message ids for mbox '%v': %w", mboxName, err) } for _, msg := range messageList { imapFlags, ok := ids[msg.ID] if !ok { if meta.FailedMessageIDs.Contains(msg.ID) { log.Warningf("Missing message '%v', but it is part of failed message set", msg.ID) } else { log.Errorf("Missing message '%v'", msg.ID) } result.AddMissingMessage(msg.UserID, msg) continue } if checkFlags { if !imapFlags.Equals(msg.Flags) { log.Errorf("Message '%v' flags do mot match, got=%v, expected=%v", msg.ID, imapFlags.ToSlice(), msg.Flags.ToSlice(), ) } } } } if !success { log.Errorf("State does not match") } else { log.Info("State matches") } return nil }(account, mboxMap); err != nil { return result, err } } // Check for orphaned messages (only present in All Mail) if progressCB != nil { progressCB(fmt.Sprintf("Checking user %v for orphans", usr.Name())) } log.Debugf("Checking for orphans") for _, m := range meta.Metadata { filteredLabels := xslices.Filter(m.LabelIDs, func(t string) bool { switch t { case proton.AllMailLabel: return false case proton.AllSentLabel: return false case proton.AllDraftsLabel: return false case proton.OutboxLabel: return false default: return true } }) if len(filteredLabels) == 0 { log.Warnf("Message %v is only present in All Mail (Subject=%v)", m.ID, m.Subject) } } } return result, nil } func (bridge *Bridge) DebugDownloadFailedMessages( ctx context.Context, result CheckClientStateResult, exportPath string, progressCB func(string, int, int), ) error { bridge.usersLock.RLock() defer bridge.usersLock.RUnlock() for userID, messages := range result.MissingMessages { usr, ok := bridge.users[userID] if !ok { return fmt.Errorf("failed to find user with id %v", userID) } userDir := filepath.Join(exportPath, userID) if err := os.MkdirAll(userDir, 0o700); err != nil { return fmt.Errorf("failed to create directory '%v': %w", userDir, err) } if err := usr.DebugDownloadMessages(ctx, userDir, messages, progressCB); err != nil { return err } } return nil } func clientGetMessageIDs(client *goimapclient.Client, mailbox string) (map[string]imap.FlagSet, error) { status, err := client.Select(mailbox, true) if err != nil { return nil, err } if status.Messages == 0 { return nil, nil } resCh := make(chan *goimap.Message) section, err := goimap.ParseBodySectionName("BODY[HEADER]") if err != nil { return nil, err } fetchItems := []goimap.FetchItem{"BODY[HEADER]", goimap.FetchFlags} seq, err := goimap.ParseSeqSet("1:*") if err != nil { return nil, err } go func() { if err := client.Fetch( seq, fetchItems, resCh, ); err != nil { panic(err) } }() messages := iterator.Collect(iterator.Chan(resCh)) ids := make(map[string]imap.FlagSet, len(messages)) for i, m := range messages { literal, err := io.ReadAll(m.GetBody(section)) if err != nil { return nil, err } header, err := rfc822.NewHeader(literal) if err != nil { return nil, fmt.Errorf("failed to parse header for msg %v: %w", i, err) } internalID, ok := header.GetChecked("X-Pm-Internal-Id") if !ok { logrus.WithField("pkg", "bridge/debug").Errorf("Message %v does not have internal id", internalID) continue } messageFlags := imap.NewFlagSet(m.Flags...) // Recent and Deleted are not part of the proton flag set. messageFlags.RemoveFromSelf("\\Recent") messageFlags.RemoveFromSelf("\\Deleted") ids[internalID] = messageFlags } return ids, nil }