diff --git a/.munkit/MEMORY.md b/.munkit/MEMORY.md index f8c4661..a2293bd 100644 --- a/.munkit/MEMORY.md +++ b/.munkit/MEMORY.md @@ -65,4 +65,4 @@ IMASUS App is the participant-facing workshop application for the IMASUS project - Do not choose a license or product direction details that the team has not confirmed yet. - Do not treat placeholder workshop copy or landing page content as final product content. - Evaluation/grading of projects is explicitly out of scope. -- No in-app notifications. Email is limited to transactional flows (registration, password recovery, invitations) plus admin-only manual workshop broadcasts for follow-ups/news; no broader CRM, automation, or newsletter system. +- No in-app notifications. Email is limited to transactional flows (registration, password recovery, invitations) plus admin-only manual workshop broadcasts for follow-ups/news, with at most one optional PDF attachment; no broader CRM, automation, or newsletter system. diff --git a/.munkit/specs/2026-04-29-admin-workshop-emails/brief.md b/.munkit/specs/2026-04-29-admin-workshop-emails/brief.md index ba1b96e..1963641 100644 --- a/.munkit/specs/2026-04-29-admin-workshop-emails/brief.md +++ b/.munkit/specs/2026-04-29-admin-workshop-emails/brief.md @@ -44,6 +44,7 @@ communications surface without turning the product into a campaign tool. ### Composer - [ ] Admin can enter a subject line and HTML body. +- [ ] Admin may optionally attach one PDF file to the email. - [ ] The body is authored in a rich-text editor consistent with the app's existing editor stack, or a constrained HTML-capable surface if that is materially simpler to implement. @@ -58,6 +59,8 @@ communications surface without turning the product into a campaign tool. - [ ] Sending creates one email per recipient through the app mailer layer. - [ ] The delivered email includes an HTML part and a plain-text part. +- [ ] If a PDF attachment is present, it is included in both test sends and + real sends. - [ ] The plain-text part may be generated from the HTML body in this spec; it does not require separate authoring. - [ ] Subject and body are frozen at send time for that delivery batch. @@ -70,6 +73,8 @@ communications surface without turning the product into a campaign tool. sent later without reading SMTP logs. - [ ] The persisted record stores at least: sender, workshop, audience type, subject, HTML body snapshot, recipient count, and sent timestamp. +- [ ] If a PDF attachment is present, the send record retains it so admins can + see that it was included later. - [ ] A workshop-level or admin-level index page lists previous sends newest first. - [ ] This spec does not require per-recipient delivery/open/click analytics. @@ -109,7 +114,7 @@ end - Template library, saved blocks, or branded template builder. - Audience segmentation by participation status, project activity, country, role within project, or any other derived rule. -- Attachments. +- Multiple attachments or non-PDF attachments. - A/B testing, open tracking, click tracking, or unsubscribe analytics. - Reply handling / shared inbox workflows. - General-purpose newsletter infrastructure for the public website. diff --git a/.munkit/specs/2026-04-29-admin-workshop-emails/notes.md b/.munkit/specs/2026-04-29-admin-workshop-emails/notes.md index 54bd4e7..1463506 100644 --- a/.munkit/specs/2026-04-29-admin-workshop-emails/notes.md +++ b/.munkit/specs/2026-04-29-admin-workshop-emails/notes.md @@ -29,3 +29,14 @@ Scratch space and rationale for the lightweight admin broadcast email spec. - Existing production mail path is SES SMTP (`MEMORY.md` Deployment), so the implementation can build on the established mailer stack rather than adding a new provider. + +## Follow-up: PDF attachment + +- Expanded the original no-attachments boundary to allow one optional PDF per + workshop broadcast. +- Implementation uses an Active Storage blob uploaded during the first POST, + then round-trips its signed blob id through preview, back-to-edit, confirm, + and send-test flows. No draft table was introduced. +- Validation is draft-level: PDF only, max 10 MB. +- Real sends persist the attachment on `WorkshopEmailBroadcast`; test sends + attach the blob directly without persisting a broadcast record. diff --git a/app/controllers/admin/workshop_emails_controller.rb b/app/controllers/admin/workshop_emails_controller.rb index 69548ba..bdd5be5 100644 --- a/app/controllers/admin/workshop_emails_controller.rb +++ b/app/controllers/admin/workshop_emails_controller.rb @@ -46,14 +46,14 @@ def send_test subject: @draft.normalized_subject, body_html: @draft.normalized_html, body_text: @draft.normalized_text, - recipient: current_user + recipient: current_user, + pdf_attachment_blob: @draft.pdf_attachment_blob ).deliver_later - @preview_mode = true - flash.now[:notice] = t(".notice", - default: "Sent a test email to %{email}.", - email: current_user.email) - render :new + redirect_to new_admin_workshop_email_path(@workshop, workshop_email: draft_attributes), + notice: t(".notice", + default: "Sent a test email to %{email}.", + email: current_user.email) end private @@ -66,14 +66,23 @@ def build_draft WorkshopEmailDraft.new( workshop: @workshop, sender: current_user, - audience: workshop_email_params[:audience], - subject: workshop_email_params[:subject], - body: workshop_email_params[:body] + audience: draft_attributes[:audience], + subject: draft_attributes[:subject], + body: draft_attributes[:body], + pdf_attachment_signed_id: draft_attributes[:pdf_attachment_signed_id], + pdf_attachment_blob: resolved_pdf_attachment_blob ) end - def workshop_email_params - params.fetch(:workshop_email, {}).permit(:audience, :subject, :body) + def draft_attributes + attrs = params.fetch(:workshop_email, {}).permit(:audience, :subject, :body, :pdf_attachment_signed_id).to_h + attrs["pdf_attachment_signed_id"] = + if remove_pdf_attachment? + nil + else + resolved_pdf_attachment_blob&.signed_id || attrs["pdf_attachment_signed_id"] + end + attrs end def load_recent_broadcasts @@ -84,4 +93,31 @@ def render_new_with_errors @preview_mode = false render :new, status: :unprocessable_content end + + def resolved_pdf_attachment_blob + return @resolved_pdf_attachment_blob if defined?(@resolved_pdf_attachment_blob) + + @resolved_pdf_attachment_blob = + if remove_pdf_attachment? + nil + elsif uploaded_pdf_attachment.present? + ActiveStorage::Blob.create_and_upload!( + io: uploaded_pdf_attachment.tempfile, + filename: uploaded_pdf_attachment.original_filename, + content_type: uploaded_pdf_attachment.content_type + ) + elsif params.dig(:workshop_email, :pdf_attachment_signed_id).present? + ActiveStorage::Blob.find_signed(params.dig(:workshop_email, :pdf_attachment_signed_id)) + end + rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound + @resolved_pdf_attachment_blob = nil + end + + def uploaded_pdf_attachment + params.dig(:workshop_email, :pdf_attachment) + end + + def remove_pdf_attachment? + params.dig(:workshop_email, :remove_pdf_attachment) == "1" + end end diff --git a/app/mailers/workshop_email_broadcast_mailer.rb b/app/mailers/workshop_email_broadcast_mailer.rb index 0aa9c5c..779dff5 100644 --- a/app/mailers/workshop_email_broadcast_mailer.rb +++ b/app/mailers/workshop_email_broadcast_mailer.rb @@ -3,20 +3,34 @@ def broadcast(broadcast, recipient) @broadcast = broadcast @recipient = recipient @workshop = broadcast.workshop + attach_pdf(broadcast.pdf_attachment) if broadcast.pdf_attachment.attached? I18n.with_locale(recipient.preferred_locale.presence || @workshop.communication_locale) do mail(to: recipient.email, subject: broadcast.subject) end end - def test_message(sender:, workshop:, subject:, body_html:, body_text:, recipient:) + def test_message(sender:, workshop:, subject:, body_html:, body_text:, recipient:, pdf_attachment_blob: nil) @sender = sender @workshop = workshop @body_html = body_html @body_text = body_text + attach_pdf(pdf_attachment_blob) if pdf_attachment_blob.present? I18n.with_locale(recipient.preferred_locale.presence || workshop.communication_locale) do mail(to: recipient.email, subject: subject) end end + + private + + def attach_pdf(blob_or_attachment) + blob = blob_or_attachment.respond_to?(:blob) ? blob_or_attachment.blob : blob_or_attachment + return if blob.blank? + + attachments[blob.filename.to_s] = { + mime_type: blob.content_type, + content: blob.download + } + end end diff --git a/app/models/workshop_email_broadcast.rb b/app/models/workshop_email_broadcast.rb index fb08958..618d904 100644 --- a/app/models/workshop_email_broadcast.rb +++ b/app/models/workshop_email_broadcast.rb @@ -3,6 +3,7 @@ class WorkshopEmailBroadcast < ApplicationRecord belongs_to :sender, class_name: "User" belongs_to :workshop + has_one_attached :pdf_attachment validates :audience, inclusion: { in: AUDIENCES } validates :subject, :body_html, :body_text, :sent_at, presence: true diff --git a/app/models/workshop_email_draft.rb b/app/models/workshop_email_draft.rb index 8652f78..0f26fc9 100644 --- a/app/models/workshop_email_draft.rb +++ b/app/models/workshop_email_draft.rb @@ -2,17 +2,23 @@ class WorkshopEmailDraft include ActiveModel::Model include ActiveModel::Attributes + MAX_PDF_ATTACHMENT_SIZE = 10.megabytes + attribute :audience, :string attribute :subject, :string attribute :body, :string + attribute :pdf_attachment_signed_id, :string - attr_accessor :workshop, :sender + attr_accessor :workshop, :sender, :pdf_attachment_blob validates :workshop, :sender, presence: true - validates :audience, inclusion: { in: WorkshopEmailBroadcast::AUDIENCES } validates :subject, presence: true validate :body_present validate :sender_is_admin + validate :pdf_attachment_is_resolved + validate :pdf_attachment_is_pdf + validate :pdf_attachment_size_within_limit + validate :audience_valid_for_delivery, on: :delivery validate :recipients_present, on: :delivery def recipients @@ -63,6 +69,18 @@ def audience_label default: audience.to_s.humanize) end + def pdf_attachment? + pdf_attachment_blob.present? + end + + def pdf_attachment_filename + pdf_attachment_blob&.filename&.to_s + end + + def pdf_attachment_byte_size + pdf_attachment_blob&.byte_size.to_i + end + private def body_present @@ -83,4 +101,33 @@ def recipients_present errors.add(:base, I18n.t("admin.workshop_emails.errors.empty_audience", default: "This workshop does not have any recipients in the selected audience.")) end + + def audience_valid_for_delivery + return if audience.in?(WorkshopEmailBroadcast::AUDIENCES) + + errors.add(:audience, :inclusion) + end + + def pdf_attachment_is_resolved + return if pdf_attachment_signed_id.blank? || pdf_attachment_blob.present? + + errors.add(:pdf_attachment, I18n.t("admin.workshop_emails.errors.invalid_attachment", + default: "The attached PDF could not be loaded. Please upload it again.")) + end + + def pdf_attachment_is_pdf + return unless pdf_attachment? + return if pdf_attachment_blob.content_type == "application/pdf" + + errors.add(:pdf_attachment, I18n.t("admin.workshop_emails.errors.attachment_must_be_pdf", + default: "Attach a PDF file.")) + end + + def pdf_attachment_size_within_limit + return unless pdf_attachment? + return if pdf_attachment_blob.byte_size <= MAX_PDF_ATTACHMENT_SIZE + + errors.add(:pdf_attachment, I18n.t("admin.workshop_emails.errors.attachment_too_large", + default: "The PDF must be 10 MB or smaller.")) + end end diff --git a/app/services/send_workshop_email_broadcast.rb b/app/services/send_workshop_email_broadcast.rb index 8b297c0..9a60560 100644 --- a/app/services/send_workshop_email_broadcast.rb +++ b/app/services/send_workshop_email_broadcast.rb @@ -21,6 +21,8 @@ def call sent_at: Time.current ) + broadcast.pdf_attachment.attach(draft.pdf_attachment_blob) if draft.pdf_attachment? + draft.recipients.find_each do |recipient| WorkshopEmailBroadcastMailer.broadcast(broadcast, recipient).deliver_later end diff --git a/app/views/admin/workshop_emails/index.html.erb b/app/views/admin/workshop_emails/index.html.erb index 571d762..cbb2f64 100644 --- a/app/views/admin/workshop_emails/index.html.erb +++ b/app/views/admin/workshop_emails/index.html.erb @@ -49,6 +49,17 @@ + <% if broadcast.pdf_attachment.attached? %> +
+

<%= t(".attachment", default: "PDF attachment") %>

+

+ <%= link_to broadcast.pdf_attachment.filename.to_s, + url_for(broadcast.pdf_attachment), + class: "font-medium text-imasus-dark-green underline-offset-2 hover:underline" %> +

+
+ <% end %> +
<%= sanitized_email_html(broadcast.body_html) %> diff --git a/app/views/admin/workshop_emails/new.html.erb b/app/views/admin/workshop_emails/new.html.erb index f28e7f9..43e2fda 100644 --- a/app/views/admin/workshop_emails/new.html.erb +++ b/app/views/admin/workshop_emails/new.html.erb @@ -12,6 +12,18 @@

<%= t(".lead", default: "Write a manual follow-up or news update for this workshop. Preview it first, send a test to yourself if needed, then confirm the real send.") %>

+ <% if flash[:notice].present? %> +

+ <%= flash[:notice] %> +

+ <% end %> + + <% if flash[:alert].present? %> +

+ <%= flash[:alert] %> +

+ <% end %> + <% if @draft.errors.any? %> + <% if @draft.pdf_attachment? %> +
+

<%= t(".attachment", default: "PDF attachment") %>

+

<%= @draft.pdf_attachment_filename %>

+
+ <% end %> +
<%= @draft.normalized_html.html_safe %> @@ -60,9 +79,11 @@ local: true, data: { turbo: false }, class: "contents" do %> + <%= hidden_field_tag :authenticity_token, form_authenticity_token %> <%= hidden_field_tag "workshop_email[audience]", @draft.audience %> <%= hidden_field_tag "workshop_email[subject]", @draft.subject %> <%= hidden_field_tag "workshop_email[body]", @draft.body %> + <%= hidden_field_tag "workshop_email[pdf_attachment_signed_id]", @draft.pdf_attachment_signed_id if @draft.pdf_attachment? %> <%= hidden_field_tag :edit_mode, "1" %> <%= submit_tag t(".back_to_edit", default: "Back to edit"), class: "inline-flex items-center justify-center rounded-full border border-imasus-dark-green/20 px-5 py-3 text-sm font-semibold text-imasus-dark-green transition hover:bg-imasus-dark-green/5" %> @@ -72,9 +93,11 @@ local: true, data: { turbo: false }, class: "contents" do %> + <%= hidden_field_tag :authenticity_token, form_authenticity_token %> <%= hidden_field_tag "workshop_email[audience]", @draft.audience %> <%= hidden_field_tag "workshop_email[subject]", @draft.subject %> <%= hidden_field_tag "workshop_email[body]", @draft.body %> + <%= hidden_field_tag "workshop_email[pdf_attachment_signed_id]", @draft.pdf_attachment_signed_id if @draft.pdf_attachment? %> <%= hidden_field_tag :confirm_send, "1" %> <%= submit_tag t(".confirm_send", default: "Confirm and send"), class: form_primary_button_classes, @@ -90,6 +113,7 @@ local: true, data: { turbo: false }, class: "space-y-6" do |f| %> + <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<%= @workshop.title %>
@@ -114,6 +138,29 @@ <%= rich_textarea_tag "workshop_email[body]", @draft.body, class: "trix-content trix-content--email-compose" %>
+
+ <%= f.label :pdf_attachment, t(".attachment", default: "PDF attachment"), class: form_label_classes %> +

<%= t(".attachment_hint", default: "Optional. PDF only, up to 10 MB.") %>

+ <%= render "shared/file_drop_zone", + form: f, + attribute: :pdf_attachment, + accept: "application/pdf,.pdf", + hint: t(".attachment_drop_zone_hint", default: "One PDF attachment. 10 MB max."), + label: t(".attachment_upload", default: "Upload a PDF") %> + <%= hidden_field_tag "workshop_email[pdf_attachment_signed_id]", @draft.pdf_attachment_signed_id if @draft.pdf_attachment? %> + + <% if @draft.pdf_attachment? %> +
+

<%= @draft.pdf_attachment_filename %>

+

<%= number_to_human_size(@draft.pdf_attachment_byte_size) %>

+ +
+ <% end %> +
+
<%= f.submit t(".preview", default: "Review recipients and preview"), class: form_primary_button_classes %> diff --git a/config/locales/el.yml b/config/locales/el.yml index 9dcc1f6..f33d2bb 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -384,6 +384,9 @@ el: both: "Participants and facilitators" errors: empty_audience: "This workshop does not have any recipients in the selected audience." + invalid_attachment: "The attached PDF could not be loaded. Please upload it again." + attachment_must_be_pdf: "Attach a PDF file." + attachment_too_large: "The PDF must be 10 MB or smaller." index: title: "Emails for %{workshop}" back_to_workshop: "← Back to workshop" @@ -394,6 +397,7 @@ el: audience: "Audience" sender: "Sender" workshop: "Workshop" + attachment: "PDF attachment" empty_title: "No workshop emails sent yet." empty_body: "When an admin sends a workshop follow-up or news email, it will appear here." new: @@ -408,6 +412,11 @@ el: subject: "Subject" body: "Email body" body_hint: "Use the editor for light formatting, links, and lists. The email will be sent as HTML with a plain-text fallback." + attachment: "PDF attachment" + attachment_hint: "Optional. PDF only, up to 10 MB." + attachment_drop_zone_hint: "One PDF attachment. 10 MB max." + attachment_upload: "Upload a PDF" + remove_attachment: "Remove this PDF" preview: "Review recipients and preview" send_test: "Send test to myself" preview_heading: "Preview" diff --git a/config/locales/en.yml b/config/locales/en.yml index c999062..c1fb506 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -459,6 +459,9 @@ en: both: "Participants and facilitators" errors: empty_audience: "This workshop does not have any recipients in the selected audience." + invalid_attachment: "The attached PDF could not be loaded. Please upload it again." + attachment_must_be_pdf: "Attach a PDF file." + attachment_too_large: "The PDF must be 10 MB or smaller." index: title: "Emails for %{workshop}" back_to_workshop: "← Back to workshop" @@ -469,6 +472,7 @@ en: audience: "Audience" sender: "Sender" workshop: "Workshop" + attachment: "PDF attachment" empty_title: "No workshop emails sent yet." empty_body: "When an admin sends a workshop follow-up or news email, it will appear here." new: @@ -483,6 +487,11 @@ en: subject: "Subject" body: "Email body" body_hint: "Use the editor for light formatting, links, and lists. The email will be sent as HTML with a plain-text fallback." + attachment: "PDF attachment" + attachment_hint: "Optional. PDF only, up to 10 MB." + attachment_drop_zone_hint: "One PDF attachment. 10 MB max." + attachment_upload: "Upload a PDF" + remove_attachment: "Remove this PDF" preview: "Review recipients and preview" send_test: "Send test to myself" preview_heading: "Preview" diff --git a/config/locales/es.yml b/config/locales/es.yml index 8787de6..f1efe09 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -379,6 +379,9 @@ es: both: "Participants and facilitators" errors: empty_audience: "This workshop does not have any recipients in the selected audience." + invalid_attachment: "The attached PDF could not be loaded. Please upload it again." + attachment_must_be_pdf: "Attach a PDF file." + attachment_too_large: "The PDF must be 10 MB or smaller." index: title: "Emails for %{workshop}" back_to_workshop: "← Back to workshop" @@ -389,6 +392,7 @@ es: audience: "Audience" sender: "Sender" workshop: "Workshop" + attachment: "PDF attachment" empty_title: "No workshop emails sent yet." empty_body: "When an admin sends a workshop follow-up or news email, it will appear here." new: @@ -403,6 +407,11 @@ es: subject: "Subject" body: "Email body" body_hint: "Use the editor for light formatting, links, and lists. The email will be sent as HTML with a plain-text fallback." + attachment: "PDF attachment" + attachment_hint: "Optional. PDF only, up to 10 MB." + attachment_drop_zone_hint: "One PDF attachment. 10 MB max." + attachment_upload: "Upload a PDF" + remove_attachment: "Remove this PDF" preview: "Review recipients and preview" send_test: "Send test to myself" preview_heading: "Preview" diff --git a/config/locales/it.yml b/config/locales/it.yml index e08c703..75aeaf0 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -384,6 +384,9 @@ it: both: "Participants and facilitators" errors: empty_audience: "This workshop does not have any recipients in the selected audience." + invalid_attachment: "The attached PDF could not be loaded. Please upload it again." + attachment_must_be_pdf: "Attach a PDF file." + attachment_too_large: "The PDF must be 10 MB or smaller." index: title: "Emails for %{workshop}" back_to_workshop: "← Back to workshop" @@ -394,6 +397,7 @@ it: audience: "Audience" sender: "Sender" workshop: "Workshop" + attachment: "PDF attachment" empty_title: "No workshop emails sent yet." empty_body: "When an admin sends a workshop follow-up or news email, it will appear here." new: @@ -408,6 +412,11 @@ it: subject: "Subject" body: "Email body" body_hint: "Use the editor for light formatting, links, and lists. The email will be sent as HTML with a plain-text fallback." + attachment: "PDF attachment" + attachment_hint: "Optional. PDF only, up to 10 MB." + attachment_drop_zone_hint: "One PDF attachment. 10 MB max." + attachment_upload: "Upload a PDF" + remove_attachment: "Remove this PDF" preview: "Review recipients and preview" send_test: "Send test to myself" preview_heading: "Preview" diff --git a/test/controllers/admin/workshop_emails_controller_test.rb b/test/controllers/admin/workshop_emails_controller_test.rb index 995b517..d0e7104 100644 --- a/test/controllers/admin/workshop_emails_controller_test.rb +++ b/test/controllers/admin/workshop_emails_controller_test.rb @@ -35,6 +35,13 @@ def draft_params(audience: "both") } end + def pdf_upload + Rack::Test::UploadedFile.new( + Rails.root.join("test/fixtures/files/sample-document.pdf"), + "application/pdf" + ) + end + test "unauthenticated users are redirected to login" do get admin_workshop_emails_path(@workshop) assert_redirected_to new_session_path @@ -66,6 +73,14 @@ def draft_params(audience: "both") assert_select "h2", text: "Past update" end + test "new composer embeds authenticity tokens in rendered forms" do + sign_in(@admin) + get new_admin_workshop_email_path(@workshop) + + assert_response :success + assert_select "input[name=?][type=?]", "authenticity_token", "hidden", minimum: 1 + end + test "preview renders recipient count without persisting a broadcast" do sign_in(@admin) @@ -81,6 +96,17 @@ def draft_params(audience: "both") assert_select "select[name=?]", "workshop_email[audience]", count: 0 end + test "preview preserves an uploaded pdf attachment" do + sign_in(@admin) + + post admin_workshop_emails_path(@workshop), + params: draft_params.merge(workshop_email: draft_params.fetch(:workshop_email).merge(pdf_attachment: pdf_upload)) + + assert_response :success + assert_select "p", text: "sample-document.pdf" + assert_select "input[name=?][type=?]", "workshop_email[pdf_attachment_signed_id]", "hidden", count: 2 + end + test "back to edit returns from preview to the composer without sending" do sign_in(@admin) @@ -99,12 +125,21 @@ def draft_params(audience: "both") assert_no_difference -> { WorkshopEmailBroadcast.count } do assert_emails 1 do - post send_test_admin_workshop_emails_path(@workshop), params: draft_params + post send_test_admin_workshop_emails_path(@workshop), + params: draft_params(audience: nil).merge(workshop_email: draft_params(audience: nil).fetch(:workshop_email).merge(pdf_attachment: pdf_upload)) end end + assert_redirected_to %r{/admin/workshops/spain/emails/new} + assert_match "workshop_email%5Bpdf_attachment_signed_id%5D=", response.location + follow_redirect! assert_response :success assert_match @admin.email, flash[:notice] + assert_select "select[name=?]", "workshop_email[audience]", count: 1 + assert_select "input[type=submit][value=?]", I18n.t("admin.workshop_emails.new.confirm_send"), count: 0 + assert_select "input[name=?][type=?]", "workshop_email[pdf_attachment_signed_id]", "hidden", count: 1 + assert_equal 1, ActionMailer::Base.deliveries.last.attachments.count + assert_equal "sample-document.pdf", ActionMailer::Base.deliveries.last.attachments.first.filename.to_s end test "confirmed send persists the broadcast and emails every selected recipient" do @@ -112,7 +147,8 @@ def draft_params(audience: "both") assert_difference -> { WorkshopEmailBroadcast.count }, 1 do assert_emails 2 do - post admin_workshop_emails_path(@workshop), params: draft_params.merge(confirm_send: "1") + post admin_workshop_emails_path(@workshop), + params: draft_params.merge(confirm_send: "1", workshop_email: draft_params.fetch(:workshop_email).merge(pdf_attachment: pdf_upload)) end end @@ -121,6 +157,7 @@ def draft_params(audience: "both") assert_equal @workshop, broadcast.workshop assert_equal "both", broadcast.audience assert_equal 2, broadcast.recipient_count + assert broadcast.pdf_attachment.attached? assert_redirected_to admin_workshop_emails_path(@workshop) end diff --git a/test/fixtures/files/sample-document.pdf b/test/fixtures/files/sample-document.pdf new file mode 100644 index 0000000..0b41c40 Binary files /dev/null and b/test/fixtures/files/sample-document.pdf differ diff --git a/test/mailers/workshop_email_broadcast_mailer_test.rb b/test/mailers/workshop_email_broadcast_mailer_test.rb index 913c423..5d54554 100644 --- a/test/mailers/workshop_email_broadcast_mailer_test.rb +++ b/test/mailers/workshop_email_broadcast_mailer_test.rb @@ -22,12 +22,19 @@ class WorkshopEmailBroadcastMailerTest < ActionMailer::TestCase recipient_count: 1, sent_at: Time.current ) + broadcast.pdf_attachment.attach( + io: Rails.root.join("test/fixtures/files/sample-document.pdf").open, + filename: "sample-document.pdf", + content_type: "application/pdf" + ) email = WorkshopEmailBroadcastMailer.broadcast(broadcast, recipient) assert_equal [ "part-mailer@example.com" ], email.to assert_equal "Workshop follow-up", email.subject assert_equal 2, email.parts.size + assert_equal 1, email.attachments.count + assert_equal "sample-document.pdf", email.attachments.first.filename.to_s assert_includes email.html_part.body.to_s, "Thanks" assert_includes email.text_part.body.to_s, "Thanks for coming." assert_includes email.body.encoded, "Taller IMASUS Espana" diff --git a/test/models/workshop_email_draft_test.rb b/test/models/workshop_email_draft_test.rb index 41d0929..38d4082 100644 --- a/test/models/workshop_email_draft_test.rb +++ b/test/models/workshop_email_draft_test.rb @@ -46,6 +46,17 @@ def setup assert_includes draft.errors.full_messages, I18n.t("admin.workshop_emails.errors.empty_audience") end + test "test-send validation does not require an audience" do + draft = WorkshopEmailDraft.new( + workshop: @workshop, + sender: @admin, + subject: "Follow-up", + body: "
Hello team
" + ) + + assert draft.valid? + end + test "normalized html renders attached images to real img tags" do blob = ActiveStorage::Blob.create_and_upload!( io: Rails.root.join("test/fixtures/files/sample-image.png").open, @@ -66,4 +77,46 @@ def setup assert_includes draft.normalized_html, "sample-image.png" assert_not_includes draft.normalized_html, "Hello team
", + pdf_attachment_signed_id: blob.signed_id, + pdf_attachment_blob: blob + ) + + assert draft.valid?(:delivery) + assert_equal "sample-document.pdf", draft.pdf_attachment_filename + end + + test "pdf attachment validation rejects non-pdf blobs" do + blob = ActiveStorage::Blob.create_and_upload!( + io: StringIO.new("not a pdf"), + filename: "notes.txt", + content_type: "text/plain" + ) + + draft = WorkshopEmailDraft.new( + workshop: @workshop, + sender: @admin, + audience: "participants", + subject: "Follow-up", + body: "
Hello team
", + pdf_attachment_signed_id: blob.signed_id, + pdf_attachment_blob: blob + ) + + assert_not draft.valid? + assert_includes draft.errors[:pdf_attachment], I18n.t("admin.workshop_emails.errors.attachment_must_be_pdf") + end end