forked from Silverfish/proton-bridge
We build too many walls and not enough bridges
This commit is contained in:
468
pkg/message/parser.go
Normal file
468
pkg/message/parser.go
Normal file
@ -0,0 +1,468 @@
|
||||
// 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 message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
pmmime "github.com/ProtonMail/proton-bridge/pkg/mime"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/jaytaylor/html2text"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func parseAttachment(filename string, mediaType string, h textproto.MIMEHeader) (att *pmapi.Attachment) {
|
||||
if decoded, err := pmmime.DecodeHeader(filename); err == nil {
|
||||
filename = decoded
|
||||
}
|
||||
if filename == "" {
|
||||
ext, err := mime.ExtensionsByType(mediaType)
|
||||
if err == nil && len(ext) > 0 {
|
||||
filename = "attachment" + ext[0]
|
||||
}
|
||||
}
|
||||
|
||||
att = &pmapi.Attachment{
|
||||
Name: filename,
|
||||
MIMEType: mediaType,
|
||||
Header: h,
|
||||
}
|
||||
|
||||
headerContentID := strings.Trim(h.Get("Content-Id"), " <>")
|
||||
|
||||
if headerContentID != "" {
|
||||
att.ContentID = headerContentID
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var reEmailComment = regexp.MustCompile("[(][^)]*[)]") //nolint[gochecknoglobals]
|
||||
|
||||
// parseAddressComment removes the comments completely even though they should be allowed
|
||||
// http://tools.wordtothewise.com/rfc/822
|
||||
// NOTE: This should be supported in go>1.10 but it seems it's not ¯\_(ツ)_/¯
|
||||
func parseAddressComment(raw string) string {
|
||||
return reEmailComment.ReplaceAllString(raw, "")
|
||||
}
|
||||
|
||||
// Some clients incorrectly format messages with embedded attachments to have a format like
|
||||
// I. text/plain II. attachment III. text/plain
|
||||
// which we need to convert to a single HTML part with an embedded attachment.
|
||||
func combineParts(m *pmapi.Message, parts []io.Reader, headers []textproto.MIMEHeader, convertPlainToHTML bool, atts *[]io.Reader) (isHTML bool, err error) { //nolint[funlen]
|
||||
isHTML = true
|
||||
foundText := false
|
||||
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
h := headers[i]
|
||||
|
||||
disp, dispParams, _ := pmmime.ParseMediaType(h.Get("Content-Disposition"))
|
||||
|
||||
d := pmmime.DecodeContentEncoding(part, h.Get("Content-Transfer-Encoding"))
|
||||
if d == nil {
|
||||
log.Warnf("Unsupported Content-Transfer-Encoding '%v'", h.Get("Content-Transfer-Encoding"))
|
||||
d = part
|
||||
}
|
||||
|
||||
contentType := h.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
}
|
||||
mediaType, params, _ := pmmime.ParseMediaType(contentType)
|
||||
|
||||
if strings.HasPrefix(mediaType, "text/") && mediaType != "text/calendar" && disp != "attachment" {
|
||||
// This is text.
|
||||
var b []byte
|
||||
if b, err = ioutil.ReadAll(d); err != nil {
|
||||
continue
|
||||
}
|
||||
b, err = pmmime.DecodeCharset(b, params)
|
||||
if err != nil {
|
||||
log.Warn("Decode charset error: ", err)
|
||||
return false, err
|
||||
}
|
||||
contents := string(b)
|
||||
if strings.Contains(mediaType, "text/plain") && len(contents) > 0 {
|
||||
if !convertPlainToHTML {
|
||||
isHTML = false
|
||||
} else {
|
||||
contents = plaintextToHTML(contents)
|
||||
}
|
||||
} else if strings.Contains(mediaType, "text/html") && len(contents) > 0 {
|
||||
contents, err = stripHTML(contents)
|
||||
if err != nil {
|
||||
return isHTML, err
|
||||
}
|
||||
}
|
||||
m.Body = contents + m.Body
|
||||
foundText = true
|
||||
} else {
|
||||
// This is an attachment.
|
||||
filename := dispParams["filename"]
|
||||
if filename == "" {
|
||||
// Using "name" in Content-Type is discouraged.
|
||||
filename = params["name"]
|
||||
}
|
||||
if filename == "" && mediaType == "text/calendar" {
|
||||
filename = "event.ics"
|
||||
}
|
||||
|
||||
att := parseAttachment(filename, mediaType, h)
|
||||
|
||||
b := &bytes.Buffer{}
|
||||
if _, err = io.Copy(b, d); err != nil {
|
||||
continue
|
||||
}
|
||||
if foundText && att.ContentID == "" && strings.Contains(mediaType, "image") {
|
||||
// Treat this as an inline attachment even though it is not marked as one.
|
||||
hasher := sha256.New()
|
||||
_, _ = hasher.Write([]byte(att.Name + strconv.Itoa(b.Len())))
|
||||
bytes := hasher.Sum(nil)
|
||||
cid := hex.EncodeToString(bytes) + "@protonmail.com"
|
||||
|
||||
att.ContentID = cid
|
||||
embeddedHTML := makeEmbeddedImageHTML(cid, att.Name)
|
||||
m.Body = embeddedHTML + m.Body
|
||||
}
|
||||
|
||||
m.Attachments = append(m.Attachments, att)
|
||||
*atts = append(*atts, b)
|
||||
}
|
||||
}
|
||||
if isHTML {
|
||||
m.Body = addOuterHTMLTags(m.Body)
|
||||
}
|
||||
return isHTML, nil
|
||||
}
|
||||
|
||||
func checkHeaders(headers []textproto.MIMEHeader) bool {
|
||||
foundAttachment := false
|
||||
|
||||
for i := 0; i < len(headers); i++ {
|
||||
h := headers[i]
|
||||
|
||||
mediaType, _, _ := pmmime.ParseMediaType(h.Get("Content-Type"))
|
||||
|
||||
if !strings.HasPrefix(mediaType, "text/") {
|
||||
foundAttachment = true
|
||||
} else if foundAttachment {
|
||||
// This means that there is a text part after the first attachment,
|
||||
// so we will have to convert the body from plain->HTML.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================== 7bit Filter ==========================
|
||||
// For every MIME part in the tree that has "8bit" or "binary" content
|
||||
// transfer encoding: transcode it to "quoted-printable".
|
||||
|
||||
type SevenBitFilter struct {
|
||||
target pmmime.VisitAcceptor
|
||||
}
|
||||
|
||||
func NewSevenBitFilter(targetAccepter pmmime.VisitAcceptor) *SevenBitFilter {
|
||||
return &SevenBitFilter{
|
||||
target: targetAccepter,
|
||||
}
|
||||
}
|
||||
|
||||
func decodePart(partReader io.Reader, header textproto.MIMEHeader) (decodedPart io.Reader) {
|
||||
decodedPart = pmmime.DecodeContentEncoding(partReader, header.Get("Content-Transfer-Encoding"))
|
||||
if decodedPart == nil {
|
||||
log.Warnf("Unsupported Content-Transfer-Encoding '%v'", header.Get("Content-Transfer-Encoding"))
|
||||
decodedPart = partReader
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (sd SevenBitFilter) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSibling bool, isFirst, isLast bool) error {
|
||||
cte := strings.ToLower(header.Get("Content-Transfer-Encoding"))
|
||||
if isFirst && pmmime.IsLeaf(header) && cte != "quoted-printable" && cte != "base64" && cte != "7bit" {
|
||||
decodedPart := decodePart(partReader, header)
|
||||
|
||||
filteredHeader := textproto.MIMEHeader{}
|
||||
for k, v := range header {
|
||||
filteredHeader[k] = v
|
||||
}
|
||||
filteredHeader.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||
//filteredHeader.Set("Content-Transfer-Encoding", "base64")
|
||||
|
||||
filteredBuffer := &bytes.Buffer{}
|
||||
decodedSlice, _ := ioutil.ReadAll(decodedPart)
|
||||
w := quotedprintable.NewWriter(filteredBuffer)
|
||||
//w := base64.NewEncoder(base64.StdEncoding, filteredBuffer)
|
||||
if _, err := w.Write(decodedSlice); err != nil {
|
||||
log.Errorf("cannot write quotedprintable from %q: %v", cte, err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
log.Errorf("cannot close quotedprintable from %q: %v", cte, err)
|
||||
}
|
||||
|
||||
_ = sd.target.Accept(filteredBuffer, filteredHeader, hasPlainSibling, true, isLast)
|
||||
} else {
|
||||
_ = sd.target.Accept(partReader, header, hasPlainSibling, isFirst, isLast)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// =================== HTML Only convertor ==================================
|
||||
// In any part of MIME tree structure, replace standalone text/html with
|
||||
// multipart/alternative containing both text/html and text/plain.
|
||||
|
||||
type HTMLOnlyConvertor struct {
|
||||
target pmmime.VisitAcceptor
|
||||
}
|
||||
|
||||
func NewHTMLOnlyConvertor(targetAccepter pmmime.VisitAcceptor) *HTMLOnlyConvertor {
|
||||
return &HTMLOnlyConvertor{
|
||||
target: targetAccepter,
|
||||
}
|
||||
}
|
||||
|
||||
func randomBoundary() string {
|
||||
var buf [30]byte
|
||||
_, err := io.ReadFull(rand.Reader, buf[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", buf[:])
|
||||
}
|
||||
|
||||
func (hoc HTMLOnlyConvertor) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSiblings bool, isFirst, isLast bool) error {
|
||||
mediaType, _, err := pmmime.ParseMediaType(header.Get("Content-Type"))
|
||||
if isFirst && err == nil && mediaType == "text/html" && !hasPlainSiblings {
|
||||
multiPartHeaders := make(textproto.MIMEHeader)
|
||||
for k, v := range header {
|
||||
multiPartHeaders[k] = v
|
||||
}
|
||||
boundary := randomBoundary()
|
||||
multiPartHeaders.Set("Content-Type", "multipart/alternative; boundary=\""+boundary+"\"")
|
||||
childCte := header.Get("Content-Transfer-Encoding")
|
||||
|
||||
_ = hoc.target.Accept(partReader, multiPartHeaders, false, true, false)
|
||||
|
||||
partData, _ := ioutil.ReadAll(partReader)
|
||||
|
||||
htmlChildHeaders := make(textproto.MIMEHeader)
|
||||
htmlChildHeaders.Set("Content-Transfer-Encoding", childCte)
|
||||
htmlChildHeaders.Set("Content-Type", "text/html")
|
||||
htmlReader := bytes.NewReader(partData)
|
||||
_ = hoc.target.Accept(htmlReader, htmlChildHeaders, false, true, false)
|
||||
|
||||
_ = hoc.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, false)
|
||||
|
||||
plainChildHeaders := make(textproto.MIMEHeader)
|
||||
plainChildHeaders.Set("Content-Transfer-Encoding", childCte)
|
||||
plainChildHeaders.Set("Content-Type", "text/plain")
|
||||
unHtmlized, err := html2text.FromReader(bytes.NewReader(partData))
|
||||
if err != nil {
|
||||
unHtmlized = string(partData)
|
||||
}
|
||||
plainReader := strings.NewReader(unHtmlized)
|
||||
_ = hoc.target.Accept(plainReader, plainChildHeaders, false, true, true)
|
||||
|
||||
_ = hoc.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, true)
|
||||
} else {
|
||||
_ = hoc.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ======= Public Key Attacher ========
|
||||
|
||||
type PublicKeyAttacher struct {
|
||||
target pmmime.VisitAcceptor
|
||||
attachedPublicKey string
|
||||
attachedPublicKeyName string
|
||||
appendToMultipart bool
|
||||
depth int
|
||||
}
|
||||
|
||||
func NewPublicKeyAttacher(targetAccepter pmmime.VisitAcceptor, attachedPublicKey, attachedPublicKeyName string) *PublicKeyAttacher {
|
||||
return &PublicKeyAttacher{
|
||||
target: targetAccepter,
|
||||
attachedPublicKey: attachedPublicKey,
|
||||
attachedPublicKeyName: attachedPublicKeyName,
|
||||
appendToMultipart: false,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func split(input string, sliceLength int) string {
|
||||
processed := input
|
||||
result := ""
|
||||
for len(processed) > 0 {
|
||||
cutPoint := min(sliceLength, len(processed))
|
||||
part := processed[0:cutPoint]
|
||||
result = result + part + "\n"
|
||||
processed = processed[cutPoint:]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func createKeyAttachment(publicKey, publicKeyName string) (headers textproto.MIMEHeader, contents io.Reader) {
|
||||
attachmentHeaders := make(textproto.MIMEHeader)
|
||||
attachmentHeaders.Set("Content-Type", "application/pgp-key; name=\""+publicKeyName+"\"")
|
||||
attachmentHeaders.Set("Content-Transfer-Encoding", "base64")
|
||||
attachmentHeaders.Set("Content-Disposition", "attachment; filename=\""+publicKeyName+".asc.pgp\"")
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
w := base64.NewEncoder(base64.StdEncoding, buffer)
|
||||
_, _ = w.Write([]byte(publicKey))
|
||||
_ = w.Close()
|
||||
|
||||
return attachmentHeaders, strings.NewReader(split(buffer.String(), 73))
|
||||
}
|
||||
|
||||
func (pka *PublicKeyAttacher) Accept(partReader io.Reader, header textproto.MIMEHeader, hasPlainSiblings bool, isFirst, isLast bool) error {
|
||||
if isFirst && !pmmime.IsLeaf(header) {
|
||||
pka.depth++
|
||||
}
|
||||
if isLast && !pmmime.IsLeaf(header) {
|
||||
defer func() {
|
||||
pka.depth--
|
||||
}()
|
||||
}
|
||||
isRoot := (header.Get("From") != "")
|
||||
mediaType, _, err := pmmime.ParseMediaType(header.Get("Content-Type"))
|
||||
if isRoot && isFirst && err == nil && pka.attachedPublicKey != "" { //nolint[gocritic]
|
||||
if strings.HasPrefix(mediaType, "multipart/mixed") {
|
||||
pka.appendToMultipart = true
|
||||
_ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast)
|
||||
} else {
|
||||
// Create two siblings with attachment in the case toplevel is not multipart/mixed.
|
||||
multiPartHeaders := make(textproto.MIMEHeader)
|
||||
for k, v := range header {
|
||||
multiPartHeaders[k] = v
|
||||
}
|
||||
boundary := randomBoundary()
|
||||
multiPartHeaders.Set("Content-Type", "multipart/mixed; boundary=\""+boundary+"\"")
|
||||
multiPartHeaders.Del("Content-Transfer-Encoding")
|
||||
|
||||
_ = pka.target.Accept(partReader, multiPartHeaders, false, true, false)
|
||||
|
||||
originalHeader := make(textproto.MIMEHeader)
|
||||
originalHeader.Set("Content-Type", header.Get("Content-Type"))
|
||||
if header.Get("Content-Transfer-Encoding") != "" {
|
||||
originalHeader.Set("Content-Transfer-Encoding", header.Get("Content-Transfer-Encoding"))
|
||||
}
|
||||
|
||||
_ = pka.target.Accept(partReader, originalHeader, false, true, false)
|
||||
_ = pka.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, false)
|
||||
|
||||
attachmentHeaders, attachmentReader := createKeyAttachment(pka.attachedPublicKey, pka.attachedPublicKeyName)
|
||||
|
||||
_ = pka.target.Accept(attachmentReader, attachmentHeaders, false, true, true)
|
||||
_ = pka.target.Accept(partReader, multiPartHeaders, hasPlainSiblings, false, true)
|
||||
}
|
||||
} else if isLast && pka.depth == 1 && pka.attachedPublicKey != "" {
|
||||
_ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, false)
|
||||
attachmentHeaders, attachmentReader := createKeyAttachment(pka.attachedPublicKey, pka.attachedPublicKeyName)
|
||||
_ = pka.target.Accept(attachmentReader, attachmentHeaders, hasPlainSiblings, true, true)
|
||||
_ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, true)
|
||||
} else {
|
||||
_ = pka.target.Accept(partReader, header, hasPlainSiblings, isFirst, isLast)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ======= Parser ==========
|
||||
|
||||
func Parse(r io.Reader, attachedPublicKey, attachedPublicKeyName string) (m *pmapi.Message, mimeBody string, plainContents string, atts []io.Reader, err error) {
|
||||
secondReader := new(bytes.Buffer)
|
||||
_, _ = secondReader.ReadFrom(r)
|
||||
|
||||
mimeBody = secondReader.String()
|
||||
|
||||
mm, err := mail.ReadMessage(secondReader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if m, err = parseHeader(mm.Header); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h := textproto.MIMEHeader(m.Header)
|
||||
mmBodyData, err := ioutil.ReadAll(mm.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
printAccepter := pmmime.NewMIMEPrinter()
|
||||
|
||||
publicKeyAttacher := NewPublicKeyAttacher(printAccepter, attachedPublicKey, attachedPublicKeyName)
|
||||
sevenBitFilter := NewSevenBitFilter(publicKeyAttacher)
|
||||
|
||||
plainTextCollector := pmmime.NewPlainTextCollector(sevenBitFilter)
|
||||
htmlOnlyConvertor := NewHTMLOnlyConvertor(plainTextCollector)
|
||||
|
||||
visitor := pmmime.NewMimeVisitor(htmlOnlyConvertor)
|
||||
err = pmmime.VisitAll(bytes.NewReader(mmBodyData), h, visitor)
|
||||
/*
|
||||
err = visitor.VisitAll(h, bytes.NewReader(mmBodyData))
|
||||
*/
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mimeBody = printAccepter.String()
|
||||
plainContents = plainTextCollector.GetPlainText()
|
||||
|
||||
parts, headers, err := pmmime.GetAllChildParts(bytes.NewReader(mmBodyData), h)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
convertPlainToHTML := checkHeaders(headers)
|
||||
isHTML, err := combineParts(m, parts, headers, convertPlainToHTML, &atts)
|
||||
|
||||
if isHTML {
|
||||
m.MIMEType = "text/html"
|
||||
} else {
|
||||
m.MIMEType = "text/plain"
|
||||
}
|
||||
|
||||
return m, mimeBody, plainContents, atts, err
|
||||
}
|
||||
Reference in New Issue
Block a user