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" %> +
+<%= 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? %><%= t(".attachment", default: "PDF attachment") %>
+<%= @draft.pdf_attachment_filename %>
+<%= 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) %>
+ +