diff --git a/appinfo/info.xml b/appinfo/info.xml index 0fdb533cd0..bcc6603b6b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -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/). ]]> - 5.10.0-rc.1 + 5.11.0-dev.1 agpl Christoph Wurst GretaD diff --git a/lib/Controller/DraftsController.php b/lib/Controller/DraftsController.php index 89385b37e0..f757e0a096 100644 --- a/lib/Controller/DraftsController.php +++ b/lib/Controller/DraftsController.php @@ -87,7 +87,8 @@ public function create( ?int $sendAt = null, ?int $draftId = null, bool $requestMdn = false, - bool $isPgpMime = false) : JsonResponse { + bool $isPgpMime = false, + bool $isAiGenerated = false) : JsonResponse { $effectiveUserId = $this->delegationService->resolveAccountUserId($accountId, $this->userId); $account = $this->accountService->find($effectiveUserId, $accountId); if ($draftId !== null) { @@ -109,6 +110,7 @@ public function create( $message->setSmimeEncrypt($smimeEncrypt); $message->setRequestMdn($requestMdn); $message->setPgpMime($isPgpMime); + $message->setAiGenerated($isAiGenerated); if (!empty($smimeCertificateId)) { $smimeCertificate = $this->smimeService->findCertificate($smimeCertificateId, $effectiveUserId); @@ -161,7 +163,8 @@ public function update(int $id, ?int $smimeCertificateId = null, ?int $sendAt = null, bool $requestMdn = false, - bool $isPgpMime = false): JsonResponse { + bool $isPgpMime = false, + bool $isAiGenerated = false): JsonResponse { $effectiveUserId = $this->delegationService->resolveLocalMessageUserId($id, $this->userId); $message = $this->service->getMessage($id, $effectiveUserId); $account = $this->accountService->find($effectiveUserId, $accountId); @@ -182,6 +185,7 @@ public function update(int $id, $message->setSmimeEncrypt($smimeEncrypt); $message->setRequestMdn($requestMdn); $message->setPgpMime($isPgpMime); + $message->setAiGenerated($isAiGenerated); if (!empty($smimeCertificateId)) { $smimeCertificate = $this->smimeService->findCertificate($smimeCertificateId, $effectiveUserId); diff --git a/lib/Controller/OutboxController.php b/lib/Controller/OutboxController.php index c5a98780b2..002117edc4 100644 --- a/lib/Controller/OutboxController.php +++ b/lib/Controller/OutboxController.php @@ -103,6 +103,7 @@ public function create( ?int $sendAt = null, bool $requestMdn = false, bool $isPgpMime = false, + bool $isAiGenerated = false, ): JsonResponse { $effectiveUserId = $this->delegationService->resolveAccountUserId($accountId, $this->userId); $account = $this->accountService->find($effectiveUserId, $accountId); @@ -126,6 +127,7 @@ public function create( $message->setSmimeSign($smimeSign); $message->setSmimeEncrypt($smimeEncrypt); $message->setRequestMdn($requestMdn); + $message->setAiGenerated($isAiGenerated); if (!empty($smimeCertificateId)) { $smimeCertificate = $this->smimeService->findCertificate($smimeCertificateId, $effectiveUserId); @@ -198,6 +200,7 @@ public function update( ?int $sendAt = null, bool $requestMdn = false, bool $isPgpMime = false, + bool $isAiGenerated = false, ): JsonResponse { $effectiveUserId = $this->delegationService->resolveLocalMessageUserId($id, $this->userId); $message = $this->service->getMessage($id, $effectiveUserId); @@ -219,6 +222,7 @@ public function update( $message->setSmimeSign($smimeSign); $message->setSmimeEncrypt($smimeEncrypt); $message->setRequestMdn($requestMdn); + $message->setAiGenerated($isAiGenerated); // Reset the status to make it retryable. $message->setFailed(false); diff --git a/lib/Db/LocalMessage.php b/lib/Db/LocalMessage.php index 8e905ccef5..4b45134525 100644 --- a/lib/Db/LocalMessage.php +++ b/lib/Db/LocalMessage.php @@ -53,6 +53,8 @@ * @method setRaw(string|null $raw) * @method bool getRequestMdn() * @method setRequestMdn(bool $mdn) + * @method bool isAiGenerated() + * @method void setAiGenerated(bool $aiGenerated) */ class LocalMessage extends Entity implements JsonSerializable { public const TYPE_OUTGOING = 0; @@ -141,6 +143,9 @@ class LocalMessage extends Entity implements JsonSerializable { /** @var bool */ protected $requestMdn; + /** @var bool */ + protected $aiGenerated; + public function __construct() { $this->addType('type', 'integer'); $this->addType('accountId', 'integer'); @@ -155,7 +160,7 @@ public function __construct() { $this->addType('smimeEncrypt', 'boolean'); $this->addType('status', 'integer'); $this->addType('requestMdn', 'boolean'); - + $this->addType('aiGenerated', 'boolean'); } #[\Override] @@ -195,6 +200,7 @@ public function jsonSerialize() { 'status' => $this->getStatus(), 'raw' => $this->getRaw(), 'requestMdn' => $this->getRequestMdn(), + 'isAiGenerated' => $this->isAiGenerated() === true, ]; } diff --git a/lib/IMAP/ImapMessageFetcher.php b/lib/IMAP/ImapMessageFetcher.php index 71ff349e50..561c681d2e 100644 --- a/lib/IMAP/ImapMessageFetcher.php +++ b/lib/IMAP/ImapMessageFetcher.php @@ -55,6 +55,7 @@ class ImapMessageFetcher { private string $rawReferences = ''; private string $dispositionNotificationTo = ''; private bool $hasDkimSignature = false; + private bool $isAiGenerated = false; private array $phishingDetails = []; private ?string $unsubscribeUrl = null; private bool $isOneClickUnsubscribe = false; @@ -262,6 +263,7 @@ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPM $this->rawReferences, $this->dispositionNotificationTo, $this->hasDkimSignature, + $this->isAiGenerated, $this->phishingDetails, $this->unsubscribeUrl, $this->isOneClickUnsubscribe, @@ -527,10 +529,8 @@ private function decodeSubject(Horde_Imap_Client_Data_Envelope $envelope): strin } private function parseHeaders(Horde_Imap_Client_Data_Fetch $fetch): void { - /** @var resource $headersStream */ - $headersStream = $fetch->getHeaderText('0', Horde_Imap_Client_Data_Fetch::HEADER_STREAM); - $parsedHeaders = Horde_Mime_Headers::parseHeaders($headersStream); - fclose($headersStream); + /** @var Horde_Mime_Headers $parsedHeaders */ + $parsedHeaders = $fetch->getHeaderText('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE); $references = $parsedHeaders->getHeader('references'); if ($references !== null) { @@ -545,6 +545,9 @@ private function parseHeaders(Horde_Imap_Client_Data_Fetch $fetch): void { $dkimSignatureHeader = $parsedHeaders->getHeader('dkim-signature'); $this->hasDkimSignature = $dkimSignatureHeader !== null; + $aiGeneratedHeader = $parsedHeaders->getHeader('x-ai-generated'); + $this->isAiGenerated = $aiGeneratedHeader !== null && trim($aiGeneratedHeader->value_single) === '1'; + if ($this->runPhishingCheck) { $this->phishingDetails = $this->phishingDetectionService->checkHeadersForPhishing($this->userId, $parsedHeaders, $fetch->getFlags(), $this->hasHtmlMessage, $this->htmlMessage); } diff --git a/lib/Migration/Version5201Date20260604000000.php b/lib/Migration/Version5201Date20260604000000.php new file mode 100644 index 0000000000..7da87cfb7c --- /dev/null +++ b/lib/Migration/Version5201Date20260604000000.php @@ -0,0 +1,36 @@ +getTable('mail_local_messages'); + if (!$table->hasColumn('ai_generated')) { + $table->addColumn('ai_generated', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index ed9d95a144..02495cbb08 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -72,6 +72,7 @@ public function __construct( private string $rawReferences, private string $dispositionNotificationTo, private bool $hasDkimSignature, + private bool $isAiGenerated, private array $phishingDetails, private ?string $unsubscribeUrl, private bool $isOneClickUnsubscribe, @@ -293,6 +294,7 @@ public function jsonSerialize() { 'unsubscribeMailto' => $this->unsubscribeMailto, 'scheduling' => $this->scheduling, 'isPgpMimeEncrypted' => $this->isPgpMimeEncrypted, + 'aiGenerated' => $this->isAiGenerated, ]; } diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index e58a36d930..d44f7b6f4b 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -138,6 +138,10 @@ public function sendMessage(Account $account, LocalMessage $localMessage): void $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses(Horde_Mime_Mdn::MDN_HEADER, $from->toHorde())); } + if ($localMessage->isAiGenerated()) { + $fccHeaders->addHeader('X-AI-Generated', '1'); + } + // For SMTP delivery: strip Bcc so it never appears in the transmitted // message (RFC 5321). All three recipient lists are passed as SMTP // envelope recipients so every addressee still receives the mail. diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 846c9ece14..e1d05434a7 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -400,6 +400,12 @@ @uncheck="requestMdnVal = false"> {{ t('mail', 'Request a read receipt') }} + + {{ t('mail', 'Mark as AI generated') }} +
{{ envelope.from && envelope.from[0] ? envelope.from[0].label : '' }} + + + {{ t('mail', 'Contains AI content') }} +
.sender { margin-inline-start: calc(var(--default-grid-baseline) * 3); + display: flex; + align-items: center; + gap: 6px; + min-width: 0; &--expanded { color: var(--color-text-maxcontrast); @@ -1485,4 +1499,11 @@ export default { display: inline; align-items: center; } + + .ai-generated-label { + display: flex; + align-items: center; + gap: 4px; + opacity: 0.8; + } diff --git a/tests/Unit/Controller/DraftsControllerTest.php b/tests/Unit/Controller/DraftsControllerTest.php index cc77da35e4..1dd9e74fed 100644 --- a/tests/Unit/Controller/DraftsControllerTest.php +++ b/tests/Unit/Controller/DraftsControllerTest.php @@ -218,6 +218,7 @@ public function testCreate(): void { $message->setUpdatedAt(123456); $message->setRequestMdn(false); $message->setPgpMime(false); + $message->setAiGenerated(false); $to = [['label' => 'Lewis', 'email' => 'tent@stardewvalley.com']]; $cc = [['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']]; @@ -272,6 +273,7 @@ public function testCreateFromDraft(): void { $message->setUpdatedAt(123456); $message->setRequestMdn(false); $message->setPgpMime(false); + $message->setAiGenerated(false); $to = [['label' => 'Lewis', 'email' => 'tent@stardewvalley.com']]; $cc = [['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']]; @@ -329,6 +331,7 @@ public function testCreateWithEmptyRecipients(): void { $message->setUpdatedAt(123456); $message->setRequestMdn(false); $message->setPgpMime(false); + $message->setAiGenerated(false); $account = new Account(new MailAccount()); $this->accountService->expects(self::once()) diff --git a/tests/Unit/Controller/ListControllerTest.php b/tests/Unit/Controller/ListControllerTest.php index 5dc66ccbe9..9eb4c7667b 100644 --- a/tests/Unit/Controller/ListControllerTest.php +++ b/tests/Unit/Controller/ListControllerTest.php @@ -149,6 +149,7 @@ public function testUnsupportedMessage(): void { '', '', false, + false, [], null, false, @@ -213,6 +214,7 @@ public function testUnsubscribe(): void { '', '', false, + false, [], 'https://un.sub.scribe/me', true, diff --git a/tests/Unit/Controller/OutboxControllerTest.php b/tests/Unit/Controller/OutboxControllerTest.php index 227447ebe3..4bd4f1d7de 100644 --- a/tests/Unit/Controller/OutboxControllerTest.php +++ b/tests/Unit/Controller/OutboxControllerTest.php @@ -315,6 +315,7 @@ public function testCreate(): void { $message->setType(LocalMessage::TYPE_OUTGOING); $message->setRequestMdn(false); $message->setPgpMime(false); + $message->setAiGenerated(false); $to = [['label' => 'Lewis', 'email' => 'tent@stardewvalley.com']]; $cc = [['label' => 'Pierre', 'email' => 'generalstore@stardewvalley.com']]; diff --git a/tests/Unit/Model/IMAPMessageTest.php b/tests/Unit/Model/IMAPMessageTest.php index 388f1fd933..fea857e738 100644 --- a/tests/Unit/Model/IMAPMessageTest.php +++ b/tests/Unit/Model/IMAPMessageTest.php @@ -72,6 +72,7 @@ public function testIconvHtmlMessage() { '', 'disposition', false, + false, [], null, false, @@ -115,6 +116,7 @@ public function testSerialize() { '', 'disposition', false, + false, [], null, false, @@ -156,9 +158,11 @@ public function testSerialize() { 'hasHtmlBody' => true, 'dispositionNotificationTo' => 'disposition', 'hasDkimSignature' => false, + 'aiGenerated' => false, 'phishingDetails' => [], 'scheduling' => [], 'isPgpMimeEncrypted' => false, + 'aiGenerated' => false, ], $json); $this->assertEquals(1234, $json['uid']); }