mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-16 07:06:45 +00:00
GODT-1779: Remove go-imap
This commit is contained in:
61
internal/user/builder.go
Normal file
61
internal/user/builder.go
Normal 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
30
internal/user/crypto.go
Normal 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
12
internal/user/errors.go
Normal 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
230
internal/user/events.go
Normal 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
293
internal/user/imap.go
Normal 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
330
internal/user/smtp.go
Normal 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)
|
||||
}
|
||||
69
internal/user/smtp_packages.go
Normal file
69
internal/user/smtp_packages.go
Normal 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
579
internal/user/smtp_prefs.go
Normal 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")
|
||||
}
|
||||
}
|
||||
445
internal/user/smtp_prefs_test.go
Normal file
445
internal/user/smtp_prefs_test.go
Normal 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
254
internal/user/sync.go
Normal 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
219
internal/user/user.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user