Co-Authored-By: Daniel Kesselberg <mail@danielkesselberg.de>
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
This commit is contained in:
Christoph Wurst 2023-05-02 20:07:38 +02:00
Родитель 5e67cc9c53
Коммит f2b61b383b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: CC42AC2A7F0E56D8
8 изменённых файлов: 452 добавлений и 15 удалений

Просмотреть файл

@ -390,6 +390,11 @@ return [
'url' => '/integration/microsoft-auth',
'verb' => 'GET',
],
[
'name' => 'list#unsubscribe',
'url' => '/api/list/unsubscribe/{id}',
'verb' => 'POST',
],
],
'resources' => [
'accounts' => ['url' => '/api/accounts'],

Просмотреть файл

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
/*
* @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Mail\Controller;
use Exception;
use OCA\Mail\AppInfo\Application;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Http\JsonResponse;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Service\AccountService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\Http\Client\IClientService;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class ListController extends Controller {
private IMailManager $mailManager;
private AccountService $accountService;
private IMAPClientFactory $clientFactory;
private IClientService $httpClientService;
private LoggerInterface $logger;
private ?string $currentUserId;
public function __construct(IRequest $request,
IMailManager $mailManager,
AccountService $accountService,
IMAPClientFactory $clientFactory,
IClientService $httpClientService,
LoggerInterface $logger,
?string $userId) {
parent::__construct(Application::APP_ID, $request);
$this->mailManager = $mailManager;
$this->accountService = $accountService;
$this->clientFactory = $clientFactory;
$this->request = $request;
$this->httpClientService = $httpClientService;
$this->logger = $logger;
$this->currentUserId = $userId;
}
/**
* @param int $messageId
* @NoAdminRequired
* @UserRateThrottle(limit=10, period=3600)
* @return JsonResponse
*/
public function unsubscribe(int $id): JsonResponse {
try {
$message = $this->mailManager->getMessage($this->currentUserId, $id);
$mailbox = $this->mailManager->getMailbox($this->currentUserId, $message->getMailboxId());
$account = $this->accountService->find($this->currentUserId, $mailbox->getAccountId());
} catch (DoesNotExistException $e) {
return JsonResponse::fail(null, Http::STATUS_NOT_FOUND);
}
$client = $this->clientFactory->getClient($account);
try {
$imapMessage = $this->mailManager->getImapMessage(
$client,
$account,
$mailbox,
$message->getUid(),
true
);
$unsubscribeUrl = $imapMessage->getUnsubscribeUrl();
if ($unsubscribeUrl === null || !$imapMessage->isOneClickUnsubscribe()) {
return JsonResponse::fail(null, Http::STATUS_FORBIDDEN);
}
$httpClient = $this->httpClientService->newClient();
$httpClient->post($unsubscribeUrl, [
'body' => [
'List-Unsubscribe' => 'One-Click'
]
]);
} catch (Exception $e) {
$this->logger->error('Could not unsubscribe mailing list', [
'exception' => $e,
]);
return JsonResponse::error('Unknown error');
} finally {
$client->logout();
}
return JsonResponse::success();
}
}

Просмотреть файл

@ -45,6 +45,7 @@ use OCA\Mail\Service\Html;
use OCA\Mail\Service\SmimeService;
use OCP\AppFramework\Db\DoesNotExistException;
use function str_starts_with;
use function strtolower;
class ImapMessageFetcher {
/** @var string[] */
@ -70,6 +71,7 @@ class ImapMessageFetcher {
private string $rawReferences = '';
private string $dispositionNotificationTo = '';
private ?string $unsubscribeUrl = null;
private bool $isOneClickUnsubscribe = false;
private ?string $unsubscribeMailto = null;
public function __construct(int $uid,
@ -251,6 +253,7 @@ class ImapMessageFetcher {
$this->rawReferences,
$this->dispositionNotificationTo,
$this->unsubscribeUrl,
$this->isOneClickUnsubscribe,
$this->unsubscribeMailto,
$envelope->in_reply_to,
$isEncrypted,
@ -512,19 +515,29 @@ class ImapMessageFetcher {
$this->dispositionNotificationTo = $dispositionNotificationTo->value_single;
}
$listUnsubscribeHeader = $parsedHeaders->getHeader('list-unsubscribe');
if ($listUnsubscribeHeader !== null) {
$listHeaders = new Horde_ListHeaders();
/** @var Horde_ListHeaders_Base[] $headers */
$headers = $listHeaders->parse($listUnsubscribeHeader->name, $listUnsubscribeHeader->value_single);
foreach ($headers as $header) {
if (str_starts_with($header->url, 'http')) {
$this->unsubscribeUrl = $header->url;
break;
}
if (str_starts_with($header->url, 'mailto')) {
$this->unsubscribeMailto = $header->url;
break;
$dkimSignatureHeader = $parsedHeaders->getHeader('dkim-signature');
$hasDkimSignature = $dkimSignatureHeader !== null;
if ($hasDkimSignature) {
$listUnsubscribeHeader = $parsedHeaders->getHeader('list-unsubscribe');
if ($listUnsubscribeHeader !== null) {
$listHeaders = new Horde_ListHeaders();
/** @var Horde_ListHeaders_Base[] $headers */
$headers = $listHeaders->parse($listUnsubscribeHeader->name, $listUnsubscribeHeader->value_single);
foreach ($headers as $header) {
if (str_starts_with($header->url, 'http')) {
$this->unsubscribeUrl = $header->url;
$unsubscribePostHeader = $parsedHeaders->getHeader('List-Unsubscribe-Post');
if ($unsubscribePostHeader !== null) {
$this->isOneClickUnsubscribe = strtolower($unsubscribePostHeader->value_single) === 'list-unsubscribe=one-click';
}
break;
}
if (str_starts_with($header->url, 'mailto')) {
$this->unsubscribeMailto = $header->url;
break;
}
}
}
}

Просмотреть файл

@ -77,6 +77,7 @@ class IMAPMessage implements IMessage, JsonSerializable {
private string $rawReferences;
private string $dispositionNotificationTo;
private ?string $unsubscribeUrl;
private bool $isOneClickUnsubscribe;
private ?string $unsubscribeMailto;
private string $rawInReplyTo;
private bool $isEncrypted;
@ -103,6 +104,7 @@ class IMAPMessage implements IMessage, JsonSerializable {
string $rawReferences,
string $dispositionNotificationTo,
?string $unsubscribeUrl,
bool $isOneClickUnsubscribe,
?string $unsubscribeMailto,
string $rawInReplyTo,
bool $isEncrypted,
@ -129,6 +131,7 @@ class IMAPMessage implements IMessage, JsonSerializable {
$this->rawReferences = $rawReferences;
$this->dispositionNotificationTo = $dispositionNotificationTo;
$this->unsubscribeUrl = $unsubscribeUrl;
$this->isOneClickUnsubscribe = $isOneClickUnsubscribe;
$this->unsubscribeMailto = $unsubscribeMailto;
$this->rawInReplyTo = $rawInReplyTo;
$this->isEncrypted = $isEncrypted;
@ -312,6 +315,7 @@ class IMAPMessage implements IMessage, JsonSerializable {
'hasHtmlBody' => $this->hasHtmlMessage,
'dispositionNotificationTo' => $this->getDispositionNotificationTo(),
'unsubscribeUrl' => $this->unsubscribeUrl,
'isOneClickUnsubscribe' => $this->isOneClickUnsubscribe,
'unsubscribeMailto' => $this->unsubscribeMailto,
'scheduling' => $this->scheduling,
];
@ -443,6 +447,14 @@ class IMAPMessage implements IMessage, JsonSerializable {
return $this->signatureIsValid;
}
public function getUnsubscribeUrl(): ?string {
return $this->unsubscribeUrl;
}
public function isOneClickUnsubscribe(): bool {
return $this->isOneClickUnsubscribe;
}
/**
* Cast all values from an IMAP message into the correct DB format
*

Просмотреть файл

@ -199,7 +199,14 @@
:data="error"
:auto-margin="true"
role="alert" />
<ConfirmModal v-if="message && message.unsubscribeUrl && showListUnsubscribeConfirmation"
<ConfirmModal v-if="message && message.unsubscribeUrl && message.isOneClickUnsubscribe && showListUnsubscribeConfirmation"
:confirm-text="t('mail', 'Unsubscribe')"
:title="t('mail', 'Unsubscribe via link')"
@cancel="showListUnsubscribeConfirmation = false"
@confirm="unsubscribeViaOneClick">
{{ t('mail', 'Unsubscribing will stop all messages from the mailing list {sender}', { sender: from }) }}
</ConfirmModal>
<ConfirmModal v-else-if="message && message.unsubscribeUrl && showListUnsubscribeConfirmation"
:confirm-text="t('mail', 'Unsubscribe')"
:confirm-url="message.unsubscribeUrl"
:title="t('mail', 'Unsubscribe via link')"
@ -249,6 +256,7 @@ import NoTrashMailboxConfiguredError from '../errors/NoTrashMailboxConfiguredErr
import { isPgpText } from '../crypto/pgp'
import NcActions from '@nextcloud/vue/dist/Components/NcActions'
import NcActionText from '@nextcloud/vue/dist/Components/NcActionText'
import { unsubscribe } from '../service/ListService'
// Ternary loading state
const LOADING_DONE = 0
@ -612,6 +620,20 @@ export default {
return t('mail', 'Could not archive message')
}
},
async unsubscribeViaOneClick() {
try {
this.unsubscribing = true
await unsubscribe(this.envelope.databaseId)
showSuccess(t('mail', 'Unsubscribe request sent'))
} catch (error) {
logger.error('Could not one-click unsubscribe', { error })
showError(t('mail', 'Could not unsubscribe from mailing list'))
} finally {
this.unsubscribing = false
this.showListUnsubscribeConfirmation = false
}
},
async unsubscribeViaMailto() {
const mailto = this.message.unsubscribeMailto
const [email, paramString] = mailto.replace(/^mailto:/, '').split('?')

Просмотреть файл

@ -0,0 +1,31 @@
/**
* @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export async function unsubscribe(id) {
const url = generateUrl('/apps/mail/api/list/unsubscribe/{id}', {
id,
})
axios.post(url)
}

Просмотреть файл

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
/*
* @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Mail\Tests\Unit\Controller;
use ChristophWurst\Nextcloud\Testing\ServiceMockObject;
use ChristophWurst\Nextcloud\Testing\TestCase;
use Horde_Imap_Client_DateTime;
use OCA\Mail\Account;
use OCA\Mail\AddressList;
use OCA\Mail\Controller\ListController;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\Message;
use OCA\Mail\Model\IMAPMessage;
use OCA\Mail\Service\Html;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\Http\Client\IClient;
class ListControllerTest extends TestCase {
private ServiceMockObject $serviceMock;
private ListController $controller;
protected function setUp(): void {
parent::setUp();
$this->serviceMock = $this->createServiceMock(ListController::class, [
'userId' => 'user123',
]);
$this->controller = $this->serviceMock->getService();
}
public function testMessageNotFound(): void {
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMessage')
->willThrowException(new DoesNotExistException(''));
$response = $this->controller->unsubscribe(123);
self::assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
}
public function testMailboxNotFound(): void {
$message = new Message();
$message->setMailboxId(321);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMessage')
->willReturn($message);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMailbox')
->with('user123', 321)
->willThrowException(new DoesNotExistException(''));
$response = $this->controller->unsubscribe(123);
self::assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
}
public function testAccountNotFound(): void {
$message = new Message();
$message->setMailboxId(321);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMessage')
->willReturn($message);
$mailbox = new Mailbox();
$mailbox->setAccountId(567);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMailbox')
->with('user123', 321)
->willReturn($mailbox);
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('find')
->with('user123', 567)
->willThrowException(new DoesNotExistException(''));
$response = $this->controller->unsubscribe(123);
self::assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
}
public function testUnsupportedMessage(): void {
$message = new Message();
$message->setUid(987);
$message->setMailboxId(321);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMessage')
->willReturn($message);
$mailbox = new Mailbox();
$mailbox->setAccountId(567);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMailbox')
->with('user123', 321)
->willReturn($mailbox);
$mailAccount = new MailAccount([]);
$account = new Account($mailAccount);
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('find')
->with('user123', 567)
->willReturn($account);
$imapMessage = new IMAPMessage(
123,
'',
[],
new AddressList([]),
new AddressList([]),
new AddressList([]),
new AddressList([]),
new AddressList([]),
'',
'',
'',
false,
[],
[],
false,
[],
new Horde_Imap_Client_DateTime(),
'',
'',
null,
false,
'',
'',
false,
false,
false,
$this->createMock(Html::class),
);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getImapMessage')
->willReturn($imapMessage);
$response = $this->controller->unsubscribe(123);
self::assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
}
public function testUnsubscribe(): void {
$message = new Message();
$message->setUid(987);
$message->setMailboxId(321);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMessage')
->willReturn($message);
$mailbox = new Mailbox();
$mailbox->setAccountId(567);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getMailbox')
->with('user123', 321)
->willReturn($mailbox);
$mailAccount = new MailAccount([]);
$account = new Account($mailAccount);
$this->serviceMock->getParameter('accountService')
->expects(self::once())
->method('find')
->with('user123', 567)
->willReturn($account);
$imapMessage = new IMAPMessage(
123,
'',
[],
new AddressList([]),
new AddressList([]),
new AddressList([]),
new AddressList([]),
new AddressList([]),
'',
'',
'',
false,
[],
[],
false,
[],
new Horde_Imap_Client_DateTime(),
'',
'',
'https://un.sub.scribe/me',
true,
'',
'',
false,
false,
false,
$this->createMock(Html::class),
);
$this->serviceMock->getParameter('mailManager')
->expects(self::once())
->method('getImapMessage')
->willReturn($imapMessage);
$httpClient = $this->createMock(IClient::class);
$this->serviceMock->getParameter('httpClientService')
->expects(self::once())
->method('newClient')
->willReturn($httpClient);
$httpClient->expects(self::once())
->method('post')
->with('https://un.sub.scribe/me');
$response = $this->controller->unsubscribe(123);
self::assertEquals(Http::STATUS_OK, $response->getStatus());
}
}

Просмотреть файл

@ -87,7 +87,8 @@ class IMAPMessageTest extends TestCase {
'',
'disposition',
null,
null,
false,
'',
'',
false,
false,
@ -126,6 +127,7 @@ class IMAPMessageTest extends TestCase {
'',
'disposition',
null,
false,
null,
'',
false,
@ -157,6 +159,7 @@ class IMAPMessageTest extends TestCase {
'important' => true,
],
'unsubscribeUrl' => null,
'isOneClickUnsubscribe' => false,
'unsubscribeMailto' => null,
'hasHtmlBody' => true,
'dispositionNotificationTo' => 'disposition',