-
![]()
-
![]()
-
![]()
+
@@ -67,14 +65,25 @@ export default {
},
computed: {
- previewURL() {
- if (this.attachment.hasPreview && this.attachment.id > 0) {
+ previewSrc() {
+ // uploaded attachment
+ if (this.attachment.type === 'local' && this.attachment.imageBlobURL !== false) {
+ return this.attachment.imageBlobURL
+ }
+
+ // attachment from cloud
+ if (this.attachment.type === 'cloud' && this.attachment.hasPreview && this.attachment.id > 0) {
return generateUrl(`/core/preview?fileId=${this.attachment.id}&x=100&y=100&a=0`)
}
- return ''
- },
- getIcon() {
+ // embedded image when forwarding a message
+ if (this.attachment.type === 'message-attachment-inline' && this.attachment.databaseId) {
+ return generateUrl('/apps/mail/api/messages/{id}/attachment/{attachmentId}', {
+ id: this.attachment.databaseId,
+ attachmentId: this.attachment.id,
+ })
+ }
+
return OC.MimeType.getIconUrl(this.attachment.fileType)
},
diff --git a/src/components/ComposerAttachments.vue b/src/components/ComposerAttachments.vue
index a885a093e8..a375427c3d 100644
--- a/src/components/ComposerAttachments.vue
+++ b/src/components/ComposerAttachments.vue
@@ -199,6 +199,8 @@ export default {
finished: true,
sizeString: this.formatBytes(attachment.size),
imageBlobURL: attachment.isImage ? attachment.downloadUrl : attachment.mimeUrl,
+ type: attachment.type,
+ databaseId: attachment?.databaseId,
})
return attachment
})
@@ -281,6 +283,7 @@ export default {
finished: false,
error: false,
hasPreview: false,
+ type: 'local',
controller,
}
this.attachments.push(attachment)
diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue
index 82908cf751..bf0ab933a4 100644
--- a/src/components/TextEditor.vue
+++ b/src/components/TextEditor.vue
@@ -212,6 +212,17 @@ export default {
},
],
},
+
+ htmlSupport: {
+ allow: [
+ {
+ name: 'img',
+ attributes: {
+ 'data-cid': true,
+ },
+ },
+ ],
+ },
},
}
},
diff --git a/src/store/mainStore/actions.js b/src/store/mainStore/actions.js
index 687eb16b0e..97e8db8881 100644
--- a/src/store/mainStore/actions.js
+++ b/src/store/mainStore/actions.js
@@ -61,7 +61,10 @@ import {
} from '../../service/caldavService.js'
import { moveDraft, updateDraft } from '../../service/DraftService.js'
import * as FollowUpService from '../../service/FollowUpService.js'
-import { addInternalAddress, removeInternalAddress } from '../../service/InternalAddressService.js'
+import {
+ addInternalAddress,
+ removeInternalAddress,
+} from '../../service/InternalAddressService.js'
import {
clearMailbox,
create as createMailbox,
@@ -92,14 +95,25 @@ import {
} from '../../service/MessageService.js'
import { showNewMessagesNotification } from '../../service/NotificationService.js'
import { savePreference } from '../../service/PreferenceService.js'
-import { createQuickAction, deleteQuickAction, updateQuickAction } from '../../service/QuickActionsService.js'
+import {
+ createQuickAction,
+ deleteQuickAction,
+ updateQuickAction,
+} from '../../service/QuickActionsService.js'
import {
getActiveScript,
updateActiveScript,
updateAccount as updateSieveAccount,
} from '../../service/SieveService.js'
-import * as SmimeCertificateService from '../../service/SmimeCertificateService.js'
-import { createTextBlock, deleteTextBlock, fetchMyTextBlocks, fetchSharedTextBlocks, updateTextBlock } from '../../service/TextBlockService.js'
+import * as SmimeCertificateService
+ from '../../service/SmimeCertificateService.js'
+import {
+ createTextBlock,
+ deleteTextBlock,
+ fetchMyTextBlocks,
+ fetchSharedTextBlocks,
+ updateTextBlock,
+} from '../../service/TextBlockService.js'
import * as ThreadService from '../../service/ThreadService.js'
import { normalizedEnvelopeListId } from '../../util/normalization.js'
import {
@@ -558,13 +572,7 @@ export default function mainStoreActions() {
bodyHtml: data.bodyHtml,
bodyPlain: data.bodyPlain,
forwardFrom: reply.data,
- attachments: original.attachments.map((attachment) => ({
- ...attachment,
- mailboxId: original.mailboxId,
- // messageId for attachments is actually the uid
- uid: attachment.messageId,
- type: 'message-attachment',
- })),
+ attachments: this.prepareAttachments(original),
},
})
return
@@ -614,6 +622,37 @@ export default function mainStoreActions() {
}
})
},
+ prepareAttachments(original) {
+ const attachments = []
+
+ if (original.attachments) {
+ for (const attachment of original.attachments) {
+ attachments.push({
+ ...attachment,
+ databaseId: original.databaseId,
+ mailboxId: original.mailboxId,
+ // messageId for attachments is actually the uid
+ uid: attachment.messageId,
+ type: 'message-attachment',
+ })
+ }
+ }
+
+ if (original.inlineAttachments) {
+ for (const inlineAttachment of original.inlineAttachments) {
+ attachments.push({
+ ...inlineAttachment,
+ databaseId: original.databaseId,
+ mailboxId: original.mailboxId,
+ // messageId for attachments is actually the uid
+ uid: inlineAttachment.messageId,
+ type: 'message-attachment-inline',
+ })
+ }
+ }
+
+ return attachments
+ },
async stopComposerSession({
restoreOriginalSendAt = false,
moveToImap = false,
diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php
index 8577019883..b8837778c1 100644
--- a/tests/Unit/Controller/MessagesControllerTest.php
+++ b/tests/Unit/Controller/MessagesControllerTest.php
@@ -477,6 +477,8 @@ public function testDownloadAttachments() {
'image/png',
'abcdefg',
7,
+ null,
+ null,
),
];
diff --git a/tests/Unit/Db/LocalAttachmentTest.php b/tests/Unit/Db/LocalAttachmentTest.php
new file mode 100644
index 0000000000..f8eae85136
--- /dev/null
+++ b/tests/Unit/Db/LocalAttachmentTest.php
@@ -0,0 +1,42 @@
+setDisposition(LocalAttachment::DISPOSITION_ATTACHMENT);
+
+ $this->assertTrue($attachment->isDispositionAttachmentOrInline());
+ }
+
+ public function testIsDispositionAttachmentOrInlineWithInline(): void {
+ $attachment = new LocalAttachment();
+ $attachment->setDisposition(LocalAttachment::DISPOSITION_INLINE);
+
+ $this->assertTrue($attachment->isDispositionAttachmentOrInline());
+ }
+
+ public function testIsDispositionAttachmentOrInlineWithNull(): void {
+ $attachment = new LocalAttachment();
+ $attachment->setDisposition(LocalAttachment::DISPOSITION_OMIT);
+
+ $this->assertFalse($attachment->isDispositionAttachmentOrInline());
+ }
+
+ public function testIsDispositionAttachmentOrInlineWithNoDispositionSet(): void {
+ $attachment = new LocalAttachment();
+
+ $this->assertFalse($attachment->isDispositionAttachmentOrInline());
+ }
+}
diff --git a/tests/Unit/IMAP/MessageMapperTest.php b/tests/Unit/IMAP/MessageMapperTest.php
index b51f871573..864bcca156 100644
--- a/tests/Unit/IMAP/MessageMapperTest.php
+++ b/tests/Unit/IMAP/MessageMapperTest.php
@@ -778,7 +778,7 @@ public function testGetAttachmentFilenameFromContentDisposition(): void {
);
$this->assertEquals('test.pdf', $attachment->getName());
- $this->assertEquals('application/octet-stream', $attachment->getType());
+ $this->assertEquals('application/pdf', $attachment->getType());
$this->assertEquals($bodyContent, $attachment->getContent());
}
@@ -807,7 +807,7 @@ public function testGetAttachmentFilenameFromContentTypeName(): void {
);
$this->assertEquals('fallback.pdf', $attachment->getName());
- $this->assertEquals('application/octet-stream', $attachment->getType());
+ $this->assertEquals('application/pdf', $attachment->getType());
}
public function testGetAttachmentTextCalendarContentType(): void {
@@ -908,7 +908,8 @@ public function testGetAttachmentEncryptedWithRegularAttachment(): void {
);
$this->assertEquals('report.pdf', $attachment->getName());
- $this->assertEquals('application/octet-stream', $attachment->getType());
+ $this->assertEquals('application/pdf', $attachment->getType());
+ $this->assertEquals('attachment', $attachment->disposition);
}
public function testGetAttachmentEncryptedWithInlineImage(): void {
@@ -926,6 +927,7 @@ public function testGetAttachmentEncryptedWithInlineImage(): void {
);
$this->assertEquals('nextcloud.png', $attachment->getName());
- $this->assertEquals('application/octet-stream', $attachment->getType());
+ $this->assertEquals('image/png', $attachment->getType());
+ $this->assertEquals('inline', $attachment->disposition);
}
}
diff --git a/tests/Unit/Service/Attachment/AttachmentServiceTest.php b/tests/Unit/Service/Attachment/AttachmentServiceTest.php
index 36cdf600aa..d0984d7c81 100644
--- a/tests/Unit/Service/Attachment/AttachmentServiceTest.php
+++ b/tests/Unit/Service/Attachment/AttachmentServiceTest.php
@@ -97,6 +97,7 @@ public function testAddFileWithUploadException() {
'userId' => $userId,
'fileName' => 'cat.jpg',
'createdAt' => 123456,
+ 'disposition' => LocalAttachment::DISPOSITION_ATTACHMENT,
]);
$persistedAttachment = LocalAttachment::fromParams([
'id' => 123,
@@ -129,12 +130,14 @@ public function testAddFile() {
$attachment = LocalAttachment::fromParams([
'userId' => $userId,
'fileName' => 'cat.jpg',
- 'createdAt' => 123456
+ 'createdAt' => 123456,
+ 'disposition' => LocalAttachment::DISPOSITION_ATTACHMENT,
]);
$persistedAttachment = LocalAttachment::fromParams([
'id' => 123,
'userId' => $userId,
'fileName' => 'cat.jpg',
+ 'disposition' => LocalAttachment::DISPOSITION_ATTACHMENT,
]);
$this->mapper->expects($this->once())
@@ -154,6 +157,8 @@ public function testAddFileFromStringWithUploadException() {
'userId' => $userId,
'fileName' => 'cat.jpg',
'mimeType' => 'image/jpg',
+ 'contentId' => null,
+ 'disposition' => null,
'createdAt' => 123456,
]);
$persistedAttachment = LocalAttachment::fromParams([
@@ -169,14 +174,14 @@ public function testAddFileFromStringWithUploadException() {
->willReturn($persistedAttachment);
$this->storage->expects($this->once())
->method('saveContent')
- ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds'))
+ ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('Lorem ipsum dolor sit amet'))
->willThrowException(new NotPermittedException());
$this->mapper->expects($this->once())
->method('delete')
->with($this->equalTo($persistedAttachment));
$this->expectException(UploadException::class);
- $this->service->addFileFromString($userId, 'cat.jpg', 'image/jpg', 'sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds');
+ $this->service->addFileFromString($userId, 'cat.jpg', 'image/jpg', 'Lorem ipsum dolor sit amet', null, null);
}
public function testAddFileFromString() {
@@ -200,9 +205,16 @@ public function testAddFileFromString() {
->willReturn($persistedAttachment);
$this->storage->expects($this->once())
->method('saveContent')
- ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds'));
-
- $this->service->addFileFromString($userId, 'cat.jpg', 'image/jpg', 'sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds');
+ ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('Lorem ipsum dolor sit amet'));
+
+ $this->service->addFileFromString(
+ $userId,
+ 'cat.jpg',
+ 'image/jpg',
+ 'Lorem ipsum dolor sit amet',
+ null,
+ null,
+ );
}
public function testDeleteAttachment(): void {
@@ -336,14 +348,14 @@ public function testHandleAttachmentsForwardedMessageAttachment(): void {
$this->messageMapper->expects(self::once())
->method('getFullText')
->with($client, $mailbox->getName(), $message->getUid(), $userId)
- ->willReturn('sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds');
+ ->willReturn('Lorem ipsum dolor sit amet');
$this->mapper->expects($this->once())
->method('insert')
->with($this->equalTo($attachment))
->willReturn($persistedAttachment);
$this->storage->expects($this->once())
->method('saveContent')
- ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds'));
+ ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('Lorem ipsum dolor sit amet'));
$this->service->handleAttachments($account, [$attachments], $client);
}
@@ -353,6 +365,8 @@ public function testHandleAttachmentsForwardedAttachment(): void {
'userId' => $userId,
'fileName' => 'cat.jpg',
'mimeType' => 'text/plain',
+ 'contentId' => null,
+ 'disposition' => 'attachment',
'createdAt' => 123456,
]);
$persistedAttachment = LocalAttachment::fromParams([
@@ -373,18 +387,27 @@ public function testHandleAttachmentsForwardedAttachment(): void {
'type' => 'message-attachment',
'mailboxId' => $mailbox->getId(),
'uid' => 999,
+ 'id' => '2',
'fileName' => 'cat.jpg',
'mimeType' => 'text/plain',
];
- $imapAttachment = ['sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds'];
+ $imapAttachment = new \OCA\Mail\Attachment(
+ '2',
+ 'cat.jpg',
+ 'text/plain',
+ 'Lorem ipsum dolor sit amet',
+ strlen('Lorem ipsum dolor sit amet'),
+ null,
+ 'attachment',
+ );
$this->mailManager->expects(self::once())
->method('getMailbox')
->with($account->getUserId(), $mailbox->getId())
->willReturn($mailbox);
$this->messageMapper->expects(self::once())
- ->method('getRawAttachments')
- ->with($client, $mailbox->getName(), 999)
+ ->method('getAttachment')
+ ->with($client, $mailbox->getName(), 999, '2', $userId)
->willReturn($imapAttachment);
$this->mapper->expects($this->once())
->method('insert')
@@ -392,7 +415,7 @@ public function testHandleAttachmentsForwardedAttachment(): void {
->willReturn($persistedAttachment);
$this->storage->expects($this->once())
->method('saveContent')
- ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds'));
+ ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('Lorem ipsum dolor sit amet'));
$this->service->handleAttachments($account, [$attachments], $client);
}
@@ -420,7 +443,7 @@ public function testHandleAttachmentsCloudAttachmentNoDownloadPermission(): void
$file = $this->createConfiguredMock(File::class, [
'getName' => 'cat.jpg',
'getMimeType' => 'text/plain',
- 'getContent' => 'sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds',
+ 'getContent' => 'Lorem ipsum dolor sit amet',
'getStorage' => $storage
]);
$account = $this->createConfiguredMock(Account::class, [
@@ -452,7 +475,7 @@ public function testHandleAttachmentsCloudAttachment(): void {
$file = $this->createConfiguredMock(File::class, [
'getName' => 'cat.jpg',
'getMimeType' => 'text/plain',
- 'getContent' => 'sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds',
+ 'getContent' => 'Lorem ipsum dolor sit amet',
'getStorage' => $this->createMock(SharedStorage::class)
]);
$account = $this->createConfiguredMock(Account::class, [
@@ -492,7 +515,7 @@ public function testHandleAttachmentsCloudAttachment(): void {
->willReturn($persistedAttachment);
$this->storage->expects($this->once())
->method('saveContent')
- ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('sjdhfkjsdhfkjsdhfkjdshfjhdskfjhds'));
+ ->with($this->equalTo($userId), $this->equalTo(123), $this->equalTo('Lorem ipsum dolor sit amet'));
$this->service->handleAttachments($account, [$attachments], $client);
}
diff --git a/tests/Unit/Service/HtmlPurify/TransformCidDataAttrTest.php b/tests/Unit/Service/HtmlPurify/TransformCidDataAttrTest.php
new file mode 100644
index 0000000000..23e5a557c8
--- /dev/null
+++ b/tests/Unit/Service/HtmlPurify/TransformCidDataAttrTest.php
@@ -0,0 +1,156 @@
+ 'image001@example.com',
+ 'url' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/7',
+ ],
+ [
+ 'cid' => 'image002@example.com',
+ 'url' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/8',
+ ],
+ ];
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->config = HTMLPurifier_Config::createDefault();
+ }
+
+ private function makeContext(string $tagName = 'img'): HTMLPurifier_Context {
+ $context = new HTMLPurifier_Context();
+ $token = new HTMLPurifier_Token_Start($tagName);
+ $context->register('CurrentToken', $token);
+ return $context;
+ }
+
+ public function testNonImgTagIsUnchanged(): void {
+ $transform = new TransformCidDataAttr($this->inlineAttachments);
+ $attr = ['src' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/7'];
+ $context = $this->makeContext('div');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertArrayNotHasKey('data-cid', $result);
+ }
+
+ public function testImgWithoutSrcIsUnchanged(): void {
+ $transform = new TransformCidDataAttr($this->inlineAttachments);
+ $attr = ['alt' => 'image'];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertArrayNotHasKey('data-cid', $result);
+ }
+
+ public function testMatchingSrcSetsCidAttribute(): void {
+ $transform = new TransformCidDataAttr($this->inlineAttachments);
+ $attr = ['src' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/7'];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertSame('image001@example.com', $result['data-cid']);
+ }
+
+ public function testSecondAttachmentMatches(): void {
+ $transform = new TransformCidDataAttr($this->inlineAttachments);
+ $attr = ['src' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/8'];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertSame('image002@example.com', $result['data-cid']);
+ }
+
+ public function testNonMatchingSrcLeavesNoCidAttribute(): void {
+ $transform = new TransformCidDataAttr($this->inlineAttachments);
+ $attr = ['src' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/99'];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertArrayNotHasKey('data-cid', $result);
+ }
+
+ public function testPathComparisonIgnoresSchemeAndHost(): void {
+ $transform = new TransformCidDataAttr($this->inlineAttachments);
+ // Same path, different scheme/host (e.g. behind a reverse proxy)
+ $attr = ['src' => 'http://internal.proxy/index.php/apps/mail/api/messages/42/attachments/7'];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertSame('image001@example.com', $result['data-cid']);
+ }
+
+ public function testAttachmentWithoutCidIsSkipped(): void {
+ $attachments = [
+ ['url' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/7'],
+ ];
+ $transform = new TransformCidDataAttr($attachments);
+ $attr = ['src' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/7'];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertArrayNotHasKey('data-cid', $result);
+ }
+
+ public function testAttachmentWithoutUrlIsSkipped(): void {
+ $attachments = [
+ ['cid' => 'image001@example.com'],
+ ];
+ $transform = new TransformCidDataAttr($attachments);
+ $attr = ['src' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/7'];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertArrayNotHasKey('data-cid', $result);
+ }
+
+ public function testEmptyInlineAttachments(): void {
+ $transform = new TransformCidDataAttr([]);
+ $attr = ['src' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/7'];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertArrayNotHasKey('data-cid', $result);
+ }
+
+ public function testExistingAttributesArePreserved(): void {
+ $transform = new TransformCidDataAttr($this->inlineAttachments);
+ $attr = [
+ 'src' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/7',
+ 'alt' => 'inline image',
+ 'width' => '100',
+ ];
+ $context = $this->makeContext('img');
+
+ $result = $transform->transform($attr, $this->config, $context);
+
+ $this->assertSame('inline image', $result['alt']);
+ $this->assertSame('100', $result['width']);
+ $this->assertSame('image001@example.com', $result['data-cid']);
+ }
+}
diff --git a/tests/Unit/Service/HtmlPurify/TransformURLSchemeTest.php b/tests/Unit/Service/HtmlPurify/TransformURLSchemeTest.php
index 607c132535..d00583e395 100644
--- a/tests/Unit/Service/HtmlPurify/TransformURLSchemeTest.php
+++ b/tests/Unit/Service/HtmlPurify/TransformURLSchemeTest.php
@@ -10,7 +10,6 @@
namespace OCA\Mail\Tests\Unit\Service\HtmlPurify;
use ChristophWurst\Nextcloud\Testing\TestCase;
-use Closure;
use HTMLPurifier_Config;
use HTMLPurifier_Context;
use HTMLPurifier_URI;
@@ -23,29 +22,18 @@ class TransformURLSchemeTest extends TestCase {
private TransformURLScheme $filter;
private IURLGenerator|MockObject $urlGenerator;
private IRequest|MockObject $request;
- private Closure $mapCidToAttachmentId;
+
+ private array $inlineAttachments = [
+ ['cid' => 'valid-cid', 'url' => 'https://mail.example.com/index.php/apps/mail/api/messages/42/attachments/123'],
+ ];
protected function setUp(): void {
parent::setUp();
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->request = $this->createMock(IRequest::class);
- $this->mapCidToAttachmentId = function (string $cid) {
- if ($cid === 'valid-cid') {
- return 123;
- }
- return null;
- };
-
- $messageParameters = [
- 'accountId' => 1,
- 'folderId' => 'INBOX',
- 'id' => 42,
- ];
-
$this->filter = new TransformURLScheme(
- $messageParameters,
- $this->mapCidToAttachmentId,
+ $this->inlineAttachments,
$this->urlGenerator,
$this->request,
);
@@ -211,11 +199,6 @@ public function testCidSchemeWithValidAttachment(): void {
$config = HTMLPurifier_Config::createDefault();
$context = new HTMLPurifier_Context();
- $this->urlGenerator->expects($this->once())
- ->method('linkToRouteAbsolute')
- ->with('mail.messages.downloadAttachment', $this->anything())
- ->willReturn('https://mail.example.com/download/123');
-
$result = $this->filter->filter($uri, $config, $context);
$this->assertTrue($result);
@@ -291,34 +274,17 @@ public function testUriWithFragment(): void {
$this->assertStringContainsString('%23section', $uri->query);
}
- public function testCidSchemeMapsCidToAttachmentId(): void {
- $messageParameters = [
- 'accountId' => 1,
- 'folderId' => 'INBOX',
- 'id' => 42,
- ];
-
- $filter = new TransformURLScheme(
- $messageParameters,
- $this->mapCidToAttachmentId,
- $this->urlGenerator,
- $this->request,
- );
-
+ public function testCidSchemeRewritesUriFromUrl(): void {
$uri = new HTMLPurifier_URI('cid', null, null, null, 'valid-cid', null, null);
$config = HTMLPurifier_Config::createDefault();
$context = new HTMLPurifier_Context();
- $this->urlGenerator->expects($this->once())
- ->method('linkToRouteAbsolute')
- ->with('mail.messages.downloadAttachment', [
- 'accountId' => 1,
- 'folderId' => 'INBOX',
- 'id' => 42,
- 'attachmentId' => 123,
- ])
- ->willReturn('https://mail.example.com/download/123');
-
- $filter->filter($uri, $config, $context);
+ $this->urlGenerator->expects($this->never())->method('linkToRouteAbsolute');
+
+ $this->filter->filter($uri, $config, $context);
+
+ $this->assertSame('https', $uri->scheme);
+ $this->assertSame('mail.example.com', $uri->host);
+ $this->assertStringContainsString('/apps/mail/api/messages/42/attachments/123', $uri->path);
}
}
diff --git a/tests/Unit/Service/MailManagerTest.php b/tests/Unit/Service/MailManagerTest.php
index 0cd1f549d0..5bd24acc40 100644
--- a/tests/Unit/Service/MailManagerTest.php
+++ b/tests/Unit/Service/MailManagerTest.php
@@ -537,7 +537,9 @@ public function testGetMailAttachments(): void {
'cat.png',
'image/png',
'abcdefg',
- 7
+ 7,
+ null,
+ null,
),
];
$client = $this->createStub(Horde_Imap_Client_Socket::class);
diff --git a/tests/Unit/Service/MimeMessageTest.php b/tests/Unit/Service/MimeMessageTest.php
index 2d17703d9a..146a3bf26c 100644
--- a/tests/Unit/Service/MimeMessageTest.php
+++ b/tests/Unit/Service/MimeMessageTest.php
@@ -47,6 +47,7 @@ public function testTextPlain() {
$part = $this->mimeMessage->build(
$messageData->getBody(),
null,
+ false,
[],
);
@@ -69,6 +70,7 @@ public function testMultipartAlternative() {
$part = $this->mimeMessage->build(
$messageData->getBody(),
$messageData->getBody(),
+ false,
[],
);
@@ -97,6 +99,7 @@ public function testMultipartAlternativeEmptyContent() {
$part = $this->mimeMessage->build(
$messageData->getBody(),
$messageData->getBody(),
+ false,
[],
);
@@ -122,15 +125,17 @@ public function testMultipartMixedAlternative() {
false
);
- $attachment1 = $this->createAttachmentDetails(
+ $attachment1 = $this->createAttachmentPart(
'nextcloud logo',
file_get_contents(__DIR__ . '/../../../tests/data/nextcloud.png'),
- 'image/png'
+ 'image/png',
+ 'attachment',
);
$part = $this->mimeMessage->build(
$messageData->getBody(),
$messageData->getBody(),
+ false,
[$attachment1],
);
@@ -167,21 +172,24 @@ public function testMultipartMixedRelated() {
false
);
- $attachment1 = $this->createAttachmentDetails(
+ $attachment1 = $this->createAttachmentPart(
'nextcloud logo',
file_get_contents(__DIR__ . '/../../../tests/data/nextcloud.png'),
- 'image/png'
+ 'image/png',
+ 'attachment',
);
- $attachment2 = $this->createAttachmentDetails(
+ $attachment2 = $this->createAttachmentPart(
'sensitive animals logo',
file_get_contents(__DIR__ . '/../../../tests/data/test.txt'),
- 'text/plain'
+ 'text/plain',
+ 'attachment',
);
$part = $this->mimeMessage->build(
$messageData->getBody(),
$messageData->getBody(),
+ false,
[$attachment1, $attachment2],
);
@@ -224,6 +232,106 @@ public function testMultipartMixedRelated() {
$this->assertEquals('attachment', $attachmentPart2->getDisposition());
}
+ public function testInlineAttachmentGoesIntoMultipartRelated(): void {
+ $inlinePart = $this->createAttachmentPart(
+ 'inline image',
+ file_get_contents(__DIR__ . '/../../../tests/data/nextcloud.png'),
+ 'image/png',
+ 'inline',
+ );
+ $inlinePart->setContentId('test-inline-image@example.com');
+
+ $part = $this->mimeMessage->build(
+ 'Hello',
+ '
Hello
',
+ false,
+ [$inlinePart],
+ );
+
+ $this->assertEquals('multipart/related', $part->getType());
+
+ /** @var Horde_Mime_Part[] $subParts */
+ $subParts = $part->getParts();
+ $this->assertCount(2, $subParts);
+ $this->assertEquals('inline', $subParts[1]->getDisposition());
+ $this->assertEquals('image/png', $subParts[1]->getType());
+ $this->assertEquals('test-inline-image@example.com', $subParts[1]->getContentId());
+ }
+
+ public function testInlineAndNormalAttachmentsSeparated(): void {
+ $inlinePart = $this->createAttachmentPart(
+ 'inline image',
+ file_get_contents(__DIR__ . '/../../../tests/data/nextcloud.png'),
+ 'image/png',
+ 'inline',
+ );
+ $inlinePart->setContentId('test-inline-image@example.com');
+
+ $normalPart = $this->createAttachmentPart(
+ 'document.txt',
+ 'some content',
+ 'text/plain',
+ 'attachment',
+ );
+
+ $part = $this->mimeMessage->build(
+ 'Hello',
+ '
Hello
',
+ false,
+ [$inlinePart, $normalPart],
+ );
+
+ // multipart/mixed wraps (multipart/related + normal attachment)
+ $this->assertEquals('multipart/mixed', $part->getType());
+
+ /** @var Horde_Mime_Part[] $subParts */
+ $subParts = $part->getParts();
+ $this->assertCount(2, $subParts);
+
+ $this->assertEquals('multipart/related', $subParts[0]->getType());
+ $this->assertEquals('attachment', $subParts[1]->getDisposition());
+
+ $relatedSubParts = $subParts[0]->getParts();
+ $this->assertEquals('inline', $relatedSubParts[1]->getDisposition());
+ $this->assertEquals('test-inline-image@example.com', $relatedSubParts[1]->getContentId());
+ }
+
+ public function testRewriteSrcToCidFromDataCidAttribute(): void {
+ $inlinePart = $this->createAttachmentPart(
+ 'inline image',
+ file_get_contents(__DIR__ . '/../../../tests/data/nextcloud.png'),
+ 'image/png',
+ 'inline',
+ );
+ $inlinePart->setContentId('test-inline-image@example.com');
+
+ $html = '

';
+
+ $part = $this->mimeMessage->build(
+ null,
+ $html,
+ false,
+ [$inlinePart],
+ );
+
+ $this->assertEquals('multipart/related', $part->getType());
+
+ $relatedSubParts = $part->getParts();
+ $this->assertCount(2, $relatedSubParts);
+
+ $alternativePart = $relatedSubParts[0];
+ $this->assertEquals('multipart/alternative', $alternativePart->getType());
+
+ $alternativeSubParts = $alternativePart->getParts();
+ $this->assertCount(2, $alternativeSubParts);
+ $this->assertEquals('text/plain', $alternativeSubParts[0]->getType());
+ $this->assertEquals('text/html', $alternativeSubParts[1]->getType());
+
+ $htmlBody = $alternativeSubParts[1]->getContents();
+ $this->assertStringContainsString('cid:test-inline-image@example.com', $htmlBody);
+ $this->assertStringNotContainsString('data-cid', $htmlBody);
+ }
+
public function testMultipartAlternativeGreek() {
$messageData = new NewMessageData(
$this->account,
@@ -240,6 +348,7 @@ public function testMultipartAlternativeGreek() {
$part = $this->mimeMessage->build(
null,
$messageData->getBody(),
+ false,
[],
);
@@ -267,18 +376,47 @@ public function testMultipartAlternativeGreek() {
);
}
- /**
- * OCA\Mail\Model\Message::createAttachmentDetails
- *
- * @param string $name
- * @param string $content
- * @param string $mime
- * @return void
- */
- private function createAttachmentDetails(string $name, string $content, string $mime): Horde_Mime_Part {
+ public function testEmbeddedImageSkipNonImages(): void {
+ $body = file_get_contents(__DIR__ . '/../../../tests/data/mime-html-image.txt');
+ // replace image/png type with a non-image type to trigger the skip.
+ $body = str_replace('data:image/png;base64,', 'data:text/html;base64,', $body);
+
+ $messageData = new NewMessageData(
+ $this->account,
+ new AddressList(),
+ new AddressList(),
+ new AddressList(),
+ 'Text, HTML but invalid inline image',
+ $body,
+ [],
+ true,
+ false
+ );
+
+ $part = $this->mimeMessage->build(
+ $messageData->getBody(),
+ $messageData->getBody(),
+ false,
+ [],
+ );
+
+ $this->assertEquals('multipart/alternative', $part->getType());
+
+ /** @var Horde_Mime_Part[] $alternativeSubParts */
+ $alternativeSubParts = $part->getParts();
+ $this->assertCount(2, $alternativeSubParts);
+ $this->assertEquals('text/plain', $alternativeSubParts[0]->getType());
+ $this->assertEquals('text/html', $alternativeSubParts[1]->getType());
+
+ $htmlBody = $alternativeSubParts[1]->getContents();
+ $this->assertStringContainsString('data:text/html;base64,', $htmlBody);
+ $this->assertStringNotContainsString('data-cid', $htmlBody);
+ }
+
+ private function createAttachmentPart(string $name, string $content, string $mime, string $disposition): Horde_Mime_Part {
$part = new Horde_Mime_Part();
$part->setCharset('us-ascii');
- $part->setDisposition('attachment');
+ $part->setDisposition($disposition);
$part->setName($name);
$part->setContents($content);
$part->setType($mime);
diff --git a/tests/Unit/Service/TransmissionServiceTest.php b/tests/Unit/Service/TransmissionServiceTest.php
index db060d2f30..7ba3bbfe9b 100644
--- a/tests/Unit/Service/TransmissionServiceTest.php
+++ b/tests/Unit/Service/TransmissionServiceTest.php
@@ -91,6 +91,7 @@ public function testHandleAttachment(): void {
$attachment = new LocalAttachment();
$attachment->setFileName('test.txt');
$attachment->setMimeType('text/plain');
+ $attachment->setDisposition(LocalAttachment::DISPOSITION_ATTACHMENT);
$file = new InMemoryFile(
'test.txt',
@@ -355,23 +356,48 @@ public function testHandleAttachmentKeepAdditionalContentTypeParameters(): void
$mailAccount = new MailAccount();
$mailAccount->setUserId('bob');
$account = new Account($mailAccount);
-
$attachment = new LocalAttachment();
- $attachment->setFileName('event.ics');
- $attachment->setMimeType('text/calendar; method=REQUEST');
-
+ $attachment->setFileName(null);
+ $attachment->setMimeType('text/calendar; method=REQUEST; charset="utf-8"; name=event.ics');
+ // iMIP attachments must not carry a Content-Disposition header.
+ // See https://github.com/nextcloud/mail/issues/10416
+ $attachment->setDisposition(LocalAttachment::DISPOSITION_OMIT);
$file = new InMemoryFile(
'event.ics',
"BEGIN:VCALENDAR\nEND:VCALENDAR"
);
-
$this->attachmentService->expects(self::once())
->method('getAttachment')
->willReturn([$attachment, $file]);
$part = $this->transmissionService->handleAttachment($account, ['id' => 1, 'type' => 'local']);
- $this->assertEquals('event.ics', $part->getContentTypeParameter('name'));
+ $this->assertEquals('text/calendar', $part->getType());
$this->assertEquals('REQUEST', $part->getContentTypeParameter('method'));
+ $this->assertEquals('utf-8', $part->getContentTypeParameter('charset'));
+ $this->assertEquals('event.ics', $part->getContentTypeParameter('name'));
+ }
+
+ public function testHandleAttachmentImipOmitsContentDisposition(): void {
+ $mailAccount = new MailAccount();
+ $mailAccount->setUserId('bob');
+ $account = new Account($mailAccount);
+ $attachment = new LocalAttachment();
+ $attachment->setFileName(null);
+ $attachment->setMimeType('text/calendar; method=REQUEST; charset="utf-8"; name=event.ics');
+ // iMIP attachments must not carry a Content-Disposition header.
+ // See https://github.com/nextcloud/mail/issues/10416
+ $attachment->setDisposition(LocalAttachment::DISPOSITION_OMIT);
+ $file = new InMemoryFile(
+ 'event.ics',
+ "BEGIN:VCALENDAR\nEND:VCALENDAR"
+ );
+ $this->attachmentService->expects(self::once())
+ ->method('getAttachment')
+ ->willReturn([$attachment, $file]);
+
+ $part = $this->transmissionService->handleAttachment($account, ['id' => 1, 'type' => 'local']);
+
+ $this->assertEquals('', $part->getDisposition());
}
}
diff --git a/tests/stubs/php-polyfill.php b/tests/stubs/php-polyfill.php
new file mode 100644
index 0000000000..6070fe6997
--- /dev/null
+++ b/tests/stubs/php-polyfill.php
@@ -0,0 +1,14 @@
+