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.10.0-rc.1</version>
<version>5.11.0-dev.1</version>
<licence>agpl</licence>
<author homepage="https://github.com/ChristophWurst">Christoph Wurst</author>
<author homepage="https://github.com/GretaD">GretaD</author>
Expand Down
8 changes: 6 additions & 2 deletions lib/Controller/DraftsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions lib/Controller/OutboxController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion lib/Db/LocalMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -155,7 +160,7 @@ public function __construct() {
$this->addType('smimeEncrypt', 'boolean');
$this->addType('status', 'integer');
$this->addType('requestMdn', 'boolean');

$this->addType('aiGenerated', 'boolean');
}

#[\Override]
Expand Down Expand Up @@ -195,6 +200,7 @@ public function jsonSerialize() {
'status' => $this->getStatus(),
'raw' => $this->getRaw(),
'requestMdn' => $this->getRequestMdn(),
'isAiGenerated' => $this->isAiGenerated() === true,
];
}

Expand Down
11 changes: 7 additions & 4 deletions lib/IMAP/ImapMessageFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
36 changes: 36 additions & 0 deletions lib/Migration/Version5201Date20260604000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?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\IOutput;
use OCP\Migration\SimpleMigrationStep;

/**
* @psalm-api
*/
class Version5201Date20260604000000 extends SimpleMigrationStep {
#[\Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();

$table = $schema->getTable('mail_local_messages');
if (!$table->hasColumn('ai_generated')) {
$table->addColumn('ai_generated', Types::BOOLEAN, [
'notnull' => false,
'default' => false,
]);
}

return $schema;
}
}
2 changes: 2 additions & 0 deletions lib/Model/IMAPMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -293,6 +294,7 @@ public function jsonSerialize() {
'unsubscribeMailto' => $this->unsubscribeMailto,
'scheduling' => $this->scheduling,
'isPgpMimeEncrypted' => $this->isPgpMimeEncrypted,
'aiGenerated' => $this->isAiGenerated,
];
}

Expand Down
4 changes: 4 additions & 0 deletions lib/Service/MailTransmission.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/components/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@
@uncheck="requestMdnVal = false">
{{ t('mail', 'Request a read receipt') }}
</ActionCheckbox>
<ActionCheckbox
:checked="isAiGeneratedVal"
@check="isAiGeneratedVal = true"
@uncheck="isAiGeneratedVal = false">
{{ t('mail', 'Mark as AI generated') }}
</ActionCheckbox>
<ActionCheckbox
v-if="smimeCertificateForCurrentAlias"
:checked="wantsSmimeSign"
Expand Down Expand Up @@ -717,6 +723,11 @@ export default {
default: false,
},

isAiGenerated: {
type: Boolean,
default: false,
},

accounts: {
type: Array,
required: true,
Expand Down Expand Up @@ -755,6 +766,7 @@ export default {

editorMode: (this.body?.format !== 'html') ? EDITOR_MODE_TEXT : EDITOR_MODE_HTML,
requestMdnVal: this.requestMdn,
isAiGeneratedVal: this.isAiGenerated,

@kesselb kesselb Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude: Please also reset isAiGeneratedVal in reset method

changeSignature: false,
loadingIndicatorTo: false,
loadingIndicatorCc: false,
Expand Down Expand Up @@ -1070,6 +1082,10 @@ export default {
this.$emit('update:request-mdn', val)
},

isAiGeneratedVal(val) {
this.$emit('update:is-ai-generated', val)
},

selectedAlias: {
handler() {
const aliasEmailAddress = this.selectedAlias.emailAddress
Expand Down Expand Up @@ -1264,6 +1280,7 @@ export default {
inReplyToMessageId: this.inReplyToMessageId ?? (this.replyTo ? this.replyTo.messageId : undefined),
isHtml: !this.encrypt && !this.editorPlainText,
requestMdn: this.requestMdnVal,
isAiGenerated: this.isAiGeneratedVal,
sendAt: this.sendAtVal !== 0 ? Math.floor(this.sendAtVal / 1000) : undefined,
smimeSign: this.shouldSmimeSign,
smimeEncrypt: this.shouldSmimeEncrypt,
Expand Down Expand Up @@ -1344,6 +1361,7 @@ export default {
}
if (this.smartReply) {
this.bus.emit('append-to-body-at-cursor', this.smartReply)
this.isAiGeneratedVal = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude flagged this block, because a onEditorReady event (e.g the user is changing the alias) would insert the smart reply again, reset isAiGeneratedVal to true, and overwrite the user's choice. I think it's okay for now to ignore it, but we should look into a better way as follow up.

}
},

Expand Down
2 changes: 2 additions & 0 deletions src/components/NewMessageModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
:is-first-open="modalFirstOpen"
:is-draft="composerData.draftId !== undefined"
:request-mdn="composerData.requestMdn"
:is-ai-generated="composerData.isAiGenerated"
:accounts="accounts"
@update:from-account="patchComposerData({ accountId: $event })"
@update:from-alias="patchComposerData({ aliasId: $event })"
Expand All @@ -105,6 +106,7 @@
@update:smime-sign="patchComposerData({ smimeSign: $event })"
@update:smime-encrypt="patchComposerData({ smimeSign: $event })"
@update:request-mdn="patchComposerData({ requestMdn: $event })"
@update:is-ai-generated="patchComposerData({ isAiGenerated: $event })"
@draft="onDraft"
@discard-draft="discardDraft"
@upload-attachment="onAttachmentUploading"
Expand Down
21 changes: 21 additions & 0 deletions src/components/ThreadEnvelope.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@
<div class="envelope__header__left__sender-subject-tags">
<div class="sender" :class="{ 'sender--expanded': expanded }">
{{ envelope.from && envelope.from[0] ? envelope.from[0].label : '' }}
<span v-if="hasAiGeneratedContent" class="ai-generated-label">
<AiIcon :size="14" />
{{ t('mail', 'Contains AI content') }}
</span>
</div>
<NcButton
v-if="expanded && hasRecipients"
Expand Down Expand Up @@ -384,6 +388,7 @@ import { NcActionButton, NcButton } from '@nextcloud/vue'
import { mapStores } from 'pinia'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionText from '@nextcloud/vue/components/NcActionText'
import AiIcon from '@nextcloud/vue/components/NcAssistantIcon'
import ArchiveIcon from 'vue-material-design-icons/ArchiveArrowDownOutline.vue'
import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue'
import ChevronUpIcon from 'vue-material-design-icons/ChevronUp.vue'
Expand Down Expand Up @@ -439,6 +444,7 @@ const Loading = Object.seal({
export default {
name: 'ThreadEnvelope',
components: {
AiIcon,
MailFilterFromEnvelope,
EventModal,
TaskModal,
Expand Down Expand Up @@ -610,6 +616,10 @@ export default {
&& isPgpText(this.envelope.previewText)
},

hasAiGeneratedContent() {
return this.message?.aiGenerated === true
},

isImportant() {
return this.mainStore
.getEnvelopeTags(this.envelope.databaseId)
Expand Down Expand Up @@ -1189,6 +1199,10 @@ export default {
<style lang="scss" scoped>
.sender {
margin-inline-start: calc(var(--default-grid-baseline) * 3);
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
Comment on lines +1202 to +1205

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Replace hardcoded pixel gaps with Nextcloud CSS variables.

gap: 6px and gap: 4px are magic numbers. Per coding guidelines, spacing should use Nextcloud CSS variables (with calc() for finer control) rather than literal pixel values.

As per coding guidelines: "Use Nextcloud CSS variables for all CSS colors, spacing, and dimensions; do not use magic numbers. Use calc(x*var) when needing more specific control over dimensions with CSS variables."

♻️ Proposed fix
 	.sender {
 		margin-inline-start: calc(var(--default-grid-baseline) * 3);
 		display: flex;
 		align-items: center;
-		gap: 6px;
+		gap: calc(var(--default-grid-baseline) * 1.5);
 		min-width: 0;
 	.ai-generated-label {
 		display: flex;
 		align-items: center;
-		gap: 4px;
+		gap: var(--default-grid-baseline);
 		opacity: 0.8;
 	}

Also applies to: 1502-1508

Source: Coding guidelines


&--expanded {
color: var(--color-text-maxcontrast);
Expand Down Expand Up @@ -1485,4 +1499,11 @@ export default {
display: inline;
align-items: center;
}

.ai-generated-label {
display: flex;
align-items: center;
gap: 4px;
opacity: 0.8;
}
</style>
3 changes: 3 additions & 0 deletions tests/Unit/Controller/DraftsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']];

Expand Down Expand Up @@ -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']];

Expand Down Expand Up @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions tests/Unit/Controller/ListControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ public function testUnsupportedMessage(): void {
'',
'',
false,
false,
[],
null,
false,
Expand Down Expand Up @@ -213,6 +214,7 @@ public function testUnsubscribe(): void {
'',
'',
false,
false,
[],
'https://un.sub.scribe/me',
true,
Expand Down
Loading
Loading