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
5 changes: 2 additions & 3 deletions lib/Controller/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NoAdminRequired is imported but not used anywhere in this controller (the file still uses @NoAdminRequired docblock annotations, not the attribute). Please drop the unused import to avoid dead code and keep imports clean.

Suggested change
use OCP\AppFramework\Http\Attribute\NoAdminRequired;

Copilot uses AI. Check for mistakes.
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\JSONResponse;
Expand Down Expand Up @@ -645,9 +646,7 @@ public function getHtmlBody(int $id, bool $plain = false): Response {
$mailbox,
$message->getUid(),
true
)->getHtmlBody(
$id
);
)->getHtmlBody($id);
} finally {
$client->logout();
}
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
47 changes: 47 additions & 0 deletions lib/Migration/Version5008Date20260320125737.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?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');
$attachmentsTable->addColumn('content_id', Types::STRING, [
'notnull' => false,
]);
$attachmentsTable->addColumn('disposition', Types::STRING, [
'notnull' => false,
]);
Comment on lines +37 to +42
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration adds content_id and disposition unconditionally once the table exists. If the migration partially ran (or the columns already exist for some reason), addColumn() will throw and block upgrades. Guard each addColumn() with hasColumn() checks (or use addColumn only when missing).

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

Copilot uses AI. Check for mistakes.
}

return $schema;
}
}
31 changes: 20 additions & 11 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,
* 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,17 +364,11 @@ public function jsonSerialize() {
* @return string
*/
public function getHtmlBody(int $id): string {
return $this->htmlService->sanitizeHtmlMailBody($this->htmlMessage, [
'id' => $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
20 changes: 19 additions & 1 deletion lib/Provider/Command/MessageSend.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,29 @@ public function perform(string $userId, string $serviceId, IMessage $message, ar
$attachments = [];
try {
foreach ($message->getAttachments() as $entry) {
/*
* The mail provider API has no disposition field, so we infer it:
* omit the Content-Disposition header (null) when name is null,
* otherwise default to attachment.
*
* See https://github.com/nextcloud/mail/issues/10416 for the original motivation.
*
* Previously handled in TransmissionService::handleAttachment;
* moved here now that LocalAttachment carries a disposition field.
*/
if ($entry->getName() === null) {
$disposition = LocalAttachment::DISPOSITION_OMIT;
} else {
$disposition = LocalAttachment::DISPOSITION_ATTACHMENT;
}

$attachments[] = $this->attachmentService->addFileFromString(
$userId,
(string)$entry->getName(),
(string)$entry->getType(),
(string)$entry->getContents()
(string)$entry->getContents(),
null,
$disposition,
);
}
} catch (UploadException $e) {
Expand Down
4 changes: 3 additions & 1 deletion lib/Service/AntiSpamService.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,12 @@ public function sendReportEmail(Account $account, Mailbox $mailbox, int $uid, st
$mimeMessage = new MimeMessage(
new DataUriParser()
);

$mimePart = $mimeMessage->build(
null,
$message->getContent(),
$message->getAttachments()
false,
array_values($message->getAttachments()),
);

$mail->setBasePart($mimePart);
Expand Down
Loading
Loading