]> source.dussan.org Git - nextcloud-server.git/commitdiff
iMIP email improvements (take 2)
authorbrad2014 <brad2014@users.noreply.github.com>
Tue, 8 Oct 2019 07:23:26 +0000 (00:23 -0700)
committerbrad2014 <brad2014@users.noreply.github.com>
Thu, 20 Aug 2020 20:16:47 +0000 (22:16 +0200)
This PR is a replacement for PR #17195. It is intended to be simpler
to review and approve, with fewer changes, some disabled by default.

It addresses issues #12391 and #13555, with the following changes:

- The plainText of iMIP emails has been upgraded as described in
issue #12391. The HTML design style has not been changed.

- Some of the HTML and plainText content has been rearranged
(simplified header language, moving the event title to from text
body to the first item in the bullet list, spelling corrections,
moving the description to the end of the list), per issue #12391.

- The interface for EMailTemplate has been extended: addBodyListItem
now takes an optional `plainIndent` parameter. Existing callers
see no change. Where new calls set the  new parameter >0, the list
item label (metaInfo) is put in column 1, and the value is indented
into column 2 (properly accounting for multiple lines, if any).

- An optional dav config setting has been added,
`invitation_list_attendees`. It defaults to 'no', leaving emails
unchanged. If set by the site admin to 'yes', then iMIP emails
include, for the organizer and each attendee, their name, email,
and a ✔︎ if they have accepted the invitation.

- Minor refactoring.

Notes:

- The labels for organizers and attendees list items are new, and
require translation/localization.

- Dav config settings are documented in the code, but not in the
Administrator's Guide.

Signed-off-by: brad2014 <brad2014@users.noreply.github.com>
apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
lib/private/Mail/EMailTemplate.php

index 6358a3a0293976843c31a5a4759f1347aa968587..011314b41d6b1b85e8e8b3d52b4140857c685021 100644 (file)
@@ -108,6 +108,7 @@ class IMipPlugin extends SabreIMipPlugin {
        public const METHOD_REQUEST = 'request';
        public const METHOD_REPLY = 'reply';
        public const METHOD_CANCEL = 'cancel';
+       public const IMIP_INDENT = 15; // Enough for the length of all body bullet items, in all languages
 
        /**
         * @param IConfig $config
@@ -204,26 +205,6 @@ class IMipPlugin extends SabreIMipPlugin {
                $meetingTitle = $vevent->SUMMARY;
                $meetingDescription = $vevent->DESCRIPTION;
 
-               $start = $vevent->DTSTART;
-               if (isset($vevent->DTEND)) {
-                       $end = $vevent->DTEND;
-               } elseif (isset($vevent->DURATION)) {
-                       $isFloating = $vevent->DTSTART->isFloating();
-                       $end = clone $vevent->DTSTART;
-                       $endDateTime = $end->getDateTime();
-                       $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue()));
-                       $end->setDateTime($endDateTime, $isFloating);
-               } elseif (!$vevent->DTSTART->hasTime()) {
-                       $isFloating = $vevent->DTSTART->isFloating();
-                       $end = clone $vevent->DTSTART;
-                       $endDateTime = $end->getDateTime();
-                       $endDateTime = $endDateTime->modify('+1 day');
-                       $end->setDateTime($endDateTime, $isFloating);
-               } else {
-                       $end = clone $vevent->DTSTART;
-               }
-
-               $meetingWhen = $this->generateWhenString($l10n, $start, $end);
 
                $meetingUrl = $vevent->URL;
                $meetingLocation = $vevent->LOCATION;
@@ -263,8 +244,7 @@ class IMipPlugin extends SabreIMipPlugin {
 
                $this->addSubjectAndHeading($template, $l10n, $method, $summary,
                        $meetingAttendeeName, $meetingInviteeName);
-               $this->addBulletList($template, $l10n, $meetingWhen, $meetingLocation,
-                       $meetingDescription, $meetingUrl);
+               $this->addBulletList($template, $l10n, $vevent);
 
 
                // Only add response buttons to invitation requests: Fix Issue #11230
@@ -370,7 +350,6 @@ class IMipPlugin extends SabreIMipPlugin {
                return $lastOccurrence;
        }
 
-
        /**
         * @param Message $iTipMessage
         * @return null|Property
@@ -420,10 +399,29 @@ class IMipPlugin extends SabreIMipPlugin {
 
        /**
         * @param IL10N $l10n
-        * @param Property $dtstart
-        * @param Property $dtend
+        * @param VEvent $vevent
         */
-       private function generateWhenString(IL10N $l10n, Property $dtstart, Property $dtend) {
+       private function generateWhenString(IL10N $l10n, VEvent $vevent) {
+
+               $dtstart = $vevent->DTSTART;
+               if (isset($vevent->DTEND)) {
+                       $dtend = $vevent->DTEND;
+               } elseif (isset($vevent->DURATION)) {
+                       $isFloating = $vevent->DTSTART->isFloating();
+                       $dtend = clone $vevent->DTSTART;
+                       $endDateTime = $end->getDateTime();
+                       $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue()));
+                       $dtend->setDateTime($endDateTime, $isFloating);
+               } elseif (!$vevent->DTSTART->hasTime()) {
+                       $isFloating = $vevent->DTSTART->isFloating();
+                       $dtend = clone $vevent->DTSTART;
+                       $endDateTime = $end->getDateTime();
+                       $endDateTime = $endDateTime->modify('+1 day');
+                       $dtend->setDateTime($endDateTime, $isFloating);
+               } else {
+                       $dtend = clone $vevent->DTSTART;
+               }
+
                $isAllDay = $dtstart instanceof Property\ICalendar\Date;
 
                /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */
@@ -507,49 +505,127 @@ class IMipPlugin extends SabreIMipPlugin {
         * @param IL10N $l10n
         * @param string $method
         * @param string $summary
-        * @param string $attendeeName
-        * @param string $inviteeName
         */
        private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n,
-                                                                                 $method, $summary, $attendeeName, $inviteeName) {
+                                                                                 $method, $summary) {
                if ($method === self::METHOD_CANCEL) {
-                       $template->setSubject('Cancelled: ' . $summary);
-                       $template->addHeading($l10n->t('Invitation canceled'), $l10n->t('Hello %s,', [$attendeeName]));
-                       $template->addBodyText($l10n->t('The meeting »%1$s« with %2$s was canceled.', [$summary, $inviteeName]));
+                       $template->setSubject('Canceled: ' . $summary);
+                       $template->addHeading($l10n->t('Invitation canceled'));
                } elseif ($method === self::METHOD_REPLY) {
                        $template->setSubject('Re: ' . $summary);
-                       $template->addHeading($l10n->t('Invitation updated'), $l10n->t('Hello %s,', [$attendeeName]));
-                       $template->addBodyText($l10n->t('The meeting »%1$s« with %2$s was updated.', [$summary, $inviteeName]));
+                       $template->addHeading($l10n->t('Invitation updated'));
                } else {
                        $template->setSubject('Invitation: ' . $summary);
-                       $template->addHeading($l10n->t('%1$s invited you to »%2$s«', [$inviteeName, $summary]), $l10n->t('Hello %s,', [$attendeeName]));
+                       $template->addHeading($l10n->t('Invitation'));
                }
        }
 
        /**
         * @param IEMailTemplate $template
         * @param IL10N $l10n
-        * @param string $time
-        * @param string $location
-        * @param string $description
-        * @param string $url
+        * @param VEVENT $vevent
         */
-       private function addBulletList(IEMailTemplate $template, IL10N $l10n, $time, $location, $description, $url) {
-               $template->addBodyListItem($time, $l10n->t('When:'),
-                       $this->getAbsoluteImagePath('filetypes/text-calendar.svg'));
+       private function addBulletList(IEMailTemplate $template, IL10N $l10n, $vevent) {
 
-               if ($location) {
-                       $template->addBodyListItem($location, $l10n->t('Where:'),
-                               $this->getAbsoluteImagePath('filetypes/location.svg'));
+               if ($vevent->SUMMARY) {
+                       $template->addBodyListItem($vevent->SUMMARY, $l10n->t('Title:'),
+                               $this->getAbsoluteImagePath('filetypes/text.svg'),'','',self::IMIP_INDENT);
                }
-               if ($description) {
-                       $template->addBodyListItem((string)$description, $l10n->t('Description:'),
-                               $this->getAbsoluteImagePath('filetypes/text.svg'));
+               $meetingWhen = $this->generateWhenString($l10n, $vevent);
+               if ($meetingWhen) {
+                       $template->addBodyListItem($meetingWhen, $l10n->t('Time:'),
+                               $this->getAbsoluteImagePath('filetypes/text-calendar.svg'),'','',self::IMIP_INDENT);
                }
-               if ($url) {
-                       $template->addBodyListItem((string)$url, $l10n->t('Link:'),
-                               $this->getAbsoluteImagePath('filetypes/link.svg'));
+               if ($vevent->LOCATION) {
+                       $template->addBodyListItem($vevent->LOCATION, $l10n->t('Location:'),
+                               $this->getAbsoluteImagePath('filetypes/location.svg'),'','',self::IMIP_INDENT);
                }
+               if ($vevent->URL) {
+                       $template->addBodyListItem(sprintf('<a href="%s">%s</a>',
+                                       htmlspecialchars($vevent->URL),
+                                       htmlspecialchars($vevent->URL)),
+                               $l10n->t('Link:'),
+                               $this->getAbsoluteImagePath('filetypes/link.svg'),
+                               $vevent->URL,'',self::IMIP_INDENT);
+               }
+
+               $this->addAttendees($template, $l10n, $vevent);
+
+               /* Put description last, like an email body, since it can be arbitrarily long */
+               if ($vevent->DESCRIPTION) {
+                       $template->addBodyListItem($vevent->DESCRIPTION, $l10n->t('Description:'),
+                               $this->getAbsoluteImagePath('filetypes/text.svg'),'','',self::IMIP_INDENT);
+               }
+       }
+
+       /**
+        * addAttendees: add organizer and attendee names/emails to iMip mail.
+        *
+        * Enable with DAV setting: invitation_list_attendees (default: no)
+        *
+        * The default is 'no', which matches old behavior, and is privacy preserving.
+        *
+        * To enable including attendees in invitation emails:
+        *   % php occ config:app:set dav invitation_list_attendees --value yes
+        *
+        * @param IEMailTemplate $template
+        * @param IL10N $l10n
+        * @param Message $iTipMessage
+        * @param int $lastOccurrence
+        * @author brad2014 on github.com
+        */
+
+       private function addAttendees(IEMailTemplate $template, IL10N $l10n, VEvent $vevent) {
+               if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') {
+                       return;
+               }
+
+               if (isset($vevent->ORGANIZER)) {
+                       $organizer = $vevent->ORGANIZER;
+                       $organizerURI = $organizer->getNormalizedValue();
+                       list($scheme,$organizerEmail) = explode(':',$organizerURI,2); # strip off scheme mailto:
+                       $organizerName = isset($organizer['CN']) ? $organizer['CN'] : null;
+                       $organizerHTML = sprintf('<a href="%s">%s</a>',
+                               htmlspecialchars($organizerURI),
+                               htmlspecialchars($organizerName ?: $organizerEmail));
+                       $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail);
+                       if (isset($organizer['PARTSTAT'])
+                               && strcasecmp($organizer['PARTSTAT'], 'ACCEPTED') === 0) {
+                               $organizerHTML .= ' ✔︎';
+                               $organizerText .= ' ✔︎';
+                       }
+                       $template->addBodyListItem($organizerHTML, $l10n->t('Organizer:'),
+                               $this->getAbsoluteImagePath('filetypes/text-vcard.svg'),
+                               $organizerText,'',self::IMIP_INDENT);
+               }
+
+               $attendees = $vevent->select('ATTENDEE');
+               if (count($attendees) === 0) {
+                       return;
+               }
+
+               $attendeesHTML = [];
+               $attendeesText = [];
+               foreach ($attendees as $attendee) {
+                       $attendeeURI = $attendee->getNormalizedValue();
+                       list($scheme,$attendeeEmail) = explode(':',$attendeeURI,2); # strip off scheme mailto:
+                       $attendeeName = isset($attendee['CN']) ? $attendee['CN'] : null;
+                       $attendeeHTML = sprintf('<a href="%s">%s</a>',
+                               htmlspecialchars($attendeeURI),
+                               htmlspecialchars($attendeeName ?: $attendeeEmail));
+                       $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail);
+                       if (isset($attendee['PARTSTAT'])
+                               && strcasecmp($attendee['PARTSTAT'], 'ACCEPTED') === 0) {
+                               $attendeeHTML .= ' ✔︎';
+                               $attendeeText .= ' ✔︎';
+                       }
+                       array_push($attendeesHTML, $attendeeHTML);
+                       array_push($attendeesText, $attendeeText);
+               }
+
+               $template->addBodyListItem(implode('<br/>',$attendeesHTML), $l10n->t('Attendees:'),
+                       $this->getAbsoluteImagePath('filetypes/text-vcard.svg'),
+                       implode("\n",$attendeesText),'',self::IMIP_INDENT);
        }
 
        /**
index 2c8efa7e010f9f556b557a24e3e8c8a8dc10397e..054378c2afa0105af3502ac1d21ed9b01b7cad65 100644 (file)
@@ -450,14 +450,16 @@ EOF;
         *   if empty the $text is used, if false none will be used
         * @param string|bool $plainMetaInfo Meta info that is used in the plain text email
         *   if empty the $metaInfo is used, if false none will be used
+        * @param integer plainIndent If > 0, Indent plainText by this amount.
         * @since 12.0.0
         */
-       public function addBodyListItem(string $text, string $metaInfo = '', string $icon = '', $plainText = '', $plainMetaInfo = '') {
+       public function addBodyListItem(string $text, string $metaInfo = '', string $icon = '', $plainText = '', $plainMetaInfo = '', $plainIndent = 0) {
                $this->ensureBodyListOpened();
 
                if ($plainText === '') {
                        $plainText = $text;
                        $text = htmlspecialchars($text);
+                       $text = str_replace("\n", "<br/>", $text); // convert newlines to HTML breaks
                }
                if ($plainMetaInfo === '') {
                        $plainMetaInfo = $metaInfo;
@@ -475,11 +477,27 @@ EOF;
                }
                $this->htmlBody .= vsprintf($this->listItem, [$icon, $htmlText]);
                if ($plainText !== false) {
-                       $this->plainBody .= '  * ' . $plainText;
-                       if ($plainMetaInfo !== false) {
-                               $this->plainBody .= ' (' . $plainMetaInfo . ')';
+                       if ($plainIndent === 0) {
+                               /*
+                                * If plainIndent is not set by caller, this is the old NC17 layout code.
+                                */
+                               $this->plainBody .= '  * ' . $plainText;
+                               if ($plainMetaInfo !== false) {
+                                       $this->plainBody .= ' (' . $plainMetaInfo . ')';
+                               }
+                               $this->plainBody .= PHP_EOL;
+                       } else {
+                               /*
+                                * Caller can set plainIndent > 0 to format plainText in tabular fashion.
+                                * with plainMetaInfo in column 1, and plainText in column 2.
+                                * The plainMetaInfo label is right justified in a field of width
+                                * "plainIndent". Multilines after the first are indented plainIndent+1
+                                * (to account for space after label).  Fixes: #12391
+                                */
+                               $this->plainBody .= sprintf("%${plainIndent}s %s\n",
+                                       $plainMetaInfo,
+                                       str_replace("\n", "\n" . str_repeat(' ', $plainIndent+1), $plainText));
                        }
-                       $this->plainBody .= PHP_EOL;
                }
        }
 
@@ -538,7 +556,7 @@ EOF;
                $textColor = $this->themingDefaults->getTextColorPrimary();
 
                $this->htmlBody .= vsprintf($this->buttonGroup, [$color, $color, $urlLeft, $color, $textColor, $textColor, $textLeft, $urlRight, $textRight]);
-               $this->plainBody .= $plainTextLeft . ': ' . $urlLeft . PHP_EOL;
+               $this->plainBody .= PHP_EOL . $plainTextLeft . ': ' . $urlLeft . PHP_EOL;
                $this->plainBody .= $plainTextRight . ': ' . $urlRight . PHP_EOL . PHP_EOL;
        }