From 975ef558610a2283e7d117ef4a3272a0b7d4298d Mon Sep 17 00:00:00 2001 From: brad2014 Date: Wed, 18 Sep 2019 08:35:28 -0700 Subject: [PATCH 1/2] Redesign iMIP email to better format both HTML and plain text. These iMIP emails are sent when calendar events are sent when calendar events containing invitees are created, updated, or deleted. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses issues #12391 and #13555 The event is now presented in the mail using a tabular format. Features of new design: - Tabular format is visually clearer than previous bullet list. - Organizer and attendee list are now included as labeled elements. - RSVP accept is indicated for organizer and each attendee with a ✔︎. - Eliminating linked design icons removes most privacy-eroding beacons (partially addressing issue #17187). Logo still TBD. Also: - Corrected some wording/spelling issues in emails. - Renamed $meetingInviteeName to $meetingOrganizerName (someone confused invitee and invitor). - Minor refactoring. Signed-off-by: Brad Rubenstein brad@wbr.tech --- apps/dav/lib/CalDAV/Schedule/IMipPlugin.php | 171 ++++++++++++-------- 1 file changed, 107 insertions(+), 64 deletions(-) diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 56b3ab04ddc59..9665cee727844 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -175,34 +175,12 @@ public function schedule(Message $iTipMessage) { $l10n = $this->l10nFactory->get('dav', $lang); $meetingAttendeeName = $recipientName ?: $recipient; - $meetingInviteeName = $senderName ?: $sender; + $meetingOrganizerName = $senderName ?: $sender; $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; $defaultVal = '--'; @@ -218,7 +196,7 @@ public function schedule(Message $iTipMessage) { $data = array( 'attendee_name' => (string)$meetingAttendeeName ?: $defaultVal, - 'invitee_name' => (string)$meetingInviteeName ?: $defaultVal, + 'invitee_name' => (string)$meetingOrganizerName ?: $defaultVal, 'meeting_title' => (string)$meetingTitle ?: $defaultVal, 'meeting_description' => (string)$meetingDescription ?: $defaultVal, 'meeting_url' => (string)$meetingUrl ?: $defaultVal, @@ -236,9 +214,8 @@ public function schedule(Message $iTipMessage) { $template->addHeader(); $this->addSubjectAndHeading($template, $l10n, $method, $summary, - $meetingAttendeeName, $meetingInviteeName); - $this->addBulletList($template, $l10n, $meetingWhen, $meetingLocation, - $meetingDescription, $meetingUrl); + $meetingAttendeeName, $meetingOrganizerName); + $this->addEventTable($template, $l10n, $vevent); // Only add response buttons to invitation requests: Fix Issue #11230 @@ -269,7 +246,7 @@ public function schedule(Message $iTipMessage) { if (strcmp('yes', $invitationLinkRecipients[0]) === 0 || in_array(strtolower($recipient), $invitationLinkRecipients) || in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { - $this->addResponseButtons($template, $l10n, $iTipMessage, $lastOccurrence); + $this->addResponseButtons($template, $l10n, $iTipMessage, $lastOccurrence); } } @@ -395,10 +372,29 @@ private function getAttendeeRSVP(Property $attendee = null) { /** * @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 */ @@ -482,45 +478,102 @@ private function isDayEqual(\DateTime $dtStart, \DateTime $dtEnd) { * @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')); } else if ($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 addEventTable(IEMailTemplate $template, IL10N $l10n, VEvent $vevent) { + /* TODO [brad2014]: should event-title background be a theme color? */ + $htmlText = ' + + + '; + $plainText = ''; + if ($vevent->SUMMARY) { + $htmlText .= vsprintf('', [ + htmlspecialchars($l10n->t('Title:')), htmlspecialchars($vevent->SUMMARY) + ]); + $plainText .= vsprintf("%15s %s\n", [$l10n->t('Title:'), $vevent->SUMMARY]); + } + if ($vevent->LOCATION) { + $htmlText .= vsprintf('', [ + htmlspecialchars($l10n->t('Location:')), htmlspecialchars($vevent->LOCATION) + ]); + $plainText .= vsprintf("%15s %s\n", [$l10n->t('Location:'), $vevent->LOCATION]); + } + if ($vevent->URL) { + $htmlText .= vsprintf('', [ + htmlspecialchars($l10n->t('Link:')), htmlspecialchars($vevent->URL) + ]); + $plainText .= vsprintf("%15s %s\n", [$l10n->t('Link:'), $vevent->URL]); + } + if (true) { + $meetingWhen = $this->generateWhenString($l10n, $vevent); + + $htmlText .= vsprintf('', [ + htmlspecialchars($l10n->t('Time:')), htmlspecialchars($meetingWhen) + ]); + $plainText .= vsprintf("%15s %s\n", [$l10n->t('Time:'), $meetingWhen]); + } + if ($vevent->ORGANIZER) { + $organizer = $vevent->ORGANIZER; + $organizerName = substr($organizer->getValue(),7); // strip off mailto: + $partStat = $organizer->offsetGet('PARTSTAT'); + if (($partStat instanceof Parameter) && (strcasecmp($partStat->getValue(), 'ACCEPTED') === 0)) { + $organizerName .= ' ✔︎'; + } - if ($location) { - $template->addBodyListItem($location, $l10n->t('Where:'), - $this->getAbsoluteImagePath('filetypes/location.svg')); + $htmlText .= vsprintf('', [ + htmlspecialchars($l10n->t('Organizer:')), htmlspecialchars($organizerName) + ]); + $plainText .= vsprintf("%15s %s\n", [$l10n->t('Organizer:'), $organizerName]); } - if ($description) { - $template->addBodyListItem((string)$description, $l10n->t('Description:'), - $this->getAbsoluteImagePath('filetypes/text.svg')); + if ($vevent->DESCRIPTION) { + $htmlText .= vsprintf('', [ + htmlspecialchars($l10n->t('Description:')), str_replace(PHP_EOL,'
', htmlspecialchars($vevent->DESCRIPTION)) + ]); + $plainText .= vsprintf("%15s %s\n", [ + $l10n->t('Description:'), + str_replace(PHP_EOL, PHP_EOL.str_repeat(' ',16), $vevent->DESCRIPTION)]); } - if ($url) { - $template->addBodyListItem((string)$url, $l10n->t('Link:'), - $this->getAbsoluteImagePath('filetypes/link.svg')); + $attendees = $vevent->select('ATTENDEE'); + $attendeeNames = []; + if (count($attendees)) { + foreach ($attendees as $attendee) { + $attendeeName = substr($attendee->getValue(),7); // strip off mailto: + $partStat = $attendee->offsetGet('PARTSTAT'); + if (($partStat instanceof Parameter) && (strcasecmp($partStat->getValue(), 'ACCEPTED') === 0)) { + $attendeeName .= ' ✔︎'; + } + array_push($attendeeNames, $attendeeName); + } + $htmlText .= vsprintf('', [ + htmlspecialchars($l10n->t('Attendees:')), implode('
',$attendeeNames) + ]); + $plainText .= vsprintf("%15s %s\n", [$l10n->t('Attendees:'), implode(PHP_EOL.str_repeat(' ',16),$attendeeNames)]); } + $htmlText .= '
%s%s
%s%s
%s%s
%s%s
%s%s
%s%s
%s%s
'; + $plainText .= PHP_EOL; + $template->addBodyText($htmlText, $plainText); } /** @@ -555,16 +608,6 @@ private function addResponseButtons(IEMailTemplate $template, IL10N $l10n, $template->addBodyText($html, $text); } - /** - * @param string $path - * @return string - */ - private function getAbsoluteImagePath($path) { - return $this->urlGenerator->getAbsoluteURL( - $this->urlGenerator->imagePath('core', $path) - ); - } - /** * @param Message $iTipMessage * @param int $lastOccurrence From 7b8e069f8c92bd820680605771c71944b1ce404a Mon Sep 17 00:00:00 2001 From: brad2014 Date: Wed, 18 Sep 2019 10:16:37 -0700 Subject: [PATCH 2/2] In iMIP event tables, put CSS inline. Fixes presentation on mail programs (like gmail) that don't accept style blocks in HTML body. Signed-off-by: brad@wbr.tech --- apps/dav/lib/CalDAV/Schedule/IMipPlugin.php | 34 ++++++++++----------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 9665cee727844..f76e6d37155fb 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -497,31 +497,31 @@ private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, * @param VEvent $vevent */ private function addEventTable(IEMailTemplate $template, IL10N $l10n, VEvent $vevent) { - /* TODO [brad2014]: should event-title background be a theme color? */ - $htmlText = ' - - - '; $plainText = ''; + $htmlText = vsprintf('
',[ + "border: 2px solid black; border-collapse: collapse;" + ]); + /* TODO [brad2014]: should tdHeadStyle background be a theme color? */ + $trHtml = vsprintf('', + [ + "vertical-align:top;", + "padding:3px .5em;background:#8cf;font-weight:bold;text-align:right;", + "padding:3px .5em;" + ]); if ($vevent->SUMMARY) { - $htmlText .= vsprintf('', [ + $htmlText .= vsprintf($trHtml, [ htmlspecialchars($l10n->t('Title:')), htmlspecialchars($vevent->SUMMARY) ]); $plainText .= vsprintf("%15s %s\n", [$l10n->t('Title:'), $vevent->SUMMARY]); } if ($vevent->LOCATION) { - $htmlText .= vsprintf('', [ + $htmlText .= vsprintf($trHtml, [ htmlspecialchars($l10n->t('Location:')), htmlspecialchars($vevent->LOCATION) ]); $plainText .= vsprintf("%15s %s\n", [$l10n->t('Location:'), $vevent->LOCATION]); } if ($vevent->URL) { - $htmlText .= vsprintf('', [ + $htmlText .= vsprintf($trHtml, [ htmlspecialchars($l10n->t('Link:')), htmlspecialchars($vevent->URL) ]); $plainText .= vsprintf("%15s %s\n", [$l10n->t('Link:'), $vevent->URL]); @@ -529,7 +529,7 @@ private function addEventTable(IEMailTemplate $template, IL10N $l10n, VEvent $ve if (true) { $meetingWhen = $this->generateWhenString($l10n, $vevent); - $htmlText .= vsprintf('', [ + $htmlText .= vsprintf($trHtml, [ htmlspecialchars($l10n->t('Time:')), htmlspecialchars($meetingWhen) ]); $plainText .= vsprintf("%15s %s\n", [$l10n->t('Time:'), $meetingWhen]); @@ -542,13 +542,13 @@ private function addEventTable(IEMailTemplate $template, IL10N $l10n, VEvent $ve $organizerName .= ' ✔︎'; } - $htmlText .= vsprintf('', [ + $htmlText .= vsprintf($trHtml, [ htmlspecialchars($l10n->t('Organizer:')), htmlspecialchars($organizerName) ]); $plainText .= vsprintf("%15s %s\n", [$l10n->t('Organizer:'), $organizerName]); } if ($vevent->DESCRIPTION) { - $htmlText .= vsprintf('', [ + $htmlText .= vsprintf($trHtml, [ htmlspecialchars($l10n->t('Description:')), str_replace(PHP_EOL,'
', htmlspecialchars($vevent->DESCRIPTION)) ]); $plainText .= vsprintf("%15s %s\n", [ @@ -566,7 +566,7 @@ private function addEventTable(IEMailTemplate $template, IL10N $l10n, VEvent $ve } array_push($attendeeNames, $attendeeName); } - $htmlText .= vsprintf('', [ + $htmlText .= vsprintf($trHtml, [ htmlspecialchars($l10n->t('Attendees:')), implode('
',$attendeeNames) ]); $plainText .= vsprintf("%15s %s\n", [$l10n->t('Attendees:'), implode(PHP_EOL.str_repeat(' ',16),$attendeeNames)]);
%%s%%s
%s%s
%s%s
%s%s
%s%s
%s%s
%s%s
%s%s