Skip to content

Commit f0dd367

Browse files
authored
Merge pull request #53814 from nextcloud/bug/53811/charset-imip
fix(imip): set charset for imip attachment
2 parents d3dd428 + 4dee178 commit f0dd367

File tree

2 files changed

+206
-4
lines changed

2 files changed

+206
-4
lines changed

apps/dav/lib/CalDAV/Schedule/IMipPlugin.php

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,6 @@ public function schedule(Message $iTipMessage) {
249249
// convert iTip Message to string
250250
$itip_msg = $iTipMessage->message->serialize();
251251

252-
$user = null;
253252
$mailService = null;
254253

255254
try {
@@ -261,8 +260,14 @@ public function schedule(Message $iTipMessage) {
261260
$mailService = $this->mailManager->findServiceByAddress($user->getUID(), $sender);
262261
}
263262
}
263+
264+
// The display name in Nextcloud can use utf-8.
265+
// As the default charset for text/* is us-ascii, it's important to explicitly define it.
266+
// See https://www.rfc-editor.org/rfc/rfc6047.html#section-2.4.
267+
$contentType = 'text/calendar; method=' . $iTipMessage->method . '; charset="utf-8"';
268+
264269
// evaluate if a mail service was found and has sending capabilities
265-
if ($mailService !== null && $mailService instanceof IMessageSend) {
270+
if ($mailService instanceof IMessageSend) {
266271
// construct mail message and set required parameters
267272
$message = $mailService->initiateMessage();
268273
$message->setFrom(
@@ -274,10 +279,12 @@ public function schedule(Message $iTipMessage) {
274279
$message->setSubject($template->renderSubject());
275280
$message->setBodyPlain($template->renderText());
276281
$message->setBodyHtml($template->renderHtml());
282+
// Adding name=event.ics is a trick to make the invitation also appear
283+
// as a file attachment in mail clients like Thunderbird or Evolution.
277284
$message->setAttachments((new Attachment(
278285
$itip_msg,
279286
null,
280-
'text/calendar; name=event.ics; method=' . $iTipMessage->method,
287+
$contentType . '; name=event.ics',
281288
true
282289
)));
283290
// send message
@@ -293,10 +300,12 @@ public function schedule(Message $iTipMessage) {
293300
(($senderName !== null) ? [$sender => $senderName] : [$sender])
294301
);
295302
$message->useTemplate($template);
303+
// Using a different content type because Symfony Mailer/Mime will append the name to
304+
// the content type header and attachInline does not allow null.
296305
$message->attachInline(
297306
$itip_msg,
298307
'event.ics',
299-
'text/calendar; method=' . $iTipMessage->method
308+
$contentType,
300309
);
301310
$failed = $this->mailer->send($message);
302311
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\DAV\Tests\unit\CalDAV\Schedule;
10+
11+
use OC\L10N\L10N;
12+
use OC\URLGenerator;
13+
use OCA\DAV\CalDAV\EventComparisonService;
14+
use OCA\DAV\CalDAV\Schedule\IMipPlugin;
15+
use OCA\DAV\CalDAV\Schedule\IMipService;
16+
use OCP\AppFramework\Utility\ITimeFactory;
17+
use OCP\Defaults;
18+
use OCP\IAppConfig;
19+
use OCP\IConfig;
20+
use OCP\IDBConnection;
21+
use OCP\IURLGenerator;
22+
use OCP\IUser;
23+
use OCP\IUserSession;
24+
use OCP\L10N\IFactory;
25+
use OCP\Mail\IMailer;
26+
use OCP\Mail\IMessage;
27+
use OCP\Mail\Provider\IManager;
28+
use OCP\Mail\Provider\IMessageSend;
29+
use OCP\Mail\Provider\IService;
30+
use OCP\Mail\Provider\Message as MailProviderMessage;
31+
use OCP\Security\ISecureRandom;
32+
use PHPUnit\Framework\MockObject\MockObject;
33+
use Psr\Log\LoggerInterface;
34+
use Psr\Log\NullLogger;
35+
use Sabre\VObject\Component\VCalendar;
36+
use Sabre\VObject\Component\VEvent;
37+
use Sabre\VObject\ITip\Message;
38+
use Sabre\VObject\Property\ICalendar\CalAddress;
39+
use Symfony\Component\Mime\Email;
40+
use Test\TestCase;
41+
42+
class IMipPluginCharsetTest extends TestCase {
43+
// Dependencies
44+
private Defaults&MockObject $defaults;
45+
private IAppConfig&MockObject $appConfig;
46+
private IConfig&MockObject $config;
47+
private IDBConnection&MockObject $db;
48+
private IFactory $l10nFactory;
49+
private IManager&MockObject $mailManager;
50+
private IMailer&MockObject $mailer;
51+
private ISecureRandom&MockObject $random;
52+
private ITimeFactory&MockObject $timeFactory;
53+
private IUrlGenerator&MockObject $urlGenerator;
54+
private IUserSession&MockObject $userSession;
55+
private LoggerInterface $logger;
56+
57+
// Services
58+
private EventComparisonService $eventComparisonService;
59+
private IMipPlugin $imipPlugin;
60+
private IMipService $imipService;
61+
62+
// ITip Message
63+
private Message $itipMessage;
64+
65+
protected function setUp(): void {
66+
// Used by IMipService and IMipPlugin
67+
$today = new \DateTime('2025-06-15 14:30');
68+
$this->timeFactory = $this->createMock(ITimeFactory::class);
69+
$this->timeFactory->method('getTime')
70+
->willReturn($today->getTimestamp());
71+
$this->timeFactory->method('getDateTime')
72+
->willReturn($today);
73+
74+
// IMipService
75+
$this->urlGenerator = $this->createMock(URLGenerator::class);
76+
$this->config = $this->createMock(IConfig::class);
77+
$this->db = $this->createMock(IDBConnection::class);
78+
$this->random = $this->createMock(ISecureRandom::class);
79+
$l10n = $this->createMock(L10N::class);
80+
$this->l10nFactory = $this->createMock(IFactory::class);
81+
$this->l10nFactory->method('findGenericLanguage')
82+
->willReturn('en');
83+
$this->l10nFactory->method('findLocale')
84+
->willReturn('en_US');
85+
$this->l10nFactory->method('get')
86+
->willReturn($l10n);
87+
$this->imipService = new IMipService(
88+
$this->urlGenerator,
89+
$this->config,
90+
$this->db,
91+
$this->random,
92+
$this->l10nFactory,
93+
$this->timeFactory,
94+
);
95+
96+
// EventComparisonService
97+
$this->eventComparisonService = new EventComparisonService();
98+
99+
// IMipPlugin
100+
$this->appConfig = $this->createMock(IAppConfig::class);
101+
$message = new \OC\Mail\Message(new Email(), false);
102+
$this->mailer = $this->createMock(IMailer::class);
103+
$this->mailer->method('createMessage')
104+
->willReturn($message);
105+
$this->mailer->method('validateMailAddress')
106+
->willReturn(true);
107+
$this->logger = new NullLogger();
108+
$this->defaults = $this->createMock(Defaults::class);
109+
$this->defaults->method('getName')
110+
->willReturn('Instance Name 123');
111+
$user = $this->createMock(IUser::class);
112+
$user->method('getUID')
113+
->willReturn('luigi');
114+
$this->userSession = $this->createMock(IUserSession::class);
115+
$this->userSession->method('getUser')
116+
->willReturn($user);
117+
$this->mailManager = $this->createMock(IManager::class);
118+
$this->imipPlugin = new IMipPlugin(
119+
$this->appConfig,
120+
$this->mailer,
121+
$this->logger,
122+
$this->timeFactory,
123+
$this->defaults,
124+
$this->userSession,
125+
$this->imipService,
126+
$this->eventComparisonService,
127+
$this->mailManager,
128+
);
129+
130+
// ITipMessage
131+
$calendar = new VCalendar();
132+
$event = new VEvent($calendar, 'VEVENT');
133+
$event->UID = 'uid-1234';
134+
$event->SEQUENCE = 1;
135+
$event->SUMMARY = 'Lunch';
136+
$event->DTSTART = new \DateTime('2025-06-20 12:30:00');
137+
$organizer = new CalAddress($calendar, 'ORGANIZER', 'mailto:[email protected]');
138+
$event->add($organizer);
139+
$attendee = new CalAddress($calendar, 'ATTENDEE', 'mailto:[email protected]', ['RSVP' => 'TRUE', 'CN' => 'José']);
140+
$event->add($attendee);
141+
$calendar->add($event);
142+
$this->itipMessage = new Message();
143+
$this->itipMessage->method = 'REQUEST';
144+
$this->itipMessage->message = $calendar;
145+
$this->itipMessage->sender = 'mailto:[email protected]';
146+
$this->itipMessage->senderName = 'Luigi';
147+
$this->itipMessage->recipient = 'mailto:' . '[email protected]';
148+
}
149+
150+
public function testCharsetMailer(): void {
151+
// Arrange
152+
$symfonyEmail = null;
153+
$this->mailer->expects(self::once())
154+
->method('send')
155+
->willReturnCallback(function (IMessage $message) use (&$symfonyEmail): array {
156+
if ($message instanceof \OC\Mail\Message) {
157+
$symfonyEmail = $message->getSymfonyEmail();
158+
}
159+
return [];
160+
});
161+
162+
// Act
163+
$this->imipPlugin->schedule($this->itipMessage);
164+
165+
// Assert
166+
$this->assertNotNull($symfonyEmail);
167+
$body = $symfonyEmail->getBody()->toString();
168+
$this->assertStringContainsString('Content-Type: text/calendar; method=REQUEST; charset="utf-8"; name=event.ics', $body);
169+
}
170+
171+
public function testCharsetMailProvider(): void {
172+
// Arrange
173+
$this->appConfig->method('getValueBool')
174+
->with('core', 'mail_providers_enabled', true)
175+
->willReturn(true);
176+
$mailMessage = new MailProviderMessage();
177+
$mailService = $this->createStubForIntersectionOfInterfaces([IService::class, IMessageSend::class]);
178+
$mailService->method('initiateMessage')
179+
->willReturn($mailMessage);
180+
$mailService->expects(self::once())
181+
->method('sendMessage');
182+
$this->mailManager->method('findServiceByAddress')
183+
->willReturn($mailService);
184+
185+
// Act
186+
$this->imipPlugin->schedule($this->itipMessage);
187+
188+
// Assert
189+
$attachments = $mailMessage->getAttachments();
190+
$this->assertCount(1, $attachments);
191+
$this->assertStringContainsString('text/calendar; method=REQUEST; charset="utf-8"; name=event.ics', $attachments[0]->getType());
192+
}
193+
}

0 commit comments

Comments
 (0)