forked from Silverfish/proton-bridge
- GODT-1158: simple on-disk cache in store - GODT-1158: better member naming in event loop - GODT-1158: create on-disk cache during bridge setup - GODT-1158: better job options - GODT-1158: rename GetLiteral to GetRFC822 - GODT-1158: rename events -> currentEvents - GODT-1158: unlock cache per-user - GODT-1158: clean up cache after logout - GODT-1158: randomized encrypted cache passphrase - GODT-1158: Opt out of on-disk cache in settings - GODT-1158: free space in cache - GODT-1158: make tests compile - GODT-1158: optional compression - GODT-1158: cache custom location - GODT-1158: basic capacity checker - GODT-1158: cache free space config - GODT-1158: only unlock cache if pmapi client is unlocked as well - GODT-1158: simple background sync worker - GODT-1158: set size/bodystructure when caching message - GODT-1158: limit store db update blocking with semaphore - GODT-1158: dumb 10-semaphore - GODT-1158: properly handle delete; remove bad bodystructure handling - GODT-1158: hacky fix for caching after logout... baaaaad - GODT-1158: cache worker - GODT-1158: compute body structure lazily - GODT-1158: cache size in store - GODT-1158: notify cacher when adding to store - GODT-1158: 15 second store cache watcher - GODT-1158: enable cacher - GODT-1158: better cache worker starting/stopping - GODT-1158: limit cacher to less concurrency than disk cache - GODT-1158: message builder prio + pchan pkg - GODT-1158: fix pchan, use in message builder - GODT-1158: no sem in cacher (rely on message builder prio) - GODT-1158: raise priority of existing jobs when requested - GODT-1158: pending messages in on-disk cache - GODT-1158: WIP just a note about deleting messages from disk cache - GODT-1158: pending wait when trying to write - GODT-1158: pending.add to return bool - GODT-1225: Headers in bodystructure are stored as bytes. - GODT-1158: fixing header caching - GODT-1158: don't cache in background - GODT-1158: all concurrency set in settings - GODT-1158: worker pools inside message builder - GODT-1158: fix linter issues - GODT-1158: remove completed builds from builder - GODT-1158: remove builder pool - GODT-1158: cacher defer job done properly - GODT-1158: fix linter - GODT-1299: Continue with bodystructure build if deserialization failed - GODT-1324: Delete messages from the cache when they are deleted on the server - GODT-1158: refactor cache tests - GODT-1158: move builder to app/bridge - GODT-1306: Migrate cache on disk when location is changed (and delete when disabled)
386 lines
11 KiB
Go
386 lines
11 KiB
Go
// Copyright (c) 2021 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 store
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/mail"
|
|
"net/textproto"
|
|
"strings"
|
|
|
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
|
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
bolt "go.etcd.io/bbolt"
|
|
)
|
|
|
|
// CreateDraft creates draft with attachments.
|
|
// If `attachedPublicKey` is passed, it's added to attachments.
|
|
// Both draft and attachments are encrypted with passed `kr` key.
|
|
func (store *Store) CreateDraft(
|
|
kr *crypto.KeyRing,
|
|
message *pmapi.Message,
|
|
attachmentReaders []io.Reader,
|
|
attachedPublicKey,
|
|
attachedPublicKeyName string,
|
|
parentID string) (*pmapi.Message, []*pmapi.Attachment, error) {
|
|
attachments := store.prepareDraftAttachments(message, attachmentReaders, attachedPublicKey, attachedPublicKeyName)
|
|
|
|
if err := encryptDraft(kr, message, attachments); err != nil {
|
|
return nil, nil, errors.Wrap(err, "failed to encrypt draft")
|
|
}
|
|
|
|
if ok, err := store.checkDraftTotalSize(message, attachments); err != nil {
|
|
return nil, nil, err
|
|
} else if !ok {
|
|
return nil, nil, errors.New("message is too large")
|
|
}
|
|
|
|
draftAction := store.getDraftAction(message)
|
|
draft, err := store.client().CreateDraft(exposeContextForSMTP(), message, parentID, draftAction)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "failed to create draft")
|
|
}
|
|
|
|
// Do poll only when call to API succeeded.
|
|
defer store.eventLoop.pollNow()
|
|
|
|
createdAttachments := []*pmapi.Attachment{}
|
|
for _, att := range attachments {
|
|
att.attachment.MessageID = draft.ID
|
|
|
|
createdAttachment, err := store.client().CreateAttachment(exposeContextForSMTP(), att.attachment, att.encReader, att.sigReader)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "failed to create attachment")
|
|
}
|
|
createdAttachments = append(createdAttachments, createdAttachment)
|
|
}
|
|
|
|
return draft, createdAttachments, nil
|
|
}
|
|
|
|
type draftAttachment struct {
|
|
attachment *pmapi.Attachment
|
|
reader io.Reader
|
|
sigReader io.Reader
|
|
encReader io.Reader
|
|
}
|
|
|
|
func (store *Store) prepareDraftAttachments(
|
|
message *pmapi.Message,
|
|
attachmentReaders []io.Reader,
|
|
attachedPublicKey,
|
|
attachedPublicKeyName string) []*draftAttachment {
|
|
attachments := []*draftAttachment{}
|
|
for idx, attachment := range message.Attachments {
|
|
attachments = append(attachments, &draftAttachment{
|
|
attachment: attachment,
|
|
reader: attachmentReaders[idx],
|
|
})
|
|
}
|
|
|
|
message.Attachments = nil
|
|
|
|
if attachedPublicKey != "" {
|
|
publicKeyAttachment := &pmapi.Attachment{
|
|
Name: attachedPublicKeyName + ".asc",
|
|
MIMEType: "application/pgp-keys",
|
|
Header: textproto.MIMEHeader{},
|
|
}
|
|
attachments = append(attachments, &draftAttachment{
|
|
attachment: publicKeyAttachment,
|
|
reader: strings.NewReader(attachedPublicKey),
|
|
})
|
|
}
|
|
|
|
return attachments
|
|
}
|
|
|
|
func encryptDraft(kr *crypto.KeyRing, message *pmapi.Message, attachments []*draftAttachment) error {
|
|
// Since this is a draft, we don't need to sign it.
|
|
if err := message.Encrypt(kr, nil); err != nil {
|
|
return errors.Wrap(err, "failed to encrypt message")
|
|
}
|
|
|
|
for _, att := range attachments {
|
|
attachment := att.attachment
|
|
attachmentBody, err := ioutil.ReadAll(att.reader)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to read attachment")
|
|
}
|
|
|
|
r := bytes.NewReader(attachmentBody)
|
|
sigReader, err := attachment.DetachedSign(kr, r)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to sign attachment")
|
|
}
|
|
att.sigReader = sigReader
|
|
|
|
r = bytes.NewReader(attachmentBody)
|
|
encReader, err := attachment.Encrypt(kr, r)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to encrypt attachment")
|
|
}
|
|
att.encReader = encReader
|
|
|
|
att.reader = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (store *Store) checkDraftTotalSize(message *pmapi.Message, attachments []*draftAttachment) (bool, error) {
|
|
maxUpload, err := store.GetMaxUpload()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
var attSize int64
|
|
for _, att := range attachments {
|
|
b, err := ioutil.ReadAll(att.encReader)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
attSize += int64(len(b))
|
|
att.encReader = bytes.NewBuffer(b)
|
|
}
|
|
|
|
return int64(len(message.Body))+attSize <= maxUpload, nil
|
|
}
|
|
|
|
func (store *Store) getDraftAction(message *pmapi.Message) int {
|
|
// If not a reply, must be a forward.
|
|
if len(message.Header["In-Reply-To"]) == 0 {
|
|
return pmapi.DraftActionForward
|
|
}
|
|
return pmapi.DraftActionReply
|
|
}
|
|
|
|
// SendMessage sends the message.
|
|
func (store *Store) SendMessage(messageID string, req *pmapi.SendMessageReq) error {
|
|
defer store.eventLoop.pollNow()
|
|
_, _, err := store.client().SendMessage(exposeContextForSMTP(), messageID, req)
|
|
return err
|
|
}
|
|
|
|
// getAllMessageIDs returns all API IDs of messages in the local database.
|
|
func (store *Store) getAllMessageIDs() (apiIDs []string, err error) {
|
|
err = store.db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(metadataBucket)
|
|
return b.ForEach(func(k, v []byte) error {
|
|
apiIDs = append(apiIDs, string(k))
|
|
return nil
|
|
})
|
|
})
|
|
return
|
|
}
|
|
|
|
// getMessageFromDB returns pmapi struct of message by API ID.
|
|
func (store *Store) getMessageFromDB(apiID string) (msg *pmapi.Message, err error) {
|
|
err = store.db.View(func(tx *bolt.Tx) error {
|
|
msg, err = store.txGetMessage(tx, apiID)
|
|
return err
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
func (store *Store) txGetMessage(tx *bolt.Tx, apiID string) (*pmapi.Message, error) {
|
|
return store.txGetMessageFromBucket(tx.Bucket(metadataBucket), apiID)
|
|
}
|
|
|
|
func (store *Store) txGetMessageFromBucket(b *bolt.Bucket, apiID string) (*pmapi.Message, error) {
|
|
msgb := b.Get([]byte(apiID))
|
|
if msgb == nil {
|
|
return nil, ErrNoSuchAPIID
|
|
}
|
|
msg := &pmapi.Message{}
|
|
if err := json.Unmarshal(msgb, msg); err != nil {
|
|
return nil, err
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
func (store *Store) txPutMessage(metaBucket *bolt.Bucket, onlyMeta *pmapi.Message) error {
|
|
b, err := json.Marshal(onlyMeta)
|
|
if err != nil {
|
|
return errors.Wrap(err, "cannot marshall metadata")
|
|
}
|
|
err = metaBucket.Put([]byte(onlyMeta.ID), b)
|
|
if err != nil {
|
|
return errors.Wrap(err, "cannot add to metadata bucket")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createOrUpdateMessageEvent is helper to create only one message with
|
|
// createOrUpdateMessagesEvent.
|
|
func (store *Store) createOrUpdateMessageEvent(msg *pmapi.Message) error {
|
|
return store.createOrUpdateMessagesEvent([]*pmapi.Message{msg})
|
|
}
|
|
|
|
// createOrUpdateMessagesEvent tries to create or update messages in database.
|
|
// This function is optimised for insertion of many messages at once.
|
|
// It calls createLabelsIfMissing if needed.
|
|
func (store *Store) createOrUpdateMessagesEvent(msgs []*pmapi.Message) error { //nolint[funlen]
|
|
store.log.WithField("msgs", msgs).Trace("Creating or updating messages in the store")
|
|
|
|
// Strip non meta first to reduce memory (no need to keep all old msg ID data during update).
|
|
err := store.db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(metadataBucket)
|
|
for _, msg := range msgs {
|
|
clearNonMetadata(msg)
|
|
txUpdateMetadataFromDB(b, msg, store.log)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
affectedLabels := map[string]bool{}
|
|
for _, m := range msgs {
|
|
for _, l := range m.LabelIDs {
|
|
affectedLabels[l] = true
|
|
}
|
|
}
|
|
if err = store.createLabelsIfMissing(affectedLabels); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Updating metadata and mailboxes is not atomic, but this is OK.
|
|
// The worst case scenario is we have metadata but not updated mailboxes
|
|
// which is OK as without information in mailboxes IMAP we will never ask
|
|
// for metadata. Also, when doing the operation again, it will simply
|
|
// update the metadata.
|
|
// The reason to split is efficiency--it's more memory efficient.
|
|
|
|
// Update metadata.
|
|
err = store.db.Update(func(tx *bolt.Tx) error {
|
|
metaBucket := tx.Bucket(metadataBucket)
|
|
for _, msg := range msgs {
|
|
err := store.txPutMessage(metaBucket, msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update mailboxes.
|
|
err = store.db.Update(func(tx *bolt.Tx) error {
|
|
for _, a := range store.addresses {
|
|
if err := a.txCreateOrUpdateMessages(tx, msgs); err != nil {
|
|
store.log.WithError(err).Error("cannot update maiboxes")
|
|
return errors.Wrap(err, "cannot add to mailboxes bucket")
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Notify the cacher that it should start caching messages.
|
|
for _, msg := range msgs {
|
|
store.cacher.newJob(msg.ID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// clearNonMetadata to not allow to store decrypted or encrypted data i.e. body
|
|
// and attachments.
|
|
func clearNonMetadata(onlyMeta *pmapi.Message) {
|
|
onlyMeta.Body = ""
|
|
onlyMeta.Attachments = nil
|
|
}
|
|
|
|
// txUpdateMetadataFromDB changes the the onlyMeta data.
|
|
// If there is stored message in metaBucket the size, header and MIMEType are
|
|
// not changed if already set. To change these:
|
|
// * size must be updated by Message.SetSize
|
|
// * contentType and header must be updated by Message.SetContentTypeAndHeader.
|
|
func txUpdateMetadataFromDB(metaBucket *bolt.Bucket, onlyMeta *pmapi.Message, log *logrus.Entry) {
|
|
msgb := metaBucket.Get([]byte(onlyMeta.ID))
|
|
if msgb == nil {
|
|
return
|
|
}
|
|
|
|
// It is faster to unmarshal only the needed items.
|
|
stored := &struct {
|
|
Size int64
|
|
Header string
|
|
MIMEType string
|
|
}{}
|
|
if err := json.Unmarshal(msgb, stored); err != nil {
|
|
log.WithError(err).
|
|
Error("Fail to unmarshal from DB, metadata will be overwritten")
|
|
return
|
|
}
|
|
|
|
// Keep content type.
|
|
onlyMeta.MIMEType = stored.MIMEType
|
|
if stored.Header != "" && stored.Header != "(No Header)" {
|
|
tmpMsg, err := mail.ReadMessage(
|
|
strings.NewReader(stored.Header + "\r\n\r\n"),
|
|
)
|
|
if err == nil {
|
|
onlyMeta.Header = tmpMsg.Header
|
|
} else {
|
|
log.WithError(err).
|
|
Error("Fail to parse, the header will be overwritten")
|
|
}
|
|
}
|
|
}
|
|
|
|
// deleteMessageEvent is helper to delete only one message with deleteMessagesEvent.
|
|
func (store *Store) deleteMessageEvent(apiID string) error {
|
|
return store.deleteMessagesEvent([]string{apiID})
|
|
}
|
|
|
|
// deleteMessagesEvent deletes the message from metadata and all mailbox buckets.
|
|
func (store *Store) deleteMessagesEvent(apiIDs []string) error {
|
|
for _, messageID := range apiIDs {
|
|
if err := store.cache.Rem(store.UserID(), messageID); err != nil {
|
|
logrus.WithError(err).Error("Failed to remove message from cache")
|
|
}
|
|
}
|
|
|
|
return store.db.Update(func(tx *bolt.Tx) error {
|
|
for _, apiID := range apiIDs {
|
|
if err := tx.Bucket(metadataBucket).Delete([]byte(apiID)); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, a := range store.addresses {
|
|
if err := a.txDeleteMessage(tx, apiID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|