Files
proton-bridge/internal/imap/mailbox_messages.go

491 lines
14 KiB
Go

// 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
}