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. 48
      app/helpers/mail_digest_helper.rb
  3. 75
      app/helpers/mail_notification_helper.rb
  4. 30
      app/mailers/digest_mailer.rb
  5. 95
      app/views/digest_mailer/work_packages.html.erb
  6. 39
      app/views/digest_mailer/work_packages.text.erb
  7. 9
      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
def digest_timespan_text
end_time = Time.parse(Setting.notification_email_digest_time)
def digest_summary_text(notification_count, mentioned_count)
mentioned = mentioned_count > 1 ? 'plural' : 'singular'
notifications = notification_count > 1 ? 'plural' : 'singular'
I18n.t(:"mail.digests.time_frame",
start: format_time(end_time - 1.day),
end: format_time(end_time))
summary = I18n.t(:"mail.digests.unread_notification_#{notifications}",
number_unread: notification_count).to_s
unless mentioned_count === 0
summary << " #{I18n.t(:"mail.digests.including_mention_#{mentioned}",
number_mentioned: mentioned_count)}"
end
summary
end
def digest_notification_timestamp_text(notification, html: true)
def digest_notification_timestamp_text(notification, html: true, extended_text: false)
journal = notification.journal
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)
end
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: format_time(journal.created_at)))
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

@ -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::TextFormatting
include Redmine::I18n
include MailDigestHelper
helper :mail_digest
helper :mail_digest,
:mail_notification
MAX_SHOWN_WORK_PACKAGES = 15
class << self
def generate_message_id(_, user)
@ -54,17 +58,27 @@ class DigestMailer < ApplicationMailer
open_project_headers User: recipient.name
message_id nil, recipient
@notifications_by_project = load_notifications(notification_ids)
.group_by(&:project)
.transform_values { |of_project| of_project.group_by(&:resource) }
@user = recipient
@notification_ids = notification_ids
@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
subject = "#{Setting.app_title} - #{digest_summary_text(notification_ids.size, @mentioned_count)}"
mail to: recipient.mail,
subject: I18n.t('mail.digests.work_packages.subject',
date: format_time_as_date(Time.current),
number: notification_ids.count)
subject: subject
end
end

@ -1,58 +1,65 @@
<div style="color: #777; font-weight: bold">
<%= digest_timespan_text %>
</div>
<%= render partial: 'mailer/notification_mailer_header',
locals: {
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| %>
<section style="margin-bottom: 3em; margin-top: 5em">
<h1 style="font-size: 2em; margin-bottom: 1.5em"><%= link_to_project(project, only_path: false) %></h1>
<% notifications_by_work_package.each do |work_package, notifications| %>
<section style="margin-bottom: 3em;">
<h2 style="margin-bottom: 1em; font-size: 1.5em;"><%= link_to_work_package work_package, status: true, only_path: false, no_hidden: true %></h2>
<% notifications.sort_by(&:created_at).each do |notification| %>
<table width="100%" border="0" cellpadding="0" cellspacing="0">
<tr>
<td width="20px"></td>
<td style="font-weight: normal; font-size: 1.1em;">
<% @aggregated_notifications.first(DigestMailer::MAX_SHOWN_WORK_PACKAGES).each do | work_package, notifications_by_work_package| %>
<%= render layout: 'mailer/notification_row',
locals: {
work_package: work_package,
notifications_by_work_package: notifications_by_work_package
} do %>
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="font-size: 12px;">
<% notifications_by_work_package.each do | notification | %>
<% if notification.journal.notes.present? %>
<tr style="color: #878787; line-height: 20px; font-size: 14px;">
<td>
<%= digest_comment_text(notification) %>
<%= digest_notification_timestamp_text(notification) %>
</td>
<td style="text-align: right">
<%= I18n.t(
:"mail.digests.work_packages.reason.#{notification.reason_mail_digest || :unknown}",
default: '-') %>
</tr>
<% end %>
<% notification.journal.details.each do |detail| %>
<tr style="color: #878787; line-height: 20px; font-size: 14px;">
<td>
<%= notification.journal.render_detail(detail, only_path: false) %>
<%= digest_notification_timestamp_text(notification) %>
</td>
<td width="20px"></td>
</tr>
<% end %>
<% end %>
</table>
<% end %>
<% end %>
<% journal = notification.journal %>
<table width="100%" border="0" cellpadding="0" cellspacing="0">
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="margin: 30px 0;">
<tr>
<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>
<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 %>
<ul>
<% journal.details.each do |detail| %>
<li><%= journal.render_detail(detail, only_path: false) %></li>
</span>
<a
target="_blank"
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 %>
</ul>
</td>
<td>
<%= render partial: 'mailer/notification_settings_button' %>
</td>
</tr>
</table>
<div style="margin-bottom: 3em"></div>
<% end %>
</section>
<% end %>
</section>
<% end %>

@ -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) %>
= <%= project.name %> =
<%= "=" * (project.name.length + 4) %>
<% notifications_by_work_package.each do |work_package, notifications| %>
<%= "*" * (work_package.to_s.length + 4) %>
* <%= work_package.to_s %> *
<%= "*" * (work_package.to_s.length + 4) %>
<% notifications.sort_by(&:created_at).each do |notification| %>
<%= "=" * (('# ' + work_package.id.to_s + work_package.subject).length + 4) %>
= #<%= work_package.id %> <%= work_package.subject %> =
<%= "=" * (('# ' + work_package.id.to_s + work_package.subject).length + 4) %>
<% notifications_by_work_package.each do | notification | %>
<%= "-" * 20 %>
<%= digest_notification_timestamp_text(notification, html: false) %> (<%= I18n.t('mail.digests.work_packages.reason.prefix',
reason: I18n.t(:"mail.digests.work_packages.reason.#{notification.reason_mail_digest}")) %>)
<% unique_reasons = unique_reasons_of_notifications(notifications_by_work_package) %>
<%= digest_notification_timestamp_text(
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 %>)
<% journal = notification.journal %>
<% if journal.notes.present? %>
<%= I18n.t(:label_comment_added) %>:
<%= journal.notes %>
<% end %>
<% journal.details.each do |detail| %>
* <%= journal.render_detail(detail, only_path: false, no_html: true) %>
<% end %>
<% end %>
<%= "-" * 20 %>
<% end %>
<% end %>
<%= "-" * 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 %>

@ -30,8 +30,8 @@ See COPYRIGHT and LICENSE files for more details.
<head>
<style>
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
color-scheme: light;
supported-color-schemes: light;
}
body {
@ -41,10 +41,11 @@ See COPYRIGHT and LICENSE files for more details.
background: #FFFFFF;
}
/* Enforcing the white background */
@media (prefers-color-scheme: dark) {
body {
color: #CCC !important;
background: #222222 !important;
color: #484848 !important;
background: #FFFFFF !important;
}
}

@ -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:
actions: 'Actions'
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:
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:
watched: 'watched'
involved: 'assignee or responsible'
mentioned: 'mentioned'
watched: 'Watched'
involved: 'Assigned or responsible'
mentioned: 'Mentioned'
subscribed: 'all'
prefix: 'Received because of the notification setting: %{reason}'
subject: 'Daily project summary for %{date} - %{number} updates'
updated_at: '%{user} updated at %{timestamp}'
see_all: 'See all'
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_information: "Your account information"

@ -53,6 +53,9 @@ describe "Digest email", type: :feature, js: true do
work_package
involved_work_package
allow(CustomStyle.current)
.to receive(:logo).and_return(nil)
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
end
@ -132,8 +135,6 @@ describe "Digest email", type: :feature, js: true do
.to be 1
expect(ActionMailer::Base.deliveries.first.subject)
.to eql I18n.t(:'mail.digests.work_packages.subject',
date: Time.current.strftime('%m/%d/%Y'),
number: 1)
.to eql "OpenProject - 1 unread notification including a mention"
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 }
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
expect(mail.subject)
.to eql I18n.t('mail.digests.work_packages.subject',
date: format_time_as_date(Time.current),
number: 1)
.to eql "OpenProject - 1 unread notification"
end
it 'sends to the recipient' do
@ -104,20 +107,26 @@ describe DigestMailer, type: :mailer do
.to eql recipient.name
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)
.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)
.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)
.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)
.to have_selector('body section section li',
text: "Subject changed from old subject to new subject")
.to have_text(expected_details_text, normalize_ws: true)
end
context 'with only a deleted work package for the digest' do

Loading…
Cancel
Save