From f87ca8232672ced2600ec105c67538d7cb98d131 Mon Sep 17 00:00:00 2001 From: Ben Halpern Date: Fri, 25 Oct 2024 11:55:04 -0400 Subject: [PATCH] Add custom emails to admin area (#21340) * Add custom emails to admin area * Fix i18n * Adjust newsletters * Adjust newsletters * Newsletter i18n * Newsletter i18n * Fix email spec * Get headers working * Get headers working --- app/controllers/admin/emails_controller.rb | 36 +++++++ app/mailers/custom_mailer.rb | 18 ++++ app/models/admin_menu.rb | 1 + app/models/audience_segment.rb | 1 + app/models/email.rb | 26 +++++ app/views/admin/emails/_form.html.erb | 46 +++++++++ app/views/admin/emails/index.html.erb | 56 +++++++++++ app/views/admin/emails/new.html.erb | 7 ++ app/views/admin/emails/show.html.erb | 4 + .../custom_mailer/custom_email.html.erb | 1 + .../emails/batch_custom_send_worker.rb | 13 +++ config/locales/controllers/admin/en.yml | 5 + config/locales/controllers/admin/fr.yml | 5 + config/locales/mailers/en.yml | 2 + config/locales/mailers/fr.yml | 2 + config/routes/admin.rb | 1 + db/migrate/20241018175616_create_emails.rb | 10 ++ ...023131302_add_name_to_audience_segments.rb | 5 + db/schema.rb | 13 ++- spec/factories/emails.rb | 6 ++ spec/factories/users.rb | 8 ++ spec/mailers/custom_mailer_spec.rb | 59 +++++++++++ .../mailers/previews/custom_mailer_preview.rb | 9 ++ spec/models/email_spec.rb | 98 +++++++++++++++++++ spec/requests/admin/emails_spec.rb | 70 +++++++++++++ 25 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 app/controllers/admin/emails_controller.rb create mode 100644 app/mailers/custom_mailer.rb create mode 100644 app/models/email.rb create mode 100644 app/views/admin/emails/_form.html.erb create mode 100644 app/views/admin/emails/index.html.erb create mode 100644 app/views/admin/emails/new.html.erb create mode 100644 app/views/admin/emails/show.html.erb create mode 100644 app/views/mailers/custom_mailer/custom_email.html.erb create mode 100644 app/workers/emails/batch_custom_send_worker.rb create mode 100644 db/migrate/20241018175616_create_emails.rb create mode 100644 db/migrate/20241023131302_add_name_to_audience_segments.rb create mode 100644 spec/factories/emails.rb create mode 100644 spec/mailers/custom_mailer_spec.rb create mode 100644 spec/mailers/previews/custom_mailer_preview.rb create mode 100644 spec/models/email_spec.rb create mode 100644 spec/requests/admin/emails_spec.rb diff --git a/app/controllers/admin/emails_controller.rb b/app/controllers/admin/emails_controller.rb new file mode 100644 index 000000000000..32f3e0264df9 --- /dev/null +++ b/app/controllers/admin/emails_controller.rb @@ -0,0 +1,36 @@ +module Admin + class EmailsController < Admin::ApplicationController + layout "admin" + + def index + @emails = Email.page(params[:page] || 1).includes([:audience_segment]).order("id DESC").per(25) + end + + def new + @audience_segments = AudienceSegment.all + @email = Email.new + end + + def show + @email = Email.find(params[:id]) + end + + def create + @email = Email.new(email_params) + if @email.save + flash[:success] = I18n.t("admin.emails_controller.created") + redirect_to admin_email_path(@email.id) + else + @audience_segments = AudienceSegment.all + flash[:danger] = @email.errors_as_sentence + render :new + end + end + + private + + def email_params + params.permit(:subject, :body, :audience_segment_id) + end + end +end \ No newline at end of file diff --git a/app/mailers/custom_mailer.rb b/app/mailers/custom_mailer.rb new file mode 100644 index 000000000000..23db6c5f024e --- /dev/null +++ b/app/mailers/custom_mailer.rb @@ -0,0 +1,18 @@ +class CustomMailer < ApplicationMailer + default from: -> { email_from(I18n.t("mailers.custom_mailer.from")) } + + def custom_email + @user = params[:user] + @content = params[:content] + @unsubscribe = generate_unsubscribe_token(@user.id, :email_newsletter) + + # set sendgrid category in the header using smtp api + # https://docs.sendgrid.com/for-developers/sending-email/building-an-x-smtpapi-header + if ForemInstance.sendgrid_enabled? + smtpapi_header = { category: "Custom Email" }.to_json + headers["X-SMTPAPI"] = smtpapi_header + end + + mail(to: @user.email, subject: params[:subject]) + end +end diff --git a/app/models/admin_menu.rb b/app/models/admin_menu.rb index 3eb12cf59ddf..05ac87d58e4f 100644 --- a/app/models/admin_menu.rb +++ b/app/models/admin_menu.rb @@ -22,6 +22,7 @@ class AdminMenu item(name: "organizations"), item(name: "podcasts"), item(name: "tags"), + item(name: "emails"), ] scope :customization, "tools-line", [ diff --git a/app/models/audience_segment.rb b/app/models/audience_segment.rb index 5eec767a6aec..e4d6a402852f 100644 --- a/app/models/audience_segment.rb +++ b/app/models/audience_segment.rb @@ -17,6 +17,7 @@ class AudienceSegment < ApplicationRecord has_many :segmented_users, dependent: :destroy has_many :users, through: :segmented_users + has_many :emails, dependent: :nullify after_validation :persist_recently_active_users, unless: :manual? diff --git a/app/models/email.rb b/app/models/email.rb new file mode 100644 index 000000000000..b795df3499e8 --- /dev/null +++ b/app/models/email.rb @@ -0,0 +1,26 @@ +class Email < ApplicationRecord + belongs_to :audience_segment, optional: true + + after_create :deliver_to_users + + validates :subject, presence: true + validates :body, presence: true + + BATCH_SIZE = Rails.env.production? ? 1000 : 10 + + def deliver_to_users + user_scope = if audience_segment + audience_segment.users.registered.joins(:notification_setting) + .where(notification_setting: { email_newsletter: true }) + .where.not(email: "") + else + User.registered.joins(:notification_setting) + .where(notification_setting: { email_newsletter: true }) + .where.not(email: "") + end + + user_scope.find_in_batches(batch_size: BATCH_SIZE) do |users_batch| + Emails::BatchCustomSendWorker.perform_async(users_batch.map(&:id), subject, body) + end + end +end diff --git a/app/views/admin/emails/_form.html.erb b/app/views/admin/emails/_form.html.erb new file mode 100644 index 000000000000..b355440fd4be --- /dev/null +++ b/app/views/admin/emails/_form.html.erb @@ -0,0 +1,46 @@ +
+
+ +
+ <%= label_tag :audience_segment_id, "Audience Segment:", class: "crayons-field__label" %> + <%= select_tag :audience_segment_id, options_for_select([["Entire list", nil]] + @audience_segments.map { |s| [s.name || s.type_of, s.id]}), class: "crayons-textfield", autocomplete: "off" %> +
+ +
+ <%= label_tag :subject, "Subject:", class: "crayons-field__label" %> + <%= text_field_tag :subject, @email.subject, class: "crayons-textfield", autocomplete: "off" %> +
+ +
+ <%= label_tag :body, "Body Content:", class: "crayons-field__label" %> + <%= text_area_tag :body, @email.body, size: "100x5", class: "crayons-textfield" %> +
+
+ +
+
+ <% if @email.persisted? %> +

Preview

+

Subject: <%= @email.subject %>

+

Body:

+
+ <%= simple_format(@email.body) %> +
+ <% else %> +
+

+ Use this form to compose a new email. Fill in the subject, body, and specify the recipients. +

+

+ You can choose to send the email immediately or schedule it for a later time. +

+

+ Select the appropriate recipient group or provide a custom list of email addresses. +

+
+ <% end %> +
+
+
+ +<%#= javascript_include_tag "admin/emails", defer: true %> diff --git a/app/views/admin/emails/index.html.erb b/app/views/admin/emails/index.html.erb new file mode 100644 index 000000000000..76938bbe6366 --- /dev/null +++ b/app/views/admin/emails/index.html.erb @@ -0,0 +1,56 @@ +

Emails

+ +
+ + + + <%= paginate @emails %> + + + + + + + + + + + + + <% @emails.each do |email| %> + + + + + + + + + <% end %> + +
SubjectSent AtSegmentBody
<%= link_to email.subject, admin_email_path(email) %><%= email.created_at %><%= email.audience_segment&.name || email.audience_segment&.type_of || "All" %><%= truncate(email.body, length: 100) %><%= link_to "Details", admin_email_path(email), class: "crayons-btn" %> + +
+ + <%= render partial: "admin/shared/destroy_confirmation_modal" %> + <%= paginate @emails %> +
diff --git a/app/views/admin/emails/new.html.erb b/app/views/admin/emails/new.html.erb new file mode 100644 index 000000000000..2a09575168a4 --- /dev/null +++ b/app/views/admin/emails/new.html.erb @@ -0,0 +1,7 @@ +

Make a new Email

+
+ <%= form_for([:admin, @email], url: admin_emails_path, method: :post) do %> + <%= render "form" %> + <%= submit_tag "Send Billboard", class: "c-btn c-btn--primary" %> + <% end %> +
diff --git a/app/views/admin/emails/show.html.erb b/app/views/admin/emails/show.html.erb new file mode 100644 index 000000000000..b79f78ddfea4 --- /dev/null +++ b/app/views/admin/emails/show.html.erb @@ -0,0 +1,4 @@ +

<%= @email.subject %>

+
+ <%= @email.body %> +
diff --git a/app/views/mailers/custom_mailer/custom_email.html.erb b/app/views/mailers/custom_mailer/custom_email.html.erb new file mode 100644 index 000000000000..b3dc4d500f5f --- /dev/null +++ b/app/views/mailers/custom_mailer/custom_email.html.erb @@ -0,0 +1 @@ +<%= @content.html_safe %> \ No newline at end of file diff --git a/app/workers/emails/batch_custom_send_worker.rb b/app/workers/emails/batch_custom_send_worker.rb new file mode 100644 index 000000000000..cea979780007 --- /dev/null +++ b/app/workers/emails/batch_custom_send_worker.rb @@ -0,0 +1,13 @@ +module Emails + class BatchCustomSendWorker + include Sidekiq::Job + + sidekiq_options queue: :medium_priority, retry: 15 + + def perform(user_ids, subject, content) + user_ids.each do |id| + CustomMailer.with(user: User.find(id), subject: subject, content: subject).custom_email.deliver_now + end + end + end +end \ No newline at end of file diff --git a/config/locales/controllers/admin/en.yml b/config/locales/controllers/admin/en.yml index a3dcede2af9a..550776ed9a08 100644 --- a/config/locales/controllers/admin/en.yml +++ b/config/locales/controllers/admin/en.yml @@ -33,6 +33,11 @@ en: deleted: Billboard has been deleted! updated: Billboard has been updated! wrong: Something went wrong with deleting the Billboard. + emails_controller: + created: Emails are being sent! + deleted: Email has been deleted! + updated: Email has been updated! + wrong: Something went wrong with deleting the Email. gdpr_delete_requests_controller: deleted: Successfully marked as deleted extensions_controller: diff --git a/config/locales/controllers/admin/fr.yml b/config/locales/controllers/admin/fr.yml index 7f1e6009cf8f..4cf9055de395 100644 --- a/config/locales/controllers/admin/fr.yml +++ b/config/locales/controllers/admin/fr.yml @@ -33,6 +33,11 @@ fr: deleted: L'annonce a été supprimée ! updated: L'annonce publicitaire a été mise à jour ! wrong: Quelque chose s'est mal passé avec la suppression de l'annonce publicitaire. + emails_controller: + created: Emails are being sent! + deleted: Email has been deleted! + updated: Email has been updated! + wrong: Something went wrong with deleting the Email. extensions_controller: update_success: Les extensions ont été mises à jour. gdpr_delete_requests_controller: diff --git a/config/locales/mailers/en.yml b/config/locales/mailers/en.yml index d1e87c88068c..058ac29f7374 100644 --- a/config/locales/mailers/en.yml +++ b/config/locales/mailers/en.yml @@ -12,6 +12,8 @@ en: other_community_posts: other %{community} posts you might like other_top_posts: other top %{community} posts other_trending_posts: other trending %{community} posts + custom_mailer: + from: Newsletter notify_mailer: account_deleted: "%{community} - Account Deletion Confirmation" deletion_requested: "%{community} - Account Deletion Requested" diff --git a/config/locales/mailers/fr.yml b/config/locales/mailers/fr.yml index 95d0ff111dfd..9e8808403775 100644 --- a/config/locales/mailers/fr.yml +++ b/config/locales/mailers/fr.yml @@ -12,6 +12,8 @@ fr: other_community_posts: d'autres posts de la %{community} que vous pourriez aimer other_top_posts: d'autres articles tendance de la %{community} other_trending_posts: d'autres articles tendance de la %{community}. + custom_mailer: + from: Newsletter notify_mailer: account_deleted: "%{community} - Confirmation de la suppression du compte" deletion_requested: "%{community} - Suppression du compte demandée" diff --git a/config/routes/admin.rb b/config/routes/admin.rb index d26f26a5c1a5..96e9f3eee196 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -96,6 +96,7 @@ patch "update_org_credits" end end + resources :emails resources :podcasts, only: %i[index edit update destroy] do member do post :fetch diff --git a/db/migrate/20241018175616_create_emails.rb b/db/migrate/20241018175616_create_emails.rb new file mode 100644 index 000000000000..ff07b025f681 --- /dev/null +++ b/db/migrate/20241018175616_create_emails.rb @@ -0,0 +1,10 @@ +class CreateEmails < ActiveRecord::Migration[7.0] + def change + create_table :emails do |t| + t.string :subject, null: false + t.text :body, null: false + t.references :audience_segment, foreign_key: true + t.timestamps + end + end +end diff --git a/db/migrate/20241023131302_add_name_to_audience_segments.rb b/db/migrate/20241023131302_add_name_to_audience_segments.rb new file mode 100644 index 000000000000..ec0b12c77130 --- /dev/null +++ b/db/migrate/20241023131302_add_name_to_audience_segments.rb @@ -0,0 +1,5 @@ +class AddNameToAudienceSegments < ActiveRecord::Migration[7.0] + def change + add_column :audience_segments, :name, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 42b6927b6abf..aa2803ded692 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_09_09_155030) do +ActiveRecord::Schema[7.0].define(version: 2024_10_23_131302) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "ltree" @@ -184,6 +184,7 @@ create_table "audience_segments", force: :cascade do |t| t.datetime "created_at", null: false + t.string "name" t.integer "type_of" t.datetime "updated_at", null: false end @@ -528,6 +529,15 @@ t.index ["user_id"], name: "index_email_authorizations_on_user_id" end + create_table "emails", force: :cascade do |t| + t.bigint "audience_segment_id" + t.text "body", null: false + t.datetime "created_at", null: false + t.string "subject", null: false + t.datetime "updated_at", null: false + t.index ["audience_segment_id"], name: "index_emails_on_audience_segment_id" + end + create_table "feed_events", force: :cascade do |t| t.bigint "article_id", null: false t.integer "article_position" @@ -1478,6 +1488,7 @@ add_foreign_key "display_ad_events", "users", on_delete: :cascade add_foreign_key "display_ads", "organizations", on_delete: :cascade add_foreign_key "email_authorizations", "users", on_delete: :cascade + add_foreign_key "emails", "audience_segments" add_foreign_key "feed_events", "articles", on_delete: :cascade add_foreign_key "feed_events", "users", on_delete: :nullify add_foreign_key "feedback_messages", "users", column: "affected_id", on_delete: :nullify diff --git a/spec/factories/emails.rb b/spec/factories/emails.rb new file mode 100644 index 000000000000..ac343c9acd19 --- /dev/null +++ b/spec/factories/emails.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :email do + subject { Faker::Lorem.sentence } + body { Faker::Lorem.sentence } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 968d3782c5b4..4405dc773b0e 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -201,5 +201,13 @@ .update_columns(email_newsletter: true, email_digest_periodic: true) end end + + trait :without_newsletters do + after(:create) do |user| + Users::NotificationSetting.find_by(user_id: user.id) + .update_columns(email_newsletter: false, email_digest_periodic: true) + end + end + end end diff --git a/spec/mailers/custom_mailer_spec.rb b/spec/mailers/custom_mailer_spec.rb new file mode 100644 index 000000000000..18a89e443d7d --- /dev/null +++ b/spec/mailers/custom_mailer_spec.rb @@ -0,0 +1,59 @@ +# spec/mailers/custom_mailer_spec.rb + +require "rails_helper" + +RSpec.describe CustomMailer, type: :mailer do + describe "#custom_email" do + let(:user) { create(:user) } + let(:content) { "Hello, this is a test email." } + let(:subject) { "Test Email Subject" } + let(:unsubscribe_token) { "unsubscribe_token" } + let(:mail) { described_class.with(user: user, content: content, subject: subject).custom_email } + + before do + allow_any_instance_of(CustomMailer).to receive(:generate_unsubscribe_token).and_return(unsubscribe_token) + allow_any_instance_of(CustomMailer).to receive(:email_from).and_return("no-reply@example.com") + end + + context "when SendGrid is enabled" do + before do + allow(ForemInstance).to receive(:sendgrid_enabled?).and_return(true) + end + + it "sets the X-SMTPAPI header with the correct category" do + mail.deliver_now + + expect(mail.header["X-SMTPAPI"]).not_to be_nil + smtpapi_header = JSON.parse(mail.header["X-SMTPAPI"].value) + expect(smtpapi_header).to have_key("category") + expect(smtpapi_header["category"]).to include("Custom Email") + end + + it "sends the email with the correct details" do + expect(mail.to).to eq([user.email]) + expect(mail.subject).to eq(subject) + expect(mail.from).to eq(["no-reply@example.com"]) + expect(mail.body.encoded).to include(content) + expect(mail.body.encoded).to include(unsubscribe_token) + end + end + + context "when SendGrid is disabled" do + before do + allow(ForemInstance).to receive(:sendgrid_enabled?).and_return(false) + end + + it "does not set the X-SMTPAPI header" do + expect(mail.headers["X-SMTPAPI"]).to be_nil + end + + it "sends the email with the correct details" do + expect(mail.to).to eq([user.email]) + expect(mail.subject).to eq(subject) + expect(mail.from).to eq(["no-reply@example.com"]) + expect(mail.body.encoded).to include(content) + expect(mail.body.encoded).to include(unsubscribe_token) + end + end + end +end diff --git a/spec/mailers/previews/custom_mailer_preview.rb b/spec/mailers/previews/custom_mailer_preview.rb new file mode 100644 index 000000000000..26b642564380 --- /dev/null +++ b/spec/mailers/previews/custom_mailer_preview.rb @@ -0,0 +1,9 @@ +# Preview all emails at http://localhost:3000/rails/mailers/digest_mailer +class CustomMailerPreview < ActionMailer::Preview + def custom_email + user = User.last + content = "

Custom Email Content

This is a custom email content.

" + subject = "Custom Email Subject" + CustomMailer.with(user: user, content: content, subject: subject).custom_email + end +end diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb new file mode 100644 index 000000000000..96498a25075b --- /dev/null +++ b/spec/models/email_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +RSpec.describe Email, type: :model do + describe "Associations" do + it { should belong_to(:audience_segment).optional } + end + + describe "Callbacks" do + it "calls #deliver_to_users after create" do + email = build(:email) + expect(email).to receive(:deliver_to_users) + email.save + end + end + + describe "#deliver_to_users" do + let(:user_with_notifications) { create(:user, :with_newsletters) } + let(:user_without_notifications) { create(:user, :without_newsletters) } + + context "when there is an audience segment" do + let(:audience_segment) { create(:audience_segment) } + let(:email) { create(:email, audience_segment: audience_segment) } + + before do + # Allow audience_segment.users to return users that belong to the segment + allow(audience_segment).to receive(:users).and_return(User.where(id: user_with_notifications.id)) + # Alternatively, you could also populate the database with relevant users + end + + it "sends the emails to the users in the audience segment with email newsletters enabled" do + expect(Emails::BatchCustomSendWorker).to receive(:perform_async).with( + [user_with_notifications.id], + email.subject, + email.body + ) + email.send(:deliver_to_users) + end + end + + context "when there is no audience segment" do + let(:email) { create(:email, audience_segment: nil) } + + before do + # Mock User.registered scope to return only users with newsletters enabled + allow(User).to receive(:registered).and_return(User.where(id: user_with_notifications.id)) + end + + it "sends the emails to all registered users with email newsletters enabled" do + expect(Emails::BatchCustomSendWorker).to receive(:perform_async).with( + [user_with_notifications.id], + email.subject, + email.body + ) + email.send(:deliver_to_users) + end + end + + context "when no users have email newsletters enabled" do + let(:email) { create(:email) } + + before do + # Mock User.registered scope to return no users + allow(User).to receive(:registered).and_return(User.none) + end + + it "does not enqueue any jobs" do + expect(Emails::BatchCustomSendWorker).not_to receive(:perform_async) + email.send(:deliver_to_users) + end + end + + context "batch processing" do + let(:email) { create(:email, audience_segment: nil) } + + it "processes users in batches" do + batch_size = Email::BATCH_SIZE + users_batch_1 = create_list(:user, batch_size, :with_newsletters) + users_batch_2 = create_list(:user, batch_size, :with_newsletters) + + # Mock User.registered scope to return all users in two batches + allow(User).to receive(:registered).and_return(User.where(id: users_batch_1.pluck(:id) + users_batch_2.pluck(:id))) + + expect(Emails::BatchCustomSendWorker).to receive(:perform_async).with( + users_batch_1.map(&:id), + email.subject, + email.body + ) + expect(Emails::BatchCustomSendWorker).to receive(:perform_async).with( + users_batch_2.map(&:id), + email.subject, + email.body + ) + + email.send(:deliver_to_users) + end + end + end +end diff --git a/spec/requests/admin/emails_spec.rb b/spec/requests/admin/emails_spec.rb new file mode 100644 index 000000000000..48915e7253fe --- /dev/null +++ b/spec/requests/admin/emails_spec.rb @@ -0,0 +1,70 @@ +# spec/requests/admin/emails_controller_spec.rb + +require 'rails_helper' + +RSpec.describe "/admin/content_manager/emails" do + let(:admin_user) { create(:user, :admin) } + let(:audience_segment) { create(:audience_segment) } + + before do + # Sign in as admin user + sign_in admin_user + end + + describe "GET /admin/emails" do + it "renders the index template and displays emails" do + email1 = create(:email, subject: "First Email") + email2 = create(:email, subject: "Second Email") + get admin_emails_path + expect(response.body).to include("First Email", "Second Email") + end + end + + describe "GET /admin/emails/new" do + it "renders the new template with a form" do + get new_admin_email_path + expect(response.body).to include('name="subject"', 'name="body"', 'name="audience_segment_id"') + end + end + + describe "POST /admin/emails" do + context "with valid parameters" do + it "creates a new email and redirects to its page" do + valid_attributes = { + subject: "Test Subject", + body: "Test Body", + audience_segment_id: audience_segment.id + } + expect { + post admin_emails_path, params: valid_attributes + }.to change(Email, :count).by(1) + expect(response).to redirect_to(admin_email_path(Email.last)) + follow_redirect! + expect(flash[:success]).to eq(I18n.t("admin.emails_controller.created")) + end + end + + context "with invalid parameters" do + it "does not create a new email and re-renders the new template" do + invalid_attributes = { + subject: "", + body: "", + audience_segment_id: nil + } + expect { + post admin_emails_path, params: invalid_attributes + }.not_to change(Email, :count) + expect(response.body).to include(">Subject can't be blank") + expect(flash[:danger]).to be_present + end + end + end + + describe "GET /admin/emails/:id" do + it "renders the show template for the email" do + email = create(:email, subject: "Show Email") + get admin_email_path(email) + expect(response.body).to include("Show Email") + end + end +end