Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The rating depends on the installed text processing backend. See [the rating ove

Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/).
]]></description>
<version>5.8.0-dev.2</version>
<version>5.8.0-dev.3</version>
<licence>agpl</licence>
<author homepage="https://github.com/ChristophWurst">Christoph Wurst</author>
<author homepage="https://github.com/GretaD">GretaD</author>
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"scripts": {
"cs:check": "php-cs-fixer fix --dry-run --diff",
"cs:fix": "php-cs-fixer fix",
"lint": "find . -name \\*.php -not -path './vendor*/*' -print0 | xargs -0 -n1 php -l",
"lint": "find . -name \\*.php -not -path './vendor*/*' -not -path './tests/stubs/*' -print0 | xargs -0 -n1 php -l",
"psalm": "psalm.phar",
"psalm:fix": "psalm.phar --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType",
"post-install-cmd": [
Expand Down
9 changes: 9 additions & 0 deletions lib/Attachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public function __construct(
string $type,
string $content,
int $size,
public readonly ?string $contentId,
public readonly ?string $disposition,
) {
$this->id = $id;
$this->name = $name;
Expand All @@ -32,12 +34,19 @@ public function __construct(
}

public static function fromMimePart(Horde_Mime_Part $mimePart): self {
$disposition = $mimePart->getDisposition();
if ($disposition === '') {
$disposition = null;
}

return new Attachment(
$mimePart->getMimeId(),
$mimePart->getName(),
$mimePart->getType(),
$mimePart->getContents(),
(int)$mimePart->getBytes(),
$mimePart->getContentId(),
$disposition,
);
}

Expand Down
3 changes: 2 additions & 1 deletion lib/Contracts/IAttachmentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use OCA\Mail\Db\LocalAttachment;
use OCA\Mail\Exception\AttachmentNotFoundException;
use OCA\Mail\Service\Attachment\UploadedFile;
use OCP\Files\SimpleFS\ISimpleFile;

interface IAttachmentService {
/**
Expand All @@ -22,8 +23,8 @@ public function addFile(string $userId, UploadedFile $file): LocalAttachment;
/**
* Try to get an attachment by id
*
* @return array{0: LocalAttachment, 1: ISimpleFile}
* @throws AttachmentNotFoundException
* @return array of LocalAttachment and ISimpleFile
*/
public function getAttachment(string $userId, int $id): array;

Expand Down
30 changes: 15 additions & 15 deletions lib/Controller/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,8 @@
if ($itineraries) {
$json['itineraries'] = $itineraries;
}
$json['attachments'] = array_map(fn ($a) => $this->enrichDownloadUrl(
$id,
$a
), $json['attachments']);
$json['attachments'] = $this->enrichAttachments($id, $json['attachments']);
$json['inlineAttachments'] = $this->enrichAttachments($id, $json['inlineAttachments']);

Check failure on line 263 in lib/Controller/MessagesController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

InvalidArrayOffset

lib/Controller/MessagesController.php:263:62: InvalidArrayOffset: Cannot access value on variable $json using offset value of 'inlineAttachments', expecting 'uid', 'messageId', 'from', 'to', 'replyTo', 'cc', 'bcc', 'subject', 'dateInt', 'flags', 'hasHtmlBody', 'body', 'dispositionNotificationTo', 'hasDkimSignature', 'phishingDetails', 'unsubscribeUrl', 'isOneClickUnsubscribe', 'unsubscribeMailTo', 'scheduling', 'attachments' or 'itineraries' (see https://psalm.dev/115)

Check failure on line 263 in lib/Controller/MessagesController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

InvalidArrayOffset

lib/Controller/MessagesController.php:263:62: InvalidArrayOffset: Cannot access value on variable $json using offset value of 'inlineAttachments', expecting 'uid', 'messageId', 'from', 'to', 'replyTo', 'cc', 'bcc', 'subject', 'dateInt', 'flags', 'hasHtmlBody', 'body', 'dispositionNotificationTo', 'hasDkimSignature', 'phishingDetails', 'unsubscribeUrl', 'isOneClickUnsubscribe', 'unsubscribeMailTo', 'scheduling', 'attachments' or 'itineraries' (see https://psalm.dev/115)

Check failure on line 263 in lib/Controller/MessagesController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

InvalidArrayOffset

lib/Controller/MessagesController.php:263:62: InvalidArrayOffset: Cannot access value on variable $json using offset value of 'inlineAttachments', expecting 'uid', 'messageId', 'from', 'to', 'replyTo', 'cc', 'bcc', 'subject', 'dateInt', 'flags', 'hasHtmlBody', 'body', 'dispositionNotificationTo', 'hasDkimSignature', 'phishingDetails', 'unsubscribeUrl', 'isOneClickUnsubscribe', 'unsubscribeMailTo', 'scheduling', 'attachments' or 'itineraries' (see https://psalm.dev/115)
$json['accountId'] = $account->getId();
$json['mailboxId'] = $mailbox->getId();
$json['databaseId'] = $message->getId();
Expand Down Expand Up @@ -645,9 +643,7 @@
$mailbox,
$message->getUid(),
true
)->getHtmlBody(
$id
);
)->getHtmlBody($id);
} finally {
$client->logout();
}
Expand Down Expand Up @@ -1049,20 +1045,24 @@
}
}

private function enrichAttachments(int $id, array $attachments): array {
return array_map(
fn ($attachment) => $this->enrichAttachment($id, $attachment),
$attachments
);
}

/**
* @param int $id
* @param array $attachment
*
* @return array
*/
private function enrichDownloadUrl(int $id,
array $attachment) {
$downloadUrl = $this->urlGenerator->linkToRoute('mail.messages.downloadAttachment',
[
'id' => $id,
'attachmentId' => $attachment['id'],
]);
$downloadUrl = $this->urlGenerator->getAbsoluteURL($downloadUrl);
private function enrichAttachment(int $id, array $attachment): array {
$downloadUrl = $this->urlGenerator->linkToRouteAbsolute('mail.messages.downloadAttachment', [
'id' => $id,
'attachmentId' => $attachment['id'],
]);
$attachment['downloadUrl'] = $downloadUrl;
$attachment['mimeUrl'] = $this->mimeTypeDetector->mimeTypeIcon($attachment['mime']);

Expand Down
20 changes: 20 additions & 0 deletions lib/Db/LocalAttachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,20 @@
* @method void setFileName(string $fileName)
* @method string getMimeType()
* @method void setMimeType(string $mimeType)
* @method string|null getContentId()
* @method void setContentId(?string $contentId)
* @method string|null getDisposition()
* @method void setDisposition(?string $disposition)
* @method int|null getCreatedAt()
* @method void setCreatedAt(int $createdAt)
* @method int|null getLocalMessageId()
* @method void setLocalMessageId(int $localMessageId)
*/
class LocalAttachment extends Entity implements JsonSerializable {
public const DISPOSITION_ATTACHMENT = 'attachment';
public const DISPOSITION_INLINE = 'inline';
public const DISPOSITION_OMIT = null;

/** @var string */
protected $userId;

Expand All @@ -35,6 +43,12 @@ class LocalAttachment extends Entity implements JsonSerializable {
/** @var string */
protected $mimeType;

/** @var ?string */
protected $contentId;

/** @var ?string */
protected $disposition;

/** @var int|null */
protected $createdAt;

Expand All @@ -49,8 +63,14 @@ public function jsonSerialize() {
'type' => 'local',
'fileName' => $this->fileName,
'mimeType' => $this->mimeType,
'contentId' => $this->contentId,
'disposition' => $this->disposition,
'createdAt' => $this->createdAt,
'localMessageId' => $this->localMessageId
];
}

public function isDispositionAttachmentOrInline(): bool {
return $this->disposition === self::DISPOSITION_ATTACHMENT || $this->disposition === self::DISPOSITION_INLINE;
}
}
8 changes: 7 additions & 1 deletion lib/IMAP/ImapMessageFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
use function str_starts_with;
use function strtolower;

/**
* @psalm-import-type IMAPAttachment from IMAPMessage
*/
class ImapMessageFetcher {
/** @var string[] */
private array $attachmentsToIgnore = ['signature.asc', 'smime.p7s'];
Expand All @@ -50,7 +53,9 @@ class ImapMessageFetcher {
private Horde_Imap_Client_Base $client;
private string $htmlMessage = '';
private string $plainMessage = '';
/** @var list<IMAPAttachment> */
private array $attachments = [];
/** @var list<IMAPAttachment> */
private array $inlineAttachments = [];
private bool $hasAnyAttachment = false;
private array $scheduling = [];
Expand Down Expand Up @@ -369,7 +374,8 @@ private function getPart(Horde_Mime_Part $p, string $partNo, bool $isFetched): v
'fileName' => $filename,
'mime' => $p->getType(),
'size' => $p->getBytes(),
'cid' => $p->getContentId()
'cid' => $p->getContentId(),
'disposition' => $p->getDisposition()
];
return;
}
Expand Down
45 changes: 17 additions & 28 deletions lib/IMAP/MessageMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
use Horde_Imap_Client_Socket;
use Horde_Mime_Exception;
use Horde_Mime_Headers;
use Horde_Mime_Headers_ContentParam;
use Horde_Mime_Headers_ContentParam_ContentDisposition;
use Horde_Mime_Headers_ContentParam_ContentType;
use Horde_Mime_Headers_ContentTransferEncoding;
use Horde_Mime_Part;
Expand Down Expand Up @@ -801,24 +801,15 @@ public function getAttachment(Horde_Imap_Client_Base $client,

$mimePart = new Horde_Mime_Part();

// Serve all files with a content-disposition of "attachment" to prevent Cross-Site Scripting
$mimePart->setDisposition('attachment');
$contentId = $mimeHeaders['content-id']?->value_single;
if ($contentId !== null) {
$mimePart->setContentId($contentId);
}

// Extract headers from part
$cdEl = $mimeHeaders['content-disposition'];
$contentDisposition = $cdEl instanceof Horde_Mime_Headers_ContentParam
? array_change_key_case($cdEl->params, CASE_LOWER)
: null;
if (!is_null($contentDisposition) && isset($contentDisposition['filename'])) {
$mimePart->setDispositionParameter('filename', $contentDisposition['filename']);
} else {
$ctEl = $mimeHeaders['content-type'];
$contentTypeParams = $ctEl instanceof Horde_Mime_Headers_ContentParam
? array_change_key_case($ctEl->params, CASE_LOWER)
: null;
if (isset($contentTypeParams['name'])) {
$mimePart->setContentTypeParameter('name', $contentTypeParams['name']);
}
$contentDisposition = $mimeHeaders['content-disposition'];
if ($contentDisposition instanceof Horde_Mime_Headers_ContentParam_ContentDisposition) {
$mimePart->setDisposition($contentDisposition->value_single);
$mimePart->setDispositionParameter('filename', $contentDisposition['filename'] ?? null);
}

// Content transfer encoding
Expand All @@ -827,17 +818,15 @@ public function getAttachment(Horde_Imap_Client_Base $client,
$mimePart->setTransferEncoding($tmp);
}

/* Content type */
$contentType = $mimeHeaders['content-type']?->value_single;
if (!is_null($contentType) && str_contains($contentType, 'text/calendar')) {
$mimePart->setType('text/calendar');
if ($mimePart->getContentTypeParameter('name') === null) {
$mimePart->setContentTypeParameter('name', 'calendar.ics');
$contentType = $mimeHeaders['content-type'];
if ($contentType instanceof Horde_Mime_Headers_ContentParam_ContentType) {
if (str_contains($contentType->value_single, 'text/calendar')) {
$mimePart->setType('text/calendar');
$mimePart->setContentTypeParameter('name', $contentType['name'] ?? 'calendar.ics');
} else {
$mimePart->setType($contentType->value_single);
$mimePart->setContentTypeParameter('name', $contentType['name'] ?? null);
}
} else {
// To prevent potential problems with the SOP we serve all files but calendar entries with the
// MIME type "application/octet-stream"
$mimePart->setType('application/octet-stream');
}

$mimePart->setContents($body);
Expand Down
51 changes: 51 additions & 0 deletions lib/Migration/Version5008Date20260320125737.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\Attributes\ModifyColumn;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

/**
* @psalm-api
*/
#[ModifyColumn(
table: 'mail_attachments',
description: 'Add column to store content-id and content-disposition', )
]
class Version5008Date20260320125737 extends SimpleMigrationStep {
/**
* @param Closure(): ISchemaWrapper $schemaClosure
*/
#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();

if ($schema->hasTable('mail_attachments')) {
$attachmentsTable = $schema->getTable('mail_attachments');
if (!$attachmentsTable->hasColumn('content_id')) {
$attachmentsTable->addColumn('content_id', Types::STRING, [
'notnull' => false,
]);
}
if (!$attachmentsTable->hasColumn('disposition')) {
$attachmentsTable->addColumn('disposition', Types::STRING, [
'notnull' => false,
]);
}
}

return $schema;
}
}
29 changes: 20 additions & 9 deletions lib/Model/IMAPMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@

/**
* @psalm-import-type MailIMAPFullMessage from ResponseDefinitions
*
* @psalm-type IMAPAttachment = array{
* id: string|null,
Comment thread
kesselb marked this conversation as resolved.
* messageId: int,
* fileName: string|null,
* mime: string,
* size: int,
* cid: string|null,
* disposition: string,
* }
*/
class IMAPMessage implements IMessage, JsonSerializable {
use ConvertAddresses;
Expand All @@ -53,6 +63,7 @@ class IMAPMessage implements IMessage, JsonSerializable {
public string $plainMessage;
public string $htmlMessage;
public array $attachments;
/** @var list<IMAPAttachment> */
public array $inlineAttachments;
private bool $hasAttachments;
public array $scheduling;
Expand All @@ -71,6 +82,9 @@ class IMAPMessage implements IMessage, JsonSerializable {
private bool $signatureIsValid;
private bool $isPgpMimeEncrypted;

/**
* @param list<IMAPAttachment> $inlineAttachments
*/
public function __construct(int $uid,
string $messageId,
array $flags,
Expand Down Expand Up @@ -304,6 +318,7 @@ public function getFullMessage(int $id, bool $loadBody = true): array {
if ($this->hasHtmlMessage) {
$data['hasHtmlBody'] = true;
$data['attachments'] = $this->attachments;
$data['inlineAttachments'] = $this->inlineAttachments;
return $data;
}

Expand Down Expand Up @@ -349,15 +364,11 @@ public function jsonSerialize() {
* @return string
*/
public function getHtmlBody(int $id): string {
return $this->htmlService->sanitizeHtmlMailBody($this->htmlMessage, $id, function ($cid) {
$match = array_filter($this->inlineAttachments,
static fn ($a) => $a['cid'] === $cid);
$match = array_shift($match);
if ($match === null) {
return null;
}
return $match['id'];
});
return $this->htmlService->sanitizeHtmlMailBody(
$id,
$this->htmlMessage,
$this->inlineAttachments,
);
}

/**
Expand Down
Loading
Loading