GODT-1779: Remove go-imap

This commit is contained in:
James Houlahan
2022-08-26 17:00:21 +02:00
parent 3b0bc1ca15
commit 39433fe707
593 changed files with 12725 additions and 91626 deletions

61
internal/user/builder.go Normal file
View File

@ -0,0 +1,61 @@
package user
import (
"context"
"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"
"gitlab.protontech.ch/go/liteapi"
)
type request struct {
messageID string
addrKR *crypto.KeyRing
}
type fetcher interface {
GetMessage(context.Context, string) (liteapi.Message, error)
GetAttachment(context.Context, string) ([]byte, error)
}
func newBuilder(f fetcher, msgWorkers, attWorkers int) *pool.Pool[request, *imap.MessageCreated] {
attPool := pool.New(attWorkers, func(ctx context.Context, attID string) ([]byte, error) {
return f.GetAttachment(ctx, attID)
})
msgPool := pool.New(msgWorkers, func(ctx context.Context, req request) (*imap.MessageCreated, error) {
msg, err := f.GetMessage(ctx, req.messageID)
if err != nil {
return nil, err
}
var attIDs []string
for _, att := range msg.Attachments {
attIDs = append(attIDs, att.ID)
}
attData, err := attPool.ProcessAll(ctx, attIDs)
if err != nil {
return nil, err
}
literal, err := message.BuildRFC822(req.addrKR, msg, attData, message.JobOptions{
IgnoreDecryptionErrors: true, // Whether to ignore decryption errors and create a "custom message" instead.
SanitizeDate: true, // Whether to replace all dates before 1970 with RFC822's birthdate.
AddInternalID: true, // Whether to include MessageID as X-Pm-Internal-Id.
AddExternalID: true, // Whether to include ExternalID as X-Pm-External-Id.
AddMessageDate: true, // Whether to include message time as X-Pm-Date.
AddMessageIDReference: true, // Whether to include the MessageID in References.
})
if err != nil {
return nil, err
}
return getMessageCreatedUpdate(msg, literal)
})
return msgPool
}

30
internal/user/crypto.go Normal file
View File

@ -0,0 +1,30 @@
package user
import (
"github.com/ProtonMail/gopenpgp/v2/crypto"
"gitlab.protontech.ch/go/liteapi"
)
func unlockKeyRings(user liteapi.User, addresses []liteapi.Address, keyPass []byte) (*crypto.KeyRing, map[string]*crypto.KeyRing, error) {
userKR, err := user.Keys.Unlock(keyPass, nil)
if err != nil {
return nil, nil, err
}
addrKRs := make(map[string]*crypto.KeyRing)
for _, address := range addresses {
if !address.HasKeys.Bool() {
continue
}
addrKR, err := address.Keys.Unlock(keyPass, userKR)
if err != nil {
return nil, nil, err
}
addrKRs[address.ID] = addrKR
}
return userKR, addrKRs, nil
}

12
internal/user/errors.go Normal file
View File

@ -0,0 +1,12 @@
package user
import "errors"
var (
ErrNoSuchAddress = errors.New("no such address")
ErrNotImplemented = errors.New("not implemented")
ErrNotSupported = errors.New("not supported")
ErrInvalidReturnPath = errors.New("invalid return path")
ErrInvalidRecipient = errors.New("invalid recipient")
ErrMissingAddressKey = errors.New("missing address key")
)

230
internal/user/events.go Normal file
View File

@ -0,0 +1,230 @@
package user
import (
"context"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"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 {
if event.User != nil {
if err := user.handleUserEvent(*event.User); err != nil {
return err
}
}
if len(event.Addresses) > 0 {
if err := user.handleAddressEvents(event.Addresses); err != nil {
return err
}
}
if event.MailSettings != nil {
if err := user.handleMailSettingsEvent(*event.MailSettings); err != nil {
return err
}
}
if len(event.Labels) > 0 {
if err := user.handleLabelEvents(event.Labels); err != nil {
return err
}
}
if len(event.Messages) > 0 {
if err := user.handleMessageEvents(event.Messages); err != nil {
return err
}
}
return nil
}
// handleUserEvent handles the given user event.
func (user *User) handleUserEvent(userEvent liteapi.User) error {
userKR, err := userEvent.Keys.Unlock(user.vault.KeyPass(), nil)
if err != nil {
return err
}
user.apiUser = userEvent
user.userKR = userKR
user.notifyCh <- 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 {
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,
}
case liteapi.EventUpdate:
if err := user.updateAddress(event.Address); err != nil {
return err
}
user.notifyCh <- events.UserAddressChanged{
UserID: user.ID(),
Address: event.Address.Email,
}
}
}
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)
if err != nil {
return err
}
if user.imapConn != nil {
user.imapConn.addAddress(address.Email)
}
user.addresses = append(user.addresses, address)
user.addrKRs[address.ID] = addrKR
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
}
return user.createAddress(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
})
if idx < 0 {
return liteapi.Address{}, ErrNoSuchAddress
}
if user.imapConn != nil {
user.imapConn.remAddress(user.addresses[idx].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
}
// handleMailSettingsEvent handles the given mail settings event.
func (user *User) handleMailSettingsEvent(mailSettingsEvent liteapi.MailSettings) error {
user.settings = mailSettingsEvent
return nil
}
// handleLabelEvents handles the given label events.
func (user *User) handleLabelEvents(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))
case liteapi.EventUpdate, liteapi.EventUpdateFlags:
user.updateCh <- imap.NewMailboxUpdated(imap.LabelID(event.ID), getMailboxName(event.Label))
}
}
return nil
}
// handleMessageEvents handles the given message events.
func (user *User) handleMessageEvents(messageEvents []liteapi.MessageEvent) error {
ctx, cancel := context.WithCancel(context.Background())
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
}
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)),
!event.Message.Unread.Bool(),
slices.Contains(event.Message.LabelIDs, liteapi.StarredLabel),
)
}
}
return nil
}
func getMailboxName(label liteapi.Label) []string {
var name []string
switch label.Type {
case liteapi.LabelTypeFolder:
name = []string{folderPrefix, label.Name}
case liteapi.LabelTypeLabel:
name = []string{labelPrefix, label.Name}
default:
name = []string{label.Name}
}
return name
}

293
internal/user/imap.go Normal file
View File

@ -0,0 +1,293 @@
package user
import (
"context"
"crypto/subtle"
"fmt"
"strings"
"time"
"github.com/ProtonMail/gluon/imap"
"github.com/bradenaw/juniper/xslices"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/slices"
)
var (
defaultFlags = imap.NewFlagSet(imap.FlagSeen, imap.FlagFlagged, imap.FlagDeleted)
defaultPermanentFlags = imap.NewFlagSet(imap.FlagSeen, imap.FlagFlagged, imap.FlagDeleted)
defaultAttributes = imap.NewFlagSet()
)
const (
folderPrefix = "Folders"
labelPrefix = "Labels"
)
type imapConnector struct {
client *liteapi.Client
updateCh <-chan imap.Update
addresses []string
password string
flags, permFlags, attrs imap.FlagSet
}
func newIMAPConnector(
client *liteapi.Client,
updateCh <-chan imap.Update,
addresses []string,
password string,
) *imapConnector {
return &imapConnector{
client: client,
updateCh: updateCh,
addresses: addresses,
password: password,
flags: defaultFlags,
permFlags: defaultPermanentFlags,
attrs: defaultAttributes,
}
}
// Authorize returns whether the given username/password combination are valid for this connector.
func (conn *imapConnector) Authorize(username string, password string) bool {
if subtle.ConstantTimeCompare([]byte(conn.password), []byte(password)) != 1 {
return false
}
return xslices.IndexFunc(conn.addresses, func(address string) bool {
return strings.EqualFold(address, username)
}) >= 0
}
// GetLabel returns information about the label with the given ID.
func (conn *imapConnector) GetLabel(ctx context.Context, labelID imap.LabelID) (imap.Mailbox, error) {
label, err := conn.client.GetLabel(ctx, string(labelID), liteapi.LabelTypeLabel, liteapi.LabelTypeFolder)
if err != nil {
return imap.Mailbox{}, err
}
var name []string
switch label.Type {
case liteapi.LabelTypeLabel:
name = []string{labelPrefix, label.Name}
case liteapi.LabelTypeFolder:
name = []string{folderPrefix, label.Name}
default:
name = []string{label.Name}
}
return imap.Mailbox{
ID: imap.LabelID(label.ID),
Name: name,
Flags: conn.flags,
PermanentFlags: conn.permFlags,
Attributes: conn.attrs,
}, nil
}
// CreateLabel creates a label with the given name.
func (conn *imapConnector) CreateLabel(ctx context.Context, name []string) (imap.Mailbox, error) {
if len(name) != 2 {
panic("subfolders are unsupported")
}
var labelType liteapi.LabelType
if name[0] == folderPrefix {
labelType = liteapi.LabelTypeFolder
} else {
labelType = liteapi.LabelTypeLabel
}
label, err := conn.client.CreateLabel(ctx, liteapi.CreateLabelReq{
Name: name[1:][0],
Color: "#f66",
Type: labelType,
})
if err != nil {
return imap.Mailbox{}, err
}
return imap.Mailbox{
ID: imap.LabelID(label.ID),
Name: name,
Flags: conn.flags,
PermanentFlags: conn.permFlags,
Attributes: conn.attrs,
}, nil
}
// UpdateLabel sets the name of the label with the given ID.
func (conn *imapConnector) UpdateLabel(ctx context.Context, labelID imap.LabelID, newName []string) error {
if len(newName) != 2 {
panic("subfolders are unsupported")
}
label, err := conn.client.GetLabel(ctx, string(labelID), liteapi.LabelTypeLabel, liteapi.LabelTypeFolder)
if err != nil {
return err
}
switch label.Type {
case liteapi.LabelTypeFolder:
if newName[0] != folderPrefix {
return fmt.Errorf("cannot rename folder to label")
}
case liteapi.LabelTypeLabel:
if newName[0] != labelPrefix {
return fmt.Errorf("cannot rename label to folder")
}
case liteapi.LabelTypeSystem:
return fmt.Errorf("cannot rename system label %q", label.Name)
}
if _, err := conn.client.UpdateLabel(ctx, label.ID, liteapi.UpdateLabelReq{
Name: newName[1:][0],
Color: label.Color,
}); err != nil {
return err
}
return nil
}
// DeleteLabel deletes the label with the given ID.
func (conn *imapConnector) DeleteLabel(ctx context.Context, labelID imap.LabelID) error {
return conn.client.DeleteLabel(ctx, string(labelID))
}
// GetMessage returns the message with the given ID.
func (conn *imapConnector) GetMessage(ctx context.Context, messageID imap.MessageID) (imap.Message, []imap.LabelID, error) {
message, err := conn.client.GetMessage(ctx, string(messageID))
if err != nil {
return imap.Message{}, nil, err
}
flags := imap.NewFlagSet()
if !message.Unread.Bool() {
flags = flags.Add(imap.FlagSeen)
}
if slices.Contains(message.LabelIDs, liteapi.StarredLabel) {
flags = flags.Add(imap.FlagFlagged)
}
return imap.Message{
ID: imap.MessageID(message.ID),
Flags: flags,
Date: time.Unix(message.Time, 0),
}, imapLabelIDs(message.LabelIDs), nil
}
// CreateMessage creates a new message on the remote.
func (conn *imapConnector) CreateMessage(
ctx context.Context,
labelID imap.LabelID,
literal []byte,
parsedMessage *imap.ParsedMessage,
flags imap.FlagSet,
date time.Time,
) (imap.Message, error) {
return imap.Message{}, ErrNotImplemented
}
// 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))
}
// 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))
}
// 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 {
return fmt.Errorf("labeling messages: %w", err)
}
if err := conn.client.UnlabelMessages(ctx, strMessageIDs(messageIDs), string(labelFromID)); err != nil {
return fmt.Errorf("unlabeling messages: %w", err)
}
return nil
}
// 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)...)
} else {
return conn.client.MarkMessagesUnread(ctx, strMessageIDs(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)
} else {
return conn.client.UnlabelMessages(ctx, strMessageIDs(messageIDs), liteapi.StarredLabel)
}
}
// GetUpdates returns a stream of updates that the gluon server should apply.
// It is recommended that the returned channel is buffered with at least constants.ChannelBufferCount.
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 {
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)
})
}

330
internal/user/smtp.go Normal file
View File

@ -0,0 +1,330 @@
package user
import (
"context"
"encoding/base64"
"fmt"
"io"
"runtime"
"strings"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/pkg/message"
"github.com/ProtonMail/proton-bridge/v2/pkg/message/parser"
"github.com/bradenaw/juniper/parallel"
"github.com/bradenaw/juniper/xslices"
"github.com/emersion/go-smtp"
"github.com/sirupsen/logrus"
"gitlab.protontech.ch/go/liteapi"
)
type smtpSession struct {
client *liteapi.Client
username string
addresses []liteapi.Address
userKR *crypto.KeyRing
addrKRs map[string]*crypto.KeyRing
settings liteapi.MailSettings
from string
to map[string]struct{}
}
func newSMTPSession(
client *liteapi.Client,
username string,
addresses []liteapi.Address,
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,
from: "",
to: make(map[string]struct{}),
}
}
// Discard currently processed message.
func (session *smtpSession) Reset() {
logrus.Info("SMTP session reset")
// Clear the from and to fields.
session.from = ""
session.to = make(map[string]struct{})
}
// Free all resources associated with session.
func (session *smtpSession) Logout() error {
defer session.Reset()
logrus.Info("SMTP session logout")
return nil
}
// Set return path for currently processed message.
func (session *smtpSession) Mail(from string, opts smtp.MailOptions) error {
logrus.Info("SMTP session mail")
if opts.RequireTLS {
return ErrNotImplemented
}
if opts.UTF8 {
return ErrNotImplemented
}
if opts.Auth != nil && *opts.Auth != "" && *opts.Auth != session.username {
return ErrNotImplemented
}
idx := xslices.IndexFunc(session.addresses, func(address liteapi.Address) bool {
return strings.EqualFold(address.Email, from)
})
if idx < 0 {
return ErrInvalidReturnPath
}
session.from = session.addresses[idx].ID
return nil
}
// Add recipient for currently processed message.
func (session *smtpSession) Rcpt(to string) error {
logrus.Info("SMTP session rcpt")
if to == "" {
return ErrInvalidRecipient
}
session.to[to] = struct{}{}
return nil
}
// Set currently processed message contents and send it.
func (session *smtpSession) Data(r io.Reader) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
logrus.Info("SMTP session data")
if session.from == "" {
return ErrInvalidReturnPath
}
if len(session.to) == 0 {
return ErrInvalidRecipient
}
addrKR, ok := session.addrKRs[session.from]
if !ok {
return ErrMissingAddressKey
}
addrKR, err := addrKR.FirstKey()
if err != nil {
return fmt.Errorf("failed to get first key: %w", err)
}
parser, err := parser.New(r)
if err != nil {
return fmt.Errorf("failed to create parser: %w", err)
}
if session.settings.AttachPublicKey == liteapi.AttachPublicKeyEnabled {
key, err := addrKR.GetKey(0)
if err != nil {
return fmt.Errorf("failed to get user public key: %w", err)
}
pubKey, err := key.GetArmoredPublicKey()
if err != nil {
return fmt.Errorf("failed to get user public key: %w", err)
}
parser.AttachPublicKey(pubKey, fmt.Sprintf("publickey - %v - %v", addrKR.GetIdentities()[0].Name, key.GetFingerprint()[:8]))
}
message, err := message.ParseWithParser(parser)
if err != nil {
return fmt.Errorf("failed to parse message: %w", err)
}
draft, attKeys, err := session.createDraft(ctx, addrKR, message)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
recipients, err := session.getRecipients(ctx, message.Recipients(), message.MIMEType)
if err != nil {
return fmt.Errorf("failed to get recipients: %w", err)
}
req, err := createSendReq(addrKR, message.MIMEBody, message.RichBody, message.PlainBody, recipients, attKeys)
if err != nil {
return fmt.Errorf("failed to create packages: %w", err)
}
res, err := session.client.SendDraft(ctx, draft.ID, req)
if err != nil {
return fmt.Errorf("failed to send draft: %w", err)
}
logrus.WithField("messageID", res.ID).Info("SMTP message sent")
return nil
}
func (session *smtpSession) createDraft(ctx context.Context, addrKR *crypto.KeyRing, message message.Message) (liteapi.Message, map[string]*crypto.SessionKey, error) {
encBody, err := addrKR.Encrypt(crypto.NewPlainMessageFromString(string(message.RichBody)), nil)
if err != nil {
return liteapi.Message{}, nil, fmt.Errorf("failed to encrypt message body: %w", err)
}
armBody, err := encBody.GetArmored()
if err != nil {
return liteapi.Message{}, nil, fmt.Errorf("failed to armor message body: %w", err)
}
draft, err := session.client.CreateDraft(ctx, liteapi.CreateDraftReq{
Message: liteapi.DraftTemplate{
Subject: message.Subject,
Sender: message.Sender,
ToList: message.ToList,
CCList: message.CCList,
BCCList: message.BCCList,
Body: armBody,
},
AttachmentKeyPackets: []string{},
})
if err != nil {
return liteapi.Message{}, nil, fmt.Errorf("failed to create draft: %w", err)
}
attKeys, err := session.createAttachments(ctx, addrKR, draft.ID, message.Attachments)
if err != nil {
return liteapi.Message{}, nil, fmt.Errorf("failed to create attachments: %w", err)
}
return draft, attKeys, nil
}
func (session *smtpSession) createAttachments(ctx context.Context, addrKR *crypto.KeyRing, draftID string, attachments []message.Attachment) (map[string]*crypto.SessionKey, error) {
type attKey struct {
attID string
key *crypto.SessionKey
}
keys, err := parallel.MapContext(ctx, runtime.NumCPU(), attachments, func(ctx context.Context, att message.Attachment) (attKey, error) {
sig, err := addrKR.SignDetached(crypto.NewPlainMessage(att.Data))
if err != nil {
return attKey{}, fmt.Errorf("failed to sign attachment: %w", err)
}
encData, err := addrKR.EncryptAttachment(crypto.NewPlainMessage(att.Data), att.Name)
if err != nil {
return attKey{}, fmt.Errorf("failed to encrypt attachment: %w", err)
}
attachment, err := session.client.UploadAttachment(ctx, liteapi.CreateAttachmentReq{
Filename: att.Name,
MessageID: draftID,
MIMEType: rfc822.MIMEType(att.MIMEType),
Disposition: liteapi.Disposition(att.Disposition),
ContentID: att.ContentID,
KeyPackets: encData.KeyPacket,
DataPacket: encData.DataPacket,
Signature: sig.GetBinary(),
})
if err != nil {
return attKey{}, fmt.Errorf("failed to upload attachment: %w", err)
}
keyPacket, err := base64.StdEncoding.DecodeString(attachment.KeyPackets)
if err != nil {
return attKey{}, fmt.Errorf("failed to decode key packets: %w", err)
}
key, err := addrKR.DecryptSessionKey(keyPacket)
if err != nil {
return attKey{}, fmt.Errorf("failed to decrypt session key: %w", err)
}
return attKey{attID: attachment.ID, key: key}, nil
})
if err != nil {
return nil, fmt.Errorf("failed to create attachments: %w", err)
}
attKeys := make(map[string]*crypto.SessionKey)
for _, key := range keys {
attKeys[key.attID] = key.key
}
return attKeys, nil
}
func (session *smtpSession) getRecipients(ctx context.Context, addresses []string, mimeType rfc822.MIMEType) (recipients, error) {
prefs, err := parallel.MapContext(ctx, runtime.NumCPU(), addresses, func(ctx context.Context, address string) (liteapi.SendPreferences, error) {
return session.getSendPrefs(ctx, address, mimeType)
})
if err != nil {
return nil, fmt.Errorf("failed to get recipients: %w", err)
}
recipients := make(recipients)
for idx, pref := range prefs {
recipients[addresses[idx]] = pref
}
return recipients, nil
}
func (session *smtpSession) getSendPrefs(ctx context.Context, recipient string, mimeType rfc822.MIMEType) (liteapi.SendPreferences, error) {
pubKeys, internal, err := session.client.GetPublicKeys(ctx, recipient)
if err != nil {
return liteapi.SendPreferences{}, fmt.Errorf("failed to get public keys: %w", err)
}
settings, err := session.getContactSettings(ctx, recipient)
if err != nil {
return liteapi.SendPreferences{}, fmt.Errorf("failed to get contact settings: %w", err)
}
return buildSendPrefs(settings, session.settings, pubKeys, mimeType, internal)
}
func (session *smtpSession) getContactSettings(ctx context.Context, recipient string) (liteapi.ContactSettings, error) {
contacts, err := session.client.GetAllContactEmails(ctx, recipient)
if err != nil {
return liteapi.ContactSettings{}, fmt.Errorf("failed to get contact data: %w", err)
}
idx := xslices.IndexFunc(contacts, func(contact liteapi.ContactEmail) bool {
return contact.Email == recipient
})
if idx < 0 {
return liteapi.ContactSettings{}, nil
}
contact, err := session.client.GetContact(ctx, contacts[idx].ContactID)
if err != nil {
return liteapi.ContactSettings{}, fmt.Errorf("failed to get contact: %w", err)
}
return contact.GetSettings(session.userKR, recipient)
}

View File

@ -0,0 +1,69 @@
package user
import (
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v2/pkg/message"
"github.com/bradenaw/juniper/xslices"
"gitlab.protontech.ch/go/liteapi"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
func createSendReq(
kr *crypto.KeyRing,
mimeBody message.MIMEBody,
richBody, plainBody message.Body,
recipients recipients,
attKeys map[string]*crypto.SessionKey,
) (liteapi.SendDraftReq, error) {
var req liteapi.SendDraftReq
if recs := recipients.scheme(liteapi.PGPMIMEScheme, liteapi.ClearMIMEScheme); len(recs) > 0 {
if err := req.AddMIMEPackage(kr, string(mimeBody), recs); err != nil {
return liteapi.SendDraftReq{}, err
}
}
if recs := recipients.scheme(liteapi.InternalScheme, liteapi.ClearScheme, liteapi.PGPInlineScheme); len(recs) > 0 {
if recs := recs.content(rfc822.TextHTML); len(recs) > 0 {
if err := req.AddPackage(kr, string(richBody), rfc822.TextHTML, recs, attKeys); err != nil {
return liteapi.SendDraftReq{}, err
}
}
if recs := recs.content(rfc822.TextPlain); len(recs) > 0 {
if err := req.AddPackage(kr, string(plainBody), rfc822.TextPlain, recs, attKeys); err != nil {
return liteapi.SendDraftReq{}, err
}
}
}
return req, nil
}
type recipients map[string]liteapi.SendPreferences
func (r recipients) scheme(scheme ...liteapi.EncryptionScheme) recipients {
res := make(recipients)
for _, addr := range xslices.Filter(maps.Keys(r), func(addr string) bool {
return slices.Contains(scheme, r[addr].EncryptionScheme)
}) {
res[addr] = r[addr]
}
return res
}
func (r recipients) content(mimeType ...rfc822.MIMEType) recipients {
res := make(recipients)
for _, addr := range xslices.Filter(maps.Keys(r), func(addr string) bool {
return slices.Contains(mimeType, r[addr].MIMEType)
}) {
res[addr] = r[addr]
}
return res
}

579
internal/user/smtp_prefs.go Normal file
View File

@ -0,0 +1,579 @@
// Copyright (c) 2022 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 user
import (
"encoding/base64"
"fmt"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/pkg/errors"
"gitlab.protontech.ch/go/liteapi"
)
const (
pgpInline = "pgp-inline"
pgpMIME = "pgp-mime"
pmInternal = "internal" // A mix between pgpInline and pgpMime used by PM.
)
type contactSettings struct {
Email string
Keys []string
Scheme string
Sign bool
SignIsSet bool
Encrypt bool
MIMEType rfc822.MIMEType
}
// newContactSettings converts the API settings into our local settings.
// This is due to the legacy send preferences code.
func newContactSettings(settings liteapi.ContactSettings) *contactSettings {
metadata := &contactSettings{}
if settings.MIMEType != nil {
metadata.MIMEType = *settings.MIMEType
}
if settings.Sign != nil {
metadata.Sign = *settings.Sign
metadata.SignIsSet = true
}
if settings.Encrypt != nil {
metadata.Encrypt = *settings.Encrypt
}
if settings.Scheme != nil {
switch *settings.Scheme {
case liteapi.PGPMIMEScheme:
metadata.Scheme = pgpMIME
case liteapi.PGPInlineScheme:
metadata.Scheme = pgpInline
}
}
if settings.Keys != nil {
for _, key := range settings.Keys {
b, err := key.Serialize()
if err != nil {
panic(err)
}
metadata.Keys = append(metadata.Keys, base64.StdEncoding.EncodeToString(b))
}
}
return metadata
}
func buildSendPrefs(
contactSettings liteapi.ContactSettings,
mailSettings liteapi.MailSettings,
pubKeys []liteapi.PublicKey,
mimeType rfc822.MIMEType,
isInternal bool,
) (liteapi.SendPreferences, error) {
builder := &sendPrefsBuilder{}
if err := builder.setPGPSettings(newContactSettings(contactSettings), pubKeys, isInternal); err != nil {
return liteapi.SendPreferences{}, fmt.Errorf("failed to set PGP settings: %w", err)
}
builder.setEncryptionPreferences(mailSettings)
builder.setMIMEPreferences(string(mimeType))
return builder.build(), nil
}
type sendPrefsBuilder struct {
internal bool
encrypt *bool
sign *bool
scheme *string
mimeType *rfc822.MIMEType
publicKey *crypto.KeyRing
}
func (b *sendPrefsBuilder) withInternal() {
b.internal = true
}
func (b *sendPrefsBuilder) isInternal() bool {
return b.internal
}
func (b *sendPrefsBuilder) withEncrypt(v bool) {
b.encrypt = &v
}
func (b *sendPrefsBuilder) withEncryptDefault(v bool) {
if b.encrypt == nil {
b.encrypt = &v
}
}
func (b *sendPrefsBuilder) shouldEncrypt() bool {
if b.encrypt != nil {
return *b.encrypt
}
return false
}
func (b *sendPrefsBuilder) withSign(sign bool) {
b.sign = &sign
}
func (b *sendPrefsBuilder) withSignDefault() {
v := true
if b.sign == nil {
b.sign = &v
}
}
func (b *sendPrefsBuilder) shouldSign() bool {
if b.sign != nil {
return *b.sign
}
return false
}
func (b *sendPrefsBuilder) withScheme(v string) {
b.scheme = &v
}
func (b *sendPrefsBuilder) withSchemeDefault(v string) {
if b.scheme == nil {
b.scheme = &v
}
}
func (b *sendPrefsBuilder) getScheme() string {
if b.scheme != nil {
return *b.scheme
}
return ""
}
func (b *sendPrefsBuilder) withMIMEType(v rfc822.MIMEType) {
b.mimeType = &v
}
func (b *sendPrefsBuilder) withMIMETypeDefault(v rfc822.MIMEType) {
if b.mimeType == nil {
b.mimeType = &v
}
}
func (b *sendPrefsBuilder) removeMIMEType() {
b.mimeType = nil
}
func (b *sendPrefsBuilder) getMIMEType() rfc822.MIMEType {
if b.mimeType != nil {
return *b.mimeType
}
return ""
}
func (b *sendPrefsBuilder) withPublicKey(v *crypto.KeyRing) {
b.publicKey = v
}
// Build converts the PGP scheme with a string value into a number value, and
// we may override some of the other encryption preferences with the composer
// preferences. Notice that the composer allows to select a sign preference,
// an email format preference and an encrypt-to-outside preference. The
// object we extract has the following possible value types:
//
// {
// encrypt: true | false,
// sign: true | false,
// pgpScheme: 1 (protonmail custom scheme)
// | 2 (Protonmail scheme for encrypted-to-outside email)
// | 4 (no cryptographic scheme)
// | 8 (PGP/INLINE)
// | 16 (PGP/MIME),
// mimeType: 'text/html' | 'text/plain' | 'multipart/mixed',
// publicKey: OpenPGPKey | undefined/null
// }.
func (b *sendPrefsBuilder) build() (p liteapi.SendPreferences) {
p.Encrypt = b.shouldEncrypt()
p.MIMEType = b.getMIMEType()
p.PubKey = b.publicKey
if b.shouldSign() {
p.SignatureType = liteapi.DetachedSignature
} else {
p.SignatureType = liteapi.NoSignature
}
switch {
case b.isInternal():
p.EncryptionScheme = liteapi.InternalScheme
case b.shouldSign() && b.shouldEncrypt():
if b.getScheme() == pgpInline {
p.EncryptionScheme = liteapi.PGPInlineScheme
} else {
p.EncryptionScheme = liteapi.PGPMIMEScheme
}
case b.shouldSign() && !b.shouldEncrypt():
if b.getScheme() == pgpInline {
p.EncryptionScheme = liteapi.ClearScheme
} else {
p.EncryptionScheme = liteapi.ClearMIMEScheme
}
default:
p.EncryptionScheme = liteapi.ClearScheme
}
return
}
// setPGPSettings returns a SendPreferences with the following possible values:
//
// {
// encrypt: true | false | undefined/null/'',
// sign: true | false | undefined/null/'',
// pgpScheme: 'pgp-mime' | 'pgp-inline' | undefined/null/'',
// mimeType: 'text/html' | 'text/plain' | undefined/null/'',
// publicKey: OpenPGPKey | undefined/null
// }
//
// These settings are simply a reflection of the vCard content plus the public
// key info retrieved from the API via the GET KEYS route.
func (b *sendPrefsBuilder) setPGPSettings(
vCardData *contactSettings,
apiKeys []liteapi.PublicKey,
isInternal bool,
) (err error) {
// If there is no contact metadata, we can just use a default constructed one.
if vCardData == nil {
vCardData = &contactSettings{}
}
// Sending internal.
// We are guaranteed to always receive API keys.
if isInternal {
b.withInternal()
return b.setInternalPGPSettings(vCardData, apiKeys)
}
// Sending external but with keys supplied by WKD.
// Treated pretty much same as internal.
if len(apiKeys) > 0 {
return b.setExternalPGPSettingsWithWKDKeys(vCardData, apiKeys)
}
// Sending external without any WKD keys.
// If we have a contact saved, we can use its settings.
return b.setExternalPGPSettingsWithoutWKDKeys(vCardData)
}
// setInternalPGPSettings returns SendPreferences for internal messages.
// An internal address can be either an obvious one: abc@protonmail.com,
// abc@protonmail.ch or abc@pm.me, or one belonging to a custom domain
// registered with proton.
func (b *sendPrefsBuilder) setInternalPGPSettings(
vCardData *contactSettings,
apiKeys []liteapi.PublicKey,
) (err error) {
// We're guaranteed to get at least one valid (i.e. not expired, revoked or
// marked as verification-only) public key from the server.
if len(apiKeys) == 0 {
return errors.New("an API key is necessary but wasn't provided")
}
// We always encrypt and sign internal mail.
b.withEncrypt(true)
b.withSign(true)
// We use a custom scheme for internal messages.
b.withScheme(pmInternal)
// If user has overridden the MIMEType for a contact, we use that.
// Otherwise, we take the MIMEType from the composer.
if vCardData.MIMEType != "" {
b.withMIMEType(vCardData.MIMEType)
}
sendingKey, err := pickSendingKey(vCardData, apiKeys)
if err != nil {
return
}
b.withPublicKey(sendingKey)
return nil
}
// pickSendingKey tries to determine which key to use to encrypt outgoing mail.
// It returns a keyring containing the chosen key or an error.
//
// 1. If there are pinned keys in the vCard, those should be given preference
// (assuming the fingerprint matches one of the keys served by the API).
// 2. If there are pinned keys in the vCard but no matching keys were served
// by the API, we use one of the API keys but first show a modal to the
// user to ask them to confirm that they trust the API key.
// (Use case: user doesn't trust server, pins the only keys they trust to
// the contact, rogue server sends unknown keys, user should have option
// to say they don't recognise these keys and abort the mail send.)
// 3. If there are no pinned keys, then the client should encrypt with the
// first valid key served by the API (in principle the server already
// validates the keys and the first one provided should be valid).
func pickSendingKey(vCardData *contactSettings, rawAPIKeys []liteapi.PublicKey) (kr *crypto.KeyRing, err error) {
contactKeys := make([]*crypto.Key, len(vCardData.Keys))
apiKeys := make([]*crypto.Key, len(rawAPIKeys))
for i, key := range vCardData.Keys {
var ck *crypto.Key
// Contact keys are not armored.
if ck, err = crypto.NewKey([]byte(key)); err != nil {
return
}
contactKeys[i] = ck
}
for i, key := range rawAPIKeys {
var ck *crypto.Key
// API keys are armored.
if ck, err = crypto.NewKeyFromArmored(key.PublicKey); err != nil {
return
}
apiKeys[i] = ck
}
matchedKeys := matchFingerprints(contactKeys, apiKeys)
var sendingKey *crypto.Key
switch {
// Case 1.
case len(matchedKeys) > 0:
sendingKey = matchedKeys[0]
// Case 2.
case len(matchedKeys) == 0 && len(contactKeys) > 0:
// NOTE: Here we should ask for trust confirmation.
sendingKey = apiKeys[0]
// Case 3.
default:
sendingKey = apiKeys[0]
}
return crypto.NewKeyRing(sendingKey)
}
func matchFingerprints(a, b []*crypto.Key) (res []*crypto.Key) {
aMap := make(map[string]*crypto.Key)
for _, el := range a {
aMap[el.GetFingerprint()] = el
}
for _, el := range b {
if _, inA := aMap[el.GetFingerprint()]; inA {
res = append(res, el)
}
}
return
}
func (b *sendPrefsBuilder) setExternalPGPSettingsWithWKDKeys(
vCardData *contactSettings,
apiKeys []liteapi.PublicKey,
) (err error) {
// We're guaranteed to get at least one valid (i.e. not expired, revoked or
// marked as verification-only) public key from the server.
if len(apiKeys) == 0 {
return errors.New("an API key is necessary but wasn't provided")
}
// We always encrypt and sign external mail if WKD keys are present.
b.withEncrypt(true)
b.withSign(true)
// If the contact has a specific Scheme preference, we set it (otherwise we
// leave it unset to allow it to be filled in with the default value later).
if vCardData.Scheme != "" {
b.withScheme(vCardData.Scheme)
}
// Because the email is signed, the cryptographic scheme determines the email
// format. A PGP/INLINE scheme forces to use plain text. A PGP/MIME scheme
// forces the automatic format.
switch vCardData.Scheme {
case pgpMIME:
b.removeMIMEType()
case pgpInline:
b.withMIMEType("text/plain")
}
sendingKey, err := pickSendingKey(vCardData, apiKeys)
if err != nil {
return
}
b.withPublicKey(sendingKey)
return nil
}
func (b *sendPrefsBuilder) setExternalPGPSettingsWithoutWKDKeys(
vCardData *contactSettings,
) (err error) {
b.withEncrypt(vCardData.Encrypt)
if vCardData.SignIsSet {
b.withSign(vCardData.Sign)
}
// Sign must be enabled whenever encrypt is.
if vCardData.Encrypt {
b.withSign(true)
}
// If the contact has a specific Scheme preference, we set it (otherwise we
// leave it unset to allow it to be filled in with the default value later).
if vCardData.Scheme != "" {
b.withScheme(vCardData.Scheme)
}
// If we are signing the message, the PGP scheme overrides the MIMEType.
// Otherwise, we read the MIMEType from the vCard, if set.
if vCardData.Sign {
switch vCardData.Scheme {
case pgpMIME:
b.removeMIMEType()
case pgpInline:
b.withMIMEType("text/plain")
}
} else if vCardData.MIMEType != "" {
b.withMIMEType(vCardData.MIMEType)
}
if len(vCardData.Keys) > 0 {
var key *crypto.Key
// Contact keys are not armored.
if key, err = crypto.NewKey([]byte(vCardData.Keys[0])); err != nil {
return
}
var kr *crypto.KeyRing
if kr, err = crypto.NewKeyRing(key); err != nil {
return
}
b.withPublicKey(kr)
}
return nil
}
// setEncryptionPreferences sets the undefined values in the SendPreferences
// determined thus far using using the (global) user mail settings.
// The object we extract has the following possible value types:
//
// {
// encrypt: true | false,
// sign: true | false,
// pgpScheme: 'pgp-mime' | 'pgp-inline',
// mimeType: 'text/html' | 'text/plain',
// publicKey: OpenPGPKey | undefined/null
// }
//
// The public key can still be undefined as we do not need it if the outgoing
// email is not encrypted.
func (b *sendPrefsBuilder) setEncryptionPreferences(mailSettings liteapi.MailSettings) {
// For internal addresses or external ones with WKD keys, this flag should
// always be true. For external ones, an undefined flag defaults to false.
b.withEncryptDefault(false)
// For internal addresses or external ones with WKD keys, this flag should
// always be true. For external ones, an undefined flag defaults to the user
// mail setting "Sign External messages". Otherwise we keep the defined value
// unless it conflicts with the encrypt flag (we do not allow to send
// encrypted but not signed).
if mailSettings.Sign > 0 {
b.withSignDefault()
}
if b.shouldEncrypt() {
b.withSign(true)
}
// If undefined, default to the user mail setting "Default PGP scheme".
// Otherwise keep the defined value.
switch mailSettings.PGPScheme {
case liteapi.PGPInlineScheme:
b.withSchemeDefault(pgpInline)
case liteapi.PGPMIMEScheme:
b.withSchemeDefault(pgpMIME)
case liteapi.ClearMIMEScheme, liteapi.ClearScheme, liteapi.EncryptedOutsideScheme, liteapi.InternalScheme:
// nothing to set
}
// Its value is constrained by the sign flag and the PGP scheme:
// - Sign flag = true → For a PGP/Inline scheme, the MIME type must be
// 'plain/text'. Otherwise we default to the user mail setting "Composer mode"
// - Sign flag = false → If undefined, default to the user mail setting
// "Composer mode". Otherwise keep the defined value.
if b.shouldSign() && b.getScheme() == pgpInline {
b.withMIMEType("text/plain")
} else {
b.withMIMETypeDefault(mailSettings.DraftMIMEType)
}
}
func (b *sendPrefsBuilder) setMIMEPreferences(composerMIMEType string) {
// If the sign flag (that we just determined above) is true, then the MIME
// type is determined by the PGP scheme (also determined above): we should
// use 'text/plain' for a PGP/Inline scheme, and 'multipart/mixed' otherwise.
// Otherwise we use the MIME type from the encryption preferences, unless
// the plain text option has been selecting in the composer, which should
// enforce 'text/plain' and override the encryption preference.
if !b.isInternal() && b.shouldSign() {
switch b.getScheme() {
case pgpInline:
b.withMIMEType("text/plain")
default:
b.withMIMEType("multipart/mixed")
}
} else if composerMIMEType == "text/plain" {
b.withMIMEType("text/plain")
}
}

View File

@ -0,0 +1,445 @@
// Copyright (c) 2022 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 user
import (
"testing"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.protontech.ch/go/liteapi"
)
func TestPreferencesBuilder(t *testing.T) {
testContactKey := loadContactKey(t, testPublicKey)
testOtherContactKey := loadContactKey(t, testOtherPublicKey)
tests := []struct { //nolint:maligned
name string
contactMeta *contactSettings
receivedKeys []liteapi.PublicKey
isInternal bool
mailSettings liteapi.MailSettings
composerMIMEType string
wantEncrypt bool
wantSign liteapi.SignatureType
wantScheme liteapi.EncryptionScheme
wantMIMEType rfc822.MIMEType
wantPublicKey string
}{
{
name: "internal",
contactMeta: &contactSettings{},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: true,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.InternalScheme,
wantMIMEType: "text/html",
wantPublicKey: testPublicKey,
},
{
name: "internal with contact-specific email format",
contactMeta: &contactSettings{MIMEType: "text/plain"},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: true,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.InternalScheme,
wantMIMEType: "text/plain",
wantPublicKey: testPublicKey,
},
{
name: "internal with pinned contact public key",
contactMeta: &contactSettings{Keys: []string{testContactKey}},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: true,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.InternalScheme,
wantMIMEType: "text/html",
wantPublicKey: testPublicKey,
},
{
// NOTE: Need to figured out how to test that this calls the frontend to check for user confirmation.
name: "internal with conflicting contact public key",
contactMeta: &contactSettings{Keys: []string{testOtherContactKey}},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: true,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.InternalScheme,
wantMIMEType: "text/html",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external",
contactMeta: &contactSettings{},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external with contact-specific email format",
contactMeta: &contactSettings{MIMEType: "text/plain"},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external with global pgp-inline scheme",
contactMeta: &contactSettings{},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPInlineScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPInlineScheme,
wantMIMEType: "text/plain",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external with contact-specific pgp-inline scheme overriding global pgp-mime setting",
contactMeta: &contactSettings{Scheme: pgpInline},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPInlineScheme,
wantMIMEType: "text/plain",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external with contact-specific pgp-mime scheme overriding global pgp-inline setting",
contactMeta: &contactSettings{Scheme: pgpMIME},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPInlineScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "wkd-external with additional pinned contact public key",
contactMeta: &contactSettings{Keys: []string{testContactKey}},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
// NOTE: Need to figured out how to test that this calls the frontend to check for user confirmation.
name: "wkd-external with additional conflicting contact public key",
contactMeta: &contactSettings{Keys: []string{testOtherContactKey}},
receivedKeys: []liteapi.PublicKey{{PublicKey: testPublicKey}},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "external",
contactMeta: &contactSettings{},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: liteapi.NoSignature,
wantScheme: liteapi.ClearScheme,
wantMIMEType: "text/html",
},
{
name: "external with contact-specific email format",
contactMeta: &contactSettings{MIMEType: "text/plain"},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: liteapi.NoSignature,
wantScheme: liteapi.ClearScheme,
wantMIMEType: "text/plain",
},
{
name: "external with sign enabled",
contactMeta: &contactSettings{Sign: true, SignIsSet: true},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.ClearMIMEScheme,
wantMIMEType: "multipart/mixed",
},
{
name: "external with contact sign enabled and plain text",
contactMeta: &contactSettings{MIMEType: "text/plain", Scheme: pgpInline, Sign: true, SignIsSet: true},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.ClearScheme,
wantMIMEType: "text/plain",
},
{
name: "external with sign enabled, sending plaintext, should still send as ClearMIME",
contactMeta: &contactSettings{Sign: true, SignIsSet: true},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/plain"},
wantEncrypt: false,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.ClearMIMEScheme,
wantMIMEType: "multipart/mixed",
},
{
name: "external with pinned contact public key but no intention to encrypt/sign",
contactMeta: &contactSettings{Keys: []string{testContactKey}},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: false,
wantSign: liteapi.NoSignature,
wantScheme: liteapi.ClearScheme,
wantMIMEType: "text/html",
wantPublicKey: testPublicKey,
},
{
name: "external with pinned contact public key, encrypted and signed",
contactMeta: &contactSettings{Keys: []string{testContactKey}, Encrypt: true, Sign: true, SignIsSet: true},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPMIMEScheme,
wantMIMEType: "multipart/mixed",
wantPublicKey: testPublicKey,
},
{
name: "external with pinned contact public key, encrypted and signed using contact-specific pgp-inline",
contactMeta: &contactSettings{Keys: []string{testContactKey}, Encrypt: true, Sign: true, Scheme: pgpInline, SignIsSet: true},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPMIMEScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPInlineScheme,
wantMIMEType: "text/plain",
wantPublicKey: testPublicKey,
},
{
name: "external with pinned contact public key, encrypted and signed using global pgp-inline",
contactMeta: &contactSettings{Keys: []string{testContactKey}, Encrypt: true, Sign: true, SignIsSet: true},
receivedKeys: []liteapi.PublicKey{},
isInternal: false,
mailSettings: liteapi.MailSettings{PGPScheme: liteapi.PGPInlineScheme, DraftMIMEType: "text/html"},
wantEncrypt: true,
wantSign: liteapi.DetachedSignature,
wantScheme: liteapi.PGPInlineScheme,
wantMIMEType: "text/plain",
wantPublicKey: testPublicKey,
},
}
for _, test := range tests {
test := test // Avoid using range scope test inside function literal.
t.Run(test.name, func(t *testing.T) {
b := &sendPrefsBuilder{}
require.NoError(t, b.setPGPSettings(test.contactMeta, test.receivedKeys, test.isInternal))
b.setEncryptionPreferences(test.mailSettings)
b.setMIMEPreferences(test.composerMIMEType)
prefs := b.build()
assert.Equal(t, test.wantEncrypt, prefs.Encrypt)
assert.Equal(t, test.wantSign, prefs.SignatureType)
assert.Equal(t, test.wantScheme, prefs.EncryptionScheme)
assert.Equal(t, test.wantMIMEType, prefs.MIMEType)
if prefs.PubKey != nil {
wantKey, err := crypto.NewKeyFromArmored(test.wantPublicKey)
require.NoError(t, err)
haveKey, err := prefs.PubKey.GetKey(0)
require.NoError(t, err)
assert.Equal(t, wantKey.GetFingerprint(), haveKey.GetFingerprint())
}
})
}
}
func loadContactKey(t *testing.T, key string) string {
ck, err := crypto.NewKeyFromArmored(key)
require.NoError(t, err)
pk, err := ck.GetPublicKey()
require.NoError(t, err)
return string(pk)
}
const testPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
xsBNBFRJbc0BCAC0mMLZPDBbtSCWvxwmOfXfJkE2+ssM3ux21LhD/bPiWefEWSHl
CjJ8PqPHy7snSiUuxuj3f9AvXPvg+mjGLBwu1/QsnSP24sl3qD2onl39vPiLJXUq
Zs20ZRgnvX70gjkgEzMFBxINiy2MTIG+4RU8QA7y8KzWev0btqKiMeVa+GLEHhgZ
2KPOn4Jv1q4bI9hV0C9NUe2tTXS6/Vv3vbCY7lRR0kbJ65T5c8CmpqJuASIJNrSX
M/Q3NnnsY4kBYH0s5d2FgbASQvzrjuC2rngUg0EoPsrbDEVRA2/BCJonw7aASiNC
rSP92lkZdtYlax/pcoE/mQ4WSwySFmcFT7yFABEBAAHNBlVzZXJJRMLAcgQQAQgA
JgUCVEltzwYLCQgHAwIJED62JZ7fId8kBBUIAgoDFgIBAhsDAh4BAAD0nQf9EtH9
TC0JqSs8q194Zo244jjlJFM3EzxOSULq0zbywlLORfyoo/O8jU/HIuGz+LT98JDt
nltTqfjWgu6pS3ZL2/L4AGUKEoB7OI6oIdRwzMc61sqI+Qpbzxo7rzufH4CiXZc6
cxORUgL550xSCcqnq0q1mds7h5roKDzxMW6WLiEsc1dN8IQKzC7Ec5wA7U4oNGsJ
3TyI8jkIs0IhXrRCd26K0TW8Xp6GCsfblWXosR13y89WVNgC+xrrJKTZEisc0tRl
neIgjcwEUvwfIg2n9cDUFA/5BsfzTW5IurxqDEziIVP0L44PXjtJrBQaGMPlEbtP
5i2oi3OADVX2XbvsRc7ATQRUSW3PAQgAkPnu5fps5zhOB/e618v/iF3KiogxUeRh
A68TbvA+xnFfTxCx2Vo14aOL0CnaJ8gO5yRSqfomL2O1kMq07N1MGbqucbmc+aSf
oElc+Gd5xBE/w3RcEhKcAaYTi35vG22zlZup4x3ElioyIarOssFEkQgNNyDf5AXZ
jdHLA6qVxeqAb/Ff74+y9HUmLPSsRU9NwFzvK3Jv8C/ubHVLzTYdFgYkc4W1Uug9
Ou08K+/4NEMrwnPFBbZdJAuUjQz2zW2ZiEKiBggiorH2o5N3mYUnWEmUvqL3EOS8
TbWo8UBIW3DDm2JiZR8VrEgvBtc9mVDUj/x+5pR07Fy1D6DjRmAc9wARAQABwsBf
BBgBCAATBQJUSW3SCRA+tiWe3yHfJAIbDAAA/iwH/ik9RKZMB9Ir0x5mGpKPuqhu
gwrc3d04m1sOdXJm2NtD4ddzSEvzHwaPNvEvUl5v7FVMzf6+6mYGWHyNP4+e7Rtw
YLlRpud6smuGyDSsotUYyumiqP6680ZIeWVQ+a1TThNs878mAJy1FhvQFdTmA8XI
C616hDFpamQKPlpoO1a0wZnQhrPwT77HDYEEa+hqY4Jr/a7ui40S+7xYRHKL/7ZA
S4/grWllhU3dbNrwSzrOKwrA/U0/9t738Ap6JL71YymDeaL4sutcoaahda1pTrMW
ePtrCltz6uySwbZs7GXoEzjX3EAH+6qhkUJtzMaE3YEFEoQMGzcDTUEfXCJ3zJw=
=yT9U
-----END PGP PUBLIC KEY BLOCK-----`
const testOtherPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBF8Rmj4BCACgXXxRqLsmEUWZGd0f88BteXBfi9zL+9GysOTk4n9EgINLN2PU
5rYSmWvVocO8IAfl/z9zpTJQesQjGe5lHbygUWFmjadox2ZeecZw0PWCSRdAjk6w
Q4UX0JiCo3IuICZk1t53WWRtGnhA2Q21J4b2DJg4T5ZFKgKDzDhWoGF1ZStbI5X1
0rKTGFNHgreV5PqxUjxHVtx3rgT9Mx+13QTffqKR9oaYC6mNs4TNJdhyqfaYxqGw
ElxfdS9Wz6ODXrUNuSHETfgvAmo1Qep7GkefrC1isrmXA2+a+mXzFn4L0FCG073w
Vi/lEw6R/vKfN6QukHPxwoSguow4wTyhRRmfABEBAAG0GVRlc3RUZXN0IDx0ZXN0
dGVzdEBwbS5tZT6JAU4EEwEIADgWIQTsXZU1AxlWCPT02+BKdWAu4Q1jXQUCXxGa
PgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBKdWAu4Q1jXQw+B/0ZudN+
W9EqJtL/elm7Qla47zNsFmB+pHObdGoKtp3mNc97CQoW1yQ/i/V0heBFTAioP00g
FgEk1ZUJfO++EtI8esNFdDZqY99826/Cl0FlJwubn/XYxi4XyaGTY1nhhyEJ2HWI
/mZ+Jfm9ojbHSLwO5/AHiQt5t+LPDsKLXZw1BDJTgf1xD6e36CwAZgrPGWDqCXJ9
BjlQn5hje7p0F8vYWBnnfSPkMHwibz9FlFqDh5v3XTgGpFIWDVkPVgAs8erM9AM2
TjdpGcdW8xfcymo3j/o2QUBGYGJwPTsGEO5IkFRre9c/3REa7MKIi17Y479ub0A6
2J3xgnqgI4sxmgmOuQENBF8Rmj4BCADX3BamNZsjC3I0knVIwjbz//1r8WOfNwGh
gg5LsvpfLkrsNUZy+deSwb+hS9Auyr1xsMmtVyiTPGUXTjU4uUzY2zyTYWgYfSEi
CojlXmYYLsjyPzR7KhVP6QIYZqYkOQXaCQDRlprRoFIEe4FzTCuqDHatJNwSesGy
5pPJrjiAeb47m9KaoEIacoe9D3w1z4FCKN3A8cjiWT8NRfhYTBoE/T34oXVUj8l+
jLIgVUQgGoBos160Z1Cnxd2PKWFVh/Br3QtIPTbNVDWhh5T1+N2ypbwsXCawy6fj
cbOaTLz/vF9g+RJKC0MtxdL5qUtv3d3Zn07Sg+9H6wjsboAdAvirABEBAAGJATYE
GAEIACAWIQTsXZU1AxlWCPT02+BKdWAu4Q1jXQUCXxGaPgIbDAAKCRBKdWAu4Q1j
Xc4WB/9+aTGMMTlIdAFs9rf0i7i83pUOOxuLl34YQ0t5WGsjteQ4IK+gfuFvp37W
ktv98ShOxAexbfqzGyGcYLLgaCxCbbB85fvSeX0xK/C2UbiH3Gv1z8GTelailCxt
vyx642TwpcLXW1obHaHTSIi5L35Tce9gbug9sKCRSlAH76dANYBbMLa2Bl0LSrF8
mcie9jJaPRXGOeHOyZmPZwwGhVYgadjptWqXnFz3ua8vxgqG0sefWF23F36iVz2q
UjxSE+nKLaPFLlEDLgxG4SwHkcR9fi7zaQVnXg4rEjr0uz5MSUqZC4MNB4rkhU3g
/rUMQyZupw+xJ+ayQNVBEtYZd/9u
=TNX4
-----END PGP PUBLIC KEY BLOCK-----`

254
internal/user/sync.go Normal file
View File

@ -0,0 +1,254 @@
package user
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/proton-bridge/v2/internal/events"
"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.UpdateSync(true); err != nil {
return fmt.Errorf("failed to update sync status: %w", err)
}
return nil
}
func (user *User) syncLabels(ctx context.Context) 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)
}
// Create Folders/Labels mailboxes with a random ID and with the \Noselect attribute.
for _, prefix := range []string{folderPrefix, labelPrefix} {
user.updateCh <- newPlaceHolderMailboxCreatedUpdate(prefix)
}
// Sync the API folders.
folders, err := user.client.GetLabels(ctx, liteapi.LabelTypeFolder)
if err != nil {
return err
}
for _, folder := range folders {
user.updateCh <- newMailboxCreatedUpdate(imap.LabelID(folder.ID), []string{folderPrefix, folder.Path})
}
// Sync the API labels.
labels, err := user.client.GetLabels(ctx, liteapi.LabelTypeLabel)
if err != nil {
return err
}
for _, label := range labels {
user.updateCh <- newMailboxCreatedUpdate(imap.LabelID(label.ID), []string{labelPrefix, label.Path})
}
return nil
}
func (user *User) syncMessages(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
metadata, err := user.client.GetAllMessageMetadata(ctx)
if err != nil {
return err
}
requests := xslices.Map(metadata, func(metadata liteapi.MessageMetadata) request {
return request{
messageID: metadata.ID,
addrKR: user.addrKRs[metadata.AddressID],
}
})
flusher := newFlusher(user.ID(), user.updateCh, user.notifyCh, len(metadata), chunkSize)
defer flusher.flush()
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)
return nil
}); err != nil {
return fmt.Errorf("failed to build messages: %w", err)
}
return nil
}
type flusher struct {
userID string
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(),
}
}
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.Bool() {
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
}
return imap.NewMailboxCreated(imap.Mailbox{
ID: labelID,
Name: []string{labelName},
Flags: defaultFlags,
PermanentFlags: defaultPermanentFlags,
Attributes: imap.NewFlagSet(imap.AttrNoInferiors),
})
}
func newPlaceHolderMailboxCreatedUpdate(labelName string) *imap.MailboxCreated {
return imap.NewMailboxCreated(imap.Mailbox{
ID: imap.LabelID(uuid.NewString()),
Name: []string{labelName},
Flags: defaultFlags,
PermanentFlags: defaultPermanentFlags,
Attributes: imap.NewFlagSet(imap.AttrNoSelect),
})
}
func newMailboxCreatedUpdate(labelID imap.LabelID, labelName []string) *imap.MailboxCreated {
return imap.NewMailboxCreated(imap.Mailbox{
ID: labelID,
Name: labelName,
Flags: defaultFlags,
PermanentFlags: defaultPermanentFlags,
Attributes: imap.NewFlagSet(),
})
}
func filterLabelIDs(labelIDs []string) []string {
var filteredLabelIDs []string
for _, labelID := range labelIDs {
switch labelID {
case liteapi.AllDraftsLabel, liteapi.AllSentLabel, liteapi.OutboxLabel:
// ... skip ...
default:
filteredLabelIDs = append(filteredLabelIDs, labelID)
}
}
return filteredLabelIDs
}

219
internal/user/user.go Normal file
View File

@ -0,0 +1,219 @@
package user
import (
"context"
"runtime"
"time"
"github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/gluon/imap"
"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/slices"
)
var (
DefaultEventPeriod = 20 * time.Second
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]
apiUser liteapi.User
addresses []liteapi.Address
settings liteapi.MailSettings
notifyCh chan events.Event
updateCh chan imap.Update
userKR *crypto.KeyRing
addrKRs map[string]*crypto.KeyRing
imapConn *imapConnector
}
func New(
ctx context.Context,
vault *vault.User,
client *liteapi.Client,
apiUser liteapi.User,
apiAddrs []liteapi.Address,
userKR *crypto.KeyRing,
addrKRs map[string]*crypto.KeyRing,
) (*User, error) {
if vault.EventID() == "" {
eventID, err := client.GetLatestEventID(ctx)
if err != nil {
return nil, err
}
if err := vault.UpdateEventID(eventID); err != nil {
return nil, err
}
}
settings, err := client.GetMailSettings(ctx)
if err != nil {
return nil, err
}
user := &User{
apiUser: apiUser,
addresses: apiAddrs,
settings: settings,
vault: vault,
client: client,
builder: newBuilder(client, runtime.NumCPU()*runtime.NumCPU(), runtime.NumCPU()*runtime.NumCPU()),
notifyCh: make(chan events.Event),
updateCh: make(chan imap.Update),
userKR: userKR,
addrKRs: addrKRs,
}
// When we receive an auth object, we update it in the store.
// This will be used to authorize the user on the next run.
client.AddAuthHandler(func(auth liteapi.Auth) {
if err := user.vault.UpdateAuth(auth.UID, auth.RefreshToken); err != nil {
logrus.WithError(err).Error("Failed to update auth")
}
})
// 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{
UserID: user.ID(),
}
})
// When we receive an API event, we attempt to handle it. If successful, we send the event to the event channel.
go func() {
for event := range user.client.NewEventStreamer(DefaultEventPeriod, DefaultEventJitter, vault.EventID()).Subscribe() {
if err := user.handleAPIEvent(event); err != nil {
logrus.WithError(err).Error("Failed to handle event")
} else {
if err := user.vault.UpdateEventID(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
}
func (user *User) ID() string {
return user.apiUser.ID
}
func (user *User) Name() string {
return user.apiUser.Name
}
func (user *User) Match(query string) bool {
if query == user.Name() {
return true
}
return slices.Contains(user.Addresses(), 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
},
)
}
func (user *User) GluonID() string {
return user.vault.GluonID()
}
func (user *User) GluonKey() []byte {
return user.vault.GluonKey()
}
func (user *User) BridgePass() string {
return user.vault.BridgePass()
}
func (user *User) UsedSpace() int {
return user.apiUser.UsedSpace
}
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
}
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
}
}
user.imapConn = newIMAPConnector(user.client, user.updateCh, user.Addresses(), user.vault.BridgePass())
return user.imapConn, nil
}
func (user *User) NewSMTPSession(username string) (smtp.Session, error) {
return newSMTPSession(user.client, username, user.addresses, user.userKR, user.addrKRs, user.settings), nil
}
func (user *User) Logout(ctx context.Context) error {
return user.client.AuthDelete(ctx)
}
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
}
}
// Close the user's message builder.
user.builder.Done()
// Close the user's API client.
user.client.Close()
// Close the user's notify channel.
close(user.notifyCh)
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
}