From c646c6222c7d6ba984ebda5da6525088ddfaa590 Mon Sep 17 00:00:00 2001 From: Pablo Zmdl Date: Tue, 17 Sep 2024 13:26:00 +0200 Subject: [PATCH 1/2] fix: Encapsulate PGP/MIME encrypted emails Sending emails that were encrypted using Mailvelope in API-mode require a different handling, because the encrypted cleartext contains MIME-headers. Signed-off-by: Pablo Zmdl --- lib/Controller/DraftsController.php | 10 ++++- lib/Controller/OutboxController.php | 4 ++ lib/Db/LocalMessage.php | 7 ++++ .../Version4100Date20240916174827.php | 38 +++++++++++++++++++ lib/Service/MailTransmission.php | 3 +- lib/Service/MimeMessage.php | 23 ++++++++++- src/components/Composer.vue | 1 + .../Unit/Controller/DraftsControllerTest.php | 3 ++ .../Unit/Controller/OutboxControllerTest.php | 1 + 9 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 lib/Migration/Version4100Date20240916174827.php diff --git a/lib/Controller/DraftsController.php b/lib/Controller/DraftsController.php index 4da921d886..2501d3f574 100644 --- a/lib/Controller/DraftsController.php +++ b/lib/Controller/DraftsController.php @@ -55,6 +55,7 @@ public function __construct(string $appName, * @param string $body * @param string $editorBody * @param bool $isHtml + * @param bool $isPgpMime * @param bool $smimeSign * @param bool $smimeEncrypt * @param array $to i. e. [['label' => 'Linus', 'email' => 'tent@stardewvalley.com'], ['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']] @@ -89,7 +90,8 @@ public function create( ?int $smimeCertificateId = null, ?int $sendAt = null, ?int $draftId = null, - bool $requestMdn = false) : JsonResponse { + bool $requestMdn = false, + bool $isPgpMime = false) : JsonResponse { $account = $this->accountService->find($this->userId, $accountId); if ($draftId !== null) { $this->service->handleDraft($account, $draftId); @@ -108,6 +110,7 @@ public function create( $message->setSmimeSign($smimeSign); $message->setSmimeEncrypt($smimeEncrypt); $message->setRequestMdn($requestMdn); + $message->setPgpMime($isPgpMime); if (!empty($smimeCertificateId)) { $smimeCertificate = $this->smimeService->findCertificate($smimeCertificateId, $this->userId); @@ -128,6 +131,7 @@ public function create( * @param string $body * @param string $editorBody * @param bool $isHtml + * @param bool $isPgpMime * @param bool $failed * @param array $to i. e. [['label' => 'Linus', 'email' => 'tent@stardewvalley.com'], ['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']] * @param array $cc @@ -156,7 +160,8 @@ public function update(int $id, ?string $inReplyToMessageId = null, ?int $smimeCertificateId = null, ?int $sendAt = null, - bool $requestMdn = false): JsonResponse { + bool $requestMdn = false, + bool $isPgpMime = false): JsonResponse { $message = $this->service->getMessage($id, $this->userId); $account = $this->accountService->find($this->userId, $accountId); @@ -174,6 +179,7 @@ public function update(int $id, $message->setSmimeSign($smimeSign); $message->setSmimeEncrypt($smimeEncrypt); $message->setRequestMdn($requestMdn); + $message->setPgpMime($isPgpMime); if (!empty($smimeCertificateId)) { $smimeCertificate = $this->smimeService->findCertificate($smimeCertificateId, $this->userId); diff --git a/lib/Controller/OutboxController.php b/lib/Controller/OutboxController.php index e12ee31eaf..a90e736609 100644 --- a/lib/Controller/OutboxController.php +++ b/lib/Controller/OutboxController.php @@ -105,6 +105,7 @@ public function create( ?int $smimeCertificateId = null, ?int $sendAt = null, bool $requestMdn = false, + bool $isPgpMime = false, ): JsonResponse { $account = $this->accountService->find($this->userId, $accountId); @@ -122,6 +123,7 @@ public function create( $message->setHtml($isHtml); $message->setInReplyToMessageId($inReplyToMessageId); $message->setSendAt($sendAt); + $message->setPgpMime($isPgpMime); $message->setSmimeSign($smimeSign); $message->setSmimeEncrypt($smimeEncrypt); $message->setRequestMdn($requestMdn); @@ -194,6 +196,7 @@ public function update( ?int $smimeCertificateId = null, ?int $sendAt = null, bool $requestMdn = false, + bool $isPgpMime = false, ): JsonResponse { $message = $this->service->getMessage($id, $this->userId); if ($message->getStatus() === LocalMessage::STATUS_PROCESSED) { @@ -209,6 +212,7 @@ public function update( $message->setHtml($isHtml); $message->setInReplyToMessageId($inReplyToMessageId); $message->setSendAt($sendAt); + $message->setPgpMime($isPgpMime); $message->setSmimeSign($smimeSign); $message->setSmimeEncrypt($smimeEncrypt); $message->setRequestMdn($requestMdn); diff --git a/lib/Db/LocalMessage.php b/lib/Db/LocalMessage.php index d2000ab005..ae27ea1bc4 100644 --- a/lib/Db/LocalMessage.php +++ b/lib/Db/LocalMessage.php @@ -37,6 +37,8 @@ * @method void setInReplyToMessageId(?string $inReplyToId) * @method int|null getUpdatedAt() * @method setUpdatedAt(?int $updatedAt) + * @method bool|null isPgpMime() + * @method setPgpMime(bool $pgpMime) * @method bool|null getSmimeSign() * @method setSmimeSign(bool $smimeSign) * @method int|null getSmimeCertificateId() @@ -110,6 +112,9 @@ class LocalMessage extends Entity implements JsonSerializable { /** @var int|null */ protected $updatedAt; + /** @var bool */ + protected $pgpMime; + /** @var bool|null */ protected $smimeSign; @@ -139,6 +144,7 @@ public function __construct() { $this->addType('html', 'boolean'); $this->addType('failed', 'boolean'); $this->addType('updatedAt', 'integer'); + $this->addType('pgpMime', 'boolean'); $this->addType('smimeSign', 'boolean'); $this->addType('smimeCertificateId', 'integer'); $this->addType('smimeEncrypt', 'boolean'); @@ -160,6 +166,7 @@ public function jsonSerialize() { 'body' => $this->getBody(), 'editorBody' => $this->getEditorBody(), 'isHtml' => ($this->isHtml() === true), + 'isPgpMime' => ($this->isPgpMime() === true), 'inReplyToMessageId' => $this->getInReplyToMessageId(), 'attachments' => $this->getAttachments(), 'from' => array_values( diff --git a/lib/Migration/Version4100Date20240916174827.php b/lib/Migration/Version4100Date20240916174827.php new file mode 100644 index 0000000000..02bad4c45a --- /dev/null +++ b/lib/Migration/Version4100Date20240916174827.php @@ -0,0 +1,38 @@ +getTable('mail_local_messages'); + if (!$outboxTable->hasColumn('pgp_mime')) { + $outboxTable->addColumn('pgp_mime', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + return $schema; + } +} diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index 0cdbded2e8..2e679aa5b2 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -124,7 +124,8 @@ public function sendMessage(Account $account, LocalMessage $localMessage): void $mimePart = $mimeMessage->build( $localMessage->isHtml(), $localMessage->getBody(), - $attachmentParts + $attachmentParts, + $localMessage->isPgpMime() === true ); // TODO: add smimeEncrypt check if implemented diff --git a/lib/Service/MimeMessage.php b/lib/Service/MimeMessage.php index d87cc17bac..f120618a6f 100644 --- a/lib/Service/MimeMessage.php +++ b/lib/Service/MimeMessage.php @@ -30,7 +30,7 @@ public function __construct(DataUriParser $uriParser) { * @param Horde_Mime_Part[] $attachments * @return Horde_Mime_Part */ - public function build(bool $isHtml, string $content, array $attachments): Horde_Mime_Part { + public function build(bool $isHtml, string $content, array $attachments, bool $isPgpMime = false): Horde_Mime_Part { if ($isHtml) { $imageParts = []; if (empty($content)) { @@ -115,6 +115,27 @@ public function build(bool $isHtml, string $content, array $attachments): Horde_ } else { $bodyPart = $alternativePart; } + } elseif ($isPgpMime) { + $contentPart = new Horde_Mime_Part(); + $contentPart->setType('application/octet-stream'); + $contentPart->setContentTypeParameter('name', 'encrypted.asc'); + $contentPart->setTransferEncoding('7bit'); + $contentPart->setDisposition('inline'); + $contentPart->setDispositionParameter('filename', 'encrypted.asc'); + $contentPart->setDescription('OpenPGP encrypted message'); + $contentPart->setContents($content); + + $pgpIdentPart = new Horde_Mime_Part(); + $pgpIdentPart->setType('application/pgp-encrypted'); + $pgpIdentPart->setTransferEncoding('7bit'); + $pgpIdentPart->setDescription('PGP/MIME Versions Identification'); + $pgpIdentPart->setContents('Version: 1'); + + $bodyPart = new Horde_Mime_Part(); + $bodyPart->setType('multipart/encrypted'); + $bodyPart->setContentTypeParameter('protocol', 'application/pgp-encrypted'); + $bodyPart[] = $pgpIdentPart; + $bodyPart[] = $contentPart; } else { $bodyPart = new Horde_Mime_Part(); $bodyPart->setType('text/plain'); diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 0a87822215..300f90ebb5 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -1096,6 +1096,7 @@ export default { smimeSign: this.shouldSmimeSign, smimeEncrypt: this.shouldSmimeEncrypt, smimeCertificateId: this.smimeCertificateForCurrentAlias?.id, + isPgpMime: this.encrypt, } }, saveDraft() { diff --git a/tests/Unit/Controller/DraftsControllerTest.php b/tests/Unit/Controller/DraftsControllerTest.php index 7e75bb728e..f8f304ab2c 100644 --- a/tests/Unit/Controller/DraftsControllerTest.php +++ b/tests/Unit/Controller/DraftsControllerTest.php @@ -202,6 +202,7 @@ public function testCreate(): void { $message->setSendAt(null); $message->setUpdatedAt(123456); $message->setRequestMdn(false); + $message->setPgpMime(false); $to = [['label' => 'Lewis', 'email' => 'tent@stardewvalley.com']]; $cc = [['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']]; @@ -250,6 +251,7 @@ public function testCreateFromDraft(): void { $message->setSendAt(null); $message->setUpdatedAt(123456); $message->setRequestMdn(false); + $message->setPgpMime(false); $to = [['label' => 'Lewis', 'email' => 'tent@stardewvalley.com']]; $cc = [['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']]; @@ -304,6 +306,7 @@ public function testCreateWithEmptyRecipients(): void { $message->setSendAt(null); $message->setUpdatedAt(123456); $message->setRequestMdn(false); + $message->setPgpMime(false); $account = new Account(new MailAccount()); $this->accountService->expects(self::once()) diff --git a/tests/Unit/Controller/OutboxControllerTest.php b/tests/Unit/Controller/OutboxControllerTest.php index 5b235b00f5..7e55c292c3 100644 --- a/tests/Unit/Controller/OutboxControllerTest.php +++ b/tests/Unit/Controller/OutboxControllerTest.php @@ -267,6 +267,7 @@ public function testCreate(): void { $message->setInReplyToMessageId('abc'); $message->setType(LocalMessage::TYPE_OUTGOING); $message->setRequestMdn(false); + $message->setPgpMime(false); $to = [['label' => 'Lewis', 'email' => 'tent@stardewvalley.com']]; $cc = [['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']]; From 21142f4b5dcb1e61dee3fc0603c40a44bb6589fb Mon Sep 17 00:00:00 2001 From: Pablo Zmdl Date: Thu, 19 Sep 2024 19:09:58 +0200 Subject: [PATCH 2/2] fix: Hand PGP/MIME-encrypted emails to Mailvelope Provide the ciphertext to the frontend and hand the ciphertext to Mailvelope. Signed-off-by: Pablo Zmdl --- lib/IMAP/ImapMessageFetcher.php | 9 +++++++++ lib/Model/IMAPMessage.php | 10 +++++++++- src/components/Message.vue | 5 ++++- tests/Unit/Controller/ListControllerTest.php | 2 ++ tests/Unit/Model/IMAPMessageTest.php | 3 +++ 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/IMAP/ImapMessageFetcher.php b/lib/IMAP/ImapMessageFetcher.php index e3cd701513..7deff8bbf5 100644 --- a/lib/IMAP/ImapMessageFetcher.php +++ b/lib/IMAP/ImapMessageFetcher.php @@ -62,6 +62,7 @@ class ImapMessageFetcher { private ?string $unsubscribeUrl = null; private bool $isOneClickUnsubscribe = false; private ?string $unsubscribeMailto = null; + private bool $isPgpMimeEncrypted = false; public function __construct(int $uid, string $mailbox, @@ -148,6 +149,13 @@ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPM // analyse the body part $structure = $fetch->getStructure(); + $this->isPgpMimeEncrypted = ($structure->getType() === 'multipart/encrypted' + && $structure->getContentTypeParameter('protocol') === 'application/pgp-encrypted'); + if ($this->isPgpMimeEncrypted) { + $this->plainMessage = $this->loadBodyData($structure, '2', false); + $this->attachmentsToIgnore[] = $structure->getPartByIndex(1)->getName(); + } + $this->hasAnyAttachment = $this->hasAttachments($structure); $isEncrypted = $this->smimeService->isEncrypted($fetch); @@ -267,6 +275,7 @@ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPM $isSigned, $signatureIsValid, $this->htmlService, // TODO: drop the html service dependency + $this->isPgpMimeEncrypted, ); } diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index e87e0236cc..ff408746d7 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -69,6 +69,7 @@ class IMAPMessage implements IMessage, JsonSerializable { private bool $isEncrypted; private bool $isSigned; private bool $signatureIsValid; + private bool $isPgpMimeEncrypted; public function __construct(int $uid, string $messageId, @@ -98,7 +99,8 @@ public function __construct(int $uid, bool $isEncrypted, bool $isSigned, bool $signatureIsValid, - Html $htmlService) { + Html $htmlService, + bool $isPgpMimeEncrypted) { $this->messageId = $uid; $this->realMessageId = $messageId; $this->flags = $flags; @@ -128,6 +130,7 @@ public function __construct(int $uid, $this->isSigned = $isSigned; $this->signatureIsValid = $signatureIsValid; $this->htmlService = $htmlService; + $this->isPgpMimeEncrypted = $isPgpMimeEncrypted; } public static function generateMessageId(): string { @@ -317,6 +320,7 @@ public function jsonSerialize() { 'isOneClickUnsubscribe' => $this->isOneClickUnsubscribe, 'unsubscribeMailto' => $this->unsubscribeMailto, 'scheduling' => $this->scheduling, + 'isPgpMimeEncrypted' => $this->isPgpMimeEncrypted, ]; } @@ -459,6 +463,10 @@ public function isOneClickUnsubscribe(): bool { return $this->isOneClickUnsubscribe; } + public function isPgpMimeEncrypted(): bool { + return $this->isPgpMimeEncrypted; + } + /** * Cast all values from an IMAP message into the correct DB format * diff --git a/src/components/Message.vue b/src/components/Message.vue index 11e87cf6be..d3e6412bc4 100644 --- a/src/components/Message.vue +++ b/src/components/Message.vue @@ -30,7 +30,7 @@ :message="message" :full-height="fullHeight" @load="$emit('load', $event)" /> - @@ -128,6 +128,9 @@ export default { isEncrypted() { return isPgpgMessage(this.message.hasHtmlBody ? html(this.message.body) : plain(this.message.body)) }, + isPgpMimeEncrypted() { + return this.message.isPgpMimeEncrypted + }, itineraries() { return this.message.itineraries ?? [] }, diff --git a/tests/Unit/Controller/ListControllerTest.php b/tests/Unit/Controller/ListControllerTest.php index 3003feeffe..463680a972 100644 --- a/tests/Unit/Controller/ListControllerTest.php +++ b/tests/Unit/Controller/ListControllerTest.php @@ -143,6 +143,7 @@ public function testUnsupportedMessage(): void { false, false, $this->createMock(Html::class), + false, ); $this->serviceMock->getParameter('mailManager') ->expects(self::once()) @@ -206,6 +207,7 @@ public function testUnsubscribe(): void { false, false, $this->createMock(Html::class), + false, ); $this->serviceMock->getParameter('mailManager') ->expects(self::once()) diff --git a/tests/Unit/Model/IMAPMessageTest.php b/tests/Unit/Model/IMAPMessageTest.php index 764863b0b0..0c7f1be489 100644 --- a/tests/Unit/Model/IMAPMessageTest.php +++ b/tests/Unit/Model/IMAPMessageTest.php @@ -81,6 +81,7 @@ public function testIconvHtmlMessage() { false, false, $htmlService, + false, ); $actualHtmlBody = $message->getHtmlBody(123); @@ -123,6 +124,7 @@ public function testSerialize() { false, false, $this->htmlService, + false, ); $json = $m->jsonSerialize(); @@ -156,6 +158,7 @@ public function testSerialize() { 'hasDkimSignature' => false, 'phishingDetails' => [], 'scheduling' => [], + 'isPgpMimeEncrypted' => false, ], $json); $this->assertEquals(1234, $json['uid']); }