proton-bridge/internal/bridge/debug.go

302 lines
7.9 KiB
Go

// 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 <https://www.gnu.org/licenses/>.
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
}