Skip to content
Merged
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
1 change: 1 addition & 0 deletions lib/Db/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class Message extends Entity implements JsonSerializable {
'forwarded',
'$junk',
'$notjunk',
'$phishing',
'mdnsent',
Tag::LABEL_IMPORTANT,
'$important' // @todo remove this when we have removed all references on IMAP to $important @link https://github.com/nextcloud/mail/issues/25
Expand Down
2 changes: 1 addition & 1 deletion lib/IMAP/ImapMessageFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ private function parseHeaders(Horde_Imap_Client_Data_Fetch $fetch): void {
$this->hasDkimSignature = $dkimSignatureHeader !== null;

if ($this->runPhishingCheck) {
$this->phishingDetails = $this->phishingDetectionService->checkHeadersForPhishing($parsedHeaders, $this->hasHtmlMessage, $this->htmlMessage);
$this->phishingDetails = $this->phishingDetectionService->checkHeadersForPhishing($parsedHeaders, $fetch->getFlags(), $this->hasHtmlMessage, $this->htmlMessage);
}

$listUnsubscribeHeader = $parsedHeaders->getHeader('list-unsubscribe');
Expand Down
2 changes: 1 addition & 1 deletion lib/Model/IMAPMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,6 @@ public function toDbMessage(int $mailboxId, MailAccount $account): Message {
|| in_array('junk', $flags, true)
);
$msg->setFlagNotjunk(in_array(Horde_Imap_Client::FLAG_NOTJUNK, $flags, true) || in_array('nonjunk', $flags, true));// While this is not a standard IMAP Flag, Thunderbird uses it to mark "not junk"
// @todo remove this as soon as possible @link https://github.com/nextcloud/mail/issues/25
$msg->setFlagImportant(in_array('$important', $flags, true) || in_array('$labelimportant', $flags, true) || in_array(Tag::LABEL_IMPORTANT, $flags, true));
$msg->setFlagAttachments(false);
$msg->setFlagMdnsent(in_array(Horde_Imap_Client::FLAG_MDNSENT, $flags, true));
Expand All @@ -554,6 +553,7 @@ public function toDbMessage(int $mailboxId, MailAccount $account): Message {
Horde_Imap_Client::FLAG_JUNK,
Horde_Imap_Client::FLAG_NOTJUNK,
'nonjunk', // While this is not a standard IMAP Flag, Thunderbird uses it to mark "not junk"
'$phishing', // Horde has no const for this flag yet
Horde_Imap_Client::FLAG_MDNSENT,
Horde_Imap_Client::FLAG_RECENT,
Horde_Imap_Client::FLAG_SEEN,
Expand Down
2 changes: 1 addition & 1 deletion lib/PhishingDetectionList.php
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function addCheck(PhishingDetectionResult $check): void {

private function isWarning(): bool {
foreach ($this->checks as $check) {
if (in_array($check->getType(), [PhishingDetectionResult::DATE_CHECK, PhishingDetectionResult::LINK_CHECK, PhishingDetectionResult::CUSTOM_EMAIL_CHECK, PhishingDetectionResult::CONTACTS_CHECK]) && $check->isPhishing()) {
if (in_array($check->getType(), [PhishingDetectionResult::DATE_CHECK, PhishingDetectionResult::LINK_CHECK, PhishingDetectionResult::CUSTOM_EMAIL_CHECK, PhishingDetectionResult::CONTACTS_CHECK, PhishingDetectionResult::IMAP_FLAG_CHECK]) && $check->isPhishing()) {
return true;
}
}
Expand Down
1 change: 1 addition & 0 deletions lib/PhishingDetectionResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class PhishingDetectionResult implements JsonSerializable {
public const REPLYTO_CHECK = 'Reply-To';
public const CUSTOM_EMAIL_CHECK = 'Custom Email';
public const CONTACTS_CHECK = 'Contacts';
public const IMAP_FLAG_CHECK = 'IMAP Flag';
public const TRUSTED_CHECK = 'Trusted';

private string $message = '';
Expand Down
38 changes: 38 additions & 0 deletions lib/Service/PhishingDetection/ImapFlagCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

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

namespace OCA\Mail\Service\PhishingDetection;

use Horde_Imap_Client;
use OCA\Mail\PhishingDetectionResult;
use OCP\IL10N;

class ImapFlagCheck {
protected IL10N $l10n;

public function __construct(IL10N $l10n) {
$this->l10n = $l10n;
}

/**
* @param string[] $messageFlags
*/
public function run(array $messageFlags): PhishingDetectionResult {
$flaggedAsSpam = in_array(Horde_Imap_Client::FLAG_JUNK, $messageFlags, true) || in_array('junk', $messageFlags, true);
// TODO: Use Horde const once the flag is implemented there
// (https://github.com/bytestream/Imap_Client/blob/master/lib/Horde/Imap/Client.php#L153).
$flaggedAsPhishing = in_array('$phishing', $messageFlags, true);

if ($flaggedAsSpam && $flaggedAsPhishing) {
return new PhishingDetectionResult(PhishingDetectionResult::IMAP_FLAG_CHECK, true, $this->l10n->t('Mail server marked this message as phishing attempt'));
}

return new PhishingDetectionResult(PhishingDetectionResult::IMAP_FLAG_CHECK, false);
}
}
14 changes: 13 additions & 1 deletion lib/Service/PhishingDetection/PhishingDetectionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,19 @@ public function __construct(
private DateCheck $dateCheck,
private ReplyToCheck $replyToCheck,
private LinkCheck $linkCheck,
private ImapFlagCheck $imapFlagCheck,
) {
}

public function checkHeadersForPhishing(Horde_Mime_Headers $headers, bool $hasHtmlMessage, string $htmlMessage = ''): array {
/**
* @param Horde_Mime_Headers $headers
* @param string[] $flags
* @param bool $hasHtmlMessage
* @param string $htmlMessage
* @return array
* @throws \Exception
*/
public function checkHeadersForPhishing(Horde_Mime_Headers $headers, array $flags, bool $hasHtmlMessage, string $htmlMessage = ''): array {
/** @var string|null $fromFN */
$fromFN = null;
/** @var string|null $fromEmail */
Expand Down Expand Up @@ -66,6 +75,9 @@ public function checkHeadersForPhishing(Horde_Mime_Headers $headers, bool $hasHt
if ($date !== null) {
$list->addCheck($this->dateCheck->run($date));
}

$list->addCheck($this->imapFlagCheck->run($flags));

if ($hasHtmlMessage) {
$list->addCheck($this->linkCheck->run($htmlMessage));
}
Expand Down
165 changes: 165 additions & 0 deletions src/tests/unit/components/PhishingWarning.vue.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createLocalVue, shallowMount } from '@vue/test-utils'
import PhishingWarning from '../../../components/PhishingWarning.vue'
import Nextcloud from '../../../mixins/Nextcloud.js'

const localVue = createLocalVue()
localVue.mixin(Nextcloud)

describe('PhishingWarning', () => {
it('Should only show messages from positive checks', async () => {
const view = shallowMount(PhishingWarning, {
propsData: {
phishingData: [
{
isPhishing: false,
message: 'Lorem ipsum',
},
{
isPhishing: true,
message: 'Ipsum lorem',
},
],
},
localVue,
})

expect(view.text()).not.toContain('Lorem ipsum')
expect(view.text()).toContain('Ipsum lorem')
})

it('Should show the messages of multiple positive checks', async () => {
const view = shallowMount(PhishingWarning, {
propsData: {
phishingData: [{
isPhishing: true,
message: 'Lorem ipsum',
},
{
isPhishing: true,
message: 'Ipsum lorem',
}],
},
localVue,
})

expect(view.text()).toContain('Lorem ipsum')
expect(view.text()).toContain('Ipsum lorem')
})

it('Should display the option to expand the list of suspicious links', async () => {
const view = shallowMount(PhishingWarning, {
propsData: {
phishingData: [{
isPhishing: true,
message: 'Lorem ipsum',
type: 'Link',
additionalData: [
{
href: 'http://lorem.ipsum/',
linkText: 'Stet clita kasd gubergren',
},
{
href: 'http://lorem2.ipsum/',
linkText: 'At vero eos et',
},
],
}],
},
localVue,
})

expect(view.text()).toContain('Show suspicious links')
})

it('Should hide the list of suspicious links by default', async () => {
const view = shallowMount(PhishingWarning, {
propsData: {
phishingData: [{
isPhishing: true,
message: 'Lorem ipsum',
type: 'Link',
additionalData: [
{
href: 'http://lorem.ipsum/',
linkText: 'Stet clita kasd gubergren',
},
{
href: 'http://lorem2.ipsum/',
linkText: 'At vero eos et',
},
],
}],
},
localVue,
})

expect(view.text()).not.toContain('href: http://lorem.ipsum/')
expect(view.text()).not.toContain('link text: Stet clita kasd gubergren')
expect(view.text()).not.toContain('href: http://lorem2.ipsum/')
expect(view.text()).not.toContain('At vero eos et')
})

it('Should show a list of suspicious links when requested', async () => {
const view = shallowMount(PhishingWarning, {
propsData: {
phishingData: [{
isPhishing: true,
message: 'Lorem ipsum',
type: 'Link',
additionalData: [
{
href: 'http://lorem.ipsum/',
linkText: 'Stet clita kasd gubergren',
},
{
href: 'http://lorem2.ipsum/',
linkText: 'At vero eos et',
},
],
}],
},
data() {
return { showMore: true }
},
localVue,
})

expect(view.text()).toContain('href: http://lorem.ipsum/')
expect(view.text()).toContain('link text: Stet clita kasd gubergren')
expect(view.text()).toContain('href: http://lorem2.ipsum/')
expect(view.text()).toContain('At vero eos et')
})

it('Should display the option to collapse the list of suspicious links', async () => {
const view = shallowMount(PhishingWarning, {
propsData: {
phishingData: [{
isPhishing: true,
message: 'Lorem ipsum',
type: 'Link',
additionalData: [
{
href: 'http://lorem.ipsum/',
linkText: 'Stet clita kasd gubergren',
},
{
href: 'http://lorem2.ipsum/',
linkText: 'At vero eos et',
},
],
}],
},
data() {
return { showMore: true }
},
localVue,
})

expect(view.text()).toContain('Hide suspicious links')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use OCA\Mail\Service\PhishingDetection\ContactCheck;
use OCA\Mail\Service\PhishingDetection\CustomEmailCheck;
use OCA\Mail\Service\PhishingDetection\DateCheck;
use OCA\Mail\Service\PhishingDetection\ImapFlagCheck;
use OCA\Mail\Service\PhishingDetection\LinkCheck;
use OCA\Mail\Service\PhishingDetection\PhishingDetectionService;
use OCA\Mail\Service\PhishingDetection\ReplyToCheck;
Expand All @@ -24,7 +25,6 @@
use PHPUnit\Framework\MockObject\MockObject;

class PhishingDetectionServiceIntegrationTest extends TestCase {

private ContactsIntegration|MockObject $contactsIntegration;
private IL10N|MockObject $l10n;
private ITimeFactory $timeFactory;
Expand All @@ -33,6 +33,7 @@ class PhishingDetectionServiceIntegrationTest extends TestCase {
private DateCheck $dateCheck;
private ReplyToCheck $replyToCheck;
private LinkCheck $linkCheck;
private ImapFlagCheck $imapFlagCheck;
private PhishingDetectionService $service;

protected function setUp(): void {
Expand All @@ -44,19 +45,17 @@ protected function setUp(): void {
$this->dateCheck = new DateCheck($this->l10n, \OC::$server->get(ITimeFactory::class));
$this->replyToCheck = new ReplyToCheck($this->l10n);
$this->linkCheck = new LinkCheck($this->l10n);
$this->service = new PhishingDetectionService($this->contactCheck, $this->customEmailCheck, $this->dateCheck, $this->replyToCheck, $this->linkCheck);
$this->imapFlagCheck = new ImapFlagCheck($this->l10n);
$this->service = new PhishingDetectionService($this->contactCheck, $this->customEmailCheck, $this->dateCheck, $this->replyToCheck, $this->linkCheck, $this->imapFlagCheck);
}



public function testContactCheck(): void {
$this->contactsIntegration->expects(self::once())
->method('getContactsWithName')
->with('John Doe')
->willReturn([['id' => 1, 'fn' => 'John Doe', 'email' => ['jhon@example.org','Doe@example.org']]]);

$result = $this->contactCheck->run('John Doe', 'jhon.doe@example.org');

$this->assertTrue($result->isPhishing());
}

Expand All @@ -69,12 +68,12 @@ public function testReplyToCheck(): void {
$result = $this->replyToCheck->run('jhon@example.org', 'jhon.doe@example.org');
$this->assertTrue($result->isPhishing());
}

public function testCheckHeadersForPhishing(): void {
$headerStream = fopen(__DIR__ . '/../../../data/phishing-mail-headers.txt', 'r');
$parsedHeaders = Horde_Mime_Headers::parseHeaders($headerStream);
fclose($headerStream);
$result = $this->service->checkHeadersForPhishing($parsedHeaders, false);
$result = $this->service->checkHeadersForPhishing($parsedHeaders, [], false);
$this->assertTrue($result['warning']);
}

}
Loading
Loading