diff --git a/appinfo/info.xml b/appinfo/info.xml index 0fdb533cd0..3f67e80e36 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.10.0-rc.2 agpl Christoph Wurst GretaD diff --git a/lib/Controller/DraftsController.php b/lib/Controller/DraftsController.php index 89385b37e0..4e430f4766 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, + ?string $governanceLabelId = null) : 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->setGovernanceLabelId($governanceLabelId); 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, + ?string $governanceLabelId = null): 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->setGovernanceLabelId($governanceLabelId); if (!empty($smimeCertificateId)) { $smimeCertificate = $this->smimeService->findCertificate($smimeCertificateId, $effectiveUserId); diff --git a/lib/Controller/GovernanceLabelsController.php b/lib/Controller/GovernanceLabelsController.php new file mode 100644 index 0000000000..c24a00bdfb --- /dev/null +++ b/lib/Controller/GovernanceLabelsController.php @@ -0,0 +1,53 @@ +governanceLabelService->getLabels(true)), + ); + } + + #[TrapError] + #[NoAdminRequired] + #[Route(Route::TYPE_FRONTPAGE, verb: 'GET', url: '/api/governance/labels/{id}')] + public function show(string $id): JsonResponse { + $label = $this->governanceLabelService->getLabel($id); + if ($label === null) { + return JsonResponse::fail([], Http::STATUS_NOT_FOUND); + } + + return JsonResponse::success($label); + } +} diff --git a/lib/Controller/OutboxController.php b/lib/Controller/OutboxController.php index c5a98780b2..8bbbffca51 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, + ?string $governanceLabelId = null, ): 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->setGovernanceLabelId($governanceLabelId); 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, + ?string $governanceLabelId = null, ): 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->setGovernanceLabelId($governanceLabelId); // Reset the status to make it retryable. $message->setFailed(false); diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 02063f0b99..ac6a010d31 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -21,6 +21,7 @@ use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\ContextChat\ContextChatSettingsService; +use OCA\Mail\Service\GovernanceLabelService; use OCA\Mail\Service\InternalAddressService; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; @@ -89,6 +90,7 @@ public function __construct( private IAppManager $appManager, private ContextChatSettingsService $contextChatSettingsService, private ClassificationSettingsService $classificationSettingsService, + private GovernanceLabelService $governanceLabelService, ) { parent::__construct($appName, $request); @@ -139,6 +141,11 @@ public function index(): TemplateResponse { $this->appManager->getAppVersion('mail'), ); + $this->initialStateService->provideInitialState( + 'governance-labels-available', + $this->governanceLabelService->isGovernanceAvailable(), + ); + $mailAccounts = $this->accountService->findByUserId($this->userId); $accountsJson = []; foreach ($mailAccounts as $mailAccount) { diff --git a/lib/Db/LocalMessage.php b/lib/Db/LocalMessage.php index 8e905ccef5..5bf052a577 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 string|null getGovernanceLabelId() + * @method void setGovernanceLabelId(?string $governanceLabelId) */ 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 string|null */ + protected $governanceLabelId; + public function __construct() { $this->addType('type', 'integer'); $this->addType('accountId', 'integer'); @@ -195,6 +200,7 @@ public function jsonSerialize() { 'status' => $this->getStatus(), 'raw' => $this->getRaw(), 'requestMdn' => $this->getRequestMdn(), + 'governanceLabelId' => $this->getGovernanceLabelId(), ]; } diff --git a/lib/Db/Message.php b/lib/Db/Message.php index 2c68e1f083..b78ed4d9cf 100644 --- a/lib/Db/Message.php +++ b/lib/Db/Message.php @@ -73,6 +73,8 @@ * @method void setEncrypted(bool|null $encrypted) * @method bool getMentionsMe() * @method void setMentionsMe(bool $isMentionned) + * @method string|null getGovernanceLabelId() + * @method void setGovernanceLabelId(?string $governanceLabelId) */ class Message extends Entity implements JsonSerializable { private const MUTABLE_FLAGS = [ @@ -117,6 +119,7 @@ class Message extends Entity implements JsonSerializable { protected $imipProcessed = false; protected $imipError = false; protected $mentionsMe = false; + protected $governanceLabelId; /** * @var bool|null @@ -375,6 +378,7 @@ public function jsonSerialize() { 'avatar' => $this->avatar?->jsonSerialize(), 'fetchAvatarFromClient' => $this->fetchAvatarFromClient, 'attachments' => $this->getAttachments(), + 'governanceLabelId' => $this->getGovernanceLabelId(), ]; } } diff --git a/lib/Db/MessageMapper.php b/lib/Db/MessageMapper.php index 1d80e55049..07db29a44c 100644 --- a/lib/Db/MessageMapper.php +++ b/lib/Db/MessageMapper.php @@ -582,6 +582,7 @@ public function updatePreviewDataBulk(Message ...$messages): array { ->set('imip_message', $query->createParameter('imip_message')) ->set('encrypted', $query->createParameter('encrypted')) ->set('mentions_me', $query->createParameter('mentions_me')) + ->set('governance_label_id', $query->createParameter('governance_label_id')) ->where($query->expr()->andX( $query->expr()->eq('uid', $query->createParameter('uid')), $query->expr()->eq('mailbox_id', $query->createParameter('mailbox_id')) @@ -613,6 +614,11 @@ public function updatePreviewDataBulk(Message ...$messages): array { $query->setParameter('imip_message', $message->isImipMessage(), IQueryBuilder::PARAM_BOOL); $query->setParameter('encrypted', $message->isEncrypted(), IQueryBuilder::PARAM_BOOL); $query->setParameter('mentions_me', $message->getMentionsMe(), IQueryBuilder::PARAM_BOOL); + $query->setParameter( + 'governance_label_id', + $message->getGovernanceLabelId(), + $message->getGovernanceLabelId() === null ? IQueryBuilder::PARAM_NULL : IQueryBuilder::PARAM_STR + ); $query->executeStatement(); } diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index ac657cb2c5..1f7cfaffda 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -32,6 +32,7 @@ use OCA\Mail\Html\Parser; use OCA\Mail\IMAP\Charset\Converter; use OCA\Mail\Model\IMAPMessage; +use OCA\Mail\Service\GovernanceLabelService; use OCA\Mail\Service\SmimeService; use OCA\Mail\Support\PerformanceLoggerTask; use OCP\AppFramework\Db\DoesNotExistException; @@ -891,6 +892,10 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, $isEncrypted = true; } + /** @var Horde_Mime_Headers $messageHeaders */ + $messageHeaders = $fetchData->getHeaderText('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE); + $governanceLabelHeader = $messageHeaders->getHeader(GovernanceLabelService::HEADER)?->value_single; + $structure = $fetchData->getStructure(); /** @var Horde_Mime_Part $part */ @@ -909,7 +914,7 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, $textBodyId = $structure->findBody() ?? $structure->findBody('text'); $htmlBodyId = $structure->findBody('html'); if ($textBodyId === null && $htmlBodyId === null) { - return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false); + return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false, $governanceLabelHeader); } $partsQuery = new Horde_Imap_Client_Fetch_Query(); if ($htmlBodyId !== null) { @@ -935,7 +940,7 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, $part = $parts[$fetchData->getUid()]; // This is sus - why does this even happen? A delete / move in the middle of this processing? if ($part === null) { - return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false); + return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false, $governanceLabelHeader); } /** Convert a given binary body to utf-8 according to the applicable @@ -988,6 +993,7 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, $isImipMessage, $isEncrypted, $mentionsUser, + $governanceLabelHeader, ); } @@ -1000,9 +1006,10 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, $isImipMessage, $isEncrypted, false, + $governanceLabelHeader, ); } - return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false); + return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false, $governanceLabelHeader); }, iterator_to_array($structures->getIterator())); } private function checkLinks(string $body, string $mailAddress) : bool { diff --git a/lib/IMAP/MessageStructureData.php b/lib/IMAP/MessageStructureData.php index e2710b3b78..d76ca09dfd 100644 --- a/lib/IMAP/MessageStructureData.php +++ b/lib/IMAP/MessageStructureData.php @@ -16,6 +16,7 @@ public function __construct( private bool $isImipMessage, private bool $isEncrypted, private bool $mentionsMe, + private ?string $governanceLabelHeader = null, ) { } @@ -38,4 +39,8 @@ public function isEncrypted(): bool { public function getMentionsMe(): bool { return $this->mentionsMe; } + + public function getGovernanceLabelHeader(): ?string { + return $this->governanceLabelHeader; + } } diff --git a/lib/IMAP/PreviewEnhancer.php b/lib/IMAP/PreviewEnhancer.php index 3f67545379..7d04e1a62f 100644 --- a/lib/IMAP/PreviewEnhancer.php +++ b/lib/IMAP/PreviewEnhancer.php @@ -18,6 +18,7 @@ use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\Avatar\Avatar; use OCA\Mail\Service\AvatarService; +use OCA\Mail\Service\GovernanceLabelService; use Psr\Log\LoggerInterface; use function array_key_exists; use function array_map; @@ -32,6 +33,7 @@ public function __construct( private LoggerInterface $logger, private AvatarService $avatarService, private AttachmentService $attachmentService, + private GovernanceLabelService $governanceLabelService, ) { } @@ -95,7 +97,7 @@ public function process(Account $account, Mailbox $mailbox, array $messages, boo $client->logout(); } - return $this->mapper->updatePreviewDataBulk(...array_map(static function (Message $message) use ($data) { + return $this->mapper->updatePreviewDataBulk(...array_map(function (Message $message) use ($data) { if (!array_key_exists($message->getUid(), $data)) { // Nothing to do return $message; @@ -108,6 +110,9 @@ public function process(Account $account, Mailbox $mailbox, array $messages, boo $message->setImipMessage($structureData->isImipMessage()); $message->setEncrypted($structureData->isEncrypted()); $message->setMentionsMe($structureData->getMentionsMe()); + $message->setGovernanceLabelId( + $this->governanceLabelService->resolveHeaderLabelId($structureData->getGovernanceLabelHeader()) + ); return $message; }, $messages)); diff --git a/lib/Migration/Version5200Date20260714000000.php b/lib/Migration/Version5200Date20260714000000.php new file mode 100644 index 0000000000..4b70190462 --- /dev/null +++ b/lib/Migration/Version5200Date20260714000000.php @@ -0,0 +1,53 @@ +getTable('mail_local_messages'); + if (!$localMessagesTable->hasColumn('governance_label_id')) { + $localMessagesTable->addColumn('governance_label_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + 'default' => null, + ]); + } + + $messagesTable = $schema->getTable('mail_messages'); + if (!$messagesTable->hasColumn('governance_label_id')) { + $messagesTable->addColumn('governance_label_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + 'default' => null, + ]); + } + + return $schema; + } +} diff --git a/lib/Service/GovernanceLabelService.php b/lib/Service/GovernanceLabelService.php new file mode 100644 index 0000000000..7e0f5922d0 --- /dev/null +++ b/lib/Service/GovernanceLabelService.php @@ -0,0 +1,119 @@ +> */ + private array $labelsCache = []; + + public function __construct( + private IAppManager $appManager, + private ContainerInterface $container, + private IConfig $config, + ) { + } + + public function isGovernanceAvailable(): bool { + return $this->appManager->isEnabledForAnyone('governance') + && class_exists(ResolverService::class); + } + + /** + * List all governance labels with a MAILS scope (or all scopes), with their metadata. + * + * @return array}> indexed by label ID + */ + public function getLabels(bool $allScopes = false): array { + if (!$this->isGovernanceAvailable()) { + return []; + } + + if (isset($this->labelsCache[(int)$allScopes])) { + return $this->labelsCache[(int)$allScopes]; + } + + /** @var ResolverService $resolver */ + $resolver = $this->container->get(ResolverService::class); + // Index by label ID explicitly: governance returns renumbered integer + // keys because PHP casts the numeric snowflake IDs to int array keys + $labels = []; + foreach ($allScopes ? LabelScope::cases() : [LabelScope::MAILS] as $scope) { + foreach ($resolver->getAllLabelsForScope($scope) as $label) { + $labels[$label->id] = $this->serializeLabel($label); + } + } + + return $this->labelsCache[(int)$allScopes] = $labels; + } + + /** + * Build the mail header value marking a message with a governance label. + */ + public function buildHeaderValue(string $labelId): string { + return sprintf( + 'id=%s; origin=%s', + $labelId, + $this->config->getSystemValueString('instanceid'), + ); + } + + /** + * Parse a X-NC-Governance-Label header value and return the label ID if + * it originates from this instance and the label still exists. + */ + public function resolveHeaderLabelId(?string $headerValue): ?string { + if ($headerValue === null || $headerValue === '') { + return null; + } + + if (preg_match('/^\s*id=([^;\s]+)\s*;\s*origin=(\S+)\s*$/', $headerValue, $matches) !== 1) { + return null; + } + [, $labelId, $origin] = $matches; + + if ($origin !== $this->config->getSystemValueString('instanceid')) { + return null; + } + + return $this->getLabel($labelId, true) !== null ? $labelId : null; + } + + /** + * @return array{id: string, type: string, name: string, priority: int, description: string, color: string, scopes: list}|null + */ + public function getLabel(string $id, bool $allScopes = false): ?array { + return $this->getLabels($allScopes)[$id] ?? null; + } + + /** + * @return array{id: string, type: string, name: string, priority: int, description: string, color: string, scopes: list} + */ + private function serializeLabel(BaseLabelModel $label): array { + return [ + 'id' => $label->id, + 'type' => $label->getType(), + 'name' => $label->displayName, + 'priority' => $label->priority, + 'description' => $label->userDescription, + 'color' => $label->color, + 'scopes' => array_map(static fn ($scope) => $scope->getValue(), $label->scopes), + ]; + } +} diff --git a/lib/Service/MailTransmission.php b/lib/Service/MailTransmission.php index 493e4d0c11..43ed1d49d5 100644 --- a/lib/Service/MailTransmission.php +++ b/lib/Service/MailTransmission.php @@ -71,6 +71,7 @@ public function __construct( private AliasesService $aliasesService, private TransmissionService $transmissionService, private IMailManager $mailManager, + private GovernanceLabelService $governanceLabelService, ) { } @@ -136,6 +137,13 @@ public function sendMessage(Account $account, LocalMessage $localMessage): void if ($localMessage->getRequestMdn()) { $fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses(Horde_Mime_Mdn::MDN_HEADER, $from->toHorde())); } + $governanceLabelId = $localMessage->getGovernanceLabelId(); + if ($governanceLabelId !== null && $this->governanceLabelService->getLabel($governanceLabelId, true) !== null) { + $fccHeaders->addHeader( + GovernanceLabelService::HEADER, + $this->governanceLabelService->buildHeaderValue($governanceLabelId), + ); + } // For SMTP delivery: strip Bcc so it never appears in the transmitted // message (RFC 5321). All three recipient lists are passed as SMTP diff --git a/psalm.xml b/psalm.xml index b2d115ea6d..c35e301f07 100644 --- a/psalm.xml +++ b/psalm.xml @@ -58,6 +58,9 @@ + + + diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 846c9ece14..ea7b4b4c6f 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -216,7 +216,7 @@ -
+
@@ -229,6 +229,7 @@ autocomplete="off" :placeholder="t('mail', 'Subject …')" @input="saveDraftDebounced"> +
{{ t('mail', 'This message came from a noreply address so your reply will probably not be read.') }} @@ -532,6 +533,7 @@ import Delete from 'vue-material-design-icons/TrashCanOutline.vue' import Download from 'vue-material-design-icons/TrayArrowDown.vue' import IconUpload from 'vue-material-design-icons/TrayArrowUp.vue' import ComposerAttachments from './ComposerAttachments.vue' +import GovernanceLabelPicker from './GovernanceLabelPicker.vue' import MailvelopeEditor from './MailvelopeEditor.vue' import RecipientListItem from './RecipientListItem.vue' import TextBlockModal from './textBlocks/TextBlockModal.vue' @@ -567,6 +569,7 @@ export default { ActionRadio, ButtonVue, ComposerAttachments, + GovernanceLabelPicker, TextBlockModal, ChevronLeft, Delete, @@ -717,6 +720,11 @@ export default { default: false, }, + governanceLabelId: { + type: String, + default: null, + }, + accounts: { type: Array, required: true, @@ -755,6 +763,7 @@ export default { editorMode: (this.body?.format !== 'html') ? EDITOR_MODE_TEXT : EDITOR_MODE_HTML, requestMdnVal: this.requestMdn, + governanceLabelIdVal: this.governanceLabelId, changeSignature: false, loadingIndicatorTo: false, loadingIndicatorCc: false, @@ -1070,6 +1079,10 @@ export default { this.$emit('update:request-mdn', val) }, + governanceLabelIdVal(val) { + this.$emit('update:governance-label-id', val) + }, + selectedAlias: { handler() { const aliasEmailAddress = this.selectedAlias.emailAddress @@ -1264,6 +1277,7 @@ export default { inReplyToMessageId: this.inReplyToMessageId ?? (this.replyTo ? this.replyTo.messageId : undefined), isHtml: !this.encrypt && !this.editorPlainText, requestMdn: this.requestMdnVal, + governanceLabelId: this.governanceLabelIdVal ?? undefined, sendAt: this.sendAtVal !== 0 ? Math.floor(this.sendAtVal / 1000) : undefined, smimeSign: this.shouldSmimeSign, smimeEncrypt: this.shouldSmimeEncrypt, @@ -1827,6 +1841,12 @@ export default { } } + &--subject { + display: flex; + flex-direction: row; + align-items: center; + } + .subject { font-size: 15px; font-weight: bold; diff --git a/src/components/GovernanceLabelChip.vue b/src/components/GovernanceLabelChip.vue new file mode 100644 index 0000000000..d970d77839 --- /dev/null +++ b/src/components/GovernanceLabelChip.vue @@ -0,0 +1,62 @@ + + + + + + + diff --git a/src/components/GovernanceLabelPicker.vue b/src/components/GovernanceLabelPicker.vue new file mode 100644 index 0000000000..0314ba01a3 --- /dev/null +++ b/src/components/GovernanceLabelPicker.vue @@ -0,0 +1,171 @@ + + + + + + + diff --git a/src/components/NewMessageModal.vue b/src/components/NewMessageModal.vue index 70bf666100..ca35d0f472 100644 --- a/src/components/NewMessageModal.vue +++ b/src/components/NewMessageModal.vue @@ -92,6 +92,7 @@ :is-first-open="modalFirstOpen" :is-draft="composerData.draftId !== undefined" :request-mdn="composerData.requestMdn" + :governance-label-id="composerData.governanceLabelId" :accounts="accounts" @update:from-account="patchComposerData({ accountId: $event })" @update:from-alias="patchComposerData({ aliasId: $event })" @@ -105,6 +106,7 @@ @update:smime-sign="patchComposerData({ smimeSign: $event })" @update:smime-encrypt="patchComposerData({ smimeSign: $event })" @update:request-mdn="patchComposerData({ requestMdn: $event })" + @update:governance-label-id="patchComposerData({ governanceLabelId: $event })" @draft="onDraft" @discard-draft="discardDraft" @upload-attachment="onAttachmentUploading" diff --git a/src/components/ThreadEnvelope.vue b/src/components/ThreadEnvelope.vue index 92e3415034..7328a1b48c 100644 --- a/src/components/ThreadEnvelope.vue +++ b/src/components/ThreadEnvelope.vue @@ -96,6 +96,9 @@
+
} + */ +export async function fetchGovernanceLabels() { + const url = generateUrl('/apps/mail/api/governance/labels') + + const response = await axios.get(url) + return response.data.data +} + +let labelsPromise = null + +/** + * Fetch governance labels once and cache them for the session. + * Resolves to an empty list when the governance app is not available. + * + * @return {Promise<{id: string, type: string, name: string, priority: number, description: string, color: string, scopes: string[]}[]>} + */ +export function getGovernanceLabels() { + if (!loadState('mail', 'governance-labels-available', false)) { + return Promise.resolve([]) + } + + if (labelsPromise === null) { + labelsPromise = fetchGovernanceLabels() + } + return labelsPromise +} diff --git a/tests/Integration/Service/MailTransmissionIntegrationTest.php b/tests/Integration/Service/MailTransmissionIntegrationTest.php index ca124f63d4..8ccbfee693 100644 --- a/tests/Integration/Service/MailTransmissionIntegrationTest.php +++ b/tests/Integration/Service/MailTransmissionIntegrationTest.php @@ -35,6 +35,7 @@ use OCA\Mail\Send\SentMailboxHandler; use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Attachment\UploadedFile; +use OCA\Mail\Service\GovernanceLabelService; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\TransmissionService; use OCA\Mail\SMTP\SmtpClientFactory; @@ -141,7 +142,8 @@ protected function setUp(): void { Server::get(PerformanceLogger::class), Server::get(AliasesService::class), Server::get(TransmissionService::class), - Server::get(IMailManager::class) + Server::get(IMailManager::class), + Server::get(GovernanceLabelService::class) ); } diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index 9b84226810..42c7b27c72 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -21,6 +21,7 @@ use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\ContextChat\ContextChatSettingsService; +use OCA\Mail\Service\GovernanceLabelService; use OCA\Mail\Service\InternalAddressService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; @@ -116,6 +117,8 @@ class PageControllerTest extends TestCase { private ContextChatSettingsService $contextChatSettingsService; private ClassificationSettingsService|MockObject $classificationSettingsService; + + private GovernanceLabelService|MockObject $governanceLabelService; protected function setUp(): void { parent::setUp(); @@ -147,6 +150,7 @@ protected function setUp(): void { $this->contextChatSettingsService->method('isIndexingEnabled')->willReturn(true); $this->classificationSettingsService = $this->createMock(ClassificationSettingsService::class); + $this->governanceLabelService = $this->createMock(GovernanceLabelService::class); $this->controller = new PageController( $this->appName, $this->request, @@ -172,7 +176,8 @@ protected function setUp(): void { $this->quickActionsService, $this->appManager, $this->contextChatSettingsService, - $this->classificationSettingsService + $this->classificationSettingsService, + $this->governanceLabelService ); } @@ -332,12 +337,16 @@ public function testIndex(): void { $this->classificationSettingsService->expects(($this->once())) ->method(('isClassificationEnabledByDefault')) ->willReturn(true); - $this->initialState->expects($this->exactly(27)) + $this->governanceLabelService->expects($this->once()) + ->method('isGovernanceAvailable') + ->willReturn(false); + $this->initialState->expects($this->exactly(28)) ->method('provideInitialState') ->withConsecutive( ['debug', true], ['ncVersion', '26.0.0'], ['mailVersion', '0.0.1-dev.0'], + ['governance-labels-available', false], ['accounts', $accountsJson], ['account-settings', []], ['tags', []], diff --git a/tests/Unit/IMAP/PreviewEnhancerTest.php b/tests/Unit/IMAP/PreviewEnhancerTest.php index 6a08b3deb7..fe805ae838 100644 --- a/tests/Unit/IMAP/PreviewEnhancerTest.php +++ b/tests/Unit/IMAP/PreviewEnhancerTest.php @@ -21,6 +21,7 @@ use OCA\Mail\Service\Attachment\AttachmentService; use OCA\Mail\Service\Avatar\Avatar; use OCA\Mail\Service\AvatarService; +use OCA\Mail\Service\GovernanceLabelService; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -41,6 +42,8 @@ class PreviewEnhancerTest extends TestCase { /** @var AttachmentService|MockObject */ private $attachmentService; + private $governanceLabelService; + protected function setUp(): void { parent::setUp(); @@ -50,6 +53,7 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->avatarService = $this->createMock(AvatarService::class); $this->attachmentService = $this->createMock(AttachmentService::class); + $this->governanceLabelService = $this->createMock(GovernanceLabelService::class); $this->previewEnhancer = new PreviewEnhancer( $this->imapClientFactory, @@ -57,7 +61,8 @@ protected function setUp(): void { $this->dbMapper, $this->logger, $this->avatarService, - $this->attachmentService + $this->attachmentService, + $this->governanceLabelService ); } diff --git a/tests/Unit/Service/GovernanceLabelServiceTest.php b/tests/Unit/Service/GovernanceLabelServiceTest.php new file mode 100644 index 0000000000..eaf3cff46a --- /dev/null +++ b/tests/Unit/Service/GovernanceLabelServiceTest.php @@ -0,0 +1,121 @@ +appManager = $this->createMock(IAppManager::class); + $this->container = $this->createMock(ContainerInterface::class); + $this->config = $this->createMock(IConfig::class); + $this->service = new GovernanceLabelService( + $this->appManager, + $this->container, + $this->config, + ); + } + + public function testGetLabelsWhenGovernanceIsNotEnabled(): void { + $this->appManager->method('isEnabledForAnyone') + ->with('governance') + ->willReturn(false); + $this->container->expects(self::never()) + ->method('get'); + + $labels = $this->service->getLabels(); + + self::assertSame([], $labels); + } + + public function testGetLabelWhenGovernanceIsNotEnabled(): void { + $this->appManager->method('isEnabledForAnyone') + ->with('governance') + ->willReturn(false); + + $label = $this->service->getLabel('123'); + + self::assertNull($label); + } + + public function testBuildHeaderValue(): void { + $this->config->method('getSystemValueString') + ->with('instanceid') + ->willReturn('abc123'); + + $value = $this->service->buildHeaderValue('42'); + + self::assertSame('id=42; origin=abc123', $value); + } + + public function testResolveHeaderLabelId(): void { + $this->config->method('getSystemValueString') + ->with('instanceid') + ->willReturn('abc123'); + $service = $this->getMockBuilder(GovernanceLabelService::class) + ->setConstructorArgs([$this->appManager, $this->container, $this->config]) + ->onlyMethods(['getLabel']) + ->getMock(); + $service->method('getLabel') + ->with('42') + ->willReturn(['id' => '42']); + + $labelId = $service->resolveHeaderLabelId('id=42; origin=abc123'); + + self::assertSame('42', $labelId); + } + + public function testResolveHeaderLabelIdWithForeignOrigin(): void { + $this->config->method('getSystemValueString') + ->with('instanceid') + ->willReturn('abc123'); + + $labelId = $this->service->resolveHeaderLabelId('id=42; origin=otherinstance'); + + self::assertNull($labelId); + } + + public function testResolveHeaderLabelIdWithUnknownLabel(): void { + $this->config->method('getSystemValueString') + ->with('instanceid') + ->willReturn('abc123'); + $this->appManager->method('isEnabledForAnyone') + ->with('governance') + ->willReturn(false); + + $labelId = $this->service->resolveHeaderLabelId('id=42; origin=abc123'); + + self::assertNull($labelId); + } + + public function testResolveHeaderLabelIdWithGarbage(): void { + $labelId = $this->service->resolveHeaderLabelId('not a valid header'); + + self::assertNull($labelId); + } + + public function testResolveHeaderLabelIdWithNull(): void { + $labelId = $this->service->resolveHeaderLabelId(null); + + self::assertNull($labelId); + } +} diff --git a/tests/Unit/Service/MailTransmissionTest.php b/tests/Unit/Service/MailTransmissionTest.php index fe2925fbf8..ebecd3f997 100644 --- a/tests/Unit/Service/MailTransmissionTest.php +++ b/tests/Unit/Service/MailTransmissionTest.php @@ -30,6 +30,7 @@ use OCA\Mail\Model\Message; use OCA\Mail\Model\NewMessageData; use OCA\Mail\Service\AliasesService; +use OCA\Mail\Service\GovernanceLabelService; use OCA\Mail\Service\MailTransmission; use OCA\Mail\Service\TransmissionService; use OCA\Mail\SMTP\SmtpClientFactory; @@ -51,6 +52,7 @@ class MailTransmissionTest extends TestCase { private MailTransmission $transmission; private AliasesService|MockObject $aliasService; private TransmissionService $transmissionService; + private GovernanceLabelService|MockObject $governanceLabelService; protected function setUp(): void { parent::setUp(); @@ -65,6 +67,7 @@ protected function setUp(): void { $this->aliasService = $this->createMock(AliasesService::class); $this->transmissionService = $this->createMock(TransmissionService::class); $this->mailManager = $this->createMock(IMailManager::class); + $this->governanceLabelService = $this->createMock(GovernanceLabelService::class); $this->transmission = new MailTransmission( $this->imapClientFactory, @@ -77,6 +80,7 @@ protected function setUp(): void { $this->aliasService, $this->transmissionService, $this->mailManager, + $this->governanceLabelService, ); }