We build too many walls and not enough bridges
This commit is contained in:
220
internal/imap/backend.go
Normal file
220
internal/imap/backend.go
Normal file
@ -0,0 +1,220 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package imap provides IMAP server of the Bridge.
|
||||
package imap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
imapid "github.com/ProtonMail/go-imap-id"
|
||||
"github.com/ProtonMail/proton-bridge/internal/bridge"
|
||||
"github.com/ProtonMail/proton-bridge/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/listener"
|
||||
goIMAPBackend "github.com/emersion/go-imap/backend"
|
||||
)
|
||||
|
||||
type panicHandler interface {
|
||||
HandlePanic()
|
||||
}
|
||||
|
||||
type imapBackend struct {
|
||||
panicHandler panicHandler
|
||||
bridge bridger
|
||||
updates chan interface{}
|
||||
eventListener listener.Listener
|
||||
|
||||
users map[string]*imapUser
|
||||
usersLocker sync.Locker
|
||||
|
||||
lastMailClient imapid.ID
|
||||
lastMailClientLocker sync.Locker
|
||||
|
||||
imapCache map[string]map[string]string
|
||||
imapCachePath string
|
||||
imapCacheLock *sync.RWMutex
|
||||
}
|
||||
|
||||
// NewIMAPBackend returns struct implementing go-imap/backend interface.
|
||||
func NewIMAPBackend(
|
||||
panicHandler panicHandler,
|
||||
eventListener listener.Listener,
|
||||
cfg configProvider,
|
||||
bridge *bridge.Bridge,
|
||||
) *imapBackend { //nolint[golint]
|
||||
bridgeWrap := newBridgeWrap(bridge)
|
||||
backend := newIMAPBackend(panicHandler, cfg, bridgeWrap, eventListener)
|
||||
|
||||
// We want idle updates coming from bridge's updates channel (which in turn come
|
||||
// from the bridge users' stores) to be sent to the imap backend's update channel.
|
||||
backend.updates = bridge.GetIMAPUpdatesChannel()
|
||||
|
||||
go backend.monitorDisconnectedUsers()
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
func newIMAPBackend(
|
||||
panicHandler panicHandler,
|
||||
cfg configProvider,
|
||||
bridge bridger,
|
||||
eventListener listener.Listener,
|
||||
) *imapBackend {
|
||||
return &imapBackend{
|
||||
panicHandler: panicHandler,
|
||||
bridge: bridge,
|
||||
updates: make(chan interface{}),
|
||||
eventListener: eventListener,
|
||||
|
||||
users: map[string]*imapUser{},
|
||||
usersLocker: &sync.Mutex{},
|
||||
|
||||
lastMailClient: imapid.ID{imapid.FieldName: clientNone},
|
||||
lastMailClientLocker: &sync.Mutex{},
|
||||
|
||||
imapCachePath: cfg.GetIMAPCachePath(),
|
||||
imapCacheLock: &sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *imapBackend) getUser(address string) (*imapUser, error) {
|
||||
ib.usersLocker.Lock()
|
||||
defer ib.usersLocker.Unlock()
|
||||
|
||||
address = strings.ToLower(address)
|
||||
imapUser, ok := ib.users[address]
|
||||
if ok {
|
||||
return imapUser, nil
|
||||
}
|
||||
return ib.createUser(address)
|
||||
}
|
||||
|
||||
// createUser require that address MUST be in lowercase.
|
||||
func (ib *imapBackend) createUser(address string) (*imapUser, error) {
|
||||
log.WithField("address", address).Debug("Creating new IMAP user")
|
||||
|
||||
user, err := ib.bridge.GetUser(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure you return the same user for all valid addresses when in combined mode.
|
||||
if user.IsCombinedAddressMode() {
|
||||
address = strings.ToLower(user.GetPrimaryAddress())
|
||||
if combinedUser, ok := ib.users[address]; ok {
|
||||
return combinedUser, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Client can log in only using address so we can properly close all IMAP connections.
|
||||
var addressID string
|
||||
if addressID, err = user.GetAddressID(address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newUser, err := newIMAPUser(ib.panicHandler, ib, user, addressID, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ib.users[address] = newUser
|
||||
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
// deleteUser removes a user from the users map.
|
||||
// This is a safe operation even if the user doesn't exist so it is no problem if it is done twice.
|
||||
func (ib *imapBackend) deleteUser(address string) {
|
||||
log.WithField("address", address).Debug("Deleting IMAP user")
|
||||
|
||||
ib.usersLocker.Lock()
|
||||
defer ib.usersLocker.Unlock()
|
||||
|
||||
delete(ib.users, strings.ToLower(address))
|
||||
}
|
||||
|
||||
// Login authenticates a user.
|
||||
func (ib *imapBackend) Login(username, password string) (goIMAPBackend.User, error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer ib.panicHandler.HandlePanic()
|
||||
|
||||
imapUser, err := ib.getUser(username)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("Cannot get user")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := imapUser.user.CheckBridgeLogin(password); err != nil {
|
||||
log.WithError(err).Error("Could not check bridge password")
|
||||
_ = imapUser.Logout()
|
||||
// Apple Mail sometimes generates a lot of requests very quickly.
|
||||
// It's therefore good to have a timeout after a bad login so that we can slow
|
||||
// those requests down a little bit.
|
||||
time.Sleep(10 * time.Second)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The update channel should be nil until we try to login to IMAP for the first time
|
||||
// so that it doesn't make bridge slow for users who are only using bridge for SMTP
|
||||
// (otherwise the store will be locked for 1 sec per email during synchronization).
|
||||
imapUser.user.SetIMAPIdleUpdateChannel()
|
||||
|
||||
return imapUser, nil
|
||||
}
|
||||
|
||||
// Updates returns a channel of updates for IMAP IDLE extension.
|
||||
func (ib *imapBackend) Updates() <-chan interface{} {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer ib.panicHandler.HandlePanic()
|
||||
|
||||
return ib.updates
|
||||
}
|
||||
|
||||
func (ib *imapBackend) CreateMessageLimit() *uint32 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ib *imapBackend) setLastMailClient(id imapid.ID) {
|
||||
ib.lastMailClientLocker.Lock()
|
||||
defer ib.lastMailClientLocker.Unlock()
|
||||
|
||||
if name, ok := id[imapid.FieldName]; ok && ib.lastMailClient[imapid.FieldName] != name {
|
||||
ib.lastMailClient = imapid.ID{}
|
||||
for k, v := range id {
|
||||
ib.lastMailClient[k] = v
|
||||
}
|
||||
log.Warn("Mail Client ID changed to ", ib.lastMailClient)
|
||||
ib.bridge.SetCurrentClient(
|
||||
ib.lastMailClient[imapid.FieldName],
|
||||
ib.lastMailClient[imapid.FieldVersion],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// monitorDisconnectedUsers removes users when it receives a close connection event for them.
|
||||
func (ib *imapBackend) monitorDisconnectedUsers() {
|
||||
ch := make(chan string)
|
||||
ib.eventListener.Add(events.CloseConnectionEvent, ch)
|
||||
|
||||
for address := range ch {
|
||||
// delete the user to ensure future imap login attempts use the latest bridge user
|
||||
// (bridge user might be removed-readded so we want to use the new bridge user object).
|
||||
ib.deleteUser(address)
|
||||
}
|
||||
}
|
||||
136
internal/imap/backend_cache.go
Normal file
136
internal/imap/backend_cache.go
Normal file
@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Cache keys.
|
||||
const (
|
||||
SubscriptionException = "subscription_exceptions"
|
||||
)
|
||||
|
||||
// addToCache adds item to existing item list.
|
||||
// Starting from following structure:
|
||||
// {
|
||||
// "username": {"label": "item1;item2"}
|
||||
// }
|
||||
//
|
||||
// After calling addToCache("username", "label", "newItem") we get:
|
||||
// {
|
||||
// "username": {"label": "item1;item2;newItem"}
|
||||
// }
|
||||
//
|
||||
func (ib *imapBackend) addToCache(userID, label, toAdd string) {
|
||||
list := ib.getCacheList(userID, label)
|
||||
|
||||
if list != "" {
|
||||
list = list + ";" + toAdd
|
||||
} else {
|
||||
list = toAdd
|
||||
}
|
||||
|
||||
ib.imapCacheLock.Lock()
|
||||
ib.imapCache[userID][label] = list
|
||||
ib.imapCacheLock.Unlock()
|
||||
|
||||
if err := ib.saveIMAPCache(); err != nil {
|
||||
log.Info("Backend/userinfo: could not save cache: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *imapBackend) removeFromCache(userID, label, toRemove string) {
|
||||
list := ib.getCacheList(userID, label)
|
||||
|
||||
split := strings.Split(list, ";")
|
||||
|
||||
for i, item := range split {
|
||||
if item == toRemove {
|
||||
split = append(split[:i], split[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
ib.imapCacheLock.Lock()
|
||||
ib.imapCache[userID][label] = strings.Join(split, ";")
|
||||
ib.imapCacheLock.Unlock()
|
||||
|
||||
if err := ib.saveIMAPCache(); err != nil {
|
||||
log.Info("Backend/userinfo: could not save cache: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *imapBackend) getCacheList(userID, label string) (list string) {
|
||||
if err := ib.loadIMAPCache(); err != nil {
|
||||
log.Warn("Could not load cache: ", err)
|
||||
}
|
||||
|
||||
ib.imapCacheLock.Lock()
|
||||
if ib.imapCache == nil {
|
||||
ib.imapCache = map[string]map[string]string{}
|
||||
}
|
||||
|
||||
if ib.imapCache[userID] == nil {
|
||||
ib.imapCache[userID] = map[string]string{}
|
||||
ib.imapCache[userID][SubscriptionException] = ""
|
||||
}
|
||||
|
||||
list = ib.imapCache[userID][label]
|
||||
|
||||
ib.imapCacheLock.Unlock()
|
||||
|
||||
_ = ib.saveIMAPCache()
|
||||
return
|
||||
}
|
||||
|
||||
func (ib *imapBackend) loadIMAPCache() error {
|
||||
if ib.imapCache != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ib.imapCacheLock.Lock()
|
||||
defer ib.imapCacheLock.Unlock()
|
||||
|
||||
f, err := os.Open(ib.imapCachePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() //nolint[errcheck]
|
||||
|
||||
return json.NewDecoder(f).Decode(&ib.imapCache)
|
||||
}
|
||||
|
||||
func (ib *imapBackend) saveIMAPCache() error {
|
||||
if ib.imapCache == nil {
|
||||
return errors.New("cannot save cache: cache is nil")
|
||||
}
|
||||
|
||||
ib.imapCacheLock.Lock()
|
||||
defer ib.imapCacheLock.Unlock()
|
||||
|
||||
f, err := os.Create(ib.imapCachePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() //nolint[errcheck]
|
||||
|
||||
return json.NewEncoder(f).Encode(ib.imapCache)
|
||||
}
|
||||
78
internal/imap/bridge.go
Normal file
78
internal/imap/bridge.go
Normal file
@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"github.com/ProtonMail/proton-bridge/internal/bridge"
|
||||
)
|
||||
|
||||
type configProvider interface {
|
||||
GetEventsPath() string
|
||||
GetDBDir() string
|
||||
GetIMAPCachePath() string
|
||||
}
|
||||
|
||||
type bridger interface {
|
||||
SetCurrentClient(clientName, clientVersion string)
|
||||
GetUser(query string) (bridgeUser, error)
|
||||
}
|
||||
|
||||
type bridgeUser interface {
|
||||
ID() string
|
||||
CheckBridgeLogin(password string) error
|
||||
IsCombinedAddressMode() bool
|
||||
GetAddressID(address string) (string, error)
|
||||
GetPrimaryAddress() string
|
||||
SetIMAPIdleUpdateChannel()
|
||||
UpdateUser() error
|
||||
Logout() error
|
||||
CloseConnection(address string)
|
||||
GetStore() storeUserProvider
|
||||
GetTemporaryPMAPIClient() bridge.PMAPIProvider
|
||||
}
|
||||
|
||||
type bridgeWrap struct {
|
||||
*bridge.Bridge
|
||||
}
|
||||
|
||||
// newBridgeWrap wraps bridge struct into local bridgeWrap to implement local
|
||||
// interface. Problem is that bridge is returning package bridge's User type,
|
||||
// so every method that returns User has to be overridden to fulfill the interface.
|
||||
func newBridgeWrap(bridge *bridge.Bridge) *bridgeWrap {
|
||||
return &bridgeWrap{Bridge: bridge}
|
||||
}
|
||||
|
||||
func (b *bridgeWrap) GetUser(query string) (bridgeUser, error) {
|
||||
user, err := b.Bridge.GetUser(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newBridgeUserWrap(user), nil
|
||||
}
|
||||
|
||||
type bridgeUserWrap struct {
|
||||
*bridge.User
|
||||
}
|
||||
|
||||
func newBridgeUserWrap(bridgeUser *bridge.User) *bridgeUserWrap {
|
||||
return &bridgeUserWrap{User: bridgeUser}
|
||||
}
|
||||
|
||||
func (u *bridgeUserWrap) GetStore() storeUserProvider {
|
||||
return newStoreUserWrap(u.User.GetStore())
|
||||
}
|
||||
151
internal/imap/cache/cache.go
vendored
Normal file
151
internal/imap/cache/cache.go
vendored
Normal file
@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
backendMessage "github.com/ProtonMail/proton-bridge/pkg/message"
|
||||
)
|
||||
|
||||
type key struct {
|
||||
ID string
|
||||
Timestamp int64
|
||||
Size int
|
||||
}
|
||||
|
||||
type oldestFirst []key
|
||||
|
||||
func (s oldestFirst) Len() int { return len(s) }
|
||||
func (s oldestFirst) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
||||
func (s oldestFirst) Less(i, j int) bool { return s[i].Timestamp < s[j].Timestamp }
|
||||
|
||||
type cachedMessage struct {
|
||||
key
|
||||
data []byte
|
||||
structure backendMessage.BodyStructure
|
||||
}
|
||||
|
||||
//nolint[gochecknoglobals]
|
||||
var (
|
||||
cacheTimeLimit = int64(1 * 60 * 60 * 1000) // milliseconds
|
||||
cacheSizeLimit = 100 * 1000 * 1000 // B - MUST be larger than email max size limit (~ 25 MB)
|
||||
mailCache = make(map[string]cachedMessage)
|
||||
|
||||
// cacheMutex takes care of one single operation, whereas buildMutex takes
|
||||
// care of the whole action doing multiple operations. buildMutex will protect
|
||||
// you from asking server or decrypting or building the same message more
|
||||
// than once. When first request to build the message comes, it will block
|
||||
// all other build requests. When the first one is done, all others are
|
||||
// handled by cache, not doing anything twice. With cacheMutex we are safe
|
||||
// only to not mess up with the cache, but we could end up downloading and
|
||||
// building message twice.
|
||||
cacheMutex = &sync.Mutex{}
|
||||
buildMutex = &sync.Mutex{}
|
||||
buildLocks = map[string]interface{}{}
|
||||
)
|
||||
|
||||
func (m *cachedMessage) isValidOrDel() bool {
|
||||
if m.key.Timestamp+cacheTimeLimit < timestamp() {
|
||||
delete(mailCache, m.key.ID)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func timestamp() int64 {
|
||||
return time.Now().UnixNano() / int64(time.Millisecond)
|
||||
}
|
||||
|
||||
func Clear() {
|
||||
mailCache = make(map[string]cachedMessage)
|
||||
}
|
||||
|
||||
// BuildLock locks per message level, not on global level.
|
||||
// Multiple different messages can be building at once.
|
||||
func BuildLock(messageID string) {
|
||||
for {
|
||||
buildMutex.Lock()
|
||||
if _, ok := buildLocks[messageID]; ok { // if locked, wait
|
||||
buildMutex.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
} else { // if unlocked, lock it
|
||||
buildLocks[messageID] = struct{}{}
|
||||
buildMutex.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BuildUnlock(messageID string) {
|
||||
buildMutex.Lock()
|
||||
defer buildMutex.Unlock()
|
||||
delete(buildLocks, messageID)
|
||||
}
|
||||
|
||||
func LoadMail(mID string) (reader *bytes.Reader, structure *backendMessage.BodyStructure) {
|
||||
reader = &bytes.Reader{}
|
||||
cacheMutex.Lock()
|
||||
defer cacheMutex.Unlock()
|
||||
if message, ok := mailCache[mID]; ok && message.isValidOrDel() {
|
||||
reader = bytes.NewReader(message.data)
|
||||
structure = &message.structure
|
||||
|
||||
// Update timestamp to keep emails which are used often.
|
||||
message.Timestamp = timestamp()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func SaveMail(mID string, msg []byte, structure *backendMessage.BodyStructure) {
|
||||
cacheMutex.Lock()
|
||||
defer cacheMutex.Unlock()
|
||||
|
||||
newMessage := cachedMessage{
|
||||
key: key{
|
||||
ID: mID,
|
||||
Timestamp: timestamp(),
|
||||
Size: len(msg),
|
||||
},
|
||||
data: msg,
|
||||
structure: *structure,
|
||||
}
|
||||
|
||||
// Remove old and reduce size.
|
||||
totalSize := 0
|
||||
messageList := []key{}
|
||||
for _, message := range mailCache {
|
||||
if message.isValidOrDel() {
|
||||
messageList = append(messageList, message.key)
|
||||
totalSize += message.key.Size
|
||||
}
|
||||
}
|
||||
sort.Sort(oldestFirst(messageList))
|
||||
var oldest key
|
||||
for totalSize+newMessage.key.Size >= cacheSizeLimit {
|
||||
oldest, messageList = messageList[0], messageList[1:]
|
||||
delete(mailCache, oldest.ID)
|
||||
totalSize -= oldest.Size
|
||||
}
|
||||
|
||||
// Write new.
|
||||
mailCache[mID] = newMessage
|
||||
}
|
||||
99
internal/imap/cache/cache_test.go
vendored
Normal file
99
internal/imap/cache/cache_test.go
vendored
Normal file
@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bckMsg "github.com/ProtonMail/proton-bridge/pkg/message"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var bs = &bckMsg.BodyStructure{} //nolint[gochecknoglobals]
|
||||
const testUID = "testmsg"
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
msg := []byte("Test message")
|
||||
|
||||
SaveMail(testUID, msg, bs)
|
||||
require.Equal(t, mailCache[testUID].data, msg)
|
||||
|
||||
reader, _ := LoadMail(testUID)
|
||||
require.Equal(t, reader.Len(), len(msg))
|
||||
stored := make([]byte, len(msg))
|
||||
_, _ = reader.Read(stored)
|
||||
require.Equal(t, stored, msg)
|
||||
}
|
||||
|
||||
func TestMissing(t *testing.T) {
|
||||
reader, _ := LoadMail("non-existing")
|
||||
require.Equal(t, reader.Len(), 0)
|
||||
}
|
||||
|
||||
func TestClearOld(t *testing.T) {
|
||||
cacheTimeLimit = 10
|
||||
msg := []byte("Test message")
|
||||
SaveMail(testUID, msg, bs)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
reader, _ := LoadMail(testUID)
|
||||
require.Equal(t, reader.Len(), 0)
|
||||
}
|
||||
|
||||
func TestClearBig(t *testing.T) {
|
||||
msg := []byte("Test message")
|
||||
|
||||
nSize := 3
|
||||
cacheSizeLimit = nSize*len(msg) + 1
|
||||
cacheTimeLimit = int64(nSize * nSize * 2) // be sure the message will survive
|
||||
|
||||
// It should have more than nSize items.
|
||||
for i := 0; i < nSize*nSize; i++ {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
SaveMail(fmt.Sprintf("%s%d", testUID, i), msg, bs)
|
||||
if len(mailCache) > nSize {
|
||||
t.Error("Number of items in cache should not be more than", nSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that the oldest are deleted first.
|
||||
for i := 0; i < nSize*nSize; i++ {
|
||||
iUID := fmt.Sprintf("%s%d", testUID, i)
|
||||
reader, _ := LoadMail(iUID)
|
||||
if i < nSize*(nSize-1) && reader.Len() != 0 {
|
||||
mail := mailCache[iUID]
|
||||
t.Error("LoadMail should return empty but have:", mail.data, iUID, mail.key.Timestamp)
|
||||
}
|
||||
stored := make([]byte, len(msg))
|
||||
_, _ = reader.Read(stored)
|
||||
|
||||
if i >= nSize*(nSize-1) && !bytes.Equal(stored, msg) {
|
||||
t.Error("LoadMail returned wrong message:", stored, iUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurency(t *testing.T) {
|
||||
msg := []byte("Test message")
|
||||
for i := 0; i < 10; i++ {
|
||||
go SaveMail(fmt.Sprintf("%s%d", testUID, i), msg, bs)
|
||||
}
|
||||
}
|
||||
35
internal/imap/imap.go
Normal file
35
internal/imap/imap.go
Normal file
@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import "github.com/ProtonMail/proton-bridge/pkg/config"
|
||||
|
||||
const (
|
||||
fetchMessagesWorkers = 5 // In how many workers to fetch message (group list on IMAP).
|
||||
fetchAttachmentsWorkers = 5 // In how many workers to fetch attachments (for one message).
|
||||
|
||||
clientAppleMail = "Mac OS X Mail" //nolint[deadcode]
|
||||
clientThunderbird = "Thunderbird" //nolint[deadcode]
|
||||
clientOutlookMac = "Microsoft Outlook for Mac" //nolint[deadcode]
|
||||
clientOutlookWin = "Microsoft Outlook" //nolint[deadcode]
|
||||
clientNone = ""
|
||||
)
|
||||
|
||||
var (
|
||||
log = config.GetLogEntry("imap") //nolint[gochecknoglobals]
|
||||
)
|
||||
187
internal/imap/mailbox.go
Normal file
187
internal/imap/mailbox.go
Normal file
@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/pkg/message"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/emersion/go-imap"
|
||||
specialuse "github.com/emersion/go-imap-specialuse"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type imapMailbox struct {
|
||||
panicHandler panicHandler
|
||||
user *imapUser
|
||||
name string
|
||||
|
||||
log *logrus.Entry
|
||||
|
||||
storeUser storeUserProvider
|
||||
storeAddress storeAddressProvider
|
||||
storeMailbox storeMailboxProvider
|
||||
}
|
||||
|
||||
// newIMAPMailbox returns struct implementing go-imap/mailbox interface.
|
||||
func newIMAPMailbox(panicHandler panicHandler, user *imapUser, storeMailbox storeMailboxProvider) *imapMailbox {
|
||||
return &imapMailbox{
|
||||
panicHandler: panicHandler,
|
||||
user: user,
|
||||
name: storeMailbox.Name(),
|
||||
|
||||
log: log.
|
||||
WithField("addressID", user.storeAddress.AddressID()).
|
||||
WithField("userID", user.storeUser.UserID()).
|
||||
WithField("labelID", storeMailbox.LabelID()),
|
||||
|
||||
storeUser: user.storeUser,
|
||||
storeAddress: user.storeAddress,
|
||||
storeMailbox: storeMailbox,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns this mailbox name.
|
||||
func (im *imapMailbox) Name() string {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
return im.name
|
||||
}
|
||||
|
||||
// Info returns this mailbox info.
|
||||
func (im *imapMailbox) Info() (*imap.MailboxInfo, error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
info := &imap.MailboxInfo{
|
||||
Attributes: im.getFlags(),
|
||||
Delimiter: im.storeMailbox.GetDelimiter(),
|
||||
Name: im.name,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (im *imapMailbox) getFlags() []string {
|
||||
flags := []string{imap.NoInferiorsAttr} // Subfolders are not yet supported by API.
|
||||
switch im.storeMailbox.LabelID() {
|
||||
case pmapi.SentLabel:
|
||||
flags = append(flags, specialuse.Sent)
|
||||
case pmapi.TrashLabel:
|
||||
flags = append(flags, specialuse.Trash)
|
||||
case pmapi.SpamLabel:
|
||||
flags = append(flags, specialuse.Junk)
|
||||
case pmapi.ArchiveLabel:
|
||||
flags = append(flags, specialuse.Archive)
|
||||
case pmapi.AllMailLabel:
|
||||
flags = append(flags, specialuse.All)
|
||||
case pmapi.DraftLabel:
|
||||
flags = append(flags, specialuse.Drafts)
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
// Status returns this mailbox status. The fields Name, Flags and
|
||||
// PermanentFlags in the returned MailboxStatus must be always populated. This
|
||||
// function does not affect the state of any messages in the mailbox. See RFC
|
||||
// 3501 section 6.3.10 for a list of items that can be requested.
|
||||
//
|
||||
// It always returns the state of DB (which could be different to server status).
|
||||
// Additionally it checks that all stored numbers are same as in DB and polls events if needed.
|
||||
func (im *imapMailbox) Status(items []string) (*imap.MailboxStatus, error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
l := log.WithField("status-label", im.storeMailbox.LabelID())
|
||||
l.Data["user"] = im.storeUser.UserID()
|
||||
l.Data["address"] = im.storeAddress.AddressID()
|
||||
status := imap.NewMailboxStatus(im.name, items)
|
||||
status.UidValidity = im.storeMailbox.UIDValidity()
|
||||
status.PermanentFlags = []string{
|
||||
imap.SeenFlag, strings.ToUpper(imap.SeenFlag),
|
||||
imap.FlaggedFlag, strings.ToUpper(imap.FlaggedFlag),
|
||||
imap.DeletedFlag, strings.ToUpper(imap.DeletedFlag),
|
||||
imap.DraftFlag, strings.ToUpper(imap.DraftFlag),
|
||||
message.AppleMailJunkFlag,
|
||||
message.ThunderbirdJunkFlag,
|
||||
message.ThunderbirdNonJunkFlag,
|
||||
}
|
||||
|
||||
dbTotal, dbUnread, err := im.storeMailbox.GetCounts()
|
||||
l.Debugln("DB: total", dbTotal, "unread", dbUnread, "err", err)
|
||||
if err == nil {
|
||||
status.Messages = uint32(dbTotal)
|
||||
status.Unseen = uint32(dbUnread)
|
||||
}
|
||||
|
||||
if status.UidNext, err = im.storeMailbox.GetNextUID(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Subscribe adds the mailbox to the server's set of "active" or "subscribed" mailboxes.
|
||||
func (im *imapMailbox) Subscribe() error {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
label := im.storeMailbox.LabelID()
|
||||
if !im.user.isSubscribed(label) {
|
||||
im.user.removeFromCache(SubscriptionException, label)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unsubscribe removes the mailbox to the server's set of "active" or "subscribed" mailboxes.
|
||||
func (im *imapMailbox) Unsubscribe() error {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
label := im.storeMailbox.LabelID()
|
||||
if im.user.isSubscribed(label) {
|
||||
im.user.addToCache(SubscriptionException, label)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check requests a checkpoint of the currently selected mailbox. A checkpoint
|
||||
// refers to any implementation-dependent housekeeping associated with the
|
||||
// mailbox (e.g., resolving the server's in-memory state of the mailbox with
|
||||
// the state on its disk). A checkpoint MAY take a non-instantaneous amount of
|
||||
// real time to complete. If a server implementation has no such housekeeping
|
||||
// considerations, CHECK is equivalent to NOOP.
|
||||
func (im *imapMailbox) Check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expunge permanently removes all messages that have the \Deleted flag set
|
||||
// from the currently selected mailbox.
|
||||
// Our messages do not have \Deleted flag, nothing to do here.
|
||||
func (im *imapMailbox) Expunge() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (im *imapMailbox) ListQuotas() ([]string, error) {
|
||||
return []string{""}, nil
|
||||
}
|
||||
784
internal/imap/mailbox_message.go
Normal file
784
internal/imap/mailbox_message.go
Normal file
@ -0,0 +1,784 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"github.com/ProtonMail/proton-bridge/internal/imap/cache"
|
||||
"github.com/ProtonMail/proton-bridge/internal/imap/uidplus"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/message"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/parallel"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-textwrapper"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
enmime "github.com/jhillyerd/enmime"
|
||||
"github.com/pkg/errors"
|
||||
openpgperrors "golang.org/x/crypto/openpgp/errors"
|
||||
)
|
||||
|
||||
type doNotCacheError struct{ e error }
|
||||
|
||||
func (dnc *doNotCacheError) Error() string { return dnc.e.Error() }
|
||||
func (dnc *doNotCacheError) add(err error) { dnc.e = multierror.Append(dnc.e, err) }
|
||||
func (dnc *doNotCacheError) errorOrNil() error {
|
||||
if dnc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dnc.e != nil {
|
||||
return dnc
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateMessage appends a new message to this mailbox. The \Recent flag will
|
||||
// be added regardless of whether flags is empty or not. If date is nil, the
|
||||
// current time will be used.
|
||||
//
|
||||
// If the Backend implements Updater, it must notify the client immediately
|
||||
// via a mailbox update.
|
||||
func (im *imapMailbox) CreateMessage(flags []string, date time.Time, body imap.Literal) error { // nolint[funlen]
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
m, _, _, readers, err := message.Parse(body, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addr := im.storeAddress.APIAddress()
|
||||
if addr == nil {
|
||||
return errors.New("no available address for encryption")
|
||||
}
|
||||
m.AddressID = addr.ID
|
||||
kr := addr.KeyRing()
|
||||
|
||||
// Handle imported messages which have no "Sender" address.
|
||||
// This sometimes occurs with outlook which reports errors as imported emails or for drafts.
|
||||
if m.Sender == nil {
|
||||
im.log.Warning("Append: Missing email sender. Will use main address")
|
||||
m.Sender = &mail.Address{
|
||||
Name: "",
|
||||
Address: addr.Email,
|
||||
}
|
||||
}
|
||||
|
||||
// "Drafts" needs to call special API routes.
|
||||
// Clients always append the whole message again and remove the old one.
|
||||
if im.storeMailbox.LabelID() == pmapi.DraftLabel {
|
||||
// Sender address needs to be sanitised (drafts need to match cases exactly).
|
||||
m.Sender.Address = pmapi.ConstructAddress(m.Sender.Address, addr.Email)
|
||||
|
||||
draft, _, err := im.user.storeUser.CreateDraft(kr, m, readers, "", "", "")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create draft")
|
||||
}
|
||||
|
||||
targetSeq := im.storeMailbox.GetUIDList([]string{draft.ID})
|
||||
return uidplus.AppendResponse(im.storeMailbox.UIDValidity(), targetSeq)
|
||||
}
|
||||
|
||||
// We need to make sure this is an import, and not a sent message from this account
|
||||
// (sent messages from the account will be added by the event loop).
|
||||
if im.storeMailbox.LabelID() == pmapi.SentLabel {
|
||||
sanitizedSender := pmapi.SanitizeEmail(m.Sender.Address)
|
||||
|
||||
// Check whether this message was sent by a bridge user.
|
||||
user, err := im.user.backend.bridge.GetUser(sanitizedSender)
|
||||
if err == nil && user.ID() == im.storeUser.UserID() {
|
||||
logEntry := im.log.WithField("addr", sanitizedSender).WithField("extID", m.Header.Get("Message-Id"))
|
||||
|
||||
// If we find the message in the store already, we can skip importing it.
|
||||
if foundUID := im.storeMailbox.GetUIDByHeader(&m.Header); foundUID != uint32(0) {
|
||||
logEntry.Info("Ignoring APPEND of duplicate to Sent folder")
|
||||
return uidplus.AppendResponse(im.storeMailbox.UIDValidity(), &uidplus.OrderedSeq{foundUID})
|
||||
}
|
||||
|
||||
// We didn't find the message in the store, so we are currently sending it.
|
||||
logEntry.WithField("time", date).Info("No matching UID, continuing APPEND to Sent")
|
||||
|
||||
// For now we don't import user's own messages to Sent because GetUIDByHeader is not smart enough.
|
||||
// This will be fixed in GODT-143.
|
||||
return nil
|
||||
}
|
||||
|
||||
// This is an APPEND to the Sent folder, so we will set the sent flag
|
||||
m.Flags |= pmapi.FlagSent
|
||||
}
|
||||
|
||||
message.ParseFlags(m, flags)
|
||||
if !date.IsZero() {
|
||||
m.Time = date.Unix()
|
||||
}
|
||||
|
||||
internalID := m.Header.Get("X-Pm-Internal-Id")
|
||||
references := m.Header.Get("References")
|
||||
referenceList := strings.Fields(references)
|
||||
|
||||
if len(referenceList) > 0 {
|
||||
lastReference := referenceList[len(referenceList)-1]
|
||||
// In case we are using a mail client which corrupts headers, try "References" too.
|
||||
re := regexp.MustCompile("<[a-zA-Z0-9-_=]*@protonmail.internalid>")
|
||||
match := re.FindString(lastReference)
|
||||
if match != "" {
|
||||
internalID = match[1 : len(match)-len("@protonmail.internalid>")]
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid appending a message which is already on the server. Apply the new
|
||||
// label instead. This sometimes happens which Outlook (it uses APPEND instead of COPY).
|
||||
if internalID != "" {
|
||||
// Check to see if this belongs to a different address in split mode or another ProtonMail account.
|
||||
msg, err := im.storeMailbox.GetMessage(internalID)
|
||||
if err == nil && (im.user.user.IsCombinedAddressMode() || (im.storeAddress.AddressID() == msg.Message().AddressID)) {
|
||||
IDs := []string{internalID}
|
||||
|
||||
err = im.storeMailbox.LabelMessages(IDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetSeq := im.storeMailbox.GetUIDList([]string{m.ID})
|
||||
return uidplus.AppendResponse(im.storeMailbox.UIDValidity(), targetSeq)
|
||||
}
|
||||
}
|
||||
|
||||
im.log.Info("Importing external message")
|
||||
if err := im.importMessage(m, readers, kr); err != nil {
|
||||
im.log.Error("Import failed: ", err)
|
||||
return err
|
||||
}
|
||||
|
||||
targetSeq := im.storeMailbox.GetUIDList([]string{m.ID})
|
||||
return uidplus.AppendResponse(im.storeMailbox.UIDValidity(), targetSeq)
|
||||
}
|
||||
|
||||
func (im *imapMailbox) importMessage(m *pmapi.Message, readers []io.Reader, kr *pmcrypto.KeyRing) (err error) { // nolint[funlen]
|
||||
b := &bytes.Buffer{}
|
||||
|
||||
// Overwrite content for main header for import.
|
||||
// Even if message has just simple body we should upload as multipart/mixed.
|
||||
// Each part has encrypted body and header reflects the original header.
|
||||
mainHeader := message.GetHeader(m)
|
||||
mainHeader.Set("Content-Type", "multipart/mixed; boundary="+message.GetBoundary(m))
|
||||
mainHeader.Del("Content-Disposition")
|
||||
mainHeader.Del("Content-Transfer-Encoding")
|
||||
if err = writeHeader(b, mainHeader); err != nil {
|
||||
return
|
||||
}
|
||||
mw := multipart.NewWriter(b)
|
||||
if err = mw.SetBoundary(message.GetBoundary(m)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write the body part.
|
||||
bodyHeader := make(textproto.MIMEHeader)
|
||||
bodyHeader.Set("Content-Type", m.MIMEType+"; charset=utf-8")
|
||||
bodyHeader.Set("Content-Disposition", "inline")
|
||||
bodyHeader.Set("Content-Transfer-Encoding", "7bit")
|
||||
|
||||
var p io.Writer
|
||||
if p, err = mw.CreatePart(bodyHeader); err != nil {
|
||||
return
|
||||
}
|
||||
// First, encrypt the message body.
|
||||
if err = m.Encrypt(kr, kr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(p, m.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write the attachments parts.
|
||||
for i := 0; i < len(m.Attachments); i++ {
|
||||
att := m.Attachments[i]
|
||||
r := readers[i]
|
||||
h := message.GetAttachmentHeader(att)
|
||||
if p, err = mw.CreatePart(h); err != nil {
|
||||
return
|
||||
}
|
||||
// Create line wrapper writer.
|
||||
ww := textwrapper.NewRFC822(p)
|
||||
|
||||
// Create base64 writer.
|
||||
bw := base64.NewEncoder(base64.StdEncoding, ww)
|
||||
|
||||
data, err := ioutil.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create encrypted writer.
|
||||
pgpMessage, err := kr.Encrypt(pmcrypto.NewPlainMessage(data), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := bw.Write(pgpMessage.GetBinary()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := mw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
labels := []string{}
|
||||
for _, l := range m.LabelIDs {
|
||||
if l == pmapi.StarredLabel {
|
||||
labels = append(labels, pmapi.StarredLabel)
|
||||
}
|
||||
}
|
||||
|
||||
return im.storeMailbox.ImportMessage(m, b.Bytes(), labels)
|
||||
}
|
||||
|
||||
func (im *imapMailbox) getMessage(storeMessage storeMessageProvider, items []string) (msg *imap.Message, err error) {
|
||||
im.log.WithField("msgID", storeMessage.ID()).Trace("Getting message")
|
||||
|
||||
seqNum, err := storeMessage.SequenceNumber()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m := storeMessage.Message()
|
||||
|
||||
msg = imap.NewMessage(seqNum, items)
|
||||
for _, item := range items {
|
||||
switch item {
|
||||
case imap.EnvelopeMsgAttr:
|
||||
msg.Envelope = message.GetEnvelope(m)
|
||||
case imap.BodyMsgAttr, imap.BodyStructureMsgAttr:
|
||||
var structure *message.BodyStructure
|
||||
if structure, _, err = im.getBodyStructure(storeMessage); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.BodyStructure, err = structure.IMAPBodyStructure([]int{}); err != nil {
|
||||
return
|
||||
}
|
||||
case imap.FlagsMsgAttr:
|
||||
msg.Flags = message.GetFlags(m)
|
||||
case imap.InternalDateMsgAttr:
|
||||
msg.InternalDate = time.Unix(m.Time, 0)
|
||||
case imap.SizeMsgAttr:
|
||||
// Size attribute on the server counts encrypted data. The value is cleared
|
||||
// on our part and we need to compute "real" size of decrypted data.
|
||||
if m.Size <= 0 {
|
||||
if _, _, err = im.getBodyStructure(storeMessage); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
msg.Size = uint32(m.Size)
|
||||
case imap.UidMsgAttr:
|
||||
msg.Uid, err = storeMessage.UID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
s := item
|
||||
|
||||
var section *imap.BodySectionName
|
||||
if section, err = imap.NewBodySectionName(s); err != nil {
|
||||
err = nil // Ignore error
|
||||
break
|
||||
}
|
||||
|
||||
var literal imap.Literal
|
||||
if literal, err = im.getMessageBodySection(storeMessage, section); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg.Body[section] = literal
|
||||
}
|
||||
}
|
||||
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func (im *imapMailbox) getBodyStructure(storeMessage storeMessageProvider) (
|
||||
structure *message.BodyStructure,
|
||||
bodyReader *bytes.Reader, err error,
|
||||
) {
|
||||
m := storeMessage.Message()
|
||||
id := im.storeUser.UserID() + m.ID
|
||||
cache.BuildLock(id)
|
||||
if bodyReader, structure = cache.LoadMail(id); bodyReader.Len() == 0 || structure == nil {
|
||||
var body []byte
|
||||
structure, body, err = im.buildMessage(m)
|
||||
if err == nil && structure != nil && len(body) > 0 {
|
||||
m.Size = int64(len(body))
|
||||
if err := storeMessage.SetSize(m.Size); err != nil {
|
||||
im.log.WithError(err).
|
||||
WithField("newSize", m.Size).
|
||||
WithField("msgID", m.ID).
|
||||
Warn("Cannot update size while building")
|
||||
}
|
||||
if err := storeMessage.SetContentTypeAndHeader(m.MIMEType, m.Header); err != nil {
|
||||
im.log.WithError(err).
|
||||
WithField("msgID", m.ID).
|
||||
Warn("Cannot update header while building")
|
||||
}
|
||||
// Drafts can change and we don't want to cache them.
|
||||
if !isMessageInDraftFolder(m) {
|
||||
cache.SaveMail(id, body, structure)
|
||||
}
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
if _, ok := err.(*doNotCacheError); ok {
|
||||
im.log.WithField("msgID", m.ID).Errorf("do not cache message: %v", err)
|
||||
err = nil
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
}
|
||||
cache.BuildUnlock(id)
|
||||
return structure, bodyReader, err
|
||||
}
|
||||
|
||||
func isMessageInDraftFolder(m *pmapi.Message) bool {
|
||||
for _, labelID := range m.LabelIDs {
|
||||
if labelID == pmapi.DraftLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// This will download message (or read from cache) and pick up the section,
|
||||
// extract data (header,body, both) and trim the output if needed.
|
||||
func (im *imapMailbox) getMessageBodySection(storeMessage storeMessageProvider, section *imap.BodySectionName) (literal imap.Literal, err error) { // nolint[funlen]
|
||||
var (
|
||||
structure *message.BodyStructure
|
||||
bodyReader *bytes.Reader
|
||||
header textproto.MIMEHeader
|
||||
response []byte
|
||||
)
|
||||
|
||||
im.log.WithField("msgID", storeMessage.ID()).Trace("Getting message body")
|
||||
|
||||
m := storeMessage.Message()
|
||||
|
||||
if len(section.Path) == 0 && section.Specifier == imap.HeaderSpecifier {
|
||||
// We can extract message header without decrypting.
|
||||
header = message.GetHeader(m)
|
||||
// We need to ensure we use the correct content-type,
|
||||
// otherwise AppleMail expects `text/plain` in HTML mails.
|
||||
if header.Get("Content-Type") == "" {
|
||||
if err = im.fetchMessage(m); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = im.setMessageContentType(m); err != nil {
|
||||
return
|
||||
}
|
||||
if err = storeMessage.SetContentTypeAndHeader(m.MIMEType, m.Header); err != nil {
|
||||
return
|
||||
}
|
||||
header = message.GetHeader(m)
|
||||
}
|
||||
} else {
|
||||
// The rest of cases need download and decrypt.
|
||||
structure, bodyReader, err = im.getBodyStructure(storeMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case section.Specifier == imap.EntireSpecifier && len(section.Path) == 0:
|
||||
// An empty section specification refers to the entire message, including the header.
|
||||
response, err = structure.GetSection(bodyReader, section.Path)
|
||||
case section.Specifier == imap.TextSpecifier || (section.Specifier == imap.EntireSpecifier && len(section.Path) != 0):
|
||||
// The TEXT specifier refers to the content of the message (or section), omitting the [RFC-2822] header.
|
||||
// Non-empty section with no specifier (imap.EntireSpecifier) refers to section content without header.
|
||||
response, err = structure.GetSectionContent(bodyReader, section.Path)
|
||||
case section.Specifier == imap.MimeSpecifier:
|
||||
// The MIME part specifier refers to the [MIME-IMB] header for this part.
|
||||
fallthrough
|
||||
case section.Specifier == imap.HeaderSpecifier:
|
||||
header, err = structure.GetSectionHeader(section.Path)
|
||||
default:
|
||||
err = errors.New("Unknown specifier " + section.Specifier)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Filter header. Options are: all fields, only selected fields, all fields except selected.
|
||||
if header != nil {
|
||||
// remove fields
|
||||
if len(section.Fields) != 0 && section.NotFields {
|
||||
for _, field := range section.Fields {
|
||||
header.Del(field)
|
||||
}
|
||||
}
|
||||
|
||||
fields := make([]string, 0, len(header))
|
||||
if len(section.Fields) == 0 || section.NotFields { // add all and sort
|
||||
for f := range header {
|
||||
fields = append(fields, f)
|
||||
}
|
||||
sort.Strings(fields)
|
||||
} else { // add only requested (in requested order)
|
||||
for _, f := range section.Fields {
|
||||
fields = append(fields, textproto.CanonicalMIMEHeaderKey(f))
|
||||
}
|
||||
}
|
||||
|
||||
headerBuf := &bytes.Buffer{}
|
||||
for _, canonical := range fields {
|
||||
if values, ok := header[canonical]; !ok {
|
||||
continue
|
||||
} else {
|
||||
for _, val := range values {
|
||||
fmt.Fprintf(headerBuf, "%s: %s\r\n", canonical, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
response = headerBuf.Bytes()
|
||||
}
|
||||
|
||||
// Trim any output if requested.
|
||||
literal = bytes.NewBuffer(section.ExtractPartial(response))
|
||||
return literal, nil
|
||||
}
|
||||
|
||||
func (im *imapMailbox) fetchMessage(m *pmapi.Message) (err error) {
|
||||
im.log.Trace("Fetching message")
|
||||
|
||||
complete, err := im.storeMailbox.FetchMessage(m.ID)
|
||||
if err != nil {
|
||||
im.log.WithError(err).Error("Could not get message from store")
|
||||
return
|
||||
}
|
||||
|
||||
*m = *complete.Message()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (im *imapMailbox) customMessage(m *pmapi.Message, err error, attachBody bool) {
|
||||
// Assuming quoted-printable.
|
||||
origBody := strings.Replace(m.Body, "=", "=3D", -1)
|
||||
m.Body = "Content-Type: text/html\r\n"
|
||||
m.Body = "\n<html><head></head><body style=3D\"font-family: Arial,'Helvetica Neue',Helvetica,sans-serif; font-size: 14px; \">\n"
|
||||
m.Body += "<div style=3D\"color:#555; background-color:#cf9696; padding:20px; border-radius: 4px;\" >\n<strong>Decryption error</strong><br/>Decryption of this message's encrypted content failed.<pre>\n"
|
||||
m.Body += err.Error()
|
||||
m.Body += "\n</pre></div>\n"
|
||||
|
||||
if attachBody {
|
||||
m.Body += "<div style=3D\"color:#333; background-color:#f4f4f4; border: 1px solid #acb0bf; border-radius: 2px; padding:1rem; margin:1rem 0; font-family:monospace; font-size: 1em;\" ><pre>\n"
|
||||
m.Body += origBody
|
||||
m.Body += "\n</pre></div>\n"
|
||||
}
|
||||
|
||||
m.Body += "</body></html>"
|
||||
m.MIMEType = "text/html"
|
||||
|
||||
// NOTE: we need to set header in custom message header, so we check that is non-nil.
|
||||
if m.Header == nil {
|
||||
m.Header = make(mail.Header)
|
||||
}
|
||||
}
|
||||
|
||||
func (im *imapMailbox) writeMessageBody(w io.Writer, m *pmapi.Message) (err error) {
|
||||
im.log.Trace("Writing message body")
|
||||
|
||||
if m.Body == "" {
|
||||
im.log.Trace("While writing message body, noticed message body is null, need to fetch")
|
||||
if err = im.fetchMessage(m); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
kr := im.user.client.KeyRingForAddressID(m.AddressID)
|
||||
err = message.WriteBody(w, kr, m)
|
||||
if err != nil {
|
||||
im.customMessage(m, err, true)
|
||||
_, _ = io.WriteString(w, m.Body)
|
||||
err = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (im *imapMailbox) writeAndParseMIMEBody(m *pmapi.Message) (mime *enmime.Envelope, err error) { //nolint[unused]
|
||||
b := &bytes.Buffer{}
|
||||
if err = im.writeMessageBody(b, m); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mime, err = enmime.ReadEnvelope(b)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (im *imapMailbox) writeAttachmentBody(w io.Writer, m *pmapi.Message, att *pmapi.Attachment) (err error) {
|
||||
// Retrieve encrypted attachment.
|
||||
r, err := im.user.client.GetAttachment(att.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer r.Close() //nolint[errcheck]
|
||||
|
||||
kr := im.user.client.KeyRingForAddressID(m.AddressID)
|
||||
if err = message.WriteAttachmentBody(w, kr, m, att, r); err != nil {
|
||||
// Returning an error here makes certain mail clients behave badly,
|
||||
// trying to retrieve the message again and again.
|
||||
im.log.Warn("Cannot write attachment body: ", err)
|
||||
err = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (im *imapMailbox) writeRelatedPart(p io.Writer, m *pmapi.Message, inlines []*pmapi.Attachment) (err error) {
|
||||
related := multipart.NewWriter(p)
|
||||
|
||||
_ = related.SetBoundary(message.GetRelatedBoundary(m))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err = im.writeMessageBody(buf, m); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write the body part.
|
||||
h := message.GetBodyHeader(m)
|
||||
|
||||
if p, err = related.CreatePart(h); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = buf.WriteTo(p)
|
||||
|
||||
for _, inline := range inlines {
|
||||
buf = &bytes.Buffer{}
|
||||
if err = im.writeAttachmentBody(buf, m, inline); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h := message.GetAttachmentHeader(inline)
|
||||
if p, err = related.CreatePart(h); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = buf.WriteTo(p)
|
||||
}
|
||||
|
||||
_ = related.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
noMultipart = iota // only body
|
||||
simpleMultipart // body + attachment or inline
|
||||
complexMultipart // mixed, rfc822, alternatives, ...
|
||||
)
|
||||
|
||||
func (im *imapMailbox) setMessageContentType(m *pmapi.Message) (multipartType int, err error) {
|
||||
if m.MIMEType == "" {
|
||||
err = fmt.Errorf("trying to set Content-Type without MIME TYPE")
|
||||
return
|
||||
}
|
||||
// message.MIMEType can have just three values from our server:
|
||||
// * `text/html` (refers to body type, but might contain attachments and inlines)
|
||||
// * `text/plain` (refers to body type, but might contain attachments and inlines)
|
||||
// * `multipart/mixed` (refers to external message with multipart structure)
|
||||
// The proper header content fields must be set and saved to DB based MIMEType and content.
|
||||
multipartType = noMultipart
|
||||
if m.MIMEType == pmapi.ContentTypeMultipartMixed {
|
||||
multipartType = complexMultipart
|
||||
} else if m.NumAttachments != 0 {
|
||||
multipartType = simpleMultipart
|
||||
}
|
||||
|
||||
h := textproto.MIMEHeader(m.Header)
|
||||
if multipartType == noMultipart {
|
||||
message.SetBodyContentFields(&h, m)
|
||||
} else {
|
||||
h.Set("Content-Type",
|
||||
fmt.Sprintf("%s; boundary=%s", "multipart/mixed", message.GetBoundary(m)),
|
||||
)
|
||||
}
|
||||
m.Header = mail.Header(h)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// buildMessage from PM to IMAP.
|
||||
func (im *imapMailbox) buildMessage(m *pmapi.Message) (structure *message.BodyStructure, msgBody []byte, err error) {
|
||||
im.log.Trace("Building message")
|
||||
|
||||
var errNoCache doNotCacheError
|
||||
|
||||
// If fetch or decryption fails we need to change the MIMEType (in customMessage).
|
||||
err = im.fetchMessage(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
kr := im.user.client.KeyRingForAddressID(m.AddressID)
|
||||
errDecrypt := m.Decrypt(kr)
|
||||
|
||||
if errDecrypt != nil && errDecrypt != openpgperrors.ErrSignatureExpired {
|
||||
errNoCache.add(errDecrypt)
|
||||
im.customMessage(m, errDecrypt, true)
|
||||
}
|
||||
|
||||
// Inner function can fail even when message is decrypted.
|
||||
// #1048 For example we have problem with double-encrypted messages
|
||||
// which seems as still encrypted and we try them to decrypt again
|
||||
// and that fails. For any building error is better to return custom
|
||||
// message than error because it will not be fixed and users would
|
||||
// get error message all the time and could not see some messages.
|
||||
structure, msgBody, err = im.buildMessageInner(m, kr)
|
||||
if err == pmapi.ErrAPINotReachable || err == pmapi.ErrInvalidToken || err == pmapi.ErrUpgradeApplication {
|
||||
return nil, nil, err
|
||||
} else if err != nil {
|
||||
errNoCache.add(err)
|
||||
im.customMessage(m, err, true)
|
||||
structure, msgBody, err = im.buildMessageInner(m, kr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err = errNoCache.errorOrNil()
|
||||
|
||||
return structure, msgBody, err
|
||||
}
|
||||
|
||||
func (im *imapMailbox) buildMessageInner(m *pmapi.Message, kr *pmcrypto.KeyRing) (structure *message.BodyStructure, msgBody []byte, err error) { // nolint[funlen]
|
||||
multipartType, err := im.setMessageContentType(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tmpBuf := &bytes.Buffer{}
|
||||
mainHeader := message.GetHeader(m)
|
||||
if err = writeHeader(tmpBuf, mainHeader); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(tmpBuf, "\r\n")
|
||||
|
||||
switch multipartType {
|
||||
case noMultipart:
|
||||
err = message.WriteBody(tmpBuf, kr, m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case complexMultipart:
|
||||
_, _ = io.WriteString(tmpBuf, "\r\n--"+message.GetBoundary(m)+"\r\n")
|
||||
err = message.WriteBody(tmpBuf, kr, m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(tmpBuf, "\r\n--"+message.GetBoundary(m)+"--\r\n")
|
||||
case simpleMultipart:
|
||||
atts, inlines := message.SeparateInlineAttachments(m)
|
||||
mw := multipart.NewWriter(tmpBuf)
|
||||
_ = mw.SetBoundary(message.GetBoundary(m))
|
||||
|
||||
var partWriter io.Writer
|
||||
|
||||
if len(inlines) > 0 {
|
||||
relatedHeader := message.GetRelatedHeader(m)
|
||||
if partWriter, err = mw.CreatePart(relatedHeader); err != nil {
|
||||
return
|
||||
}
|
||||
_ = im.writeRelatedPart(partWriter, m, inlines)
|
||||
} else {
|
||||
buf := &bytes.Buffer{}
|
||||
if err = im.writeMessageBody(buf, m); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Write the body part.
|
||||
bodyHeader := message.GetBodyHeader(m)
|
||||
if partWriter, err = mw.CreatePart(bodyHeader); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = buf.WriteTo(partWriter)
|
||||
}
|
||||
|
||||
// Write the attachments parts.
|
||||
input := make([]interface{}, len(atts))
|
||||
for i, att := range atts {
|
||||
input[i] = att
|
||||
}
|
||||
|
||||
processCallback := func(value interface{}) (interface{}, error) {
|
||||
att := value.(*pmapi.Attachment)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err = im.writeAttachmentBody(buf, m, att); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
collectCallback := func(idx int, value interface{}) error {
|
||||
buf := value.(*bytes.Buffer)
|
||||
defer buf.Reset()
|
||||
att := atts[idx]
|
||||
|
||||
attachmentHeader := message.GetAttachmentHeader(att)
|
||||
if partWriter, err = mw.CreatePart(attachmentHeader); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = buf.WriteTo(partWriter)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = parallel.RunParallel(fetchAttachmentsWorkers, input, processCallback, collectCallback)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = mw.Close()
|
||||
default:
|
||||
fmt.Fprintf(tmpBuf, "\r\n\r\nUknown multipart type: %d\r\n\r\n", multipartType)
|
||||
}
|
||||
|
||||
// We need to copy buffer before building body structure.
|
||||
msgBody = tmpBuf.Bytes()
|
||||
structure, err = message.NewBodyStructure(tmpBuf)
|
||||
if err != nil {
|
||||
// NOTE: We need to set structure if it fails and is empty.
|
||||
if structure == nil {
|
||||
structure = &message.BodyStructure{}
|
||||
}
|
||||
}
|
||||
return structure, msgBody, err
|
||||
}
|
||||
41
internal/imap/mailbox_message_test.go
Normal file
41
internal/imap/mailbox_message_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDoNotCache(t *testing.T) {
|
||||
var dnc doNotCacheError
|
||||
require.NoError(t, dnc.errorOrNil())
|
||||
_, ok := dnc.errorOrNil().(*doNotCacheError)
|
||||
require.True(t, !ok, "should not be type doNotCacheError")
|
||||
|
||||
dnc.add(errors.New("first"))
|
||||
require.True(t, dnc.errorOrNil() != nil, "should be error")
|
||||
_, ok = dnc.errorOrNil().(*doNotCacheError)
|
||||
require.True(t, ok, "should be type doNotCacheError")
|
||||
|
||||
dnc.add(errors.New("second"))
|
||||
dnc.add(errors.New("third"))
|
||||
t.Log(dnc.errorOrNil())
|
||||
}
|
||||
490
internal/imap/mailbox_messages.go
Normal file
490
internal/imap/mailbox_messages.go
Normal file
@ -0,0 +1,490 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/imap/uidplus"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/message"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/parallel"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// UpdateMessagesFlags alters flags for the specified message(s).
|
||||
//
|
||||
// If the Backend implements Updater, it must notify the client immediately
|
||||
// via a message update.
|
||||
func (im *imapMailbox) UpdateMessagesFlags(uid bool, seqSet *imap.SeqSet, operation imap.FlagsOp, flags []string) error {
|
||||
log.WithFields(logrus.Fields{
|
||||
"flags": flags,
|
||||
"operation": operation,
|
||||
}).Debug("Updating message flags")
|
||||
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
messageIDs, err := im.apiIDsFromSeqSet(uid, seqSet)
|
||||
if err != nil || len(messageIDs) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range flags {
|
||||
switch f {
|
||||
case imap.SeenFlag:
|
||||
switch operation {
|
||||
case imap.SetFlags, imap.AddFlags:
|
||||
_ = im.storeMailbox.MarkMessagesRead(messageIDs)
|
||||
case imap.RemoveFlags:
|
||||
_ = im.storeMailbox.MarkMessagesUnread(messageIDs)
|
||||
}
|
||||
case imap.FlaggedFlag:
|
||||
switch operation {
|
||||
case imap.SetFlags, imap.AddFlags:
|
||||
_ = im.storeMailbox.MarkMessagesStarred(messageIDs)
|
||||
case imap.RemoveFlags:
|
||||
_ = im.storeMailbox.MarkMessagesUnstarred(messageIDs)
|
||||
}
|
||||
case imap.DeletedFlag:
|
||||
if operation == imap.RemoveFlags {
|
||||
break // Nothing to do, no message has the \Deleted flag.
|
||||
}
|
||||
_ = im.storeMailbox.DeleteMessages(messageIDs)
|
||||
case imap.AnsweredFlag, imap.DraftFlag, imap.RecentFlag:
|
||||
// Not supported.
|
||||
case message.AppleMailJunkFlag, message.ThunderbirdJunkFlag:
|
||||
storeMailbox, err := im.storeAddress.GetMailbox(pmapi.SpamLabel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle custom junk flags for Apple Mail and Thunderbird.
|
||||
switch operation {
|
||||
// No label removal is necessary because Spam and Inbox are both exclusive labels so the backend
|
||||
// will automatically take care of label removal.
|
||||
case imap.SetFlags, imap.AddFlags:
|
||||
_ = storeMailbox.LabelMessages(messageIDs)
|
||||
case imap.RemoveFlags:
|
||||
_ = storeMailbox.UnlabelMessages(messageIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyMessages copies the specified message(s) to the end of the specified
|
||||
// destination mailbox. The flags and internal date of the message(s) SHOULD
|
||||
// be preserved, and the Recent flag SHOULD be set, in the copy.
|
||||
func (im *imapMailbox) CopyMessages(uid bool, seqSet *imap.SeqSet, targetLabel string) error {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
messageIDs, err := im.apiIDsFromSeqSet(uid, seqSet)
|
||||
if err != nil || len(messageIDs) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
// It is needed to get UID list before LabelingMessages because
|
||||
// messages can be removed from source during labeling (e.g. folder1 -> folder2).
|
||||
sourceSeqSet := im.storeMailbox.GetUIDList(messageIDs)
|
||||
|
||||
targetStoreMBX, err := im.storeAddress.GetMailbox(targetLabel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = targetStoreMBX.LabelMessages(messageIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetSeqSet := targetStoreMBX.GetUIDList(messageIDs)
|
||||
return uidplus.CopyResponse(im.storeMailbox.UIDValidity(), sourceSeqSet, targetSeqSet)
|
||||
}
|
||||
|
||||
// MoveMessages adds dest's label and removes this mailbox' label from each message.
|
||||
//
|
||||
// This should not be used until MOVE extension has option to send UIDPLUS
|
||||
// responses.
|
||||
func (im *imapMailbox) MoveMessages(uid bool, seqSet *imap.SeqSet, newLabel string) error {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
messageIDs, err := im.apiIDsFromSeqSet(uid, seqSet)
|
||||
if err != nil || len(messageIDs) == 0 {
|
||||
return err
|
||||
}
|
||||
storeMailbox, err := im.storeAddress.GetMailbox(newLabel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Label messages first to not loss them. If message is only in trash and we unlabel
|
||||
// it, it will be removed completely and we cannot label it back.
|
||||
if err := storeMailbox.LabelMessages(messageIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return im.storeMailbox.UnlabelMessages(messageIDs)
|
||||
}
|
||||
|
||||
// SearchMessages searches messages. The returned list must contain UIDs if
|
||||
// uid is set to true, or sequence numbers otherwise.
|
||||
func (im *imapMailbox) SearchMessages(isUID bool, criteria *imap.SearchCriteria) (ids []uint32, err error) { //nolint[gocyclo]
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer im.panicHandler.HandlePanic()
|
||||
|
||||
if criteria.Not != nil || criteria.Or[0] != nil {
|
||||
return nil, errors.New("unsupported search query")
|
||||
}
|
||||
|
||||
if criteria.Body != "" || criteria.Text != "" {
|
||||
log.Warn("Body and Text criteria not applied.")
|
||||
}
|
||||
|
||||
var apiIDs []string
|
||||
if criteria.SeqSet != nil {
|
||||
apiIDs, err = im.apiIDsFromSeqSet(false, criteria.SeqSet)
|
||||
} else {
|
||||
apiIDs, err = im.storeMailbox.GetAPIIDsFromSequenceRange(1, 0)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var apiIDsFromUID []string
|
||||
if criteria.Uid != nil {
|
||||
if apiIDs, err := im.apiIDsFromSeqSet(true, criteria.Uid); err == nil {
|
||||
apiIDsFromUID = append(apiIDsFromUID, apiIDs...)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters.
|
||||
for _, apiID := range apiIDs {
|
||||
// Filter on UIDs.
|
||||
if len(apiIDsFromUID) > 0 && !isStringInList(apiIDsFromUID, apiID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get message.
|
||||
storeMessage, err := im.storeMailbox.GetMessage(apiID)
|
||||
if err != nil {
|
||||
log.Warnf("search messages: cannot get message %q from db: %v", apiID, err)
|
||||
continue
|
||||
}
|
||||
m := storeMessage.Message()
|
||||
|
||||
// Filter addresses.
|
||||
if criteria.From != "" && !addressMatch([]*mail.Address{m.Sender}, criteria.From) {
|
||||
continue
|
||||
}
|
||||
if criteria.To != "" && !addressMatch(m.ToList, criteria.To) {
|
||||
continue
|
||||
}
|
||||
if criteria.Cc != "" && !addressMatch(m.CCList, criteria.Cc) {
|
||||
continue
|
||||
}
|
||||
if criteria.Bcc != "" && !addressMatch(m.BCCList, criteria.Bcc) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter strings.
|
||||
if criteria.Subject != "" && !strings.Contains(strings.ToLower(m.Subject), strings.ToLower(criteria.Subject)) {
|
||||
continue
|
||||
}
|
||||
if criteria.Keyword != "" && !hasKeyword(m, criteria.Keyword) {
|
||||
continue
|
||||
}
|
||||
if criteria.Unkeyword != "" && hasKeyword(m, criteria.Unkeyword) {
|
||||
continue
|
||||
}
|
||||
if criteria.Header[0] != "" {
|
||||
h := message.GetHeader(m)
|
||||
if val := h.Get(criteria.Header[0]); val == "" {
|
||||
continue // Field is not in header.
|
||||
} else if criteria.Header[1] != "" && !strings.Contains(strings.ToLower(val), strings.ToLower(criteria.Header[1])) {
|
||||
continue // Field is in header, second criteria is non-zero and field value not matched (case insensitive).
|
||||
}
|
||||
}
|
||||
|
||||
// Filter flags.
|
||||
if criteria.Flagged && !isStringInList(m.LabelIDs, pmapi.StarredLabel) {
|
||||
continue
|
||||
}
|
||||
if criteria.Unflagged && isStringInList(m.LabelIDs, pmapi.StarredLabel) {
|
||||
continue
|
||||
}
|
||||
if criteria.Seen && m.Unread == 1 {
|
||||
continue
|
||||
}
|
||||
if criteria.Unseen && m.Unread == 0 {
|
||||
continue
|
||||
}
|
||||
if criteria.Deleted {
|
||||
continue
|
||||
}
|
||||
// if criteria.Undeleted { // All messages matches this criteria }
|
||||
if criteria.Draft && (m.Has(pmapi.FlagSent) || m.Has(pmapi.FlagReceived)) {
|
||||
continue
|
||||
}
|
||||
if criteria.Undraft && !(m.Has(pmapi.FlagSent) || m.Has(pmapi.FlagReceived)) {
|
||||
continue
|
||||
}
|
||||
if criteria.Answered && !(m.Has(pmapi.FlagReplied) || m.Has(pmapi.FlagRepliedAll)) {
|
||||
continue
|
||||
}
|
||||
if criteria.Unanswered && (m.Has(pmapi.FlagReplied) || m.Has(pmapi.FlagRepliedAll)) {
|
||||
continue
|
||||
}
|
||||
if criteria.Recent && m.Has(pmapi.FlagOpened) { // opened means not recent
|
||||
continue
|
||||
}
|
||||
if criteria.Old && !m.Has(pmapi.FlagOpened) {
|
||||
continue
|
||||
}
|
||||
if criteria.New && !(!m.Has(pmapi.FlagOpened) && m.Unread == 1) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter internal date.
|
||||
if !criteria.Before.IsZero() {
|
||||
if truncated := criteria.Before.Truncate(24 * time.Hour); m.Time > truncated.Unix() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !criteria.Since.IsZero() {
|
||||
if truncated := criteria.Since.Truncate(24 * time.Hour); m.Time < truncated.Unix() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !criteria.On.IsZero() {
|
||||
truncated := criteria.On.Truncate(24 * time.Hour)
|
||||
if m.Time < truncated.Unix() || m.Time > truncated.Add(24*time.Hour).Unix() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !(criteria.SentBefore.IsZero() && criteria.SentSince.IsZero() && criteria.SentOn.IsZero()) {
|
||||
if t, err := m.Header.Date(); err == nil && !t.IsZero() {
|
||||
// Filter header date.
|
||||
if !criteria.SentBefore.IsZero() {
|
||||
if truncated := criteria.SentBefore.Truncate(24 * time.Hour); t.Unix() > truncated.Unix() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !criteria.SentSince.IsZero() {
|
||||
if truncated := criteria.SentSince.Truncate(24 * time.Hour); t.Unix() < truncated.Unix() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !criteria.SentOn.IsZero() {
|
||||
truncated := criteria.SentOn.Truncate(24 * time.Hour)
|
||||
if t.Unix() < truncated.Unix() || t.Unix() > truncated.Add(24*time.Hour).Unix() {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter size (only if size was already calculated).
|
||||
if m.Size > 0 {
|
||||
if criteria.Larger != 0 && m.Size <= int64(criteria.Larger) {
|
||||
continue
|
||||
}
|
||||
if criteria.Smaller != 0 && m.Size >= int64(criteria.Smaller) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Add the ID to response.
|
||||
var id uint32
|
||||
if isUID {
|
||||
id, err = storeMessage.UID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
id, err = storeMessage.SequenceNumber()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ListMessages returns a list of messages. seqset must be interpreted as UIDs
|
||||
// if uid is set to true and as message sequence numbers otherwise. See RFC
|
||||
// 3501 section 6.4.5 for a list of items that can be requested.
|
||||
//
|
||||
// Messages must be sent to msgResponse. When the function returns, msgResponse must be closed.
|
||||
func (im *imapMailbox) ListMessages(isUID bool, seqSet *imap.SeqSet, items []string, msgResponse chan<- *imap.Message) (err error) { //nolint[funlen]
|
||||
defer func() {
|
||||
close(msgResponse)
|
||||
if err != nil {
|
||||
log.Errorf("cannot list messages (%v, %v, %v): %v", isUID, seqSet, items, err)
|
||||
}
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
im.panicHandler.HandlePanic()
|
||||
}()
|
||||
|
||||
var markAsReadIDs []string
|
||||
markAsReadMutex := &sync.Mutex{}
|
||||
|
||||
l := log.WithField("cmd", "ListMessages")
|
||||
|
||||
apiIDs, err := im.apiIDsFromSeqSet(isUID, seqSet)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("list messages seq: %v", err)
|
||||
l.WithField("seq", seqSet).Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// From RFC: UID range of 559:* always includes the UID of the last message
|
||||
// in the mailbox, even if 559 is higher than any assigned UID value.
|
||||
// See: https://tools.ietf.org/html/rfc3501#page-61
|
||||
if isUID && seqSet.Dynamic() && len(apiIDs) == 0 {
|
||||
l.Debug("Requesting empty UID dynamic fetch, adding latest message")
|
||||
apiID, err := im.storeMailbox.GetLatestAPIID()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
apiIDs = []string{apiID}
|
||||
}
|
||||
|
||||
input := make([]interface{}, len(apiIDs))
|
||||
for i, apiID := range apiIDs {
|
||||
input[i] = apiID
|
||||
}
|
||||
|
||||
processCallback := func(value interface{}) (interface{}, error) {
|
||||
apiID := value.(string)
|
||||
|
||||
storeMessage, err := im.storeMailbox.GetMessage(apiID)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("list message from db: %v", err)
|
||||
l.WithField("apiID", apiID).Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg, err := im.getMessage(storeMessage, items)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("list message build: %v", err)
|
||||
l.WithField("metaID", storeMessage.ID()).Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if storeMessage.Message().Unread == 1 {
|
||||
for section := range msg.Body {
|
||||
// Peek means get messages without marking them as read.
|
||||
// If client does not only ask for peek, we have to mark them as read.
|
||||
if !section.Peek {
|
||||
markAsReadMutex.Lock()
|
||||
markAsReadIDs = append(markAsReadIDs, storeMessage.ID())
|
||||
markAsReadMutex.Unlock()
|
||||
msg.Flags = append(msg.Flags, imap.SeenFlag)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
collectCallback := func(idx int, value interface{}) error {
|
||||
msg := value.(*imap.Message)
|
||||
msgResponse <- msg
|
||||
return nil
|
||||
}
|
||||
|
||||
err = parallel.RunParallel(fetchMessagesWorkers, input, processCallback, collectCallback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(markAsReadIDs) > 0 {
|
||||
if err := im.storeMailbox.MarkMessagesRead(markAsReadIDs); err != nil {
|
||||
l.Warnf("Cannot mark messages as read: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiIDsFromSeqSet takes an IMAP sequence set (which can contain either
|
||||
// sequence numbers or UIDs) and returns all known API IDs in this range.
|
||||
func (im *imapMailbox) apiIDsFromSeqSet(uid bool, seqSet *imap.SeqSet) ([]string, error) {
|
||||
apiIDs := []string{}
|
||||
for _, seq := range seqSet.Set {
|
||||
var newAPIIDs []string
|
||||
var err error
|
||||
if uid {
|
||||
newAPIIDs, err = im.storeMailbox.GetAPIIDsFromUIDRange(seq.Start, seq.Stop)
|
||||
} else {
|
||||
newAPIIDs, err = im.storeMailbox.GetAPIIDsFromSequenceRange(seq.Start, seq.Stop)
|
||||
}
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
apiIDs = append(apiIDs, newAPIIDs...)
|
||||
}
|
||||
if len(apiIDs) == 0 {
|
||||
log.Debugf("Requested empty message list: %v %v", uid, seqSet)
|
||||
}
|
||||
return apiIDs, nil
|
||||
}
|
||||
|
||||
func isAddressInList(addrs []*mail.Address, query string) bool { //nolint[deadcode]
|
||||
for _, addr := range addrs {
|
||||
if strings.Contains(addr.Address, query) || strings.Contains(addr.Name, query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isStringInList(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addressMatch(addresses []*mail.Address, criteria string) bool {
|
||||
for _, addr := range addresses {
|
||||
if strings.Contains(strings.ToLower(addr.String()), strings.ToLower(criteria)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasKeyword(m *pmapi.Message, keyword string) bool {
|
||||
for _, v := range message.GetHeader(m) {
|
||||
if strings.Contains(strings.ToLower(strings.Join(v, " ")), strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
120
internal/imap/mailbox_root.go
Normal file
120
internal/imap/mailbox_root.go
Normal file
@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/store"
|
||||
|
||||
imap "github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
// The mailbox containing all custom folders or labels.
|
||||
// The purpose of this mailbox is to see "Folders" and "Labels"
|
||||
// at the root of the mailbox tree, e.g.:
|
||||
//
|
||||
// Folders << this
|
||||
// Folders/Family
|
||||
//
|
||||
// Labels << this
|
||||
// Labels/Security
|
||||
//
|
||||
// This mailbox cannot be modified or read in any way.
|
||||
type imapRootMailbox struct {
|
||||
isFolder bool
|
||||
}
|
||||
|
||||
func newFoldersRootMailbox() *imapRootMailbox {
|
||||
return &imapRootMailbox{isFolder: true}
|
||||
}
|
||||
|
||||
func newLabelsRootMailbox() *imapRootMailbox {
|
||||
return &imapRootMailbox{isFolder: false}
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) Name() string {
|
||||
if m.isFolder {
|
||||
return store.UserFoldersMailboxName
|
||||
}
|
||||
return store.UserLabelsMailboxName
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) Info() (info *imap.MailboxInfo, err error) {
|
||||
info = &imap.MailboxInfo{
|
||||
Attributes: []string{imap.NoSelectAttr},
|
||||
Delimiter: store.PathDelimiter,
|
||||
}
|
||||
|
||||
if m.isFolder {
|
||||
info.Name = store.UserFoldersMailboxName
|
||||
} else {
|
||||
info.Name = store.UserLabelsMailboxName
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) Status(items []string) (status *imap.MailboxStatus, err error) {
|
||||
status = &imap.MailboxStatus{}
|
||||
if m.isFolder {
|
||||
status.Name = store.UserFoldersMailboxName
|
||||
} else {
|
||||
status.Name = store.UserLabelsMailboxName
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) Subscribe() error {
|
||||
return errors.New("cannot subscribe to Labels or Folders mailboxes")
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) Unsubscribe() error {
|
||||
return errors.New("cannot unsubscribe from Labels or Folders mailboxes")
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) Check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []string, ch chan<- *imap.Message) error {
|
||||
close(ch)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) (ids []uint32, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) CreateMessage(flags []string, t time.Time, body imap.Literal) error {
|
||||
return errors.New("cannot create a message in this mailbox")
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap.FlagsOp, flags []string) (err error) {
|
||||
return errors.New("cannot update message flags in this mailbox")
|
||||
}
|
||||
|
||||
func (m *imapRootMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expunge is not used by Bridge. We delete the message once it is flagged as \Deleted.
|
||||
func (m *imapRootMailbox) Expunge() error {
|
||||
return nil
|
||||
}
|
||||
188
internal/imap/server.go
Normal file
188
internal/imap/server.go
Normal file
@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
imapid "github.com/ProtonMail/go-imap-id"
|
||||
"github.com/ProtonMail/proton-bridge/internal/bridge"
|
||||
"github.com/ProtonMail/proton-bridge/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/internal/imap/uidplus"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/listener"
|
||||
"github.com/emersion/go-imap"
|
||||
imapappendlimit "github.com/emersion/go-imap-appendlimit"
|
||||
imapidle "github.com/emersion/go-imap-idle"
|
||||
imapquota "github.com/emersion/go-imap-quota"
|
||||
imapspecialuse "github.com/emersion/go-imap-specialuse"
|
||||
imapserver "github.com/emersion/go-imap/server"
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type imapServer struct {
|
||||
server *imapserver.Server
|
||||
eventListener listener.Listener
|
||||
}
|
||||
|
||||
// NewIMAPServer constructs a new IMAP server configured with the given options.
|
||||
func NewIMAPServer(debugClient, debugServer bool, port int, tls *tls.Config, imapBackend *imapBackend, eventListener listener.Listener) *imapServer { //nolint[golint]
|
||||
s := imapserver.New(imapBackend)
|
||||
s.Addr = fmt.Sprintf("%v:%v", bridge.Host, port)
|
||||
s.TLSConfig = tls
|
||||
s.AllowInsecureAuth = true
|
||||
s.ErrorLog = newServerErrorLogger("server-imap")
|
||||
s.AutoLogout = 30 * time.Minute
|
||||
|
||||
if debugClient || debugServer {
|
||||
var localDebug, remoteDebug imap.WriterWithFields
|
||||
if debugClient {
|
||||
remoteDebug = &logWithFields{log: log.WithField("pkg", "imap/client"), fields: logrus.Fields{}}
|
||||
}
|
||||
if debugServer {
|
||||
localDebug = &logWithFields{log: log.WithField("pkg", "imap/server"), fields: logrus.Fields{}}
|
||||
}
|
||||
s.Debug = imap.NewDebugWithFields(localDebug, remoteDebug)
|
||||
}
|
||||
|
||||
serverID := imapid.ID{
|
||||
imapid.FieldName: "ProtonMail",
|
||||
imapid.FieldVendor: "Proton Technologies AG",
|
||||
imapid.FieldSupportURL: "https://protonmail.com/support",
|
||||
}
|
||||
|
||||
s.EnableAuth(sasl.Login, func(conn imapserver.Conn) sasl.Server {
|
||||
conn.Server().ForEachConn(func(candidate imapserver.Conn) {
|
||||
if id, ok := candidate.(imapid.Conn); ok {
|
||||
if conn.Context() == candidate.Context() {
|
||||
imapBackend.setLastMailClient(id.ID())
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return sasl.NewLoginServer(func(address, password string) error {
|
||||
user, err := conn.Server().Backend.Login(address, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := conn.Context()
|
||||
ctx.State = imap.AuthenticatedState
|
||||
ctx.User = user
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
s.Enable(
|
||||
imapidle.NewExtension(),
|
||||
//imapmove.NewExtension(), // extension is not fully implemented: if UIDPLUS exists it MUST return COPYUID and EXPUNGE continuous responses
|
||||
imapspecialuse.NewExtension(),
|
||||
imapid.NewExtension(serverID),
|
||||
imapquota.NewExtension(),
|
||||
imapappendlimit.NewExtension(),
|
||||
uidplus.NewExtension(),
|
||||
)
|
||||
|
||||
return &imapServer{
|
||||
server: s,
|
||||
eventListener: eventListener,
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the server.
|
||||
func (s *imapServer) ListenAndServe() {
|
||||
go s.monitorDisconnectedUsers()
|
||||
|
||||
log.Info("IMAP server listening at ", s.server.Addr)
|
||||
err := s.server.ListenAndServe()
|
||||
if err != nil {
|
||||
s.eventListener.Emit(events.ErrorEvent, "IMAP failed: "+err.Error())
|
||||
log.Error("IMAP failed: ", err)
|
||||
return
|
||||
}
|
||||
defer s.server.Close() //nolint[errcheck]
|
||||
|
||||
log.Info("IMAP server stopped")
|
||||
}
|
||||
|
||||
// Stops the server.
|
||||
func (s *imapServer) Close() {
|
||||
_ = s.server.Close()
|
||||
}
|
||||
|
||||
func (s *imapServer) monitorDisconnectedUsers() {
|
||||
ch := make(chan string)
|
||||
s.eventListener.Add(events.CloseConnectionEvent, ch)
|
||||
|
||||
for address := range ch {
|
||||
address := address
|
||||
log.Info("Disconnecting all open IMAP connections for ", address)
|
||||
disconnectUser := func(conn imapserver.Conn) {
|
||||
connUser := conn.Context().User
|
||||
if connUser != nil && strings.EqualFold(connUser.Username(), address) {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
s.server.ForEachConn(disconnectUser)
|
||||
}
|
||||
}
|
||||
|
||||
// logWithFields is used for debuging with additional field.
|
||||
type logWithFields struct {
|
||||
log *logrus.Entry
|
||||
fields logrus.Fields
|
||||
}
|
||||
|
||||
func (lf *logWithFields) Writer() io.Writer {
|
||||
w := lf.log.WithFields(lf.fields).WriterLevel(logrus.DebugLevel)
|
||||
lf.fields = logrus.Fields{}
|
||||
return w
|
||||
}
|
||||
|
||||
func (lf *logWithFields) SetField(key, value string) {
|
||||
lf.fields[key] = value
|
||||
}
|
||||
|
||||
// serverErrorLogger implements go-imap/logger interface.
|
||||
type serverErrorLogger struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
func newServerErrorLogger(tag string) *serverErrorLogger {
|
||||
return &serverErrorLogger{tag}
|
||||
}
|
||||
|
||||
func (s *serverErrorLogger) CheckErrorForReport(serverErr string) {
|
||||
}
|
||||
|
||||
func (s *serverErrorLogger) Printf(format string, args ...interface{}) {
|
||||
err := fmt.Sprintf(format, args...)
|
||||
s.CheckErrorForReport(err)
|
||||
log.WithField("pkg", s.tag).Error(err)
|
||||
}
|
||||
|
||||
func (s *serverErrorLogger) Println(args ...interface{}) {
|
||||
err := fmt.Sprintln(args...)
|
||||
s.CheckErrorForReport(err)
|
||||
log.WithField("pkg", s.tag).Error(err)
|
||||
}
|
||||
156
internal/imap/store.go
Normal file
156
internal/imap/store.go
Normal file
@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/mail"
|
||||
|
||||
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
|
||||
"github.com/ProtonMail/proton-bridge/internal/imap/uidplus"
|
||||
"github.com/ProtonMail/proton-bridge/internal/store"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
)
|
||||
|
||||
type storeUserProvider interface {
|
||||
UserID() string
|
||||
GetSpace() (usedSpace, maxSpace uint, err error)
|
||||
GetMaxUpload() (uint, error)
|
||||
|
||||
GetAddress(addressID string) (storeAddressProvider, error)
|
||||
|
||||
CreateDraft(
|
||||
kr *pmcrypto.KeyRing,
|
||||
message *pmapi.Message,
|
||||
attachmentReaders []io.Reader,
|
||||
attachedPublicKey,
|
||||
attachedPublicKeyName string,
|
||||
parentID string) (*pmapi.Message, []*pmapi.Attachment, error)
|
||||
}
|
||||
|
||||
type storeAddressProvider interface {
|
||||
AddressString() string
|
||||
AddressID() string
|
||||
APIAddress() *pmapi.Address
|
||||
|
||||
CreateMailbox(name string) error
|
||||
ListMailboxes() []storeMailboxProvider
|
||||
GetMailbox(name string) (storeMailboxProvider, error)
|
||||
}
|
||||
|
||||
type storeMailboxProvider interface {
|
||||
LabelID() string
|
||||
Name() string
|
||||
Color() string
|
||||
IsSystem() bool
|
||||
IsFolder() bool
|
||||
UIDValidity() uint32
|
||||
|
||||
Rename(newName string) error
|
||||
Delete() error
|
||||
|
||||
GetAPIIDsFromUIDRange(start, stop uint32) ([]string, error)
|
||||
GetAPIIDsFromSequenceRange(start, stop uint32) ([]string, error)
|
||||
GetLatestAPIID() (string, error)
|
||||
GetNextUID() (uint32, error)
|
||||
GetCounts() (dbTotal, dbUnread uint, err error)
|
||||
GetUIDList(apiIDs []string) *uidplus.OrderedSeq
|
||||
GetUIDByHeader(header *mail.Header) uint32
|
||||
GetDelimiter() string
|
||||
|
||||
GetMessage(apiID string) (storeMessageProvider, error)
|
||||
FetchMessage(apiID string) (storeMessageProvider, error)
|
||||
LabelMessages(apiID []string) error
|
||||
UnlabelMessages(apiID []string) error
|
||||
MarkMessagesRead(apiID []string) error
|
||||
MarkMessagesUnread(apiID []string) error
|
||||
MarkMessagesStarred(apiID []string) error
|
||||
MarkMessagesUnstarred(apiID []string) error
|
||||
ImportMessage(msg *pmapi.Message, body []byte, labelIDs []string) error
|
||||
DeleteMessages(apiID []string) error
|
||||
}
|
||||
|
||||
type storeMessageProvider interface {
|
||||
ID() string
|
||||
UID() (uint32, error)
|
||||
SequenceNumber() (uint32, error)
|
||||
Message() *pmapi.Message
|
||||
|
||||
SetSize(int64) error
|
||||
SetContentTypeAndHeader(string, mail.Header) error
|
||||
}
|
||||
|
||||
type storeUserWrap struct {
|
||||
*store.Store
|
||||
}
|
||||
|
||||
// newStoreUserWrap wraps store struct into local storeUserWrap to implement local
|
||||
// interface. The problem is that store returns the store package's Address type, so
|
||||
// every method that returns an address has to be overridden to fulfill the interface.
|
||||
// The same is true for other store structs i.e. storeAddress or storeMailbox.
|
||||
func newStoreUserWrap(store *store.Store) *storeUserWrap {
|
||||
return &storeUserWrap{Store: store}
|
||||
}
|
||||
|
||||
func (s *storeUserWrap) GetAddress(addressID string) (storeAddressProvider, error) {
|
||||
address, err := s.Store.GetAddress(addressID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newStoreAddressWrap(address), nil
|
||||
}
|
||||
|
||||
type storeAddressWrap struct {
|
||||
*store.Address
|
||||
}
|
||||
|
||||
func newStoreAddressWrap(address *store.Address) *storeAddressWrap {
|
||||
return &storeAddressWrap{Address: address}
|
||||
}
|
||||
|
||||
func (s *storeAddressWrap) ListMailboxes() []storeMailboxProvider {
|
||||
mailboxes := []storeMailboxProvider{}
|
||||
for _, mailbox := range s.Address.ListMailboxes() {
|
||||
mailboxes = append(mailboxes, newStoreMailboxWrap(mailbox))
|
||||
}
|
||||
return mailboxes
|
||||
}
|
||||
|
||||
func (s *storeAddressWrap) GetMailbox(name string) (storeMailboxProvider, error) {
|
||||
mailbox, err := s.Address.GetMailbox(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newStoreMailboxWrap(mailbox), nil
|
||||
}
|
||||
|
||||
type storeMailboxWrap struct {
|
||||
*store.Mailbox
|
||||
}
|
||||
|
||||
func newStoreMailboxWrap(mailbox *store.Mailbox) *storeMailboxWrap {
|
||||
return &storeMailboxWrap{Mailbox: mailbox}
|
||||
}
|
||||
|
||||
func (s *storeMailboxWrap) GetMessage(apiID string) (storeMessageProvider, error) {
|
||||
return s.Mailbox.GetMessage(apiID)
|
||||
}
|
||||
|
||||
func (s *storeMailboxWrap) FetchMessage(apiID string) (storeMessageProvider, error) {
|
||||
return s.Mailbox.FetchMessage(apiID)
|
||||
}
|
||||
198
internal/imap/uidplus/extension.go
Normal file
198
internal/imap/uidplus/extension.go
Normal file
@ -0,0 +1,198 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package uidplus DOES NOT implement full RFC4315!
|
||||
//
|
||||
// Excluded parts are:
|
||||
// * Response `UIDNOTSTICKY`: All mailboxes of Bridge support stable
|
||||
// UIDVALIDITY so it would never return this response
|
||||
//
|
||||
// Otherwise the standard RFC4315 is followed.
|
||||
package uidplus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/server"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Capability extension identifier
|
||||
const Capability = "UIDPLUS"
|
||||
|
||||
const (
|
||||
copyuid = "COPYUID"
|
||||
appenduid = "APPENDUID"
|
||||
copySuccess = "COPY successful"
|
||||
appendSucess = "APPEND successful"
|
||||
)
|
||||
|
||||
var log = logrus.WithField("pkg", "impa/uidplus") //nolint[gochecknoglobals]
|
||||
|
||||
// OrderedSeq to remember Seq in order they are added.
|
||||
// We didn't find any restriction in RFC that server must respond with ranges
|
||||
// so we decided to always do explicit list. This makes sure that no dynamic
|
||||
// ranges or out of the bound ranges are possible.
|
||||
//
|
||||
// NOTE: potential issue with response length
|
||||
// * the user selects large number of messages to be copied and the
|
||||
// response line will be long,
|
||||
// * list of UIDs which high values
|
||||
// which can create long response line. We didn't find a maximum length of one
|
||||
// IMAP response line or maximum length of IMAP "response code" with parameters.
|
||||
type OrderedSeq []uint32
|
||||
|
||||
// Len return number of added seq numbers.
|
||||
func (os OrderedSeq) Len() int { return len(os) }
|
||||
|
||||
// Add number to sequence. Zero is not acceptable UID and it won't be added to list.
|
||||
func (os *OrderedSeq) Add(num uint32) {
|
||||
if num == 0 {
|
||||
return
|
||||
}
|
||||
*os = append(*os, num)
|
||||
}
|
||||
|
||||
func (os *OrderedSeq) String() string {
|
||||
out := ""
|
||||
if len(*os) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
lastS := uint32(0)
|
||||
isRangeOpened := false
|
||||
for i, s := range *os {
|
||||
// write first
|
||||
if i == 0 {
|
||||
out += fmt.Sprintf("%d", s)
|
||||
isRangeOpened = false
|
||||
lastS = s
|
||||
continue
|
||||
}
|
||||
|
||||
isLast := (i == len(*os)-1)
|
||||
isContinuous := (lastS+1 == s)
|
||||
|
||||
if isContinuous {
|
||||
isRangeOpened = true
|
||||
lastS = s
|
||||
if isLast {
|
||||
out += fmt.Sprintf(":%d", s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isRangeOpened && !isContinuous { // close range
|
||||
out += fmt.Sprintf(":%d,%d", lastS, s)
|
||||
isRangeOpened = false
|
||||
lastS = s
|
||||
continue
|
||||
}
|
||||
|
||||
// Range is not opened and it is not continuous.
|
||||
out += fmt.Sprintf(",%d", s)
|
||||
isRangeOpened = false
|
||||
lastS = s
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// UIDExpunge implements server.Handler but has no effect because Bridge is not
|
||||
// using EXPUNGE at all. The message is deleted right after it was flagged as
|
||||
// \Deleted Bridge should simply ignore this command with empty `OK` response.
|
||||
//
|
||||
// If not implemented it would cause harmless IMAP error.
|
||||
//
|
||||
// This overrides the standard EXPUNGE functionality.
|
||||
type UIDExpunge struct{}
|
||||
|
||||
func (e *UIDExpunge) Parse(fields []interface{}) error { log.Traceln("parse", fields); return nil }
|
||||
func (e *UIDExpunge) Handle(conn server.Conn) error { log.Traceln("handle"); return nil }
|
||||
func (e *UIDExpunge) UidHandle(conn server.Conn) error { log.Traceln("uid handle"); return nil } //nolint[golint]
|
||||
|
||||
type extension struct{}
|
||||
|
||||
// NewExtension of UIDPLUS.
|
||||
func NewExtension() server.Extension {
|
||||
return &extension{}
|
||||
}
|
||||
|
||||
func (ext *extension) Capabilities(c server.Conn) []string {
|
||||
if c.Context().State&imap.AuthenticatedState != 0 {
|
||||
return []string{Capability}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *extension) Command(name string) server.HandlerFactory {
|
||||
if name == imap.Expunge {
|
||||
return func() server.Handler {
|
||||
return &UIDExpunge{}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStatusResponseCopy(uidValidity uint32, sourceSeq, targetSeq *OrderedSeq) *imap.StatusResp {
|
||||
info := copySuccess
|
||||
|
||||
if sourceSeq.Len() != 0 && targetSeq.Len() != 0 &&
|
||||
sourceSeq.Len() == targetSeq.Len() {
|
||||
info = fmt.Sprintf("[%s %d %s %s] %s",
|
||||
copyuid,
|
||||
uidValidity,
|
||||
sourceSeq.String(),
|
||||
targetSeq.String(),
|
||||
copySuccess,
|
||||
)
|
||||
}
|
||||
|
||||
return &imap.StatusResp{
|
||||
Type: imap.StatusOk,
|
||||
Info: info,
|
||||
}
|
||||
}
|
||||
|
||||
// CopyResponse prepares OK response with extended UID information about copied message.
|
||||
func CopyResponse(uidValidity uint32, sourceSeq, targetSeq *OrderedSeq) error {
|
||||
return server.ErrStatusResp(getStatusResponseCopy(uidValidity, sourceSeq, targetSeq))
|
||||
}
|
||||
|
||||
func getStatusResponseAppend(uidValidity uint32, targetSeq *OrderedSeq) *imap.StatusResp {
|
||||
info := appendSucess
|
||||
if targetSeq.Len() > 0 {
|
||||
info = fmt.Sprintf("[%s %d %s] %s",
|
||||
appenduid,
|
||||
uidValidity,
|
||||
targetSeq.String(),
|
||||
appendSucess,
|
||||
)
|
||||
}
|
||||
|
||||
return &imap.StatusResp{
|
||||
Type: imap.StatusOk,
|
||||
Info: info,
|
||||
}
|
||||
}
|
||||
|
||||
// AppendResponse prepares OK response with extended UID information about appended message.
|
||||
func AppendResponse(uidValidity uint32, targetSeq *OrderedSeq) error {
|
||||
return server.ErrStatusResp(getStatusResponseAppend(uidValidity, targetSeq))
|
||||
}
|
||||
108
internal/imap/uidplus/extension_test.go
Normal file
108
internal/imap/uidplus/extension_test.go
Normal file
@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package uidplus
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// uidValidity is constant and global for bridge IMAP.
|
||||
const uidValidity = 66
|
||||
|
||||
type testResponseData struct {
|
||||
sourceList, targetList []int
|
||||
expCopyInfo, expAppendInfo string
|
||||
}
|
||||
|
||||
func (td *testResponseData) getOrdSeqFromList(seqList []int) *OrderedSeq {
|
||||
set := &OrderedSeq{}
|
||||
for _, seq := range seqList {
|
||||
set.Add(uint32(seq))
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (td *testResponseData) testCopyAndAppendResponses(tb testing.TB) {
|
||||
sourceSeq := td.getOrdSeqFromList(td.sourceList)
|
||||
targetSeq := td.getOrdSeqFromList(td.targetList)
|
||||
|
||||
gotCopyResp := getStatusResponseCopy(uidValidity, sourceSeq, targetSeq)
|
||||
assert.Equal(tb, td.expCopyInfo, gotCopyResp.Info, "source: %v\ntarget: %v", td.sourceList, td.targetList)
|
||||
|
||||
gotAppendResp := getStatusResponseAppend(uidValidity, targetSeq)
|
||||
assert.Equal(tb, td.expAppendInfo, gotAppendResp.Info, "source: %v\ntarget: %v", td.sourceList, td.targetList)
|
||||
}
|
||||
|
||||
func TestStatusResponseInfo(t *testing.T) {
|
||||
testData := []*testResponseData{
|
||||
{ // Dynamic range must never be returned e.g 4:* (explicitly true if you OrderedSeq used instead of imap.SeqSet).
|
||||
sourceList: []int{4, 5, 6},
|
||||
targetList: []int{1, 2, 3},
|
||||
expCopyInfo: "[" + copyuid + " 66 4:6 1:3] " + copySuccess,
|
||||
expAppendInfo: "[" + appenduid + " 66 1:3] " + appendSucess,
|
||||
},
|
||||
{ // Ranges can be used only for consecutive strictly rising sequence.
|
||||
sourceList: []int{6, 7, 8, 9, 10, 1, 3, 5, 10, 11, 20, 21, 30, 31},
|
||||
targetList: []int{1, 2, 3, 4, 50, 8, 7, 6, 12, 13, 22, 23, 32, 33},
|
||||
expCopyInfo: "[" + copyuid + " 66 6:10,1,3,5,10:11,20:21,30:31 1:4,50,8,7,6,12:13,22:23,32:33] " + copySuccess,
|
||||
expAppendInfo: "[" + appenduid + " 66 1:4,50,8,7,6,12:13,22:23,32:33] " + appendSucess,
|
||||
},
|
||||
{ // Keep order (cannot use sequence set because 3,2,1 equals 1,2,3 equals 1:3 equals 3:1).
|
||||
sourceList: []int{4, 5, 8},
|
||||
targetList: []int{3, 2, 1},
|
||||
expCopyInfo: "[" + copyuid + " 66 4:5,8 3,2,1] " + copySuccess,
|
||||
expAppendInfo: "[" + appenduid + " 66 3,2,1] " + appendSucess,
|
||||
},
|
||||
{ // Incorrect count of source and target uids is wrong and we should not report it.
|
||||
sourceList: []int{1},
|
||||
targetList: []int{1, 2, 3},
|
||||
expCopyInfo: copySuccess,
|
||||
expAppendInfo: "[" + appenduid + " 66 1:3] " + appendSucess,
|
||||
},
|
||||
{
|
||||
sourceList: []int{1, 2, 3},
|
||||
targetList: []int{1},
|
||||
expCopyInfo: copySuccess,
|
||||
expAppendInfo: "[" + appenduid + " 66 1] " + appendSucess,
|
||||
},
|
||||
{ // One item should be always interpreted as one number (don't use imap.SeqSet because 1:1 means 1).
|
||||
sourceList: []int{1},
|
||||
targetList: []int{1},
|
||||
expCopyInfo: "[" + copyuid + " 66 1 1] " + copySuccess,
|
||||
expAppendInfo: "[" + appenduid + " 66 1] " + appendSucess,
|
||||
},
|
||||
{ // No UID is wrong we should not report it.
|
||||
sourceList: []int{1},
|
||||
targetList: []int{},
|
||||
expCopyInfo: copySuccess,
|
||||
expAppendInfo: appendSucess,
|
||||
},
|
||||
{ // Duplicates should be reported as list.
|
||||
sourceList: []int{1, 1, 1},
|
||||
targetList: []int{6, 6, 6},
|
||||
expCopyInfo: "[" + copyuid + " 66 1,1,1 6,6,6] " + copySuccess,
|
||||
expAppendInfo: "[" + appenduid + " 66 6,6,6] " + appendSucess,
|
||||
},
|
||||
}
|
||||
|
||||
for _, td := range testData {
|
||||
td.testCopyAndAppendResponses(t)
|
||||
}
|
||||
}
|
||||
239
internal/imap/user.go
Normal file
239
internal/imap/user.go
Normal file
@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/bridge"
|
||||
imapquota "github.com/emersion/go-imap-quota"
|
||||
goIMAPBackend "github.com/emersion/go-imap/backend"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoSuchMailbox = errors.New("no such mailbox") //nolint[gochecknoglobals]
|
||||
)
|
||||
|
||||
type imapUser struct {
|
||||
panicHandler panicHandler
|
||||
backend *imapBackend
|
||||
user bridgeUser
|
||||
client bridge.PMAPIProvider
|
||||
|
||||
storeUser storeUserProvider
|
||||
storeAddress storeAddressProvider
|
||||
|
||||
currentAddressLowercase string
|
||||
}
|
||||
|
||||
// newIMAPUser returns struct implementing go-imap/user interface.
|
||||
func newIMAPUser(
|
||||
panicHandler panicHandler,
|
||||
backend *imapBackend,
|
||||
user bridgeUser,
|
||||
addressID, address string,
|
||||
) (*imapUser, error) {
|
||||
log.WithField("address", addressID).Debug("Creating new IMAP user")
|
||||
|
||||
storeUser := user.GetStore()
|
||||
if storeUser == nil {
|
||||
return nil, errors.New("user database is not initialized")
|
||||
}
|
||||
|
||||
storeAddress, err := storeUser.GetAddress(addressID)
|
||||
if err != nil {
|
||||
log.WithField("address", addressID).Debug("Could not get store user address")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := user.GetTemporaryPMAPIClient()
|
||||
|
||||
return &imapUser{
|
||||
panicHandler: panicHandler,
|
||||
backend: backend,
|
||||
user: user,
|
||||
client: client,
|
||||
|
||||
storeUser: storeUser,
|
||||
storeAddress: storeAddress,
|
||||
|
||||
currentAddressLowercase: strings.ToLower(address),
|
||||
}, err
|
||||
}
|
||||
|
||||
func (iu *imapUser) isSubscribed(labelID string) bool {
|
||||
subscriptionExceptions := iu.backend.getCacheList(iu.storeUser.UserID(), SubscriptionException)
|
||||
exceptions := strings.Split(subscriptionExceptions, ";")
|
||||
|
||||
for _, exception := range exceptions {
|
||||
if exception == labelID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (iu *imapUser) removeFromCache(label, value string) {
|
||||
iu.backend.removeFromCache(iu.storeUser.UserID(), label, value)
|
||||
}
|
||||
|
||||
func (iu *imapUser) addToCache(label, value string) {
|
||||
iu.backend.addToCache(iu.storeUser.UserID(), label, value)
|
||||
}
|
||||
|
||||
// Username returns this user's username.
|
||||
func (iu *imapUser) Username() string {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
return iu.storeAddress.AddressString()
|
||||
}
|
||||
|
||||
// ListMailboxes returns a list of mailboxes belonging to this user.
|
||||
// If subscribed is set to true, returns only subscribed mailboxes.
|
||||
func (iu *imapUser) ListMailboxes(showOnlySubcribed bool) ([]goIMAPBackend.Mailbox, error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
mailboxes := []goIMAPBackend.Mailbox{}
|
||||
for _, storeMailbox := range iu.storeAddress.ListMailboxes() {
|
||||
if showOnlySubcribed && !iu.isSubscribed(storeMailbox.LabelID()) {
|
||||
continue
|
||||
}
|
||||
mailbox := newIMAPMailbox(iu.panicHandler, iu, storeMailbox)
|
||||
mailboxes = append(mailboxes, mailbox)
|
||||
}
|
||||
|
||||
mailboxes = append(mailboxes, newLabelsRootMailbox())
|
||||
mailboxes = append(mailboxes, newFoldersRootMailbox())
|
||||
|
||||
log.WithField("mailboxes", mailboxes).Trace("Listing mailboxes")
|
||||
|
||||
return mailboxes, nil
|
||||
}
|
||||
|
||||
// GetMailbox returns a mailbox. If it doesn't exist, it returns ErrNoSuchMailbox.
|
||||
func (iu *imapUser) GetMailbox(name string) (mb goIMAPBackend.Mailbox, err error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
storeMailbox, err := iu.storeAddress.GetMailbox(name)
|
||||
if err != nil {
|
||||
log.WithField("name", name).WithError(err).Error("Could not get mailbox")
|
||||
return
|
||||
}
|
||||
|
||||
return newIMAPMailbox(iu.panicHandler, iu, storeMailbox), nil
|
||||
}
|
||||
|
||||
// CreateMailbox creates a new mailbox.
|
||||
func (iu *imapUser) CreateMailbox(name string) error {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
return iu.storeAddress.CreateMailbox(name)
|
||||
}
|
||||
|
||||
// DeleteMailbox permanently removes the mailbox with the given name.
|
||||
func (iu *imapUser) DeleteMailbox(name string) (err error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
storeMailbox, err := iu.storeAddress.GetMailbox(name)
|
||||
if err != nil {
|
||||
log.WithField("name", name).WithError(err).Error("Could not get mailbox")
|
||||
return
|
||||
}
|
||||
|
||||
return storeMailbox.Delete()
|
||||
}
|
||||
|
||||
// RenameMailbox changes the name of a mailbox. It is an error to attempt to
|
||||
// rename a mailbox that does not exist or to rename a mailbox to a name that
|
||||
// already exists.
|
||||
func (iu *imapUser) RenameMailbox(oldName, newName string) (err error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
storeMailbox, err := iu.storeAddress.GetMailbox(oldName)
|
||||
if err != nil {
|
||||
log.WithField("name", oldName).WithError(err).Error("Could not get mailbox")
|
||||
return
|
||||
}
|
||||
|
||||
return storeMailbox.Rename(newName)
|
||||
}
|
||||
|
||||
// Logout is called when this User will no longer be used, likely because the
|
||||
// client closed the connection.
|
||||
func (iu *imapUser) Logout() (err error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
log.Debug("IMAP client logged out address ", iu.storeAddress.AddressID())
|
||||
|
||||
iu.backend.deleteUser(iu.currentAddressLowercase)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (iu *imapUser) GetQuota(name string) (*imapquota.Status, error) {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
usedSpace, maxSpace, err := iu.storeUser.GetSpace()
|
||||
if err != nil {
|
||||
log.Error("Failed getting quota: ", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resources := make(map[string][2]uint32)
|
||||
var list [2]uint32
|
||||
list[0] = uint32(usedSpace / 1000)
|
||||
list[1] = uint32(maxSpace / 1000)
|
||||
resources[imapquota.ResourceStorage] = list
|
||||
status := &imapquota.Status{
|
||||
Name: "",
|
||||
Resources: resources,
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (iu *imapUser) SetQuota(name string, resources map[string]uint32) error {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
return errors.New("quota cannot be set")
|
||||
}
|
||||
|
||||
func (iu *imapUser) CreateMessageLimit() *uint32 {
|
||||
// Called from go-imap in goroutines - we need to handle panics for each function.
|
||||
defer iu.panicHandler.HandlePanic()
|
||||
|
||||
maxUpload, err := iu.storeUser.GetMaxUpload()
|
||||
if err != nil {
|
||||
log.Error("Failed getting current user for message limit: ", err)
|
||||
zero := uint32(0)
|
||||
return &zero
|
||||
}
|
||||
|
||||
upload := uint32(maxUpload)
|
||||
return &upload
|
||||
}
|
||||
32
internal/imap/utils.go
Normal file
32
internal/imap/utils.go
Normal file
@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2020 Proton Technologies AG
|
||||
//
|
||||
// This file is part of ProtonMail Bridge.
|
||||
//
|
||||
// ProtonMail 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.
|
||||
//
|
||||
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package imap
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
)
|
||||
|
||||
func writeHeader(w io.Writer, h textproto.MIMEHeader) (err error) {
|
||||
if err = http.Header(h).Write(w); err != nil {
|
||||
return
|
||||
}
|
||||
_, err = io.WriteString(w, "\r\n")
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user