diff --git a/app/helpers/mail_layout_helper.rb b/app/helpers/mail_layout_helper.rb new file mode 100644 index 000000000000..532894e838b5 --- /dev/null +++ b/app/helpers/mail_layout_helper.rb @@ -0,0 +1,80 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 MailLayoutHelper + def placeholder_table_styles(options = {}) + default_options = { + style: 'table-layout:fixed;border-collapse:separate;border-spacing:0;font-family:Helvetica;' << + (options[:style].present? ? options.delete(:style) : ''), + cellspacing: "0", + cellpadding: "0" + } + + default_options.merge(options).map { |k, v| "#{k}=#{v}" }.join(' ') + end + + def placeholder_text_styles(**overwrites) + { + color: '#878787', + 'line-height': '24px', + 'font-size': '14px', + 'white-space': 'normal', + overflow: 'hidden', + 'max-width': '100%', + width: '100%' + }.merge(overwrites) + .map { |k, v| "#{k}: #{v}" } + .join('; ') + end + + def action_button(&block) + render( + partial: 'mailer/mailer_button', + locals: { block: } + ) + end + + def placeholder_cell(number, vertical:) + style = if vertical + "max-width:#{number}; min-width:#{number}; width:#{number}" + else + "line-height:#{number}; max-width:0; min-width:0; height:#{number}; width:0; font-size:#{number}" + end + + content_tag('td', ' '.html_safe, style:) + end + + def user_salutation(user) + case Setting.emails_salutation + when :name + I18n.t(:'mail.salutation', user: user.name) + else + I18n.t(:'mail.salutation', user: user.firstname) + end + end +end diff --git a/app/helpers/mail_notification_helper.rb b/app/helpers/mail_notification_helper.rb index a81c2c0ae771..1b7f1cdd2996 100644 --- a/app/helpers/mail_notification_helper.rb +++ b/app/helpers/mail_notification_helper.rb @@ -57,39 +57,4 @@ 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 - - def placeholder_table_styles(options = {}) - default_options = { - style: 'table-layout:fixed;border-collapse:separate;border-spacing:0;font-family:Helvetica;' << - (options[:style].present? ? options.delete(:style) : ''), - cellspacing: "0", - cellpadding: "0" - } - - default_options.merge(options).map { |k, v| "#{k}=#{v}" }.join(' ') - end - - def placeholder_text_styles(**overwrites) - { - color: '#878787', - 'line-height': '24px', - 'font-size': '14px', - 'white-space': 'normal', - overflow: 'hidden', - 'max-width': '100%', - width: '100%' - }.merge(overwrites) - .map { |k, v| "#{k}: #{v}" } - .join('; ') - end - - def placeholder_cell(number, vertical:) - style = if vertical - "max-width:#{number}; min-width:#{number}; width:#{number}" - else - "line-height:#{number}; max-width:0; min-width:0; height:#{number}; width:0; font-size:#{number}" - end - - content_tag('td', ' '.html_safe, style:) - end end diff --git a/app/mailers/announcement_mailer.rb b/app/mailers/announcement_mailer.rb index 023febaa34f0..52ddaf869568 100644 --- a/app/mailers/announcement_mailer.rb +++ b/app/mailers/announcement_mailer.rb @@ -33,9 +33,10 @@ class AnnouncementMailer < ApplicationMailer include OpenProject::StaticRouting::UrlHelpers include OpenProject::TextFormatting - helper :mail_notification + helper :mail_notification, + :mail_layout - def announce(user, subject:, body:, salutation: :firstname, body_header: nil, body_subheader: nil) + def announce(user, subject:, body:, body_header: nil, body_subheader: nil) with_locale_for(user) do localized_subject = localized(subject) @@ -44,7 +45,6 @@ def announce(user, subject:, body:, salutation: :firstname, body_header: nil, bo locals = { body: localized(body), user:, - salutation: user_salutation(user, salutation), header_summary: localized_subject, body_header: localized(body_header), body_subheader: localized(body_subheader) @@ -65,13 +65,4 @@ def localized(input) input end end - - def user_salutation(user, salutation) - case salutation - when :firstname - I18n.t(:'mail.salutation', user: user.firstname) - else - salutation % { firstname: user.firstname, lastname: user.lastname, name: user.name } - end - end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 0af90c29a2c7..f1d703f40c8b 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -31,7 +31,8 @@ class ApplicationMailer < ActionMailer::Base helper :application, # for format_text :work_packages, # for css classes - :custom_fields # for show_value + :custom_fields, # for show_value + :mail_layout # for layouting include OpenProject::LocaleHelper diff --git a/app/views/admin/settings/mail_notifications_settings/show.html.erb b/app/views/admin/settings/mail_notifications_settings/show.html.erb index 4e275a2dce84..bb12727c4d88 100644 --- a/app/views/admin/settings/mail_notifications_settings/show.html.erb +++ b/app/views/admin/settings/mail_notifications_settings/show.html.erb @@ -37,9 +37,19 @@ See COPYRIGHT and LICENSE files for more details.
<%= setting_text_field :mail_from, size: 60, container_class: '-middle' %>
<%= setting_check_box :bcc_recipients %>
<%= setting_check_box :plain_text_mail %>
+
+ <%= setting_select :emails_salutation, + [ + [User.human_attribute_name(:firstname), :firstname], + [t('mail.salutation_full_name'), :name], + ], + container_class: '-middle' %> +
-
<%= t(:setting_emails_header) %> & <%= t(:setting_emails_footer) %> +
+ <%= t(:setting_emails_header) %> + & <%= I18n.t(:setting_emails_footer) %> <%= render Settings::TextSettingComponent.new(I18n.locale, name: "emails_header") %> <%= render Settings::TextSettingComponent.new(I18n.locale, name: "emails_footer") %>
@@ -54,7 +64,7 @@ See COPYRIGHT and LICENSE files for more details. email_methods << :letter_opener if Rails.env.development? %> <%= content_tag :fieldset, id: "mail_configuration", class: "form--fieldset" do %> - <%=t(:text_setup_mail_configuration)%> + <%= t(:text_setup_mail_configuration) %>
<%= setting_select(:email_delivery_method, email_methods, id: "email_delivery_method_switch", container_class: '-slim') %>
<%= setting_text_field :smtp_address, container_class: '-middle' %>
@@ -70,7 +80,8 @@ See COPYRIGHT and LICENSE files for more details.
<%= setting_text_field :sendmail_location %>
-

Letter opener is used to render emails as a file in your Rails tmp folder. Mails will automatically open in your browser if supported.

+

Letter opener is used to render emails as a file in your Rails tmp folder. Mails will automatically open in + your browser if supported.

<% end unless OpenProject::Configuration['email_delivery_configuration'] == 'legacy' %> diff --git a/app/views/announcement_mailer/announce.html.erb b/app/views/announcement_mailer/announce.html.erb index 20c8be32dddd..824b9cbb073b 100644 --- a/app/views/announcement_mailer/announce.html.erb +++ b/app/views/announcement_mailer/announce.html.erb @@ -1,8 +1,7 @@ <%= render layout: 'mailer/spacer_table' do %> - <%= render partial: 'mailer/notification_mailer_header', + <%= render partial: 'mailer/mailer_header', locals: { summary: header_summary, - salutation: salutation, user: user } %> diff --git a/app/views/announcement_mailer/announce.text.erb b/app/views/announcement_mailer/announce.text.erb index ae763a29f2f4..bee2004dc049 100644 --- a/app/views/announcement_mailer/announce.text.erb +++ b/app/views/announcement_mailer/announce.text.erb @@ -1,4 +1,4 @@ -<%= salutation %> +<%= user_salutation(user) %> <%= header_summary %> <%= "-" * 100 %> diff --git a/app/views/digest_mailer/work_packages.html.erb b/app/views/digest_mailer/work_packages.html.erb index 4318594c6b20..38b35e85a5d6 100644 --- a/app/views/digest_mailer/work_packages.html.erb +++ b/app/views/digest_mailer/work_packages.html.erb @@ -8,13 +8,12 @@ <%= placeholder_cell('12px', vertical: true) %> - <%= render partial: 'mailer/notification_mailer_header', + <%= render partial: 'mailer/mailer_header', locals: { summary: "#{I18n.t(:'mail.digests.you_have')} #{digest_summary_text(@notification_ids.length, @mentioned_count)}", button_href: notifications_center_url, button_text: I18n.t(:'mail.notification.center'), user: @user, - salutation: I18n.t(:'mail.salutation', user: @user.firstname) } %> <% @aggregated_notifications.first(DigestMailer::MAX_SHOWN_WORK_PACKAGES).each do |work_package, notifications_by_work_package| %> diff --git a/app/views/mailer/_mailer_button.html.erb b/app/views/mailer/_mailer_button.html.erb new file mode 100644 index 000000000000..42db2321b680 --- /dev/null +++ b/app/views/mailer/_mailer_button.html.erb @@ -0,0 +1,22 @@ +> + + + <%= placeholder_cell('10px', vertical: true) %> + + +
+ > + + + +
+
+
+ > + + + +
+ <%= capture(&block) %> +
+
diff --git a/app/views/mailer/_notification_mailer_header.html.erb b/app/views/mailer/_mailer_header.html.erb similarity index 90% rename from app/views/mailer/_notification_mailer_header.html.erb rename to app/views/mailer/_mailer_header.html.erb index 210cceb658a4..3859afc37b8b 100644 --- a/app/views/mailer/_notification_mailer_header.html.erb +++ b/app/views/mailer/_mailer_header.html.erb @@ -8,13 +8,14 @@ - <%= salutation %> + <%= user_salutation(user) %> <%= placeholder_cell('8px', vertical: false) %> + <% if local_assigns[:summary] %> @@ -22,6 +23,7 @@ + <% end %> <%= placeholder_cell('24px', vertical: false) %> @@ -42,9 +44,11 @@ <% end %> + <% if local_assigns[:bottom_spacing] != false %> <%= placeholder_cell('40px', vertical: false) %> + <% end %> <%= placeholder_cell('16px', vertical: true) %> diff --git a/app/views/sharing_mailer/shared_work_package.html.erb b/app/views/sharing_mailer/shared_work_package.html.erb index a33aadd93e23..d29a6556309a 100644 --- a/app/views/sharing_mailer/shared_work_package.html.erb +++ b/app/views/sharing_mailer/shared_work_package.html.erb @@ -16,12 +16,12 @@ end %> - <%= render partial: 'mailer/notification_mailer_header', + <%= render partial: 'mailer/mailer_header', locals: { + user: @shared_with_user, summary:, button_href: shared_work_package_path(@work_package.id), button_text: I18n.t(:'mail.sharing.work_packages.open_work_package'), - salutation: I18n.t(:'mail.salutation', user: @shared_with_user.firstname) } %> <%= render layout: 'mailer/notification_row', diff --git a/app/views/work_package_mailer/mentioned.html.erb b/app/views/work_package_mailer/mentioned.html.erb index 986b6596dc1d..3f4a333d98be 100644 --- a/app/views/work_package_mailer/mentioned.html.erb +++ b/app/views/work_package_mailer/mentioned.html.erb @@ -8,13 +8,12 @@ <%= placeholder_cell('12px', vertical: true) %> - <%= render partial: 'mailer/notification_mailer_header', + <%= render partial: 'mailer/mailer_header', locals: { summary: I18n.t(:'mail.work_packages.mentioned_by', user: @journal.user), button_href: notifications_path(@work_package.id), button_text: I18n.t(:'mail.notification.see_in_center'), user: @user, - salutation: I18n.t(:'mail.salutation', user: @user.firstname) } %> <%= render layout: 'mailer/notification_row', diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index b06c32941301..a15f4bd48486 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -402,6 +402,10 @@ class Definition default: nil, env_alias: 'EMAIL_DELIVERY_METHOD' }, + emails_salutation: { + allowed: %w[firstname name], + default: :firstname + }, emails_footer: { default: { 'en' => '' diff --git a/config/locales/en.yml b/config/locales/en.yml index 206b7cb7d801..696a91a61f3b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2266,6 +2266,7 @@ en: see_in_center: 'See comment in notification center' settings: 'Change email settings' salutation: 'Hello %{user}' + salutation_full_name: 'Full name' work_packages: created_at: 'Created at %{timestamp} by %{user} ' login_to_see_all: 'Log in to see all notifications.' @@ -2749,6 +2750,7 @@ en: %{link}. setting_attachment_whitelist: "Attachment upload whitelist" setting_email_delivery_method: "Email delivery method" + setting_emails_salutation: "Address user in emails with" setting_sendmail_location: "Location of the sendmail executable" setting_smtp_enable_starttls_auto: "Automatically use STARTTLS if available" setting_smtp_ssl: "Use SSL connection" diff --git a/lib/open_project/patches/mailer_controller_preview.rb b/lib/open_project/patches/mailer_controller_preview.rb new file mode 100644 index 000000000000..468567b9f6a2 --- /dev/null +++ b/lib/open_project/patches/mailer_controller_preview.rb @@ -0,0 +1,46 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 COPYRIGHT and LICENSE files for more details. +#++ +# + +module OpenProject::Patches::MailerControllerCsp + extend ActiveSupport::Concern + + included do + prepend_before_action :extend_content_security_policy + + def extend_content_security_policy + append_content_security_policy_directives( + script_src: %w('unsafe-inline'), + ) + end + end +end + +OpenProject::Patches.patch_gem_version 'rails', '7.0.8' do + Rails::MailersController.include OpenProject::Patches::MailerControllerCsp +end diff --git a/modules/meeting/app/components/meetings/header_component.html.erb b/modules/meeting/app/components/meetings/header_component.html.erb index 32083ed30121..a85d5b23f7d5 100644 --- a/modules/meeting/app/components/meetings/header_component.html.erb +++ b/modules/meeting/app/components/meetings/header_component.html.erb @@ -26,6 +26,14 @@ item.with_leading_visual_icon(icon: :download) end + if User.current.allowed_to?(:send_meeting_agendas_notification, @meeting.project) + menu.with_item(label: t('meeting.label_mail_all_participants'), + href: notify_meeting_path(@meeting), + form_arguments: { method: :post, data: { turbo: 'false' } }) do |item| + item.with_leading_visual_icon(icon: :mail) + end + end + menu.with_item(label: t("label_meeting_delete"), scheme: :danger, href: meeting_path(@meeting), diff --git a/modules/meeting/app/controllers/meeting_contents_controller.rb b/modules/meeting/app/controllers/meeting_contents_controller.rb index 799d58c19118..0a729cf6fb94 100644 --- a/modules/meeting/app/controllers/meeting_contents_controller.rb +++ b/modules/meeting/app/controllers/meeting_contents_controller.rb @@ -90,36 +90,6 @@ def diff render_404 end - def notify - unless @content.new_record? - service = MeetingNotificationService.new(@meeting, @content_type) - result = service.call(@content, :content_for_review) - - if result.success? - flash[:notice] = I18n.t(:notice_successful_notification) - else - flash[:error] = I18n.t(:error_notification_with_errors, - recipients: result.errors.map(&:name).join('; ')) - end - end - redirect_back_or_default controller: '/meetings', action: 'show', id: @meeting - end - - def icalendar - unless @content.new_record? - service = MeetingNotificationService.new(@meeting, @content_type) - result = service.call(@content, :icalendar_notification, include_author: true) - - if result.success? - flash[:notice] = I18n.t(:notice_successful_notification) - else - flash[:error] = I18n.t(:error_notification_with_errors, - recipients: result.errors.map(&:name).join('; ')) - end - end - redirect_back_or_default controller: '/meetings', action: 'show', id: @meeting - end - def default_breadcrumb MeetingsController.new.send(:default_breadcrumb) end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 8b978318fbc8..907ecc4a4fb9 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -210,6 +210,20 @@ def download_ics end end + def notify + service = MeetingNotificationService.new(@meeting) + result = service.call(:invited) + + if result.success? + flash[:notice] = I18n.t(:notice_successful_notification) + else + flash[:error] = I18n.t(:error_notification_with_errors, + recipients: result.errors.map(&:name).join('; ')) + end + + redirect_to action: :show, id: @meeting + end + private def load_query @@ -264,8 +278,8 @@ def global_upcoming_meetings def find_meeting @meeting = Meeting - .includes([:project, :author, { participants: :user }, :agenda, :minutes]) - .find(params[:id]) + .includes([:project, :author, { participants: :user }, :agenda, :minutes]) + .find(params[:id]) @project = @meeting.project rescue ActiveRecord::RecordNotFound render_404 diff --git a/modules/meeting/app/helpers/meeting_contents_helper.rb b/modules/meeting/app/helpers/meeting_contents_helper.rb index 9d9eb4498b34..cbc2fca70154 100644 --- a/modules/meeting/app/helpers/meeting_contents_helper.rb +++ b/modules/meeting/app/helpers/meeting_contents_helper.rb @@ -45,11 +45,6 @@ def meeting_content_context_menu(content, content_type) menu << meeting_content_edit_link(content_type) if can_edit_meeting_content?(content, content_type) menu << meeting_content_history_link(content_type, content.meeting) - if saved_meeting_content_text_present?(content) - menu << meeting_content_notify_link(content_type, content.meeting) - menu << meeting_content_icalendar_link(content_type, content.meeting) - end - menu.join(' ') end @@ -129,28 +124,6 @@ def meeting_content_history_link(content_type, meeting) end end - def meeting_content_notify_link(content_type, meeting) - content_tag :li, '', class: 'toolbar-item' do - link_to_if_authorized({ controller: '/' + content_type.pluralize, - action: 'notify', meeting_id: meeting }, - method: :put, - class: 'button') do - text_with_icon(I18n.t(:label_notify), 'icon-mail1') - end - end - end - - def meeting_content_icalendar_link(content_type, meeting) - content_tag :li, '', class: 'toolbar-item' do - link_to_if_authorized({ controller: '/' + content_type.pluralize, - action: 'icalendar', meeting_id: meeting }, - method: :put, - class: 'button') do - text_with_icon(I18n.t(:label_icalendar), 'icon-calendar2') - end - end - end - def text_with_icon(text, icon) op_icon("button--icon #{icon}") + ' ' + diff --git a/modules/meeting/app/mailers/meeting_mailer.rb b/modules/meeting/app/mailers/meeting_mailer.rb index fedc207be805..e6b6abb11a11 100644 --- a/modules/meeting/app/mailers/meeting_mailer.rb +++ b/modules/meeting/app/mailers/meeting_mailer.rb @@ -26,46 +26,71 @@ # See COPYRIGHT and LICENSE files for more details. #++ +class MeetingMailer < UserMailer + def invited(meeting, user, actor) + @actor = actor + @meeting = meeting + @user = user + open_project_headers 'Project' => @meeting.project.identifier, + 'Meeting-Id' => @meeting.id -class MeetingMailer < UserMailer - def content_for_review(content, content_type, user) - @author = User.current - @meeting = content.meeting - @content_type = content_type + with_attached_ics(meeting, user) do + subject = "[#{@meeting.project.name}] #{@meeting.title}" + mail(to: user.mail, subject:) + end + end + + def rescheduled(meeting, user, actor, changes:) + @actor = actor + @user = user + @meeting = meeting + @changes = changes open_project_headers 'Project' => @meeting.project.identifier, 'Meeting-Id' => @meeting.id - User.execute_as(user) do - subject = "[#{@meeting.project.name}] #{I18n.t(:"label_#{content_type}")}: #{@meeting.title}" - mail to: user.mail, subject: + with_attached_ics(meeting, user) do + subject = "[#{@meeting.project.name}] " + subject << I18n.t('meeting.email.rescheduled.header', title: @meeting.title, actor: @actor) + mail(to: user.mail, subject:) end end - def icalendar_notification(content, content_type, user) - @meeting = content.meeting - @content_type = content_type + def icalendar_notification(meeting, user, _actor, **) + @meeting = meeting set_headers @meeting - User.execute_as(user) do + with_attached_ics(meeting, user) do timezone = Time.zone || Time.zone_default - @formatted_timezone = format_timezone_offset timezone, @meeting.start_time + subject = "[#{@meeting.project.name}] #{@meeting.title}" + mail(to: user.mail, subject:) + end + end + + private - ::Meetings::ICalService + def with_attached_ics(meeting, user) + User.execute_as(user) do + + call = ::Meetings::ICalService .new(user:, meeting: @meeting) .call - .on_success do |call| + + call.on_success do attachments['meeting.ics'] = call.result - mail(to: user.mail, subject: "[#{@meeting.project.name}] #{I18n.t(:label_meeting)}: #{@meeting.title}") + + yield + end + + call.on_failure do + Rails.logger.error { "Failed to create ICS attachment for meeting #{meeting.id}: #{call.message}" } end end end - private - def set_headers(meeting) open_project_headers 'Project' => meeting.project.identifier, 'Meeting-Id' => meeting.id headers['Content-Type'] = 'text/calendar; charset=utf-8; method="PUBLISH"; name="meeting.ics"' diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index 0290ee8cdb99..809e67c61565 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -36,7 +36,12 @@ class Meeting < ApplicationRecord has_one :agenda, dependent: :destroy, class_name: 'MeetingAgenda' has_one :minutes, dependent: :destroy, class_name: 'MeetingMinutes' has_many :contents, -> { readonly }, class_name: 'MeetingContent' - has_many :participants, dependent: :destroy, class_name: 'MeetingParticipant' + + has_many :participants, + dependent: :destroy, + class_name: 'MeetingParticipant', + after_add: :send_participant_added_mail + has_many :agenda_items, dependent: :destroy, class_name: 'MeetingAgendaItem' default_scope do @@ -57,11 +62,11 @@ class Meeting < ApplicationRecord acts_as_watchable permission: :view_meetings acts_as_searchable columns: [ - "#{table_name}.title", - "#{MeetingContent.table_name}.text", - "#{MeetingAgendaItem.table_name}.title", - "#{MeetingAgendaItem.table_name}.notes" - ], + "#{table_name}.title", + "#{MeetingContent.table_name}.text", + "#{MeetingAgendaItem.table_name}.title", + "#{MeetingAgendaItem.table_name}.notes" + ], include: %i[contents project agenda_items], references: %i[meeting_contents agenda_items], date_column: "#{table_name}.created_at" @@ -82,11 +87,11 @@ class Meeting < ApplicationRecord end validate :validate_date_and_time - before_save :update_start_time! + before_save :update_start_time! before_save :add_new_participants_as_watcher - after_initialize :set_initial_values + after_update :send_rescheduling_mail, if: -> { saved_change_to_start_time? || saved_change_to_duration? } enum state: { open: 0, # 0 -> default, leave values for future states between open and closed @@ -150,7 +155,7 @@ def all_changeable_participants changeable_participants = participants.select(&:invited).collect(&:user) changeable_participants = changeable_participants + participants.select(&:attended).collect(&:user) changeable_participants = changeable_participants + \ - User.allowed_members(:view_meetings, project) + User.allowed_members(:view_meetings, project) changeable_participants .compact @@ -215,6 +220,7 @@ def close_agenda_and_copy_to_minutes! end alias :original_participants_attributes= :participants_attributes= + def participants_attributes=(attrs) attrs.each do |participant| participant['_destroy'] = true if !(participant['attended'] || participant['invited']) @@ -310,4 +316,22 @@ def add_new_participants_as_watcher add_watcher(p.user) end end + + def send_participant_added_mail(participant) + if persisted? + MeetingMailer.invited(self, participant.user, User.current).deliver_later + end + end + + def send_rescheduling_mail + MeetingNotificationService + .new(self) + .call :rescheduled, + changes: { + old_start: saved_change_to_start_time? ? saved_change_to_start_time.first : start_time, + new_start: start_time, + old_duration: saved_change_to_duration? ? saved_change_to_duration.first : duration, + new_duration: duration + } + end end diff --git a/modules/meeting/app/services/meeting_notification_service.rb b/modules/meeting/app/services/meeting_notification_service.rb index 44c77c9b6ce7..ec08d5fb5681 100644 --- a/modules/meeting/app/services/meeting_notification_service.rb +++ b/modules/meeting/app/services/meeting_notification_service.rb @@ -1,26 +1,25 @@ class MeetingNotificationService attr_reader :meeting, :content_type - def initialize(meeting, content_type) + def initialize(meeting) @meeting = meeting - @content_type = content_type end - def call(content, action, include_author: false) - recipients_with_errors = send_notifications!(content, action, include_author:) + def call(action, include_author: false, **) + recipients_with_errors = send_notifications!(action, include_author:, **) ServiceResult.new(success: recipients_with_errors.empty?, errors: recipients_with_errors) end private - def send_notifications!(content, action, include_author:) + def send_notifications!(action, include_author:, **) author_mail = meeting.author.mail recipients_with_errors = [] - meeting.participants.includes(:user).each do |recipient| + meeting.participants.includes(:user).find_each do |recipient| next if recipient.mail == author_mail && !include_author - MeetingMailer.send(action, content, content_type, recipient.user).deliver_now + MeetingMailer.send(action, meeting, recipient.user, User.current, **).deliver_later rescue StandardError => e Rails.logger.error do "Failed to deliver #{action} notification to #{recipient.mail}: #{e.message}" diff --git a/modules/meeting/app/views/meeting_mailer/invited.html.erb b/modules/meeting/app/views/meeting_mailer/invited.html.erb new file mode 100644 index 000000000000..bc294af33267 --- /dev/null +++ b/modules/meeting/app/views/meeting_mailer/invited.html.erb @@ -0,0 +1,114 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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 COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render layout: 'mailer/spacer_table' do %> + <%= render partial: 'mailer/mailer_header', + locals: { + user: @user, + summary: I18n.t('meeting.email.invited.summary', title: @meeting.title, actor: @actor), + bottom_spacing: false + } %> + + <%= render layout: 'mailer/border_table' do %> + + <%= placeholder_cell('24px', vertical: true) %> + + + + + + + <% if @meeting.location.present? %> + + + + + <% end %> + + + + + + + + + <% if @meeting.participants.exists? %> + + + + + + + + + <% end %> +
+ <%= I18n.t(:label_meeting_date_time) %> + + <%= format_time_as_date @meeting.start_time %> <%= format_time @meeting.start_time, false %> + - + <%= format_time @meeting.end_time, false %> <%= Time.zone %> +
+ <%= Meeting.human_attribute_name(:location) %> + + <%= auto_link @meeting.location %> +
+ <%= Meeting.human_attribute_name(:project) %> + + <%= link_to @meeting.project.name, project_url(@meeting.project) %> +
+ <%= Meeting.human_attribute_name(:author) %> + + <%= @meeting.author %> +
+ <%= Meeting.human_attribute_name(:participants_invited) %> + + <%= @meeting.participants.invited.sort.join("; ") %> +
+ <%= Meeting.human_attribute_name(:participants_attended) %> + + <%= @meeting.participants.attended.sort.join("; ") %> +
+ + + <% end %> + + + + <%= placeholder_cell('20px', vertical: false) %> + +
+ + <%= action_button do %> + <%= link_to I18n.t(:'meeting.email.open_meeting_link'), + meeting_url(@meeting), + target: '_blank', + style: "color: #333333; text-decoration: none; font-size: 14px;white-space: nowrap;" %> + <% end %> +<% end %> diff --git a/modules/meeting/app/views/meeting_mailer/content_for_review.text.erb b/modules/meeting/app/views/meeting_mailer/invited.text.erb similarity index 90% rename from modules/meeting/app/views/meeting_mailer/content_for_review.text.erb rename to modules/meeting/app/views/meeting_mailer/invited.text.erb index 9a92fb74c27e..2a15f364390c 100644 --- a/modules/meeting/app/views/meeting_mailer/content_for_review.text.erb +++ b/modules/meeting/app/views/meeting_mailer/invited.text.erb @@ -27,6 +27,8 @@ See COPYRIGHT and LICENSE files for more details. ++#%> +<%= I18n.t('meeting.email.invited.summary', title: @meeting.title, actor: @actor) %> + <%= @meeting.project.name %>: <%= @meeting.title %> (<%= meeting_url(@meeting) %>) <%= @meeting.author %> @@ -34,8 +36,3 @@ See COPYRIGHT and LICENSE files for more details. <%= Meeting.human_attribute_name(:location) %>: <%= @meeting.location %> <%= Meeting.human_attribute_name(:participants_invited) %>: <%= @meeting.participants.invited.sort.join("; ") %> <%= Meeting.human_attribute_name(:participants_attended) %>: <%= @meeting.participants.attended.sort.join("; ") %> - -<%=t(:"text_review_#{@content_type}", - :author => @author, - :link => t(:"text_#{@content_type}_for_meeting", - :meeting => @meeting.title) + " (#{meeting_url(@meeting)})") %> diff --git a/modules/meeting/app/views/meeting_mailer/rescheduled.html.erb b/modules/meeting/app/views/meeting_mailer/rescheduled.html.erb new file mode 100644 index 000000000000..6a4e7bc96651 --- /dev/null +++ b/modules/meeting/app/views/meeting_mailer/rescheduled.html.erb @@ -0,0 +1,126 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 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 COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render layout: 'mailer/spacer_table' do %> + <%= render partial: 'mailer/mailer_header', + locals: { + user: @user, + summary: I18n.t('meeting.email.rescheduled.summary', title: @meeting.title, actor: @actor), + bottom_spacing: false + } %> + + <%= render layout: 'mailer/border_table' do %> + + <%= placeholder_cell('24px', vertical: true) %> + + + + + + + + + + + <% if @meeting.location.present? %> + + + + + <% end %> + + + + + + + + + <% if @meeting.participants.exists? %> + + + + + + + + + <% end %> +
+ <%= t('meeting.email.rescheduled.old_date_time') %> + + + <%= format_time_as_date @changes[:old_start] %> <%= format_time @changes[:old_start], false %> + - + <%= format_time (@changes[:old_start] + @changes[:old_duration].hours), false %> <%= Time.zone %> + +
+ <%= t('meeting.email.rescheduled.new_date_time') %> + + <%= format_time_as_date @changes[:new_start] %> <%= format_time @changes[:new_start], false %> + - + <%= format_time (@changes[:new_start] + @changes[:new_duration].hours), false %> <%= Time.zone %> +
+ <%= Meeting.human_attribute_name(:location) %> + + <%= auto_link @meeting.location %> +
+ <%= Meeting.human_attribute_name(:project) %> + + <%= link_to @meeting.project.name, project_url(@meeting.project) %> +
+ <%= Meeting.human_attribute_name(:author) %> + + <%= @meeting.author %> +
+ <%= Meeting.human_attribute_name(:participants_invited) %> + + <%= @meeting.participants.invited.sort.join("; ") %> +
+ <%= Meeting.human_attribute_name(:participants_attended) %> + + <%= @meeting.participants.attended.sort.join("; ") %> +
+ + + <% end %> + + + + <%= placeholder_cell('20px', vertical: false) %> + +
+ + <%= action_button do %> + <%= link_to I18n.t(:'meeting.email.open_meeting_link'), + meeting_url(@meeting), + target: '_blank', + style: "color: #333333; text-decoration: none; font-size: 14px;white-space: nowrap;" %> + <% end %> +<% end %> diff --git a/modules/meeting/app/views/meeting_mailer/content_for_review.html.erb b/modules/meeting/app/views/meeting_mailer/rescheduled.text.erb similarity index 54% rename from modules/meeting/app/views/meeting_mailer/content_for_review.html.erb rename to modules/meeting/app/views/meeting_mailer/rescheduled.text.erb index ab643bd20607..d601b7196b18 100644 --- a/modules/meeting/app/views/meeting_mailer/content_for_review.html.erb +++ b/modules/meeting/app/views/meeting_mailer/rescheduled.text.erb @@ -27,20 +27,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -

<%= link_to(@meeting.project.name, project_url(@meeting.project)) %>: <%= link_to(@meeting.title, meeting_url(@meeting)) %>

-<%= @meeting.author %> - - - -

- <%=raw t(:"text_review_#{@content_type}", - :author => h(@author), - :link => link_to(t(:"text_#{@content_type}_for_meeting", - :meeting => @meeting.title), - meeting_url(@meeting))) %> -

+<%= @meeting.project.name %>: <%= @meeting.title %> (<%= meeting_url(@meeting) %>) +<%= @meeting.author %> + +<%= t('meeting.email.rescheduled.body', + actor: @actor, + title: @meeting.title) %> + +<%= t('meeting.email.rescheduled.old_date_time') %>: +<%= format_time_as_date @changes[:old_start] %> <%= format_time @changes[:old_start], false %> - <%= format_time (@changes[:old_start] + @changes[:old_duration]), false %> <%= Time.zone %> + +<%= t('meeting.email.rescheduled.new_date_time') %>: +<%= format_time_as_date @changes[:new_start] %> <%= format_time @changes[:new_start], false %> - <%= format_time (@changes[:new_start] + @changes[:new_duration]), false %> <%= Time.zone %> diff --git a/modules/meeting/app/views/meetings/show.html.erb b/modules/meeting/app/views/meetings/show.html.erb index aceab9f7e5e4..d1018623c566 100644 --- a/modules/meeting/app/views/meetings/show.html.erb +++ b/modules/meeting/app/views/meetings/show.html.erb @@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details. <% html_title "#{t(:label_meeting)}: #{@meeting.title}" %> <%= toolbar title: t(:label_meeting), link_to: link_to(@meeting), - html: { class: 'meeting--main-toolbar' } do %> + html: { class: 'meeting--main-toolbar -with-dropdown' } do %> <% unless User.current.anonymous? %>
  • @@ -40,31 +40,57 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% if authorize_for(:meetings, :edit) %>
  • - <%= link_to({:controller => '/meetings', :action => 'edit', :id => @meeting}, class: 'button',:accesskey => accesskey(:edit)) do%> + <%= link_to({ :controller => '/meetings', :action => 'edit', :id => @meeting }, class: 'button', :accesskey => accesskey(:edit)) do %> <%= op_icon('button--icon icon-edit') %> <%= t(:button_edit) %> <% end %>
  • <% end %> - <% if authorize_for(:meetings, :copy) %> -
  • - <%= link_to({:controller => '/meetings', :action => 'copy', :id => @meeting}, class: 'button') do %> - <%= op_icon('button--icon icon-copy') %> - <%= t(:button_copy) %> + +
  • <% end %>
    @@ -74,28 +100,36 @@ See COPYRIGHT and LICENSE files for more details.

    <%= authoring @meeting.created_at, @meeting.author %>

    -

    <%= Meeting.human_attribute_name(:start_time) %>: <%= format_date @meeting.start_time %> <%= format_time @meeting.start_time, false %> - <%= format_time @meeting.end_time, false %> <%= Time.zone %>

    +

    + <%= Meeting.human_attribute_name(:start_time) %>: <%= format_date @meeting.start_time %> <%= format_time @meeting.start_time, false %> + - <%= format_time @meeting.end_time, false %> <%= Time.zone %>

    -

    <%= Meeting.human_attribute_name(:location) %>: <%= auto_link(h(@meeting.location), link: :all, html: { target: '_blank' }) %>

    +

    + <%= Meeting.human_attribute_name(:location) %>: <%= auto_link(h(@meeting.location), link: :all, html: { target: '_blank' }) %> +

    -

    <%= Meeting.human_attribute_name(:participants_invited) %>: <%= format_participant_list @meeting.participants.invited %>

    +

    + <%= Meeting.human_attribute_name(:participants_invited) %>: <%= format_participant_list @meeting.participants.invited %> +

    -

    <%= Meeting.human_attribute_name(:participants_attended) %>: <%= format_participant_list @meeting.participants.attended %>

    +

    + <%= Meeting.human_attribute_name(:participants_attended) %>: <%= format_participant_list @meeting.participants.attended %> +

    -<%= render_tabs [{:name => 'agenda', :action => :create_meeting_agendas, :partial => 'meeting_contents/show', :path => meeting_agenda_path(@meeting), :label => :label_meeting_agenda, :content => @meeting.agenda || @meeting.build_agenda, :content_type => "meeting_agenda"}, - {:name => 'minutes', :action => :create_meeting_minutes, :partial => 'meeting_contents/show', :path => meeting_minutes_path(@meeting), :label => :label_meeting_minutes, :content => @meeting.minutes || @meeting.build_minutes, :content_type => "meeting_minutes"}] %> +<%= render_tabs [{ :name => 'agenda', :action => :create_meeting_agendas, :partial => 'meeting_contents/show', :path => meeting_agenda_path(@meeting), :label => :label_meeting_agenda, :content => @meeting.agenda || @meeting.build_agenda, :content_type => "meeting_agenda" }, + { :name => 'minutes', :action => :create_meeting_minutes, :partial => 'meeting_contents/show', :path => meeting_minutes_path(@meeting), :label => :label_meeting_minutes, :content => @meeting.minutes || @meeting.build_minutes, :content_type => "meeting_minutes" }] %> <% if @meeting.journals.changing.present? %> -
    -

    <%=t(:label_history)%>

    - <% @meeting.journals.each do |journal| %> - <%= render_meeting_journal @meeting, journal %> - <% end %> -
    +
    +

    <%= t(:label_history) %>

    + <% @meeting.journals.each do |journal| %> + <%= render_meeting_journal @meeting, journal %> + <% end %> +
    <% end %> diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 45fc6856cf31..74f6af3d9fb7 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -110,6 +110,16 @@ en: meeting: + email: + open_meeting_link: "Open meeting" + invited: + summary: "%{actor} has sent you an invitation for the meeting %{title}" + rescheduled: + summary: "Meeting %{title} has been rescheduled by %{actor}" + body: "The meeting %{title} has been rescheduled by %{actor}." + old_date_time: "Old date/time" + new_date_time: "New date/time" + label_mail_all_participants: "Send email to all participants" types: classic: 'Classic' classic_text: 'Organize your meeting in a formattable text agenda and protocol.' @@ -143,8 +153,6 @@ en: text_meeting_closing_are_you_sure: "Are you sure you want to close the meeting agenda?" text_meeting_agenda_open_are_you_sure: "This will overwrite all changes in the minutes! Do you want to continue?" text_meeting_minutes_for_meeting: 'minutes for the meeting "%{meeting}"' - text_review_meeting_agenda: "%{author} has put the %{link} up for review." - text_review_meeting_minutes: "%{author} has put the %{link} up for review." text_notificiation_invited: "This mail contains an ics entry for the meeting below:" text_meeting_empty_heading: "Your meeting is empty" diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 6875ffe50d77..a27824fb4daa 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -53,6 +53,7 @@ put :update_details put :update_participants put :change_state + post :notify end resources :agenda_items, controller: 'meeting_agenda_items' do collection do @@ -73,8 +74,6 @@ get :diff put :close put :open - put :notify - put :icalendar post :preview end @@ -86,8 +85,6 @@ member do get :history get :diff - put :notify - get :icalendar end end @@ -95,7 +92,6 @@ member do get :history get :diff - put :notify post :preview end diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 511bb8fd71a7..e039cce8c7ef 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -85,7 +85,10 @@ class Engine < ::Rails::Engine permissible_on: :project, require: :member permission :send_meeting_agendas_notification, - { meeting_agendas: [:notify] }, + { + meetings: [:notify], + meeting_agendas: [:notify] + }, permissible_on: :project, require: :member permission :send_meeting_agendas_icalendar, diff --git a/modules/meeting/spec/controllers/meeting_contents_controller_spec.rb b/modules/meeting/spec/controllers/meeting_contents_controller_spec.rb deleted file mode 100644 index d2a656e42a65..000000000000 --- a/modules/meeting/spec/controllers/meeting_contents_controller_spec.rb +++ /dev/null @@ -1,125 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2023 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 COPYRIGHT and LICENSE files for more details. -#++ - -require 'spec_helper' - -RSpec.describe MeetingContentsController do - shared_let(:role) { create(:project_role, permissions: [:view_meetings]) } - shared_let(:project) { create(:project) } - shared_let(:author) { create(:user, member_with_roles: { project => role }) } - shared_let(:watcher1) { create(:user, member_with_roles: { project => role }) } - shared_let(:watcher2) { create(:user, member_with_roles: { project => role }) } - shared_let(:meeting) do - User.execute_as author do - create(:meeting, author:, project:) - end - end - shared_let(:meeting_agenda) do - User.execute_as author do - create(:meeting_agenda, meeting:) - end - end - - before do - ActionMailer::Base.deliveries = [] - allow_any_instance_of(MeetingContentsController).to receive(:find_content) - allow(controller).to receive(:authorize) - meeting.participants.merge([meeting.participants.build(user: watcher1, invited: true, attended: false), - meeting.participants.build(user: watcher2, invited: true, attended: false)]) - meeting.save! - controller.instance_variable_set(:@content, meeting_agenda.meeting.agenda) - controller.instance_variable_set(:@content_type, 'meeting_agenda') - end - - shared_examples_for 'delivered by mail' do - before { put action, params: { meeting_id: meeting.id } } - - it { expect(ActionMailer::Base.deliveries.count).to eql(mail_count) } - end - - describe 'PUT' do - describe 'notify' do - let(:action) { 'notify' } - - before do - author.save! - end - - it_behaves_like 'delivered by mail' do - let(:mail_count) { 2 } - end - - context 'with an error during deliver' do - before do - allow(MeetingMailer).to receive(:content_for_review).and_raise(Net::SMTPError) - end - - it 'does not raise an error' do - expect { put 'notify', params: { meeting_id: meeting.id } }.not_to raise_error - end - - it 'produces a flash message containing the mail addresses raising the error' do - put 'notify', params: { meeting_id: meeting.id } - meeting.participants.each do |participant| - expect(flash[:error]).to include(participant.name) - end - end - end - end - - describe 'icalendar' do - let(:action) { 'icalendar' } - - before do - author.save! - end - - it_behaves_like 'delivered by mail' do - let(:mail_count) { 3 } - end - - context 'with an error during deliver' do - before do - author.save! - allow(MeetingMailer).to receive(:content_for_review).and_raise(Net::SMTPError) - end - - it 'does not raise an error' do - expect { put 'notify', params: { meeting_id: meeting.id } }.not_to raise_error - end - - it 'produces a flash message containing the mail addresses raising the error' do - put 'notify', params: { meeting_id: meeting.id } - meeting.participants.each do |participant| - expect(flash[:error]).to include(participant.name) - end - end - end - end - end -end diff --git a/modules/meeting/spec/controllers/meetings_controller_spec.rb b/modules/meeting/spec/controllers/meetings_controller_spec.rb index aaec71aa276c..25b70d14a91c 100644 --- a/modules/meeting/spec/controllers/meetings_controller_spec.rb +++ b/modules/meeting/spec/controllers/meetings_controller_spec.rb @@ -223,4 +223,32 @@ end end end + + describe 'notify' do + let!(:meeting) { create(:meeting) } + let!(:participant) { create(:meeting_participant, meeting:, attended: true) } + + it 'produces a background job for notification' do + post :notify, params: { id: meeting.id } + + perform_enqueued_jobs + expect(ActionMailer::Base.deliveries.count).to eq(1) + end + + context 'with an error during deliver' do + before do + allow(MeetingMailer).to receive(:invited).and_raise(Net::SMTPError) + end + + it 'produces a flash message containing the mail addresses raising the error' do + expect { post :notify, params: { id: meeting.id } }.not_to raise_error + meeting.participants.each do |participant| + expect(flash[:error]).to include(participant.name) + end + + perform_enqueued_jobs + expect(ActionMailer::Base.deliveries.count).to eq(0) + end + end + end end diff --git a/modules/meeting/spec/features/meetings_copy_spec.rb b/modules/meeting/spec/features/meetings_copy_spec.rb index 74060cec6451..a7558b9ad4ee 100644 --- a/modules/meeting/spec/features/meetings_copy_spec.rb +++ b/modules/meeting/spec/features/meetings_copy_spec.rb @@ -78,7 +78,8 @@ click_link meeting.title - within '.meeting--main-toolbar' do + find_test_selector('meetings-more-dropdown-menu').click + page.within('.menu-drop-down-container') do click_link 'Copy' end diff --git a/modules/meeting/spec/features/meetings_delete_spec.rb b/modules/meeting/spec/features/meetings_delete_spec.rb index 5099b74948c4..bc0667ca6dbb 100644 --- a/modules/meeting/spec/features/meetings_delete_spec.rb +++ b/modules/meeting/spec/features/meetings_delete_spec.rb @@ -56,6 +56,7 @@ click_link meeting.title accept_confirm do + find_test_selector('meetings-more-dropdown-menu').click click_link "Delete" end @@ -64,6 +65,7 @@ click_link other_meeting.title accept_confirm do + find_test_selector('meetings-more-dropdown-menu').click click_link "Delete" end diff --git a/modules/meeting/spec/mailers/meeting_mailer_spec.rb b/modules/meeting/spec/mailers/meeting_mailer_spec.rb index 17444c4e7f88..1adc8d3c7c9c 100644 --- a/modules/meeting/spec/mailers/meeting_mailer_spec.rb +++ b/modules/meeting/spec/mailers/meeting_mailer_spec.rb @@ -56,8 +56,8 @@ meeting.save! end - describe 'content_for_review' do - let(:mail) { described_class.content_for_review meeting_agenda, 'meeting_agenda', author } + describe 'invited' do + let(:mail) { described_class.invited(meeting, watcher1, author) } # this is needed to call module functions from Redmine::I18n let(:i18n) do Class.new do @@ -69,21 +69,24 @@ it 'renders the headers' do expect(mail.subject).to include(meeting.project.name) expect(mail.subject).to include(meeting.title) - expect(mail.to).to contain_exactly(author.mail) + expect(mail.to).to contain_exactly(watcher1.mail) expect(mail.from).to eq([Setting.mail_from]) end it 'renders the text body' do - check_meeting_mail_content mail.text_part.body + User.execute_as(watcher1) do + check_meeting_mail_content mail.text_part.body + end end it 'renders the html body' do - check_meeting_mail_content mail.html_part.body + User.execute_as(watcher1) do + check_meeting_mail_content mail.html_part.body + end end context 'with a recipient with another time zone' do let!(:preference) { create(:user_preference, user: watcher1, time_zone: 'Asia/Tokyo') } - let(:mail) { described_class.content_for_review meeting_agenda, 'meeting_agenda', watcher1 } it 'renders the mail with the correcet locale' do expect(mail.text_part.body).to include('Tokyo') @@ -104,10 +107,11 @@ end describe 'it renders november 9th for Berlin zone' do - let(:mail) { described_class.content_for_review meeting_agenda, 'meeting_agenda', author } + let(:mail) { described_class.invited(meeting, author, author) } it 'renders the mail with the correct locale' do - expect(mail.html_part.body).to include('11/09/2021 11:00 PM-12:00 AM (GMT+01:00) Europe/Berlin') + expect(mail.html_part.body).to include('11/09/2021 11:00 PM') + expect(mail.html_part.body).to include('12:00 AM (GMT+01:00) Europe/Berlin') expect(mail.text_part.body).to include('11/09/2021 11:00 PM-12:00 AM (GMT+01:00) Europe/Berlin') expect(mail.to).to contain_exactly(author.mail) @@ -115,12 +119,14 @@ end describe 'it renders november 10th for Tokyo zone' do - let(:mail) { described_class.content_for_review meeting_agenda, 'meeting_agenda', watcher1 } let!(:preference) { create(:user_preference, user: watcher1, time_zone: 'Asia/Tokyo') } + let(:mail) { described_class.invited(meeting, watcher1, author) } it 'renders the mail with the correct locale' do + expect(mail.html_part.body).to include('11/10/2021 07:00 AM') + expect(mail.html_part.body).to include('08:00 AM (GMT+09:00) Asia/Tokyo') + expect(mail.text_part.body).to include('11/10/2021 07:00 AM-08:00 AM (GMT+09:00) Asia/Tokyo') - expect(mail.html_part.body).to include('11/10/2021 07:00 AM-08:00 AM (GMT+09:00) Asia/Tokyo') expect(mail.to).to contain_exactly(watcher1.mail) end @@ -138,7 +144,7 @@ start_time: "2021-01-19T10:00:00Z".to_time(:utc), duration: 1.0) end - let(:mail) { described_class.icalendar_notification meeting_agenda, 'meeting_agenda', author } + let(:mail) { described_class.icalendar_notification(meeting, author, author) } it 'renders the headers' do expect(mail.subject).to include(meeting.project.name) @@ -167,7 +173,8 @@ expect(body).to include(meeting.project.name) expect(body).to include(meeting.title) expect(body).to include(meeting.location) - expect(body).to include('01/19/2021 11:00 AM-12:00 PM (GMT+01:00) Europe/Berlin') + expect(body).to include('01/19/2021 11:00 AM') + expect(body).to include('12:00 PM (GMT+01:00) Europe/Berlin') expect(body).to include(meeting.participants[0].name) expect(body).to include(meeting.participants[1].name) end @@ -197,13 +204,12 @@ context 'with a recipient with another time zone' do let!(:preference) { create(:user_preference, user: watcher1, time_zone: 'Asia/Tokyo') } - let(:mail) { described_class.content_for_review meeting_agenda, 'meeting_agenda', watcher1 } + let(:mail) { described_class.icalendar_notification(meeting, watcher1, author) } it 'renders the mail with the correct locale' do expect(mail.text_part.body).to include('01/19/2021 07:00 PM-08:00 PM (GMT+09:00) Asia/Tokyo') - expect(mail.text_part.body).to include("#{author.name} has put the") - expect(mail.html_part.body).to include('01/19/2021 07:00 PM-08:00 PM (GMT+09:00) Asia/Tokyo') - expect(mail.html_part.body).to include("#{author.name} has put the") + expect(mail.html_part.body).to include('01/19/2021 07:00 PM') + expect(mail.html_part.body).to include('08:00 PM (GMT+09:00) Asia/Tokyo') expect(mail.to).to contain_exactly(watcher1.mail) end @@ -218,18 +224,19 @@ end describe 'it renders november 9th for Berlin zone' do - let(:mail) { described_class.icalendar_notification meeting_agenda, 'meeting_agenda', author } + let(:mail) { described_class.icalendar_notification(meeting, author, author) } it 'renders the mail with the correct locale' do expect(mail.text_part.body).to include('11/09/2021 11:00 PM-12:00 AM (GMT+01:00) Europe/Berlin') - expect(mail.html_part.body).to include('11/09/2021 11:00 PM-12:00 AM (GMT+01:00) Europe/Berlin') + expect(mail.html_part.body).to include('11/09/2021 11:00 PM') + expect(mail.html_part.body).to include('12:00 AM (GMT+01:00) Europe/Berlin') expect(mail.to).to contain_exactly(author.mail) end end describe 'it renders november 10th for Tokyo zone' do - let(:mail) { described_class.icalendar_notification meeting_agenda, 'meeting_agenda', watcher1 } + let(:mail) { described_class.icalendar_notification(meeting, watcher1, author) } let!(:preference) { create(:user_preference, user: watcher1, time_zone: 'Asia/Tokyo') } it 'renders the mail with the correct locale' do diff --git a/spec/mailers/announcement_mailer_spec.rb b/spec/mailers/announcement_mailer_spec.rb index fe20ba083324..a9fbc9f9e428 100644 --- a/spec/mailers/announcement_mailer_spec.rb +++ b/spec/mailers/announcement_mailer_spec.rb @@ -59,22 +59,5 @@ expect(mail.to) .to match_array [recipient.mail] end - - context 'with custom salutation' do - subject(:mail) do - described_class.announce(recipient, - subject: announcement_subject, - salutation: "What's up %s?", - body: announcement_body) - end - - it 'includes the body' do - expect(mail.body.encoded) - .to include("What's up #{recipient.name}") - - expect(mail.body.encoded) - .not_to include("Hey #{recipient.name}!") - end - end end end diff --git a/spec/mailers/previews/meeting_mailer_preview.rb b/spec/mailers/previews/meeting_mailer_preview.rb new file mode 100644 index 000000000000..5bde2a15d833 --- /dev/null +++ b/spec/mailers/previews/meeting_mailer_preview.rb @@ -0,0 +1,56 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 COPYRIGHT and LICENSE files for more details. +#++ + +class MeetingMailerPreview < ActionMailer::Preview + # Preview emails at http://localhost:3000/rails/mailers/meeting_mailer + + def rescheduled + language = params['locale'] || I18n.default_locale + actor = FactoryBot.build_stubbed(:user, lastname: 'Actor') + user = FactoryBot.build_stubbed(:user, language:) + meeting = FactoryBot.build_stubbed(:meeting, start_time: 1.day.from_now, duration: 1.0) + + changes = { + old_start: meeting.start_time, + old_duration: meeting.duration, + new_start: 5.days.from_now, + new_duration: 2.5 + } + + MeetingMailer.rescheduled(meeting, user, actor, changes:) + end + + def invited + language = params['locale'] || I18n.default_locale + actor = FactoryBot.build_stubbed(:user, lastname: 'Actor') + user = FactoryBot.build_stubbed(:user, language:) + meeting = FactoryBot.build_stubbed(:meeting) + + MeetingMailer.invited(meeting, user, actor) + end +end