Skip to content

Commit f3060cd

Browse files
Merge pull request #12632 from SoleroTG/feature/message-id-deeplink
feat: Add route to open messages via Message-ID
2 parents d16d823 + 54ce486 commit f3060cd

3 files changed

Lines changed: 284 additions & 0 deletions

File tree

appinfo/routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
*/
1111
return [
1212
'routes' => [
13+
[
14+
'name' => 'deep_link#open',
15+
'url' => '/open/{messageId}',
16+
'verb' => 'GET',
17+
],
1318
[
1419
'name' => 'page#index',
1520
'url' => '/',
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Mail\Controller;
11+
12+
use Horde_Mail_Rfc822_Identification;
13+
use OCA\Mail\Db\MailAccountMapper;
14+
use OCA\Mail\Db\MessageMapper;
15+
use OCA\Mail\Service\AccountService;
16+
use OCP\AppFramework\Controller;
17+
use OCP\AppFramework\Http\RedirectResponse;
18+
use OCP\IRequest;
19+
use OCP\IURLGenerator;
20+
use OCP\IUserSession;
21+
use Psr\Log\LoggerInterface;
22+
23+
class DeepLinkController extends Controller {
24+
public function __construct(
25+
string $appName,
26+
IRequest $request,
27+
private MailAccountMapper $mailAccountMapper,
28+
private AccountService $accountService,
29+
private MessageMapper $messageMapper,
30+
private IURLGenerator $urlGenerator,
31+
private IUserSession $userSession,
32+
private LoggerInterface $logger,
33+
) {
34+
parent::__construct($appName, $request);
35+
}
36+
37+
/**
38+
* @NoAdminRequired
39+
*
40+
* @param string $messageId
41+
* @return RedirectResponse
42+
*/
43+
public function open(string $messageId): RedirectResponse {
44+
$user = $this->userSession->getUser();
45+
if ($user === null) {
46+
return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('core.page.login'));
47+
}
48+
49+
$userId = $user->getUID();
50+
51+
try {
52+
$id = '<' . trim(trim($messageId), '<>') . '>';
53+
$parsed = new Horde_Mail_Rfc822_Identification($id);
54+
$cleanedId = $parsed->ids[0] ?? null;
55+
56+
if ($cleanedId === null) {
57+
return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('mail.page.index', []));
58+
}
59+
60+
$lightAccounts = $this->mailAccountMapper->findByUserId($userId);
61+
62+
foreach ($lightAccounts as $lightAccount) {
63+
$accountId = $lightAccount->getId();
64+
$account = $this->accountService->find($userId, $accountId);
65+
$messages = $this->messageMapper->findByMessageId($account, $cleanedId);
66+
67+
if (!empty($messages)) {
68+
$message = $messages[0];
69+
$targetId = $message->getId();
70+
71+
// IMPORTANT FIX: Use 'mail.page.thread' instead of 'mail.page#thread'
72+
$url = $this->urlGenerator->linkToRouteAbsolute(
73+
'mail.page.thread',
74+
['mailboxId' => $message->getMailboxId(), 'id' => $targetId]
75+
);
76+
77+
return new RedirectResponse($url);
78+
}
79+
}
80+
} catch (\Exception $e) {
81+
$this->logger->error('DeepLinkController: An unexpected error occurred.', [
82+
'exception' => $e,
83+
'messageId' => $messageId,
84+
]);
85+
}
86+
87+
// Fallback
88+
return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('mail.page.index', []));
89+
}
90+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Mail\Tests\Unit\Controller;
11+
12+
use ChristophWurst\Nextcloud\Testing\TestCase;
13+
use OCA\Mail\Account;
14+
use OCA\Mail\Controller\DeepLinkController;
15+
use OCA\Mail\Db\MailAccount;
16+
use OCA\Mail\Db\MailAccountMapper;
17+
use OCA\Mail\Db\Message;
18+
use OCA\Mail\Db\MessageMapper;
19+
use OCA\Mail\Service\AccountService;
20+
use OCP\AppFramework\Http\RedirectResponse;
21+
use OCP\IRequest;
22+
use OCP\IURLGenerator;
23+
use OCP\IUser;
24+
use OCP\IUserSession;
25+
use Psr\Log\LoggerInterface;
26+
27+
class DeepLinkControllerTest extends TestCase {
28+
private string $appName;
29+
private IRequest $request;
30+
private MailAccountMapper $mailAccountMapper;
31+
private AccountService $accountService;
32+
private MessageMapper $messageMapper;
33+
private IURLGenerator $urlGenerator;
34+
private IUserSession $userSession;
35+
private LoggerInterface $logger;
36+
private DeepLinkController $controller;
37+
38+
protected function setUp(): void {
39+
parent::setUp();
40+
41+
$this->appName = 'mail';
42+
$this->request = $this->createMock(IRequest::class);
43+
$this->mailAccountMapper = $this->createMock(MailAccountMapper::class);
44+
$this->accountService = $this->createMock(AccountService::class);
45+
$this->messageMapper = $this->createMock(MessageMapper::class);
46+
$this->urlGenerator = $this->createMock(IURLGenerator::class);
47+
$this->userSession = $this->createMock(IUserSession::class);
48+
$this->logger = $this->createMock(LoggerInterface::class);
49+
50+
$this->controller = new DeepLinkController(
51+
$this->appName,
52+
$this->request,
53+
$this->mailAccountMapper,
54+
$this->accountService,
55+
$this->messageMapper,
56+
$this->urlGenerator,
57+
$this->userSession,
58+
$this->logger
59+
);
60+
}
61+
62+
public function testOpenNotLoggedIn(): void {
63+
$this->userSession->expects(self::once())
64+
->method('getUser')
65+
->willReturn(null);
66+
67+
$this->urlGenerator->expects(self::once())
68+
->method('linkToRouteAbsolute')
69+
->with('core.page.login')
70+
->willReturn('http://localhost/login');
71+
72+
$response = $this->controller->open('test-message-id');
73+
74+
self::assertInstanceOf(RedirectResponse::class, $response);
75+
self::assertSame('http://localhost/login', $response->getRedirectURL());
76+
}
77+
78+
public function testOpenMessageFound(): void {
79+
$user = $this->createMock(IUser::class);
80+
$user->method('getUID')->willReturn('user123');
81+
82+
$this->userSession->expects(self::once())
83+
->method('getUser')
84+
->willReturn($user);
85+
86+
$lightAccount = new MailAccount();
87+
$lightAccount->setId(1);
88+
89+
$this->mailAccountMapper->expects(self::once())
90+
->method('findByUserId')
91+
->with('user123')
92+
->willReturn([$lightAccount]);
93+
94+
$account = $this->createMock(Account::class);
95+
$this->accountService->expects(self::once())
96+
->method('find')
97+
->with('user123', 1)
98+
->willReturn($account);
99+
100+
$message = new Message();
101+
$message->setId(42);
102+
$message->setMailboxId(5);
103+
104+
$this->messageMapper->expects(self::once())
105+
->method('findByMessageId')
106+
->with($account, '<test-message-id>')
107+
->willReturn([$message]);
108+
109+
$this->urlGenerator->expects(self::once())
110+
->method('linkToRouteAbsolute')
111+
->with('mail.page.thread', ['mailboxId' => 5, 'id' => 42])
112+
->willReturn('http://localhost/apps/mail/box/5/thread/42');
113+
114+
$response = $this->controller->open('test-message-id');
115+
116+
self::assertInstanceOf(RedirectResponse::class, $response);
117+
self::assertSame('http://localhost/apps/mail/box/5/thread/42', $response->getRedirectURL());
118+
}
119+
120+
public function testOpenMessageNotFound(): void {
121+
$user = $this->createMock(IUser::class);
122+
$user->method('getUID')->willReturn('user123');
123+
124+
$this->userSession->expects(self::once())
125+
->method('getUser')
126+
->willReturn($user);
127+
128+
$lightAccount = new MailAccount();
129+
$lightAccount->setId(1);
130+
131+
$this->mailAccountMapper->expects(self::once())
132+
->method('findByUserId')
133+
->with('user123')
134+
->willReturn([$lightAccount]);
135+
136+
$account = $this->createMock(Account::class);
137+
$this->accountService->expects(self::once())
138+
->method('find')
139+
->with('user123', 1)
140+
->willReturn($account);
141+
142+
$this->messageMapper->expects(self::once())
143+
->method('findByMessageId')
144+
->with($account, '<test-message-id>')
145+
->willReturn([]);
146+
147+
$this->urlGenerator->expects(self::once())
148+
->method('linkToRouteAbsolute')
149+
->with('mail.page.index', [])
150+
->willReturn('http://localhost/apps/mail/');
151+
152+
$response = $this->controller->open('test-message-id');
153+
154+
self::assertInstanceOf(RedirectResponse::class, $response);
155+
self::assertSame('http://localhost/apps/mail/', $response->getRedirectURL());
156+
}
157+
158+
public function testOpenException(): void {
159+
$user = $this->createMock(IUser::class);
160+
$user->method('getUID')->willReturn('user123');
161+
162+
$this->userSession->expects(self::once())
163+
->method('getUser')
164+
->willReturn($user);
165+
166+
$exception = new \RuntimeException('Database error');
167+
$this->mailAccountMapper->expects(self::once())
168+
->method('findByUserId')
169+
->with('user123')
170+
->willThrowException($exception);
171+
172+
$this->logger->expects(self::once())
173+
->method('error')
174+
->with('DeepLinkController: An unexpected error occurred.', [
175+
'exception' => $exception,
176+
'messageId' => 'test-message-id',
177+
]);
178+
179+
$this->urlGenerator->expects(self::once())
180+
->method('linkToRouteAbsolute')
181+
->with('mail.page.index', [])
182+
->willReturn('http://localhost/apps/mail/');
183+
184+
$response = $this->controller->open('test-message-id');
185+
186+
self::assertInstanceOf(RedirectResponse::class, $response);
187+
self::assertSame('http://localhost/apps/mail/', $response->getRedirectURL());
188+
}
189+
}

0 commit comments

Comments
 (0)