Files
proton-bridge/internal/services/smtp/smtp.go
Leander Beernaert 3ef526333a feat(GODT-2799): SMTP Service
Refactor code to isolate the SMTP functionality in a dedicated SMTP
service for each user as discussed in the Bridge Service Architecture
RFC.

Some shared types have been moved from `user` to `usertypes` so that
they can be shared with Service and User Code.

Finally due to lack of recursive imports, the user data SMTP needs
access to is hidden behind an interface until the User Identity service
is implemented.
2023-07-18 11:49:18 +02:00

569 lines
17 KiB
Go

// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package smtp
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"mime"
"net/mail"
"runtime"
"strings"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/rfc5322"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/services/sendrecorder"
"github.com/ProtonMail/proton-bridge/v3/internal/usertypes"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/message"
"github.com/ProtonMail/proton-bridge/v3/pkg/message/parser"
"github.com/bradenaw/juniper/parallel"
"github.com/bradenaw/juniper/xslices"
"github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
// smtpSendMail sends an email from the given address to the given recipients.
func (s *Service) smtpSendMail(ctx context.Context, authID string, from string, to []string, r io.Reader) error {
return s.user.WithSMTPData(ctx, func(ctx context.Context, apiAddrs map[string]proton.Address, user proton.User, vault *vault.User) error {
if _, err := usertypes.GetAddrID(apiAddrs, from); err != nil {
return ErrInvalidReturnPath
}
emails := xslices.Map(maps.Values(apiAddrs), func(addr proton.Address) string {
return addr.Email
})
// Read the message to send.
b, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("failed to read message: %w", err)
}
// If running a QA build, dump to disk.
if err := debugDumpToDisk(b); err != nil {
s.log.WithError(err).Warn("Failed to dump message to disk")
}
// Compute the hash of the message (to match it against SMTP messages).
hash, err := sendrecorder.GetMessageHash(b)
if err != nil {
return err
}
// Check if we already tried to send this message recently.
srID, ok, err := s.recorder.TryInsertWait(ctx, hash, to, time.Now().Add(90*time.Second))
if err != nil {
return fmt.Errorf("failed to check send hash: %w", err)
} else if !ok {
s.log.Warn("A duplicate message was already sent recently, skipping")
return nil
}
// If we fail to send this message, we should remove the hash from the send recorder.
defer s.recorder.RemoveOnFail(hash, srID)
// Create a new message parser from the reader.
parser, err := parser.New(bytes.NewReader(b))
if err != nil {
return fmt.Errorf("failed to create parser: %w", err)
}
// If the message contains a sender, use it instead of the one from the return path.
if sender, ok := getMessageSender(parser); ok {
from = sender
}
// Load the user's mail settings.
settings, err := s.client.GetMailSettings(ctx)
if err != nil {
return fmt.Errorf("failed to get mail settings: %w", err)
}
addrID, err := usertypes.GetAddrID(apiAddrs, from)
if err != nil {
return err
}
return usertypes.WithAddrKR(user, apiAddrs[addrID], vault.KeyPass(), func(userKR, addrKR *crypto.KeyRing) error {
// Use the first key for encrypting the message.
addrKR, err := addrKR.FirstKey()
if err != nil {
return fmt.Errorf("failed to get first key: %w", err)
}
// Ensure that there is always a text/html or text/plain body part. This is required by the API. If none
// exists and empty text part will be added.
parser.AttachEmptyTextPartIfNoneExists()
// If we have to attach the public key, do it now.
if settings.AttachPublicKey {
key, err := addrKR.GetKey(0)
if err != nil {
return fmt.Errorf("failed to get sending key: %w", err)
}
pubKey, err := key.GetArmoredPublicKey()
if err != nil {
return fmt.Errorf("failed to get public key: %w", err)
}
parser.AttachPublicKey(pubKey, fmt.Sprintf("publickey - %v - %v", addrKR.GetIdentities()[0].Name, key.GetFingerprint()[:8]))
}
// Parse the message we want to send (after we have attached the public key).
message, err := message.ParseWithParser(parser, false)
if err != nil {
return fmt.Errorf("failed to parse message: %w", err)
}
// Send the message using the correct key.
sent, err := s.sendWithKey(
ctx,
authID,
vault.AddressMode(),
settings,
userKR, addrKR,
emails, from, to,
message,
)
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
// If the message was successfully sent, we can update the message ID in the record.
s.recorder.SignalMessageSent(hash, srID, sent.ID)
return nil
})
})
}
// sendWithKey sends the message with the given address key.
func (s *Service) sendWithKey(
ctx context.Context,
authAddrID string,
addrMode vault.AddressMode,
settings proton.MailSettings,
userKR, addrKR *crypto.KeyRing,
emails []string,
from string,
to []string,
message message.Message,
) (proton.Message, error) {
references := message.References
if message.InReplyTo != "" {
references = append(references, message.InReplyTo)
}
parentID, err := getParentID(ctx, s.client, authAddrID, addrMode, references)
if err != nil {
if err := s.reporter.ReportMessageWithContext("Failed to get parent ID", reporter.Context{
"error": err,
"references": message.References,
}); err != nil {
logrus.WithError(err).Error("Failed to report error")
}
s.log.WithError(err).Warn("Failed to get parent ID")
}
var decBody string
// nolint:exhaustive
switch message.MIMEType {
case rfc822.TextHTML:
decBody = string(message.RichBody)
case rfc822.TextPlain:
decBody = string(message.PlainBody)
default:
return proton.Message{}, fmt.Errorf("unsupported MIME type: %v", message.MIMEType)
}
draft, err := s.createDraft(ctx, addrKR, emails, from, to, parentID, message.InReplyTo, proton.DraftTemplate{
Subject: message.Subject,
Body: decBody,
MIMEType: message.MIMEType,
Sender: message.Sender,
ToList: message.ToList,
CCList: message.CCList,
BCCList: message.BCCList,
ExternalID: message.ExternalID,
})
if err != nil {
return proton.Message{}, fmt.Errorf("failed to create attachments: %w", err)
}
attKeys, err := s.createAttachments(ctx, s.client, addrKR, draft.ID, message.Attachments)
if err != nil {
return proton.Message{}, fmt.Errorf("failed to create attachments: %w", err)
}
recipients, err := s.getRecipients(ctx, s.client, userKR, settings, draft)
if err != nil {
return proton.Message{}, fmt.Errorf("failed to get recipients: %w", err)
}
req, err := createSendReq(addrKR, message.MIMEBody, message.RichBody, message.PlainBody, recipients, attKeys)
if err != nil {
return proton.Message{}, fmt.Errorf("failed to create packages: %w", err)
}
res, err := s.client.SendDraft(ctx, draft.ID, req)
if err != nil {
return proton.Message{}, fmt.Errorf("failed to send draft: %w", err)
}
return res, nil
}
func getParentID(
ctx context.Context,
client *proton.Client,
authAddrID string,
addrMode vault.AddressMode,
references []string,
) (string, error) {
var (
parentID string
internal []string
external []string
)
// Collect all the internal and external references of the message.
for _, ref := range references {
if strings.Contains(ref, message.InternalIDDomain) {
internal = append(internal, strings.TrimSuffix(ref, "@"+message.InternalIDDomain))
} else {
external = append(external, ref)
}
}
// Try to find a parent ID in the internal references.
for _, internal := range internal {
var addrID string
if addrMode == vault.SplitMode {
addrID = authAddrID
}
metadata, err := client.GetMessageMetadata(ctx, proton.MessageFilter{
ID: []string{internal},
AddressID: addrID,
})
if err != nil {
return "", fmt.Errorf("failed to get message metadata: %w", err)
}
for _, metadata := range metadata {
if !metadata.IsDraft() {
parentID = metadata.ID
} else if err := client.DeleteMessage(ctx, metadata.ID); err != nil {
return "", fmt.Errorf("failed to delete message: %w", err)
}
}
}
// If no parent was found, try to find it in the last external reference.
// There can be multiple messages with the same external ID; in this case, we first look if
// there is a single one sent by this account (with the `MessageFlagSent` flag set), if yes,
// then pick that, otherwise don't pick any parent.
if parentID == "" && len(external) > 0 {
var addrID string
if addrMode == vault.SplitMode {
addrID = authAddrID
}
metadata, err := client.GetMessageMetadata(ctx, proton.MessageFilter{
ExternalID: external[len(external)-1],
AddressID: addrID,
})
if err != nil {
return "", fmt.Errorf("failed to get message metadata: %w", err)
}
switch len(metadata) {
case 1:
// found exactly one parent
parentID = metadata[0].ID
case 0:
// found no parents
default:
// found multiple parents, search through metadata to try to find a singular parent that
// was sent by this account.
for _, metadata := range metadata {
if metadata.Flags.Has(proton.MessageFlagSent) {
parentID = metadata.ID
break
}
}
}
}
return parentID, nil
}
func (s *Service) createDraft(
ctx context.Context,
addrKR *crypto.KeyRing,
emails []string,
from string,
to []string,
parentID string,
replyToID string,
template proton.DraftTemplate,
) (proton.Message, error) {
// Check sender: set the sender if it's missing.
if template.Sender == nil {
template.Sender = &mail.Address{Address: from}
} else if template.Sender.Address == "" {
template.Sender.Address = from
}
// Check that the sending address is owned by the user, and if so, sanitize it.
if idx := xslices.IndexFunc(emails, func(email string) bool {
return strings.EqualFold(email, usertypes.SanitizeEmail(template.Sender.Address))
}); idx < 0 {
return proton.Message{}, fmt.Errorf("address %q is not owned by user", template.Sender.Address)
} else { //nolint:revive
template.Sender.Address = constructEmail(template.Sender.Address, emails[idx])
}
// Check ToList: ensure that ToList only contains addresses we actually plan to send to.
template.ToList = xslices.Filter(template.ToList, func(addr *mail.Address) bool {
return slices.Contains(to, addr.Address)
})
// Check BCCList: any recipients not present in the ToList or CCList are BCC recipients.
for _, recipient := range to {
if !slices.Contains(xslices.Map(xslices.Join(template.ToList, template.CCList, template.BCCList), func(addr *mail.Address) string {
return addr.Address
}), recipient) {
template.BCCList = append(template.BCCList, &mail.Address{Address: recipient})
}
}
var action proton.CreateDraftAction
if len(replyToID) > 0 {
action = proton.ReplyAction
} else {
action = proton.ForwardAction
}
return s.client.CreateDraft(ctx, addrKR, proton.CreateDraftReq{
Message: template,
ParentID: parentID,
Action: action,
})
}
func (s *Service) createAttachments(
ctx context.Context,
client *proton.Client,
addrKR *crypto.KeyRing,
draftID string,
attachments []message.Attachment,
) (map[string]*crypto.SessionKey, error) {
type attKey struct {
attID string
key *crypto.SessionKey
}
keys, err := parallel.MapContext(ctx, runtime.NumCPU(), attachments, func(ctx context.Context, att message.Attachment) (attKey, error) {
defer async.HandlePanic(s.panicHandler)
s.log.WithFields(logrus.Fields{
"name": logging.Sensitive(att.Name),
"contentID": att.ContentID,
"disposition": att.Disposition,
"mime-type": att.MIMEType,
}).Debug("Uploading attachment")
switch att.Disposition {
case proton.InlineDisposition:
// Some clients use inline disposition but don't set a content ID. Our API doesn't support this.
// We could generate our own content ID, but for simplicity, we just set the disposition to attachment.
if att.ContentID == "" {
att.Disposition = proton.AttachmentDisposition
}
case proton.AttachmentDisposition:
// Nothing to do.
default:
// Some clients leave the content disposition empty or use unsupported values.
// We default to inline disposition if a content ID is set, and to attachment disposition otherwise.
if att.ContentID != "" {
att.Disposition = proton.InlineDisposition
} else {
att.Disposition = proton.AttachmentDisposition
}
}
// Exclude name from params since this is already provided using Filename.
delete(att.MIMEParams, "name")
delete(att.MIMEParams, "filename")
attachment, err := client.UploadAttachment(ctx, addrKR, proton.CreateAttachmentReq{
Filename: att.Name,
MessageID: draftID,
MIMEType: rfc822.MIMEType(mime.FormatMediaType(att.MIMEType, att.MIMEParams)),
Disposition: att.Disposition,
ContentID: att.ContentID,
Body: att.Data,
})
if err != nil {
return attKey{}, fmt.Errorf("failed to upload attachment: %w", err)
}
keyPacket, err := base64.StdEncoding.DecodeString(attachment.KeyPackets)
if err != nil {
return attKey{}, fmt.Errorf("failed to decode key packets: %w", err)
}
key, err := addrKR.DecryptSessionKey(keyPacket)
if err != nil {
return attKey{}, fmt.Errorf("failed to decrypt session key: %w", err)
}
return attKey{attID: attachment.ID, key: key}, nil
})
if err != nil {
return nil, fmt.Errorf("failed to create attachments: %w", err)
}
attKeys := make(map[string]*crypto.SessionKey)
for _, key := range keys {
attKeys[key.attID] = key.key
}
return attKeys, nil
}
func (s *Service) getRecipients(
ctx context.Context,
client *proton.Client,
userKR *crypto.KeyRing,
settings proton.MailSettings,
draft proton.Message,
) (recipients, error) {
addresses := xslices.Map(xslices.Join(draft.ToList, draft.CCList, draft.BCCList), func(addr *mail.Address) string {
return addr.Address
})
prefs, err := parallel.MapContext(ctx, runtime.NumCPU(), addresses, func(ctx context.Context, recipient string) (proton.SendPreferences, error) {
defer async.HandlePanic(s.panicHandler)
pubKeys, recType, err := client.GetPublicKeys(ctx, recipient)
if err != nil {
return proton.SendPreferences{}, fmt.Errorf("failed to get public key for %v: %w", recipient, err)
}
contactSettings, err := getContactSettings(ctx, client, userKR, recipient)
if err != nil {
return proton.SendPreferences{}, fmt.Errorf("failed to get contact settings for %v: %w", recipient, err)
}
return buildSendPrefs(contactSettings, settings, pubKeys, draft.MIMEType, recType == proton.RecipientTypeInternal)
})
if err != nil {
return nil, fmt.Errorf("failed to get send preferences: %w", err)
}
recipients := make(recipients)
for idx, pref := range prefs {
recipients[addresses[idx]] = pref
}
return recipients, nil
}
func getContactSettings(
ctx context.Context,
client *proton.Client,
userKR *crypto.KeyRing,
recipient string,
) (proton.ContactSettings, error) {
contacts, err := client.GetAllContactEmails(ctx, recipient)
if err != nil {
return proton.ContactSettings{}, fmt.Errorf("failed to get contact data: %w", err)
}
idx := xslices.IndexFunc(contacts, func(contact proton.ContactEmail) bool {
return contact.Email == recipient
})
if idx < 0 {
return proton.ContactSettings{}, nil
}
contact, err := client.GetContact(ctx, contacts[idx].ContactID)
if err != nil {
return proton.ContactSettings{}, fmt.Errorf("failed to get contact: %w", err)
}
return contact.GetSettings(userKR, recipient)
}
func getMessageSender(parser *parser.Parser) (string, bool) {
address, err := rfc5322.ParseAddressList(parser.Root().Header.Get("From"))
if err != nil {
return "", false
} else if len(address) == 0 {
return "", false
}
return address[0].Address, true
}
func constructEmail(headerEmail string, addressEmail string) string {
splitAtHeader := strings.Split(headerEmail, "@")
if len(splitAtHeader) != 2 {
return addressEmail
}
splitPlus := strings.Split(splitAtHeader[0], "+")
if len(splitPlus) != 2 {
return addressEmail
}
splitAtAddress := strings.Split(addressEmail, "@")
if len(splitAtAddress) != 2 {
return addressEmail
}
return splitAtAddress[0] + "+" + splitPlus[1] + "@" + splitAtAddress[1]
}