<%= setting_select(:email_delivery_method, email_methods, id: "email_delivery_method_switch", container_class: '-slim') %>
<%= 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 %>
+ |
- <%=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? %>