Merge pull request #9613 from opf/feature/38690-email-design-daily-reminder

[38690] Email design - Daily Reminders
pull/9630/head
Henriette Darge 3 years ago committed by GitHub
commit 4a1148acb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      app/assets/images/logo_openproject_narrow.svg
  2. 50
      app/helpers/mail_digest_helper.rb
  3. 75
      app/helpers/mail_notification_helper.rb
  4. 30
      app/mailers/digest_mailer.rb
  5. 117
      app/views/digest_mailer/work_packages.html.erb
  6. 55
      app/views/digest_mailer/work_packages.text.erb
  7. 11
      app/views/layouts/mailer.html.erb
  8. 30
      app/views/mailer/_notification_mailer_header.html.erb
  9. 57
      app/views/mailer/_notification_row.html.erb
  10. 5
      app/views/mailer/_notification_settings_button.html.erb
  11. 30
      config/locales/en.yml
  12. 7
      spec/features/notifications/digest_mail_spec.rb
  13. 29
      spec/mailers/digest_mailer_spec.rb

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

@ -29,20 +29,52 @@
#++ #++
module MailDigestHelper module MailDigestHelper
def digest_timespan_text def digest_summary_text(notification_count, mentioned_count)
end_time = Time.parse(Setting.notification_email_digest_time) mentioned = mentioned_count > 1 ? 'plural' : 'singular'
notifications = notification_count > 1 ? 'plural' : 'singular'
I18n.t(:"mail.digests.time_frame", summary = I18n.t(:"mail.digests.unread_notification_#{notifications}",
start: format_time(end_time - 1.day), number_unread: notification_count).to_s
end: format_time(end_time))
unless mentioned_count === 0
summary << " #{I18n.t(:"mail.digests.including_mention_#{mentioned}",
number_mentioned: mentioned_count)}"
end
summary
end end
def digest_notification_timestamp_text(notification, html: true) def digest_notification_timestamp_text(notification, html: true, extended_text: false)
journal = notification.journal journal = notification.journal
user = html ? link_to_user(journal.user, only_path: false) : journal.user.name user = html ? link_to_user(journal.user, only_path: false) : journal.user.name
raw(I18n.t(:"mail.digests.work_packages.#{journal.initial? ? 'created_at' : 'updated_at'}", timestamp_text(user, journal, extended_text)
user: user, end
timestamp: format_time(journal.created_at)))
def digest_comment_text(notification)
if notification.reason_mail_digest === "mentioned"
sanitize I18n.t(:'mail.digests.work_packages.mentioned')
else
sanitize I18n.t(:'mail.digests.work_packages.comment_added')
end
end
private
def timestamp_text(user, journal, extended)
value = journal.initial? ? "created" : "updated"
if extended
sanitize(
"#{I18n.t(:"mail.digests.work_packages.#{value}")} #{I18n.t(:"mail.digests.work_packages.#{value}_at",
user: user,
timestamp: journal.created_at.strftime(
I18n.t(:'time.formats.time')
))}"
)
else
sanitize(I18n.t(:"mail.digests.work_packages.#{value}_at",
user: user,
timestamp: journal.created_at.strftime(I18n.t(:'time.formats.time'))))
end
end end
end end

@ -0,0 +1,75 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module MailNotificationHelper
include ::ColorsHelper
def logo_tag(**options)
current_logo = CustomStyle.current.logo unless CustomStyle.current.nil?
if current_logo.present?
logo_file = current_logo.local_file
logo = File.read(logo_file)
suffix = MIME::Types.type_for(logo_file.path).first.content_type
else
logo = Rails.application.assets["logo_openproject_narrow.svg"]
suffix = "svg+xml"
end
email_image_tag(logo, suffix, options)
end
def email_image_tag(image, suffix, **options)
image_string = image.to_s
base64_string = Base64.strict_encode64(image_string)
image_tag "data:image/#{suffix};base64,#{base64_string}", **options
end
def unique_reasons_of_notifications(notifications)
notifications
.map(&:reason_mail_digest)
.uniq
end
def notifications_path(id)
notifications_center_url(['details', id, 'activity'])
end
def type_color(type, default_fallback)
color_id = selected_color(type)
color_id ? Color.find(color_id).hexcode : default_fallback
end
def status_colors(status)
color_id = selected_color(status)
Color.find(color_id).color_styles.map { |k, v| "#{k}:#{v};" }.join(' ') if color_id
end
end

@ -36,8 +36,12 @@ class DigestMailer < ApplicationMailer
include OpenProject::StaticRouting::UrlHelpers include OpenProject::StaticRouting::UrlHelpers
include OpenProject::TextFormatting include OpenProject::TextFormatting
include Redmine::I18n include Redmine::I18n
include MailDigestHelper
helper :mail_digest helper :mail_digest,
:mail_notification
MAX_SHOWN_WORK_PACKAGES = 15
class << self class << self
def generate_message_id(_, user) def generate_message_id(_, user)
@ -54,17 +58,27 @@ class DigestMailer < ApplicationMailer
open_project_headers User: recipient.name open_project_headers User: recipient.name
message_id nil, recipient message_id nil, recipient
@notifications_by_project = load_notifications(notification_ids) @user = recipient
.group_by(&:project) @notification_ids = notification_ids
.transform_values { |of_project| of_project.group_by(&:resource) } @aggregated_notifications = load_notifications(notification_ids)
.sort_by(&:created_at)
.reverse
.group_by(&:resource)
@mentioned_count = @aggregated_notifications
.values
.flatten
.map(&:reason_mail_digest)
.compact
.count("mentioned")
return if @notifications_by_project.empty? return if @aggregated_notifications.empty?
with_locale_for(recipient) do with_locale_for(recipient) do
subject = "#{Setting.app_title} - #{digest_summary_text(notification_ids.size, @mentioned_count)}"
mail to: recipient.mail, mail to: recipient.mail,
subject: I18n.t('mail.digests.work_packages.subject', subject: subject
date: format_time_as_date(Time.current),
number: notification_ids.count)
end end
end end

@ -1,58 +1,65 @@
<div style="color: #777; font-weight: bold"> <%= render partial: 'mailer/notification_mailer_header',
<%= digest_timespan_text %> locals: {
</div> summary: "#{I18n.t(:'mail.digests.you_have')} #{digest_summary_text(@notification_ids.length, @mentioned_count)}"
} %>
<% @notifications_by_project.each do |project, notifications_by_work_package| %> <% @aggregated_notifications.first(DigestMailer::MAX_SHOWN_WORK_PACKAGES).each do | work_package, notifications_by_work_package| %>
<section style="margin-bottom: 3em; margin-top: 5em"> <%= render layout: 'mailer/notification_row',
<h1 style="font-size: 2em; margin-bottom: 1.5em"><%= link_to_project(project, only_path: false) %></h1> locals: {
work_package: work_package,
<% notifications_by_work_package.each do |work_package, notifications| %> notifications_by_work_package: notifications_by_work_package
<section style="margin-bottom: 3em;"> } do %>
<h2 style="margin-bottom: 1em; font-size: 1.5em;"><%= link_to_work_package work_package, status: true, only_path: false, no_hidden: true %></h2> <table width="100%" border="0" cellpadding="0" cellspacing="0" style="font-size: 12px;">
<% notifications_by_work_package.each do | notification | %>
<% notifications.sort_by(&:created_at).each do |notification| %> <% if notification.journal.notes.present? %>
<tr style="color: #878787; line-height: 20px; font-size: 14px;">
<table width="100%" border="0" cellpadding="0" cellspacing="0"> <td>
<tr> <%= digest_comment_text(notification) %>
<td width="20px"></td> <%= digest_notification_timestamp_text(notification) %>
<td style="font-weight: normal; font-size: 1.1em;"> </td>
<%= digest_notification_timestamp_text(notification) %> </tr>
</td> <% end %>
<td style="text-align: right"> <% notification.journal.details.each do |detail| %>
<%= I18n.t( <tr style="color: #878787; line-height: 20px; font-size: 14px;">
:"mail.digests.work_packages.reason.#{notification.reason_mail_digest || :unknown}", <td>
default: '-') %> <%= notification.journal.render_detail(detail, only_path: false) %>
</td> <%= digest_notification_timestamp_text(notification) %>
<td width="20px"></td> </td>
</tr> </tr>
</table> <% end %>
<% end %>
<% journal = notification.journal %> </table>
<table width="100%" border="0" cellpadding="0" cellspacing="0"> <% end %>
<tr> <% end %>
<td width="20px"></td>
<td>
<%= format_text(journal.notes,
only_path: false,
object: notification.resource,
project: notification.project) %>
<% if journal.notes.present? && journal.details.any? %>
<div style="margin-bottom: 2em"></div>
<% end %>
<ul>
<% journal.details.each do |detail| %>
<li><%= journal.render_detail(detail, only_path: false) %></li>
<% end %>
</ul>
</td>
</tr>
</table>
<div style="margin-bottom: 3em"></div> <table width="100%" border="0" cellpadding="0" cellspacing="0" style="margin: 30px 0;">
<tr>
<td width="100%">
<% if @aggregated_notifications.length > DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<span style="font-size: 14px; line-height: 28px">
<% number_of_overflowing_work_packages = @aggregated_notifications.length - DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<% if number_of_overflowing_work_packages === 1 %>
<%= I18n.t(:'mail.digests.work_packages.more_to_see_singular') %>
<% else %>
<%= I18n.t(:'mail.digests.work_packages.more_to_see_plural', number: number_of_overflowing_work_packages) %>
<% end %> <% end %>
</section> </span>
<% end %> <a
</section> target="_blank"
<% end %> style="background-color: #D1E5F5;
padding: 8px 12px;
color: #1A67A3;
border: 1px solid #1A67A3;
border-radius: 16px;
text-decoration: none;
white-space: nowrap;">
<%= I18n.t(:'mail.digests.work_packages.see_all') %>
</a>
<% end %>
</td>
<td>
<%= render partial: 'mailer/notification_settings_button' %>
</td>
</tr>
</table>

@ -1,36 +1,43 @@
<%= digest_timespan_text %> <%= I18n.t(:'mail.salutation', user: @user.firstname) %>
<%= "#{I18n.t(:'mail.digests.you_have')} #{digest_summary_text(@notification_ids.length, @mentioned_count)}" %>
<%= "-" * 100 %>
<% @notifications_by_project.each do |project, notifications_by_work_package| %> <% @aggregated_notifications.first(DigestMailer::MAX_SHOWN_WORK_PACKAGES).each do | work_package, notifications_by_work_package| %>
<%= "=" * (project.name.length + 4) %> <%= "=" * (('# ' + work_package.id.to_s + work_package.subject).length + 4) %>
= <%= project.name %> = = #<%= work_package.id %> <%= work_package.subject %> =
<%= "=" * (project.name.length + 4) %> <%= "=" * (('# ' + work_package.id.to_s + work_package.subject).length + 4) %>
<% notifications_by_work_package.each do |work_package, notifications| %> <% notifications_by_work_package.each do | notification | %>
<%= "-" * 20 %>
<%= "*" * (work_package.to_s.length + 4) %> <% unique_reasons = unique_reasons_of_notifications(notifications_by_work_package) %>
* <%= work_package.to_s %> * <%= digest_notification_timestamp_text(
<%= "*" * (work_package.to_s.length + 4) %> notification,
html: false,
extended_text: true) %> (<% unique_reasons.each_with_index do |reason, index| %><%= I18n.t(:"mail.digests.work_packages.reason.#{reason || :unknown}", default: '-') %><%= ', ' unless unique_reasons.size-1 == index %><% end %>)
<% notifications.sort_by(&:created_at).each do |notification| %> <% journal = notification.journal %>
<% if journal.notes.present? %>
<%= I18n.t(:label_comment_added) %>:
<%= journal.notes %>
<%= "-" * 20 %> <% end %>
<% journal.details.each do |detail| %>
* <%= journal.render_detail(detail, only_path: false, no_html: true) %>
<% end %>
<%= digest_notification_timestamp_text(notification, html: false) %> (<%= I18n.t('mail.digests.work_packages.reason.prefix', <%= "-" * 20 %>
reason: I18n.t(:"mail.digests.work_packages.reason.#{notification.reason_mail_digest}")) %>) <% end %>
<% journal = notification.journal %>
<% if journal.notes.present? %>
<%= journal.notes %>
<% end %>
<% journal.details.each do |detail| %>
* <%= journal.render_detail(detail, only_path: false, no_html: true) %>
<% end %> <% end %>
<% end %>
<%= "-" * 20 %>
<%= "-" * 100 %>
<% if @aggregated_notifications.length > DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<% number_of_overflowing_work_packages = @aggregated_notifications.length - DigestMailer::MAX_SHOWN_WORK_PACKAGES %>
<% if number_of_overflowing_work_packages === 1 %>
<%= I18n.t(:'mail.digests.work_packages.more_to_see_singular') %> <%= I18n.t(:'mail.digests.work_packages.login_to_see_all') %>
<% else %>
<%= I18n.t(:'mail.digests.work_packages.more_to_see_plural', number: number_of_overflowing_work_packages) %> <%= I18n.t(:'mail.digests.work_packages.login_to_see_all') %>
<% end %> <% end %>
<% end %> <% end %>

@ -30,8 +30,8 @@ See COPYRIGHT and LICENSE files for more details.
<head> <head>
<style> <style>
:root { :root {
color-scheme: light dark; color-scheme: light;
supported-color-schemes: light dark; supported-color-schemes: light;
} }
body { body {
@ -41,13 +41,14 @@ See COPYRIGHT and LICENSE files for more details.
background: #FFFFFF; background: #FFFFFF;
} }
/* Enforcing the white background */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
color: #CCC !important; color: #484848 !important;
background: #222222 !important; background: #FFFFFF !important;
} }
} }
h1, h2, h3 { font-family: "Trebuchet MS", Verdana, sans-serif; margin: 0px; } h1, h2, h3 { font-family: "Trebuchet MS", Verdana, sans-serif; margin: 0px; }
h1 { font-size: 1.2em; } h1 { font-size: 1.2em; }
h2, h3 { font-size: 1.1em; } h2, h3 { font-size: 1.1em; }

@ -0,0 +1,30 @@
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="border-bottom: 1px solid #cccccc; margin-bottom: 32px;">
<tr>
<td width="100%" style="padding-top: 8px; padding-left: 12px;">
<table>
<tr>
<td style="font-size: 24px; color: #333333; padding-bottom: 5px;">
<%= I18n.t(:'mail.salutation', user: @user.firstname) %>
</td>
</tr>
<tr>
<td style="font-size:16px; color: #1A67A3; font-weight: bold; padding-bottom: 10px;">
<%= summary %>
</td>
</tr>
<tr>
<td style="padding: 10px 0 32px 0;">
<a href="<%= notifications_center_url %>"
target="_blank"
style="background: #D1E5F5; padding: 8px 12px; color: #1A67A3; border: 1px solid #1A67A3; border-radius: 16px; text-decoration: none;">
<%= I18n.t(:'mail.notification.center') %>
</a>
</td>
</tr>
</table>
</td>
<td style="width: 96px; height: 96px; vertical-align: top;">
<%= logo_tag({ alt: "#{Setting.app_title} #{I18n.t(:'mail.logo_alt_text')}", style: "height: 96px;"}) %>
</td>
</tr>
</table>

@ -0,0 +1,57 @@
<a style="border: 1px solid #E0E0E0;
margin-bottom: 16px;
padding: 12px 12px 16px 12px;
border-radius: 10px;
text-decoration: none;
display: block;"
href="<%= notifications_path(work_package.id) %>"
target="_blank">
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="margin-bottom: 9px; font-size: 14px;">
<tr>
<td >
<div style="color: #333333;
background-color: #FFFFFF;
<%= status_colors(work_package.status) %>
white-space: nowrap;
padding: 2px 12px;
height: 16px;">
<%= work_package.status %>
</div>
</td>
<td width="100%" style="padding-left: 8px;
color: #878787;">
#<%= work_package.id %> - <%= work_package.project %>
<% unique_reasons = unique_reasons_of_notifications(notifications_by_work_package) %>
<%= ' - ' unless unique_reasons.length === 1 && unique_reasons.first.nil? %>
<% unique_reasons.each_with_index do |reason, index| %>
<%= I18n.t(
:"mail.digests.work_packages.reason.#{reason || :unknown}",
default: '') %><%= ', ' unless unique_reasons.size-1 == index %>
<% end %>
</td>
<td style="text-align: right;">
<span style="background-color: #00A3FF;
color: white;
border-radius: 8px;
padding: 0px 8px;
font-size: 14px;">
<%= notifications_by_work_package.length %>
</span>
</td>
</tr>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="margin-bottom: 9px; font-size: 16px; font-weight: bold;">
<tr>
<td style="color: <%= type_color(work_package.type, '#333333') %>;
white-space: nowrap;">
<%= work_package.type.to_s.upcase %>
</td>
<td width="100%" style="padding-left: 5px; color: #333333;">
<%= work_package.subject %>
</td>
</tr>
</table>
<%= yield %>
</a>

@ -0,0 +1,5 @@
<a href="<%= my_reminders_url %>"
target="_blank"
style="padding: 8px 12px; color: #333333; border: 1px solid #878787; border-radius: 16px; text-decoration: none; white-space: nowrap;">
<%= I18n.t(:'mail.notification.settings') %>
</a>

@ -1967,17 +1967,33 @@ en:
mail: mail:
actions: 'Actions' actions: 'Actions'
digests: digests:
time_frame: 'Summary of all events you subscribed to in the period between %{start} and %{end}' including_mention_singular: 'including a mention'
including_mention_plural: 'including %{number_mentioned} mentions'
unread_notification_singular: '1 unread notification'
unread_notification_plural: '%{number_unread} unread notifications'
work_packages: work_packages:
created_at: '%{user} created at %{timestamp}' comment_added: '<b>Comment</b> added'
created: 'Created'
created_at: 'at %{timestamp} by %{user} '
login_to_see_all: 'Log in to see all notifications.'
mentioned: 'You have been <b>mentioned in a comment</b>'
more_to_see_singular: 'There is 1 more work package with notifications.'
more_to_see_plural: 'There are %{number} more work packages with notifications.'
reason: reason:
watched: 'watched' watched: 'Watched'
involved: 'assignee or responsible' involved: 'Assigned or responsible'
mentioned: 'mentioned' mentioned: 'Mentioned'
subscribed: 'all' subscribed: 'all'
prefix: 'Received because of the notification setting: %{reason}' prefix: 'Received because of the notification setting: %{reason}'
subject: 'Daily project summary for %{date} - %{number} updates' see_all: 'See all'
updated_at: '%{user} updated at %{timestamp}' updated: 'Updated'
updated_at: 'at %{timestamp} by %{user}'
you_have: 'You have'
logo_alt_text: 'Logo'
notification:
center: 'To notification center'
settings: 'Change email settings'
salutation: 'Hey %{user}!'
mail_body_account_activation_request: "A new user (%{value}) has registered. The account is pending your approval:" mail_body_account_activation_request: "A new user (%{value}) has registered. The account is pending your approval:"
mail_body_account_information: "Your account information" mail_body_account_information: "Your account information"

@ -53,6 +53,9 @@ describe "Digest email", type: :feature, js: true do
work_package work_package
involved_work_package involved_work_package
allow(CustomStyle.current)
.to receive(:logo).and_return(nil)
ActiveJob::Base.queue_adapter.enqueued_jobs.clear ActiveJob::Base.queue_adapter.enqueued_jobs.clear
end end
@ -132,8 +135,6 @@ describe "Digest email", type: :feature, js: true do
.to be 1 .to be 1
expect(ActionMailer::Base.deliveries.first.subject) expect(ActionMailer::Base.deliveries.first.subject)
.to eql I18n.t(:'mail.digests.work_packages.subject', .to eql "OpenProject - 1 unread notification including a mention"
date: Time.current.strftime('%m/%d/%Y'),
number: 1)
end end
end end

@ -78,11 +78,14 @@ describe DigestMailer, type: :mailer do
let(:mail_body) { mail.body.parts.detect { |part| part['Content-Type'].value == 'text/html' }.body.to_s } let(:mail_body) { mail.body.parts.detect { |part| part['Content-Type'].value == 'text/html' }.body.to_s }
before do
allow(CustomStyle.current)
.to receive(:logo).and_return(nil)
end
it 'notes the day and the number of notifications in the subject' do it 'notes the day and the number of notifications in the subject' do
expect(mail.subject) expect(mail.subject)
.to eql I18n.t('mail.digests.work_packages.subject', .to eql "OpenProject - 1 unread notification"
date: format_time_as_date(Time.current),
number: 1)
end end
it 'sends to the recipient' do it 'sends to the recipient' do
@ -104,20 +107,26 @@ describe DigestMailer, type: :mailer do
.to eql recipient.name .to eql recipient.name
end end
it 'includes the notifications grouped by project and work package' do it 'includes the notifications grouped by work package' do
time_stamp = journal.created_at.strftime('%I:%M %p')
expect(mail_body)
.to have_text("Hey #{recipient.firstname}!")
expected_notification_subject = "#{work_package.type.name.upcase} #{work_package.subject}"
expect(mail_body) expect(mail_body)
.to have_selector('body section h1', text: project1.name) .to have_text(expected_notification_subject, normalize_ws: true)
expected = "#{work_package.type.name} ##{work_package.id} #{work_package.status.name}: #{work_package.subject}" expected_notification_header = "#{work_package.status.name} ##{work_package.id} - #{work_package.project}"
expect(mail_body) expect(mail_body)
.to have_selector('body section section h2', text: expected) .to have_text(expected_notification_header, normalize_ws: true)
expected_journal_text = "Comment added at #{time_stamp} by #{recipient.name}"
expect(mail_body) expect(mail_body)
.to have_selector('body section section p.op-uc-p', text: journal.notes) .to have_text(expected_journal_text, normalize_ws: true)
expected_details_text = "Subject changed from old subject to new subject at #{time_stamp} by #{recipient.name}"
expect(mail_body) expect(mail_body)
.to have_selector('body section section li', .to have_text(expected_details_text, normalize_ws: true)
text: "Subject changed from old subject to new subject")
end end
context 'with only a deleted work package for the digest' do context 'with only a deleted work package for the digest' do

Loading…
Cancel
Save