forked from Silverfish/proton-bridge
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91dcb2f773 | |||
| c676c732ab | |||
| 444f2d8a12 | |||
| f10da3c7f0 | |||
| b8dd9f82bd | |||
| 1157e60972 | |||
| e9e4d8c725 | |||
| 186fa24106 | |||
| 63780b7b8d | |||
| e3e4769d78 | |||
| b2e9c4e4e9 | |||
| db4cb36538 | |||
| 984864553e | |||
| 2707a5627c | |||
| 8e0d6d41a6 | |||
| 2b76a45e17 | |||
| fce5990d19 |
15
Changelog.md
15
Changelog.md
@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
Changelog [format](http://keepachangelog.com/en/1.0.0/)
|
Changelog [format](http://keepachangelog.com/en/1.0.0/)
|
||||||
|
|
||||||
|
## [Bridge 1.8.9] James
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* GODT-1263: Fix crash on invalid or empty header.
|
||||||
|
* GODT-1235: Fix 401 response error handling.
|
||||||
|
* GODT-1261: Fix building messages with long key.
|
||||||
|
* Other: use windows-compatible filename when dumping message in QA builds.
|
||||||
|
|
||||||
|
|
||||||
|
## [Bridge 1.8.8] James
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* GODT-1234 Set attachment name 'message.eml' for `message/rfc822` attachments.
|
||||||
|
|
||||||
|
|
||||||
## [Bridge 1.8.7] James
|
## [Bridge 1.8.7] James
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
2
Makefile
2
Makefile
@ -10,7 +10,7 @@ TARGET_OS?=${GOOS}
|
|||||||
.PHONY: build build-ie build-nogui build-ie-nogui build-launcher build-launcher-ie versioner hasher
|
.PHONY: build build-ie build-nogui build-ie-nogui build-launcher build-launcher-ie versioner hasher
|
||||||
|
|
||||||
# Keep version hardcoded so app build works also without Git repository.
|
# Keep version hardcoded so app build works also without Git repository.
|
||||||
BRIDGE_APP_VERSION?=1.8.7+git
|
BRIDGE_APP_VERSION?=1.8.9+git
|
||||||
IE_APP_VERSION?=1.3.3+git
|
IE_APP_VERSION?=1.3.3+git
|
||||||
APP_VERSION:=${BRIDGE_APP_VERSION}
|
APP_VERSION:=${BRIDGE_APP_VERSION}
|
||||||
SRC_ICO:=logo.ico
|
SRC_ICO:=logo.ico
|
||||||
|
|||||||
@ -48,7 +48,7 @@ func dumpMessageData(b []byte, subject string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := ioutil.WriteFile(
|
if err := ioutil.WriteFile(
|
||||||
filepath.Join(path, fmt.Sprintf("%v-%v.eml", subject, time.Now().Format(time.RFC3339Nano))),
|
filepath.Join(path, fmt.Sprintf("%v-%v.eml", subject, time.Now().Unix())),
|
||||||
b,
|
b,
|
||||||
0600,
|
0600,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
package credentials
|
package credentials
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
@ -228,21 +229,28 @@ func (s *Store) Get(userID string) (creds *Credentials, err error) {
|
|||||||
return s.get(userID)
|
return s.get(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) get(userID string) (creds *Credentials, err error) {
|
func (s *Store) get(userID string) (*Credentials, error) {
|
||||||
log := log.WithField("user", userID)
|
log := log.WithField("user", userID)
|
||||||
|
|
||||||
_, secret, err := s.secrets.Get(userID)
|
_, secret, err := s.secrets.Get(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Warn("Could not get credentials from native keychain")
|
return nil, err
|
||||||
return
|
}
|
||||||
|
|
||||||
|
if secret == "" {
|
||||||
|
return nil, errors.New("secret is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
credentials := &Credentials{UserID: userID}
|
credentials := &Credentials{UserID: userID}
|
||||||
if err = credentials.Unmarshal(secret); err != nil {
|
|
||||||
err = fmt.Errorf("backend/credentials: malformed secret: %v", err)
|
if err := credentials.Unmarshal(secret); err != nil {
|
||||||
_ = s.secrets.Delete(userID)
|
log.WithError(fmt.Errorf("malformed secret: %w", err)).Error("Could not unmarshal secret")
|
||||||
log.WithError(err).Error("Could not unmarshal secret")
|
|
||||||
return
|
if err := s.secrets.Delete(userID); err != nil {
|
||||||
|
log.WithError(err).Error("Failed to remove malformed secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return credentials, nil
|
return credentials, nil
|
||||||
|
|||||||
@ -279,26 +279,6 @@ func (u *User) GetStoreAddresses() []string {
|
|||||||
return u.creds.EmailList()
|
return u.creds.EmailList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStoreAddresses returns a user's used addresses (with the original address in first place).
|
|
||||||
func (u *User) getStoreAddresses() []string { // nolint[unused]
|
|
||||||
addrInfo, err := u.store.GetAddressInfo()
|
|
||||||
if err != nil {
|
|
||||||
u.log.WithError(err).Error("Failed getting address info from store")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
addresses := []string{}
|
|
||||||
for _, addr := range addrInfo {
|
|
||||||
addresses = append(addresses, addr.Address)
|
|
||||||
}
|
|
||||||
|
|
||||||
if u.IsCombinedAddressMode() {
|
|
||||||
return addresses[:1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return addresses
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAddresses returns list of all addresses.
|
// GetAddresses returns list of all addresses.
|
||||||
func (u *User) GetAddresses() []string {
|
func (u *User) GetAddresses() []string {
|
||||||
u.lock.RLock()
|
u.lock.RLock()
|
||||||
|
|||||||
@ -347,6 +347,12 @@ func writeMultipartEncryptedRFC822(header message.Header, body []byte) ([]byte,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If parsed header is empty then either it is malformed or it is missing.
|
||||||
|
// Anyway message could not be considered multipart/mixed anymore since there will be no boundary.
|
||||||
|
if bodyHeader.Len() == 0 {
|
||||||
|
header.Del("Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
entFields := bodyHeader.Fields()
|
entFields := bodyHeader.Fields()
|
||||||
|
|
||||||
for entFields.Next() {
|
for entFields.Next() {
|
||||||
@ -480,7 +486,7 @@ func getAttachmentPartHeader(att *pmapi.Attachment) message.Header {
|
|||||||
hdr.SetContentDisposition(att.Disposition, map[string]string{"filename": mime.QEncoding.Encode("utf-8", att.Name)})
|
hdr.SetContentDisposition(att.Disposition, map[string]string{"filename": mime.QEncoding.Encode("utf-8", att.Name)})
|
||||||
|
|
||||||
// Use base64 for all attachments except embedded RFC822 messages.
|
// Use base64 for all attachments except embedded RFC822 messages.
|
||||||
if att.MIMEType != "message/rfc822" {
|
if att.MIMEType != rfc822Message {
|
||||||
hdr.Set("Content-Transfer-Encoding", "base64")
|
hdr.Set("Content-Transfer-Encoding", "base64")
|
||||||
} else {
|
} else {
|
||||||
hdr.Del("Content-Transfer-Encoding")
|
hdr.Del("Content-Transfer-Encoding")
|
||||||
@ -494,7 +500,10 @@ func toMessageHeader(hdr mail.Header) message.Header {
|
|||||||
|
|
||||||
for key, val := range hdr {
|
for key, val := range hdr {
|
||||||
for _, val := range val {
|
for _, val := range val {
|
||||||
res.Add(key, val)
|
// Using AddRaw instead of Add to save key-value pair as byte buffer within Header.
|
||||||
|
// This buffer is used latter on in message writer to construct message and avoid crash
|
||||||
|
// when key length is more than 76 characters long.
|
||||||
|
res.AddRaw([]byte(key + ": " + val + "\r\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,6 +52,27 @@ func TestBuildPlainMessage(t *testing.T) {
|
|||||||
expectTransferEncoding(is(`quoted-printable`))
|
expectTransferEncoding(is(`quoted-printable`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPlainMessageWithLongKey(t *testing.T) {
|
||||||
|
m := gomock.NewController(t)
|
||||||
|
defer m.Finish()
|
||||||
|
|
||||||
|
b := NewBuilder(1, 1, 1)
|
||||||
|
defer b.Done()
|
||||||
|
|
||||||
|
kr := tests.MakeKeyRing(t)
|
||||||
|
msg := newTestMessage(t, kr, "messageID", "addressID", "text/plain", "body", time.Now())
|
||||||
|
msg.Header["ReallyVeryVeryVeryVeryVeryLongLongLongLongLongLongLongKeyThatWillHaveNotSoLongValue"] = []string{"value"}
|
||||||
|
|
||||||
|
res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
section(t, res).
|
||||||
|
expectContentType(is(`text/plain`)).
|
||||||
|
expectBody(is(`body`)).
|
||||||
|
expectTransferEncoding(is(`quoted-printable`)).
|
||||||
|
expectHeader(`ReallyVeryVeryVeryVeryVeryLongLongLongLongLongLongLongKeyThatWillHaveNotSoLongValue`, is(`value`))
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildHTMLMessage(t *testing.T) {
|
func TestBuildHTMLMessage(t *testing.T) {
|
||||||
m := gomock.NewController(t)
|
m := gomock.NewController(t)
|
||||||
defer m.Finish()
|
defer m.Finish()
|
||||||
@ -99,6 +120,126 @@ func TestBuildPlainEncryptedMessage(t *testing.T) {
|
|||||||
expectBody(contains(`Where do fruits go on vacation? Pear-is!`))
|
expectBody(contains(`Where do fruits go on vacation? Pear-is!`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPlainEncryptedMessageMissingHeader(t *testing.T) {
|
||||||
|
m := gomock.NewController(t)
|
||||||
|
defer m.Finish()
|
||||||
|
|
||||||
|
b := NewBuilder(1, 1, 1)
|
||||||
|
defer b.Done()
|
||||||
|
|
||||||
|
body := readerToString(getFileReader("plaintext-missing-header.eml"))
|
||||||
|
|
||||||
|
kr := tests.MakeKeyRing(t)
|
||||||
|
msg := newTestMessage(t, kr, "messageID", "addressID", "multipart/mixed", body, time.Now())
|
||||||
|
|
||||||
|
res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
section(t, res).
|
||||||
|
expectContentType(is(`text/plain`)).
|
||||||
|
expectBody(is("How do we know that the ocean is friendly? It waves!\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlainEncryptedMessageInvalidHeader(t *testing.T) {
|
||||||
|
m := gomock.NewController(t)
|
||||||
|
defer m.Finish()
|
||||||
|
|
||||||
|
b := NewBuilder(1, 1, 1)
|
||||||
|
defer b.Done()
|
||||||
|
|
||||||
|
body := readerToString(getFileReader("plaintext-invalid-header.eml"))
|
||||||
|
|
||||||
|
kr := tests.MakeKeyRing(t)
|
||||||
|
msg := newTestMessage(t, kr, "messageID", "addressID", "multipart/mixed", body, time.Now())
|
||||||
|
|
||||||
|
res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
section(t, res).
|
||||||
|
expectContentType(is(`text/plain`)).
|
||||||
|
expectBody(is("MalformedKey Value\r\n\r\nHow do we know that the ocean is friendly? It waves!\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlainSignedEncryptedMessageMissingHeader(t *testing.T) {
|
||||||
|
m := gomock.NewController(t)
|
||||||
|
defer m.Finish()
|
||||||
|
|
||||||
|
b := NewBuilder(1, 1, 1)
|
||||||
|
defer b.Done()
|
||||||
|
|
||||||
|
body := readerToString(getFileReader("plaintext-missing-header.eml"))
|
||||||
|
|
||||||
|
kr := tests.MakeKeyRing(t)
|
||||||
|
sig := tests.MakeKeyRing(t)
|
||||||
|
|
||||||
|
enc, err := kr.Encrypt(crypto.NewPlainMessageFromString(body), sig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
arm, err := enc.GetArmored()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
msg := newRawTestMessage("messageID", "addressID", "multipart/mixed", arm, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
section(t, res).
|
||||||
|
expectContentType(is(`multipart/signed`)).
|
||||||
|
expectContentTypeParam(`micalg`, is(`SHA-256`)). // NOTE: Maybe this is bad... should probably be pgp-sha256
|
||||||
|
expectContentTypeParam(`protocol`, is(`application/pgp-signature`)).
|
||||||
|
expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
|
||||||
|
|
||||||
|
section(t, res, 1).
|
||||||
|
expectContentType(is(`text/plain`)).
|
||||||
|
expectBody(is("How do we know that the ocean is friendly? It waves!\r\n"))
|
||||||
|
|
||||||
|
section(t, res, 2).
|
||||||
|
expectContentType(is(`application/pgp-signature`)).
|
||||||
|
expectContentTypeParam(`name`, is(`OpenPGP_signature.asc`)).
|
||||||
|
expectContentDisposition(is(`attachment`)).
|
||||||
|
expectContentDispositionParam(`filename`, is(`OpenPGP_signature`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlainSignedEncryptedMessageInvalidHeader(t *testing.T) {
|
||||||
|
m := gomock.NewController(t)
|
||||||
|
defer m.Finish()
|
||||||
|
|
||||||
|
b := NewBuilder(1, 1, 1)
|
||||||
|
defer b.Done()
|
||||||
|
|
||||||
|
body := readerToString(getFileReader("plaintext-invalid-header.eml"))
|
||||||
|
|
||||||
|
kr := tests.MakeKeyRing(t)
|
||||||
|
sig := tests.MakeKeyRing(t)
|
||||||
|
|
||||||
|
enc, err := kr.Encrypt(crypto.NewPlainMessageFromString(body), sig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
arm, err := enc.GetArmored()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
msg := newRawTestMessage("messageID", "addressID", "multipart/mixed", arm, time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
res, err := b.NewJob(context.Background(), newTestFetcher(m, kr, msg), msg.ID).GetResult()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
section(t, res).
|
||||||
|
expectContentType(is(`multipart/signed`)).
|
||||||
|
expectContentTypeParam(`micalg`, is(`SHA-256`)). // NOTE: Maybe this is bad... should probably be pgp-sha256
|
||||||
|
expectContentTypeParam(`protocol`, is(`application/pgp-signature`)).
|
||||||
|
expectDate(is(`Wed, 01 Jan 2020 00:00:00 +0000`))
|
||||||
|
|
||||||
|
section(t, res, 1).
|
||||||
|
expectContentType(is(`text/plain`)).
|
||||||
|
expectBody(is("MalformedKey Value\r\n\r\nHow do we know that the ocean is friendly? It waves!\r\n"))
|
||||||
|
|
||||||
|
section(t, res, 2).
|
||||||
|
expectContentType(is(`application/pgp-signature`)).
|
||||||
|
expectContentTypeParam(`name`, is(`OpenPGP_signature.asc`)).
|
||||||
|
expectContentDisposition(is(`attachment`)).
|
||||||
|
expectContentDispositionParam(`filename`, is(`OpenPGP_signature`))
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPlainEncryptedLatin2Message(t *testing.T) {
|
func TestBuildPlainEncryptedLatin2Message(t *testing.T) {
|
||||||
m := gomock.NewController(t)
|
m := gomock.NewController(t)
|
||||||
defer m.Finish()
|
defer m.Finish()
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/emersion/go-message/textproto"
|
"github.com/emersion/go-message/textproto"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@ -37,8 +38,7 @@ func HeaderLines(header []byte) [][]byte {
|
|||||||
forEachLine(bufio.NewReader(bytes.NewReader(header)), func(line []byte) {
|
forEachLine(bufio.NewReader(bytes.NewReader(header)), func(line []byte) {
|
||||||
l := bytes.SplitN(line, []byte(`: `), 2)
|
l := bytes.SplitN(line, []byte(`: `), 2)
|
||||||
isLineContinuation := quote%2 != 0 || // no quotes opened
|
isLineContinuation := quote%2 != 0 || // no quotes opened
|
||||||
len(l) != 2 || // it doesn't have colon
|
!bytes.Equal(bytes.TrimLeftFunc(l[0], unicode.IsSpace), l[0]) // has whitespace indent at beginning
|
||||||
(len(l) == 2 && !bytes.Equal(bytes.TrimSpace(l[0]), l[0])) // has white space in front of header field
|
|
||||||
switch {
|
switch {
|
||||||
case len(bytes.TrimSpace(line)) == 0:
|
case len(bytes.TrimSpace(line)) == 0:
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
@ -89,6 +89,12 @@ func readHeaderBody(b []byte) (*textproto.Header, []byte, error) {
|
|||||||
|
|
||||||
var header textproto.Header
|
var header textproto.Header
|
||||||
|
|
||||||
|
// We assume that everything before first occurrence of empty line is header.
|
||||||
|
// If header is invalid for any reason or empty - put everything as body and let header be empty.
|
||||||
|
if !isHeaderValid(lines) {
|
||||||
|
return &header, b, nil
|
||||||
|
}
|
||||||
|
|
||||||
// We add lines in reverse so that calling textproto.WriteHeader later writes with the correct order.
|
// We add lines in reverse so that calling textproto.WriteHeader later writes with the correct order.
|
||||||
for i := len(lines) - 1; i >= 0; i-- {
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
if len(bytes.TrimSpace(lines[i])) > 0 {
|
if len(bytes.TrimSpace(lines[i])) > 0 {
|
||||||
@ -99,6 +105,20 @@ func readHeaderBody(b []byte) (*textproto.Header, []byte, error) {
|
|||||||
return &header, body, nil
|
return &header, body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isHeaderValid(headerLines [][]byte) bool {
|
||||||
|
if len(headerLines) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range headerLines {
|
||||||
|
if (bytes.IndexByte(line, ':') == -1) && (len(bytes.TrimSpace(line)) > 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func splitHeaderBody(b []byte) ([]byte, []byte, error) {
|
func splitHeaderBody(b []byte) ([]byte, []byte, error) {
|
||||||
br := bufio.NewReader(bytes.NewReader(b))
|
br := bufio.NewReader(bytes.NewReader(b))
|
||||||
|
|
||||||
|
|||||||
@ -79,3 +79,34 @@ Content-ID: <>
|
|||||||
[]byte("Content-ID: <>\n"),
|
[]byte("Content-ID: <>\n"),
|
||||||
}, HeaderLines([]byte(header)))
|
}, HeaderLines([]byte(header)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadHeaderBody(t *testing.T) {
|
||||||
|
const data = "key: value\r\n\r\nbody\n"
|
||||||
|
|
||||||
|
header, body, err := readHeaderBody([]byte(data))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, header.Len())
|
||||||
|
assert.Equal(t, "value", header.Get("key"))
|
||||||
|
assert.Equal(t, []byte("body\n"), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadHeaderBodyWithoutHeader(t *testing.T) {
|
||||||
|
const data = "body\n"
|
||||||
|
|
||||||
|
header, body, err := readHeaderBody([]byte(data))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, header.Len())
|
||||||
|
assert.Equal(t, []byte(data), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadHeaderBodyInvalidHeader(t *testing.T) {
|
||||||
|
const data = "value\r\n\r\nbody\n"
|
||||||
|
|
||||||
|
header, body, err := readHeaderBody([]byte(data))
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, header.Len())
|
||||||
|
assert.Equal(t, []byte(data), body)
|
||||||
|
}
|
||||||
|
|||||||
@ -528,6 +528,9 @@ func parseAttachment(h message.Header) (*pmapi.Attachment, error) {
|
|||||||
if att.Name == "" {
|
if att.Name == "" {
|
||||||
att.Name = mimeTypeParams["name"]
|
att.Name = mimeTypeParams["name"]
|
||||||
}
|
}
|
||||||
|
if att.Name == "" && mimeType == rfc822Message {
|
||||||
|
att.Name = "message.eml"
|
||||||
|
}
|
||||||
if att.Name == "" {
|
if att.Name == "" {
|
||||||
att.Name = "attachment.bin"
|
att.Name = "attachment.bin"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -222,6 +222,22 @@ func TestParseTextPlainWithOctetAttachmentGoodFilename(t *testing.T) {
|
|||||||
assert.Equal(t, "😁😂.txt", m.Attachments[0].Name)
|
assert.Equal(t, "😁😂.txt", m.Attachments[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseTextPlainWithRFC822Attachment(t *testing.T) {
|
||||||
|
f := getFileReader("text_plain_rfc822_attachment.eml")
|
||||||
|
|
||||||
|
m, _, plainBody, attReaders, err := Parse(f)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, `"Sender" <sender@pm.me>`, m.Sender.String())
|
||||||
|
assert.Equal(t, `"Receiver" <receiver@pm.me>`, m.ToList[0].String())
|
||||||
|
|
||||||
|
assert.Equal(t, "body", m.Body)
|
||||||
|
assert.Equal(t, "body", plainBody)
|
||||||
|
|
||||||
|
assert.Len(t, attReaders, 1)
|
||||||
|
assert.Equal(t, "message.eml", m.Attachments[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseTextPlainWithOctetAttachmentBadFilename(t *testing.T) {
|
func TestParseTextPlainWithOctetAttachmentBadFilename(t *testing.T) {
|
||||||
f := getFileReader("text_plain_octet_attachment_bad_2231_filename.eml")
|
f := getFileReader("text_plain_octet_attachment_bad_2231_filename.eml")
|
||||||
|
|
||||||
|
|||||||
@ -103,7 +103,7 @@ func (bs *BodyStructure) parseAllChildSections(r io.Reader, currentPath []int, s
|
|||||||
mediaType, params, _ := pmmime.ParseMediaType(info.Header.Get("Content-Type"))
|
mediaType, params, _ := pmmime.ParseMediaType(info.Header.Get("Content-Type"))
|
||||||
|
|
||||||
// If multipart, call getAllParts, else read to count lines.
|
// If multipart, call getAllParts, else read to count lines.
|
||||||
if (strings.HasPrefix(mediaType, "multipart/") || mediaType == "message/rfc822") && params["boundary"] != "" {
|
if (strings.HasPrefix(mediaType, "multipart/") || mediaType == rfc822Message) && params["boundary"] != "" {
|
||||||
nextPath := getChildPath(currentPath)
|
nextPath := getChildPath(currentPath)
|
||||||
|
|
||||||
var br *boundaryReader
|
var br *boundaryReader
|
||||||
|
|||||||
3
pkg/message/testdata/plaintext-invalid-header.eml
vendored
Normal file
3
pkg/message/testdata/plaintext-invalid-header.eml
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
MalformedKey Value
|
||||||
|
|
||||||
|
How do we know that the ocean is friendly? It waves!
|
||||||
1
pkg/message/testdata/plaintext-missing-header.eml
vendored
Normal file
1
pkg/message/testdata/plaintext-missing-header.eml
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
How do we know that the ocean is friendly? It waves!
|
||||||
16
pkg/message/testdata/text_plain_rfc822_attachment.eml
vendored
Normal file
16
pkg/message/testdata/text_plain_rfc822_attachment.eml
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
From: Sender <sender@pm.me>
|
||||||
|
To: Receiver <receiver@pm.me>
|
||||||
|
Content-Type: multipart/mixed; boundary=longrandomstring
|
||||||
|
|
||||||
|
--longrandomstring
|
||||||
|
|
||||||
|
body
|
||||||
|
--longrandomstring
|
||||||
|
Content-Type: message/rfc822
|
||||||
|
Content-Disposition: attachment
|
||||||
|
|
||||||
|
From: Sender <sender@pm.me>
|
||||||
|
To: Receiver <receiver@pm.me>
|
||||||
|
|
||||||
|
inner body
|
||||||
|
--longrandomstring--
|
||||||
@ -143,6 +143,51 @@ func Test401RevokedAuth(t *testing.T) {
|
|||||||
r.EqualError(t, err, ErrUnauthorized.Error())
|
r.EqualError(t, err, ErrUnauthorized.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test401RevokedAuthTokenUpdate(t *testing.T) {
|
||||||
|
var oldAuth = &AuthRefresh{
|
||||||
|
UID: "UID",
|
||||||
|
AccessToken: "oldAcc",
|
||||||
|
RefreshToken: "oldRef",
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAuth = &AuthRefresh{
|
||||||
|
UID: "UID",
|
||||||
|
AccessToken: "newAcc",
|
||||||
|
RefreshToken: "newRef",
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(newAuth); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") == ("Bearer " + oldAuth.AccessToken) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Header.Get("Authorization") == ("Bearer " + newAuth.AccessToken) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
|
||||||
|
c := New(Config{HostURL: ts.URL}).
|
||||||
|
NewClient(oldAuth.UID, oldAuth.AccessToken, oldAuth.RefreshToken, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
// The request will fail with 401, triggering a refresh. After the refresh it should succeed.
|
||||||
|
_, err := c.GetAddresses(context.Background())
|
||||||
|
r.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuth2FA(t *testing.T) {
|
func TestAuth2FA(t *testing.T) {
|
||||||
twoFACode := "code"
|
twoFACode := "code"
|
||||||
|
|
||||||
|
|||||||
@ -84,6 +84,8 @@ func (c *client) r(ctx context.Context) (*resty.Request, error) {
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// do executes fn and may repeate it in case "401 Unauthorized" error is returned.
|
||||||
|
// Note: fn may be called more than once.
|
||||||
func (c *client) do(ctx context.Context, fn func(*resty.Request) (*resty.Response, error)) (*resty.Response, error) {
|
func (c *client) do(ctx context.Context, fn func(*resty.Request) (*resty.Response, error)) (*resty.Response, error) {
|
||||||
r, err := c.r(ctx)
|
r, err := c.r(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -102,6 +104,12 @@ func (c *client) do(ctx context.Context, fn func(*resty.Request) (*resty.Respons
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We need to reconstruct request since access token is changed with authRefresh.
|
||||||
|
r, err := c.r(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return wrapNoConnection(fn(r))
|
return wrapNoConnection(fn(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,29 +64,6 @@ var errVerificationFailed = errors.New("signature verification failed")
|
|||||||
|
|
||||||
// ================= Public utility functions ======================
|
// ================= Public utility functions ======================
|
||||||
|
|
||||||
func (c *client) EncryptAndSignCards(cards []Card) ([]Card, error) {
|
|
||||||
var err error
|
|
||||||
for i := range cards {
|
|
||||||
card := &cards[i]
|
|
||||||
if isEncryptedCardType(card.Type) {
|
|
||||||
if isSignedCardType(card.Type) {
|
|
||||||
if card.Signature, err = c.sign(card.Data); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if card.Data, err = c.encrypt(card.Data, nil); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else if isSignedCardType(card.Type) {
|
|
||||||
if card.Signature, err = c.sign(card.Data); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cards, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) DecryptAndVerifyCards(cards []Card) ([]Card, error) {
|
func (c *client) DecryptAndVerifyCards(cards []Card) ([]Card, error) {
|
||||||
for i := range cards {
|
for i := range cards {
|
||||||
card := &cards[i]
|
card := &cards[i]
|
||||||
|
|||||||
@ -209,20 +209,6 @@ var testCardsCleartext = []Card{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClient_Encrypt(t *testing.T) {
|
|
||||||
c := newClient(newManager(Config{}), "")
|
|
||||||
c.userKeyRing = testPrivateKeyRing
|
|
||||||
|
|
||||||
cardEncrypted, err := c.EncryptAndSignCards(testCardsCleartext)
|
|
||||||
r.Nil(t, err)
|
|
||||||
|
|
||||||
// Result is always different, so the best way is to test it by decrypting again.
|
|
||||||
// Another test for decrypting will help us to be sure it's working.
|
|
||||||
cardCleartext, err := c.DecryptAndVerifyCards(cardEncrypted)
|
|
||||||
r.Nil(t, err)
|
|
||||||
r.Equal(t, testCardsCleartext[0].Data, cardCleartext[0].Data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_Decrypt(t *testing.T) {
|
func TestClient_Decrypt(t *testing.T) {
|
||||||
c := newClient(newManager(Config{}), "")
|
c := newClient(newManager(Config{}), "")
|
||||||
c.userKeyRing = testPrivateKeyRing
|
c.userKeyRing = testPrivateKeyRing
|
||||||
|
|||||||
@ -36,8 +36,8 @@ type PMKey struct {
|
|||||||
Fingerprint string
|
Fingerprint string
|
||||||
PrivateKey *crypto.Key
|
PrivateKey *crypto.Key
|
||||||
Primary int
|
Primary int
|
||||||
Token *string `json:",omitempty"`
|
Token string
|
||||||
Signature *string `json:",omitempty"`
|
Signature string
|
||||||
}
|
}
|
||||||
|
|
||||||
type clearable []byte
|
type clearable []byte
|
||||||
@ -84,12 +84,12 @@ func (key PMKey) getPassphraseFromToken(kr *crypto.KeyRing) (passphrase []byte,
|
|||||||
return nil, errors.New("no user key was provided")
|
return nil, errors.New("no user key was provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := crypto.NewPGPMessageFromArmored(*key.Token)
|
msg, err := crypto.NewPGPMessageFromArmored(key.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sig, err := crypto.NewPGPSignatureFromArmored(*key.Signature)
|
sig, err := crypto.NewPGPSignatureFromArmored(key.Signature)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -137,7 +137,7 @@ func (keys *PMKeys) UnlockAll(passphrase []byte, userKey *crypto.KeyRing) (kr *c
|
|||||||
for _, key := range *keys {
|
for _, key := range *keys {
|
||||||
var secret []byte
|
var secret []byte
|
||||||
|
|
||||||
if key.Token == nil || key.Signature == nil {
|
if key.Token == "" || key.Signature == "" {
|
||||||
secret = passphrase
|
secret = passphrase
|
||||||
} else if secret, err = key.getPassphraseFromToken(userKey); err != nil {
|
} else if secret, err = key.getPassphraseFromToken(userKey); err != nil {
|
||||||
return
|
return
|
||||||
@ -166,10 +166,6 @@ func (keys *PMKeys) UnlockAll(passphrase []byte, userKey *crypto.KeyRing) (kr *c
|
|||||||
// ErrNoKeyringAvailable represents an error caused by a keyring being nil or having no entities.
|
// ErrNoKeyringAvailable represents an error caused by a keyring being nil or having no entities.
|
||||||
var ErrNoKeyringAvailable = errors.New("no keyring available")
|
var ErrNoKeyringAvailable = errors.New("no keyring available")
|
||||||
|
|
||||||
func (c *client) encrypt(plain string, signer *crypto.KeyRing) (armored string, err error) {
|
|
||||||
return encrypt(c.userKeyRing, plain, signer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func encrypt(encrypter *crypto.KeyRing, plain string, signer *crypto.KeyRing) (armored string, err error) {
|
func encrypt(encrypter *crypto.KeyRing, plain string, signer *crypto.KeyRing) (armored string, err error) {
|
||||||
if encrypter == nil {
|
if encrypter == nil {
|
||||||
return "", ErrNoKeyringAvailable
|
return "", ErrNoKeyringAvailable
|
||||||
@ -209,18 +205,6 @@ func decrypt(decrypter *crypto.KeyRing, armored string) (plainBody []byte, err e
|
|||||||
return plainMessage.GetBinary(), nil
|
return plainMessage.GetBinary(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) sign(plain string) (armoredSignature string, err error) {
|
|
||||||
if c.userKeyRing == nil {
|
|
||||||
return "", ErrNoKeyringAvailable
|
|
||||||
}
|
|
||||||
plainMessage := crypto.NewPlainMessageFromString(plain)
|
|
||||||
pgpSignature, err := c.userKeyRing.SignDetached(plainMessage)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return pgpSignature.GetArmored()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) verify(plain, amroredSignature string) (err error) {
|
func (c *client) verify(plain, amroredSignature string) (err error) {
|
||||||
plainMessage := crypto.NewPlainMessageFromString(plain)
|
plainMessage := crypto.NewPlainMessageFromString(plain)
|
||||||
pgpSignature, err := crypto.NewPGPSignatureFromArmored(amroredSignature)
|
pgpSignature, err := crypto.NewPGPSignatureFromArmored(amroredSignature)
|
||||||
|
|||||||
@ -1,9 +1,34 @@
|
|||||||
|
## v1.8.9
|
||||||
|
- 2021-09-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issues with incorrect handling of 401 server error leading to random Bridge logouts
|
||||||
|
- Changed encoding of message/rfc822 - to better handle sending of the .msg files
|
||||||
|
- Fixed crash within RFC822 builder for invalid or empty headers
|
||||||
|
- Fixed crash within RFC822 builder for header with key length > 76 chars
|
||||||
|
|
||||||
|
|
||||||
|
## v1.8.7
|
||||||
|
- 2021-06-22
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Updated crypto-libraries to gopenpgp/v2 v2.1.10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed IMAP/SMTP restart in Bridge to mitigate connection issues
|
||||||
|
- Fixed unknown charset error for 'combined' messages
|
||||||
|
- Implemented a long-term fix for 'invalid or missing message signature' error
|
||||||
|
|
||||||
|
|
||||||
## v1.8.5
|
## v1.8.5
|
||||||
- 2021-06-11
|
- 2021-06-11
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|
||||||
- Implemented golang Secure Remote Password Protocol
|
- Updated golang Secure Remote Password Protocol
|
||||||
- Updated crypto-libraries to gopenpgp/v2 v2.1.9
|
- Updated crypto-libraries to gopenpgp/v2 v2.1.9
|
||||||
- Implemented new message parser (for imports from external accounts)
|
- Implemented new message parser (for imports from external accounts)
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,24 @@
|
|||||||
|
## v1.8.7
|
||||||
|
- 2021-06-24
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Updated golang Secure Remote Password Protocol
|
||||||
|
- Updated crypto-libraries to gopenpgp/v2 v2.1.10
|
||||||
|
- Implemented new message parser (for imports from external accounts)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed IMAP/SMTP restart in Bridge to mitigate connection issues
|
||||||
|
- Fixed unknown charset error for 'combined' messages
|
||||||
|
- Implemented a long-term fix for 'invalid or missing message signature' error
|
||||||
|
- Bridge not to strip PGP signatures of incoming clear text messages
|
||||||
|
- Import of messages with malformed MIME header
|
||||||
|
- Improved parsing of message headers
|
||||||
|
- Fetching bodies of non-multipart messages
|
||||||
|
- Sync and performance improvements
|
||||||
|
|
||||||
|
|
||||||
## v1.8.3
|
## v1.8.3
|
||||||
- 2021-05-27
|
- 2021-05-27
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
.PHONY: check-go check-godog install-godog test test-bridge test-ie test-live test-live-bridge test-live-ie test-stage test-debug test-live-debug bench
|
.PHONY: check-go check-godog install-godog test test-bridge test-ie test-live test-live-bridge test-live-ie test-stage test-debug test-live-debug bench
|
||||||
|
|
||||||
export GO111MODULE=on
|
export GO111MODULE=on
|
||||||
export BRIDGE_VERSION:=1.8.7+integrationtests
|
export BRIDGE_VERSION:=1.8.9+integrationtests
|
||||||
export VERBOSITY?=fatal
|
export VERBOSITY?=fatal
|
||||||
export TEST_DATA=testdata
|
export TEST_DATA=testdata
|
||||||
export TEST_APP?=bridge
|
export TEST_APP?=bridge
|
||||||
|
|||||||
@ -75,5 +75,10 @@ func (api *FakePMAPI) CreateAttachment(_ context.Context, attachment *pmapi.Atta
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
attachment.KeyPackets = base64.StdEncoding.EncodeToString(bytes)
|
attachment.KeyPackets = base64.StdEncoding.EncodeToString(bytes)
|
||||||
|
msg := api.getMessage(attachment.MessageID)
|
||||||
|
if msg == nil {
|
||||||
|
return nil, fmt.Errorf("no such message ID %q", attachment.MessageID)
|
||||||
|
}
|
||||||
|
msg.Attachments = append(msg.Attachments, attachment)
|
||||||
return attachment, nil
|
return attachment, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,10 +34,8 @@ func (api *FakePMAPI) GetMessage(_ context.Context, apiID string) (*pmapi.Messag
|
|||||||
if err := api.checkAndRecordCall(GET, "/mail/v4/messages/"+apiID, nil); err != nil {
|
if err := api.checkAndRecordCall(GET, "/mail/v4/messages/"+apiID, nil); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, message := range api.messages {
|
if msg := api.getMessage(apiID); msg != nil {
|
||||||
if message.ID == apiID {
|
return msg, nil
|
||||||
return message, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("message %s not found", apiID)
|
return nil, fmt.Errorf("message %s not found", apiID)
|
||||||
}
|
}
|
||||||
@ -175,8 +173,8 @@ func (api *FakePMAPI) SendMessage(ctx context.Context, messageID string, sendMes
|
|||||||
if err := api.checkAndRecordCall(POST, "/mail/v4/messages/"+messageID, sendMessageRequest); err != nil {
|
if err := api.checkAndRecordCall(POST, "/mail/v4/messages/"+messageID, sendMessageRequest); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
message, err := api.GetMessage(ctx, messageID)
|
message := api.getMessage(messageID)
|
||||||
if err != nil {
|
if message == nil {
|
||||||
return nil, nil, errors.Wrap(err, "draft does not exist")
|
return nil, nil, errors.Wrap(err, "draft does not exist")
|
||||||
}
|
}
|
||||||
message.Time = time.Now().Unix()
|
message.Time = time.Now().Unix()
|
||||||
@ -276,6 +274,15 @@ func (api *FakePMAPI) findMessage(newMsg *pmapi.Message) *pmapi.Message {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *FakePMAPI) getMessage(msgID string) *pmapi.Message {
|
||||||
|
for _, msg := range api.messages {
|
||||||
|
if msg.ID == msgID {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (api *FakePMAPI) addMessage(message *pmapi.Message) {
|
func (api *FakePMAPI) addMessage(message *pmapi.Message) {
|
||||||
if api.findMessage(message) != nil {
|
if api.findMessage(message) != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -120,3 +120,91 @@ Feature: SMTP sending of HTML messages with attachments
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
Scenario: Alternative plain and HTML message with rfc822 attachment
|
||||||
|
When SMTP client sends message
|
||||||
|
"""
|
||||||
|
From: Bridge Test <[userAddress]>
|
||||||
|
To: External Bridge <pm.bridge.qa@gmail.com>
|
||||||
|
Subject: Alternative plain and HTML with rfc822 attachment
|
||||||
|
Content-Type: multipart/mixed; boundary=main-parts
|
||||||
|
|
||||||
|
This is a multipart message in MIME format
|
||||||
|
|
||||||
|
--main-parts
|
||||||
|
Content-Type: multipart/alternative; boundary=alternatives
|
||||||
|
|
||||||
|
--alternatives
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
There is an attachment
|
||||||
|
|
||||||
|
|
||||||
|
--alternatives
|
||||||
|
Content-Type: text/html
|
||||||
|
|
||||||
|
<html><body>There <b>is</b> an attachment<body></html>
|
||||||
|
|
||||||
|
|
||||||
|
--alternatives--
|
||||||
|
|
||||||
|
--main-parts
|
||||||
|
Content-Type: message/rfc822
|
||||||
|
Content-Transfer-Encoding: 7bit
|
||||||
|
Content-Disposition: attachment
|
||||||
|
|
||||||
|
Received: from mx1.opensuse.org (mx1.infra.opensuse.org [192.168.47.95]) by
|
||||||
|
mailman3.infra.opensuse.org (Postfix) with ESMTP id 38BE2AC3 for
|
||||||
|
<factory@lists.opensuse.org>; Sun, 11 Jul 2021 19:50:34 +0000 (UTC)
|
||||||
|
From: "Bob " <Bob@something.net>
|
||||||
|
Sender: "Bob" <Bob@gmail.com>
|
||||||
|
To: "opensuse-factory" <opensuse-factory@opensuse.org>
|
||||||
|
Cc: "Bob" <Bob@something.net>
|
||||||
|
References: <y6ZUV5yEyOVQHETZRmi1GFe-Xumzct7QcLpGoSsi1MefGaoovfrUqdkmQ5gM6uySZ7JPIJhDkPJFDqHS1fb_mQ==@protonmail.internalid>
|
||||||
|
Subject: VirtualBox problems with kernel 5.13
|
||||||
|
Date: Sun, 11 Jul 2021 21:50:25 +0200
|
||||||
|
Message-ID: <71672e5f-24a2-c79f-03cc-4c923eb1790b@lwfinger.net>
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
X-Mailer: Microsoft Outlook 16.0
|
||||||
|
List-Unsubscribe: <mailto:factory-leave@lists.opensuse.org>
|
||||||
|
Content-Language: en-us
|
||||||
|
List-Help: <mailto:factory-request@lists.opensuse.org?subject=help>
|
||||||
|
List-Subscribe: <mailto:factory-join@lists.opensuse.org>
|
||||||
|
Thread-Index: AQFWvbNSAqFOch49YPlLU4eJWPObaQK2iKDq
|
||||||
|
|
||||||
|
I am writing this message as openSUSE's maintainer of VirtualBox.
|
||||||
|
|
||||||
|
Nearly every update of the Linux kernel to a new 5.X version breaks =
|
||||||
|
VirtualBox.
|
||||||
|
|
||||||
|
Bob
|
||||||
|
|
||||||
|
--main-parts--
|
||||||
|
|
||||||
|
"""
|
||||||
|
Then SMTP response is "OK"
|
||||||
|
And mailbox "Sent" for "user" has messages
|
||||||
|
| from | to | subject |
|
||||||
|
| [userAddress] | pm.bridge.qa@gmail.com | Alternative plain and HTML with rfc822 attachment |
|
||||||
|
And message is sent with API call
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"Message": {
|
||||||
|
"Subject": "Alternative plain and HTML with rfc822 attachment",
|
||||||
|
"Sender": {
|
||||||
|
"Name": "Bridge Test"
|
||||||
|
},
|
||||||
|
"ToList": [
|
||||||
|
{
|
||||||
|
"Address": "pm.bridge.qa@gmail.com",
|
||||||
|
"Name": "External Bridge"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"CCList": [],
|
||||||
|
"BCCList": [],
|
||||||
|
"MIMEType": "text/html"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user