mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-10 12:46:46 +00:00
530 lines
17 KiB
Go
530 lines
17 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/>.
|
|
|
|
// NOTE: Comments in this file refer to a specification in a document called "ProtonMail Encryption logic". It will be referred to via abbreviation PMEL.
|
|
|
|
package smtp
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/mail"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
|
"github.com/ProtonMail/proton-bridge/internal/events"
|
|
"github.com/ProtonMail/proton-bridge/pkg/listener"
|
|
"github.com/ProtonMail/proton-bridge/pkg/message"
|
|
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
|
goSMTPBackend "github.com/emersion/go-smtp"
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type smtpUser struct {
|
|
panicHandler panicHandler
|
|
eventListener listener.Listener
|
|
backend *smtpBackend
|
|
user bridgeUser
|
|
storeUser storeUserProvider
|
|
addressID string
|
|
}
|
|
|
|
// newSMTPUser returns struct implementing go-smtp/session interface.
|
|
func newSMTPUser(
|
|
panicHandler panicHandler,
|
|
eventListener listener.Listener,
|
|
smtpBackend *smtpBackend,
|
|
user bridgeUser,
|
|
addressID string,
|
|
) (goSMTPBackend.User, error) {
|
|
storeUser := user.GetStore()
|
|
if storeUser == nil {
|
|
return nil, errors.New("user database is not initialized")
|
|
}
|
|
|
|
return &smtpUser{
|
|
panicHandler: panicHandler,
|
|
eventListener: eventListener,
|
|
backend: smtpBackend,
|
|
user: user,
|
|
storeUser: storeUser,
|
|
addressID: addressID,
|
|
}, nil
|
|
}
|
|
|
|
// This method should eventually no longer be necessary. Everything should go via store.
|
|
func (su *smtpUser) client() pmapi.Client {
|
|
return su.user.GetTemporaryPMAPIClient()
|
|
}
|
|
|
|
// Send sends an email from the given address to the given addresses with the given body.
|
|
func (su *smtpUser) getSendPreferences(
|
|
recipient, messageMIMEType string,
|
|
mailSettings pmapi.MailSettings,
|
|
) (preferences SendPreferences, err error) {
|
|
b := &sendPreferencesBuilder{}
|
|
|
|
// 1. contact vcard data
|
|
vCardData, err := su.getContactVCardData(recipient)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// 2. api key data
|
|
apiKeys, isInternal, err := su.getAPIKeyData(recipient)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// 1 + 2 -> 3. advanced PGP settings
|
|
if err = b.setPGPSettings(vCardData, apiKeys, isInternal); err != nil {
|
|
return
|
|
}
|
|
|
|
// 4. mail settings
|
|
// Passed in from su.client().GetMailSettings()
|
|
|
|
// 3 + 4 -> 5. encryption preferences
|
|
b.setEncryptionPreferences(mailSettings)
|
|
|
|
// 6. composer preferences -- in our case, this comes from the MIME type of the message.
|
|
|
|
// 5 + 6 -> 7. send preferences
|
|
b.setMIMEPreferences(messageMIMEType)
|
|
|
|
return b.build(), nil
|
|
}
|
|
|
|
func (su *smtpUser) getContactVCardData(recipient string) (meta *ContactMetadata, err error) {
|
|
emails, err := su.client().GetContactEmailByEmail(recipient, 0, 1000)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, email := range emails {
|
|
if email.Defaults == 1 {
|
|
// NOTE: Can we still ignore this?
|
|
continue
|
|
}
|
|
|
|
var contact pmapi.Contact
|
|
if contact, err = su.client().GetContactByID(email.ContactID); err != nil {
|
|
return
|
|
}
|
|
|
|
var cards []pmapi.Card
|
|
if cards, err = su.client().DecryptAndVerifyCards(contact.Cards); err != nil {
|
|
return
|
|
}
|
|
|
|
return GetContactMetadataFromVCards(cards, recipient)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (su *smtpUser) getAPIKeyData(recipient string) (apiKeys []pmapi.PublicKey, isInternal bool, err error) {
|
|
return su.client().GetPublicKeysForEmail(recipient)
|
|
}
|
|
|
|
// Send sends an email from the given address to the given addresses with the given body.
|
|
func (su *smtpUser) Send(from string, to []string, messageReader io.Reader) (err error) { //nolint[funlen]
|
|
// Called from go-smtp in goroutines - we need to handle panics for each function.
|
|
defer su.panicHandler.HandlePanic()
|
|
|
|
mailSettings, err := su.client().GetMailSettings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var addr *pmapi.Address = su.client().Addresses().ByEmail(from)
|
|
if addr == nil {
|
|
err = errors.New("backend: invalid email address: not owned by user")
|
|
return
|
|
}
|
|
|
|
kr, err := su.client().KeyRingForAddressID(addr.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var attachedPublicKey string
|
|
var attachedPublicKeyName string
|
|
if mailSettings.AttachPublicKey > 0 {
|
|
firstKey, err := kr.GetKey(0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
attachedPublicKey, err = firstKey.GetArmoredPublicKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
attachedPublicKeyName = fmt.Sprintf("publickey - %v - %v", kr.GetIdentities()[0].Name, firstKey.GetFingerprint()[:8])
|
|
}
|
|
|
|
message, mimeBody, plainBody, attReaders, err := message.Parse(messageReader, attachedPublicKey, attachedPublicKeyName)
|
|
if err != nil {
|
|
log.WithError(err).Error("Failed to parse message")
|
|
return
|
|
}
|
|
clearBody := message.Body
|
|
|
|
externalID := message.Header.Get("Message-Id")
|
|
externalID = strings.Trim(externalID, "<>")
|
|
|
|
draftID, parentID := su.handleReferencesHeader(message)
|
|
|
|
if err = su.handleSenderAndRecipients(message, addr, from, to); err != nil {
|
|
return err
|
|
}
|
|
|
|
message.AddressID = addr.ID
|
|
|
|
// Apple Mail Message-Id has to be stored to avoid recovered message after each send.
|
|
// Before it was done only for Apple Mail, but it should work for any client. Also, the client
|
|
// is set up from IMAP and no one can be sure that the same client is used for SMTP as well.
|
|
// Also, user can use more than one client which could break the condition as well.
|
|
// If there is any problem, condition to Apple Mail only should be returned.
|
|
// Note: for that, we would need to refactor a little bit and pass the last client name from
|
|
// the IMAP through the bridge user.
|
|
message.ExternalID = externalID
|
|
|
|
// If Outlook does not get a response quickly, it will try to send the message again, leading
|
|
// to sending the same message multiple times. In case we detect the same message is in the
|
|
// sending queue, we wait a minute to finish the first request. If the message is still being
|
|
// sent after the timeout, we return an error back to the client. The UX is not the best,
|
|
// but it's better than sending the message many times. If the message was sent, we simply return
|
|
// nil to indicate it's OK.
|
|
sendRecorderMessageHash := su.backend.sendRecorder.getMessageHash(message)
|
|
isSending, wasSent := su.backend.sendRecorder.isSendingOrSent(su.client(), sendRecorderMessageHash)
|
|
|
|
startTime := time.Now()
|
|
for isSending && time.Since(startTime) < 90*time.Second {
|
|
log.Debug("Message is still in send queue, waiting for a bit")
|
|
time.Sleep(15 * time.Second)
|
|
isSending, wasSent = su.backend.sendRecorder.isSendingOrSent(su.client(), sendRecorderMessageHash)
|
|
}
|
|
if isSending {
|
|
log.Debug("Message is still in send queue, returning error to prevent client from adding it to the sent folder prematurely")
|
|
return errors.New("original message is still being sent")
|
|
}
|
|
if wasSent {
|
|
log.Debug("Message was already sent")
|
|
return nil
|
|
}
|
|
|
|
su.backend.sendRecorder.addMessage(sendRecorderMessageHash)
|
|
message, atts, err := su.storeUser.CreateDraft(kr, message, attReaders, attachedPublicKey, attachedPublicKeyName, parentID)
|
|
if err != nil {
|
|
su.backend.sendRecorder.removeMessage(sendRecorderMessageHash)
|
|
log.WithError(err).Error("Draft could not be created")
|
|
return err
|
|
}
|
|
su.backend.sendRecorder.setMessageID(sendRecorderMessageHash, message.ID)
|
|
log.WithField("messageID", message.ID).Debug("Draft was created successfully")
|
|
|
|
// We always have to create a new draft even if there already is one,
|
|
// because clients don't necessarily save the draft before sending, which
|
|
// can lead to sending the wrong message. Also clients do not necessarily
|
|
// delete the old draft.
|
|
if draftID != "" {
|
|
if err := su.client().DeleteMessages([]string{draftID}); err != nil {
|
|
log.WithError(err).WithField("draftID", draftID).Warn("Original draft cannot be deleted")
|
|
}
|
|
}
|
|
|
|
atts = append(atts, message.Attachments...)
|
|
// Decrypt attachment keys, because we will need to re-encrypt them with the recipients' public keys.
|
|
attkeys := make(map[string]*crypto.SessionKey)
|
|
attkeysEncoded := make(map[string]pmapi.AlgoKey)
|
|
|
|
for _, att := range atts {
|
|
var keyPackets []byte
|
|
if keyPackets, err = base64.StdEncoding.DecodeString(att.KeyPackets); err != nil {
|
|
return errors.Wrap(err, "decoding attachment key packets")
|
|
}
|
|
if attkeys[att.ID], err = kr.DecryptSessionKey(keyPackets); err != nil {
|
|
return errors.Wrap(err, "decrypting attachment session key")
|
|
}
|
|
attkeysEncoded[att.ID] = pmapi.AlgoKey{
|
|
Key: attkeys[att.ID].GetBase64Key(),
|
|
Algorithm: attkeys[att.ID].Algo,
|
|
}
|
|
}
|
|
|
|
plainSharedScheme := 0
|
|
htmlSharedScheme := 0
|
|
mimeSharedType := 0
|
|
|
|
plainAddressMap := make(map[string]*pmapi.MessageAddress)
|
|
htmlAddressMap := make(map[string]*pmapi.MessageAddress)
|
|
mimeAddressMap := make(map[string]*pmapi.MessageAddress)
|
|
|
|
var plainKey, htmlKey, mimeKey *crypto.SessionKey
|
|
var plainData, htmlData, mimeData []byte
|
|
|
|
containsUnencryptedRecipients := false
|
|
|
|
for _, email := range to {
|
|
if !looksLikeEmail(email) {
|
|
return errors.New(`"` + email + `" is not a valid recipient.`)
|
|
}
|
|
|
|
sendPreferences, err := su.getSendPreferences(email, message.MIMEType, mailSettings)
|
|
if !sendPreferences.Encrypt {
|
|
containsUnencryptedRecipients = true
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var signature int
|
|
if sendPreferences.Sign {
|
|
signature = pmapi.YesSignature
|
|
} else {
|
|
signature = pmapi.NoSignature
|
|
}
|
|
if sendPreferences.Scheme == pmapi.PGPMIMEPackage || sendPreferences.Scheme == pmapi.ClearMIMEPackage {
|
|
if mimeKey == nil {
|
|
if mimeKey, mimeData, err = encryptSymmetric(kr, mimeBody, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if sendPreferences.Scheme == pmapi.PGPMIMEPackage {
|
|
mimeBodyPacket, _, err := createPackets(sendPreferences.PublicKey, mimeKey, map[string]*crypto.SessionKey{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mimeAddressMap[email] = &pmapi.MessageAddress{Type: sendPreferences.Scheme, BodyKeyPacket: mimeBodyPacket, Signature: signature}
|
|
} else {
|
|
mimeAddressMap[email] = &pmapi.MessageAddress{Type: sendPreferences.Scheme, Signature: signature}
|
|
}
|
|
mimeSharedType |= sendPreferences.Scheme
|
|
} else {
|
|
switch sendPreferences.MIMEType {
|
|
case pmapi.ContentTypePlainText:
|
|
if plainKey == nil {
|
|
if plainKey, plainData, err = encryptSymmetric(kr, plainBody, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
newAddress := &pmapi.MessageAddress{Type: sendPreferences.Scheme, Signature: signature}
|
|
if sendPreferences.Encrypt && sendPreferences.PublicKey != nil {
|
|
newAddress.BodyKeyPacket, newAddress.AttachmentKeyPackets, err = createPackets(sendPreferences.PublicKey, plainKey, attkeys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
plainAddressMap[email] = newAddress
|
|
plainSharedScheme |= sendPreferences.Scheme
|
|
case pmapi.ContentTypeHTML:
|
|
if htmlKey == nil {
|
|
if htmlKey, htmlData, err = encryptSymmetric(kr, clearBody, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
newAddress := &pmapi.MessageAddress{Type: sendPreferences.Scheme, Signature: signature}
|
|
if sendPreferences.Encrypt && sendPreferences.PublicKey != nil {
|
|
newAddress.BodyKeyPacket, newAddress.AttachmentKeyPackets, err = createPackets(sendPreferences.PublicKey, htmlKey, attkeys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
htmlAddressMap[email] = newAddress
|
|
htmlSharedScheme |= sendPreferences.Scheme
|
|
}
|
|
}
|
|
}
|
|
|
|
if containsUnencryptedRecipients {
|
|
dec := new(mime.WordDecoder)
|
|
subject, err := dec.DecodeHeader(message.Header.Get("Subject"))
|
|
if err != nil {
|
|
return errors.New("error decoding subject message " + message.Header.Get("Subject"))
|
|
}
|
|
if !su.continueSendingUnencryptedMail(subject) {
|
|
if err := su.client().DeleteMessages([]string{message.ID}); err != nil {
|
|
log.WithError(err).Warn("Failed to delete canceled messages")
|
|
}
|
|
return errors.New("sending was canceled by user")
|
|
}
|
|
}
|
|
|
|
req := &pmapi.SendMessageReq{}
|
|
|
|
plainPkg := buildPackage(plainAddressMap, plainSharedScheme, pmapi.ContentTypePlainText, plainData, plainKey, attkeysEncoded)
|
|
if plainPkg != nil {
|
|
req.Packages = append(req.Packages, plainPkg)
|
|
}
|
|
|
|
htmlPkg := buildPackage(htmlAddressMap, htmlSharedScheme, pmapi.ContentTypeHTML, htmlData, htmlKey, attkeysEncoded)
|
|
if htmlPkg != nil {
|
|
req.Packages = append(req.Packages, htmlPkg)
|
|
}
|
|
|
|
if len(mimeAddressMap) > 0 {
|
|
pkg := &pmapi.MessagePackage{
|
|
Body: base64.StdEncoding.EncodeToString(mimeData),
|
|
Addresses: mimeAddressMap,
|
|
MIMEType: pmapi.ContentTypeMultipartMixed,
|
|
Type: mimeSharedType,
|
|
BodyKey: pmapi.AlgoKey{
|
|
Key: mimeKey.GetBase64Key(),
|
|
Algorithm: mimeKey.Algo,
|
|
},
|
|
}
|
|
req.Packages = append(req.Packages, pkg)
|
|
}
|
|
|
|
return su.storeUser.SendMessage(message.ID, req)
|
|
}
|
|
|
|
func (su *smtpUser) handleReferencesHeader(m *pmapi.Message) (draftID, parentID string) {
|
|
// Remove the internal IDs from the references header before sending to avoid confusion.
|
|
references := m.Header.Get("References")
|
|
newReferences := []string{}
|
|
for _, reference := range strings.Fields(references) {
|
|
if !strings.Contains(reference, "@"+pmapi.InternalIDDomain) {
|
|
newReferences = append(newReferences, reference)
|
|
} else { // internalid is the parentID.
|
|
idMatch := regexp.MustCompile(pmapi.InternalReferenceFormat).FindStringSubmatch(reference)
|
|
if len(idMatch) > 0 {
|
|
lastID := strings.TrimSuffix(strings.Trim(idMatch[0], "<>"), "@protonmail.internalid")
|
|
filter := &pmapi.MessagesFilter{ID: []string{lastID}}
|
|
if su.addressID != "" {
|
|
filter.AddressID = su.addressID
|
|
}
|
|
metadata, _, _ := su.client().ListMessages(filter)
|
|
for _, m := range metadata {
|
|
if m.IsDraft() {
|
|
draftID = m.ID
|
|
} else {
|
|
parentID = m.ID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
m.Header["References"] = newReferences
|
|
|
|
if parentID == "" && len(newReferences) > 0 {
|
|
externalID := strings.Trim(newReferences[len(newReferences)-1], "<>")
|
|
filter := &pmapi.MessagesFilter{ExternalID: externalID}
|
|
if su.addressID != "" {
|
|
filter.AddressID = su.addressID
|
|
}
|
|
metadata, _, _ := su.client().ListMessages(filter)
|
|
// There can be two or messages with the same external ID and then we cannot
|
|
// be sure which message should be parent. Better to not choose any.
|
|
if len(metadata) == 1 {
|
|
parentID = metadata[0].ID
|
|
}
|
|
}
|
|
|
|
return draftID, parentID
|
|
}
|
|
|
|
func (su *smtpUser) handleSenderAndRecipients(m *pmapi.Message, addr *pmapi.Address, from string, to []string) (err error) {
|
|
from = pmapi.ConstructAddress(from, addr.Email)
|
|
|
|
// Check sender.
|
|
if m.Sender == nil {
|
|
m.Sender = &mail.Address{Address: from}
|
|
} else {
|
|
m.Sender.Address = from
|
|
}
|
|
|
|
// Check recipients.
|
|
if len(to) == 0 {
|
|
err = errors.New("backend: no recipient specified")
|
|
return
|
|
}
|
|
|
|
// Sanitize ToList because some clients add *Sender* in the *ToList* when only Bcc is filled.
|
|
i := 0
|
|
for _, keep := range m.ToList {
|
|
keepThis := false
|
|
for _, addr := range to {
|
|
if addr == keep.Address {
|
|
keepThis = true
|
|
break
|
|
}
|
|
}
|
|
if keepThis {
|
|
m.ToList[i] = keep
|
|
i++
|
|
}
|
|
}
|
|
m.ToList = m.ToList[:i]
|
|
|
|
// Build a map of recipients visible to all.
|
|
// Bcc should be empty when sending a message.
|
|
var recipients []*mail.Address
|
|
recipients = append(recipients, m.ToList...)
|
|
recipients = append(recipients, m.CCList...)
|
|
recipients = append(recipients, m.BCCList...)
|
|
|
|
rm := map[string]bool{}
|
|
for _, r := range recipients {
|
|
rm[r.Address] = true
|
|
}
|
|
|
|
for _, r := range to {
|
|
if !rm[r] {
|
|
// Recipient is not known, add it to Bcc.
|
|
m.BCCList = append(m.BCCList, &mail.Address{Address: r})
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (su *smtpUser) continueSendingUnencryptedMail(subject string) bool {
|
|
if !su.backend.shouldReportOutgoingNoEnc() {
|
|
return true
|
|
}
|
|
|
|
// GUI should always respond in 10 seconds, but let's have safety timeout
|
|
// in case GUI will not respond properly. If GUI didn't respond, we cannot
|
|
// be sure if user even saw the notice: better to not send the e-mail.
|
|
req := su.backend.confirmer.NewRequest(15 * time.Second)
|
|
|
|
su.eventListener.Emit(events.OutgoingNoEncEvent, req.ID()+":"+subject)
|
|
|
|
res, err := req.Result()
|
|
if err != nil {
|
|
logrus.WithError(err).Error("Failed to determine whether to send unencrypted, assuming no")
|
|
return false
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
// Logout is called when this User will no longer be used.
|
|
func (su *smtpUser) Logout() error {
|
|
log.Debug("SMTP client logged out user ", su.addressID)
|
|
return nil
|
|
}
|