Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .munkit/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion .munkit/specs/2026-04-29-admin-workshop-emails/brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions .munkit/specs/2026-04-29-admin-workshop-emails/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
58 changes: 47 additions & 11 deletions app/controllers/admin/workshop_emails_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
16 changes: 15 additions & 1 deletion app/mailers/workshop_email_broadcast_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions app/models/workshop_email_broadcast.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 49 additions & 2 deletions app/models/workshop_email_draft.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions app/services/send_workshop_email_broadcast.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/views/admin/workshop_emails/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@
</div>
</dl>

<% if broadcast.pdf_attachment.attached? %>
<div class="mt-5 rounded-2xl border border-imasus-dark-green/10 bg-imasus-light-blue/15 px-4 py-3 text-sm text-stone-700">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-imasus-dark-green/45"><%= t(".attachment", default: "PDF attachment") %></p>
<p class="mt-1">
<%= 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" %>
</p>
</div>
<% end %>

<div class="mt-5 rounded-2xl bg-stone-50 p-4">
<div class="prose prose-stone max-w-none trix-content">
<%= sanitized_email_html(broadcast.body_html) %>
Expand Down
47 changes: 47 additions & 0 deletions app/views/admin/workshop_emails/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@
<p class="mt-4 text-base leading-7 text-stone-700"><%= 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.") %></p>
</header>

<% if flash[:notice].present? %>
<p class="mt-8 rounded-2xl bg-imasus-mint/40 px-4 py-3 text-sm text-imasus-dark-green">
<%= flash[:notice] %>
</p>
<% end %>

<% if flash[:alert].present? %>
<p class="mt-8 rounded-2xl bg-imasus-red/10 px-4 py-3 text-sm text-imasus-red">
<%= flash[:alert] %>
</p>
<% end %>

<% if @draft.errors.any? %>
<div class="<%= form_error_box_classes %> mt-8" role="alert">
<h2 class="font-semibold text-imasus-red"><%= t(".errors_heading", count: @draft.errors.count, default: "Fix %{count} issue(s) before continuing.") %></h2>
Expand Down Expand Up @@ -49,6 +61,13 @@
</div>
</dl>

<% if @draft.pdf_attachment? %>
<div class="mt-5 rounded-2xl border border-imasus-dark-green/10 bg-imasus-light-blue/15 px-4 py-3 text-sm text-stone-700">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-imasus-dark-green/45"><%= t(".attachment", default: "PDF attachment") %></p>
<p class="mt-1 font-medium text-imasus-dark-green"><%= @draft.pdf_attachment_filename %></p>
</div>
<% end %>

<div class="mt-6 rounded-2xl border border-imasus-dark-green/10 bg-stone-50 p-5">
<div class="prose prose-stone max-w-none trix-content">
<%= @draft.normalized_html.html_safe %>
Expand All @@ -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" %>
Expand All @@ -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,
Expand All @@ -90,6 +113,7 @@
local: true,
data: { turbo: false },
class: "space-y-6" do |f| %>
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<div>
<label class="<%= form_label_classes %>"><%= t(".workshop", default: "Workshop") %></label>
<div class="<%= form_input_classes(readonly: true) %>"><%= @workshop.title %></div>
Expand All @@ -114,6 +138,29 @@
<%= rich_textarea_tag "workshop_email[body]", @draft.body, class: "trix-content trix-content--email-compose" %>
</div>

<div>
<%= f.label :pdf_attachment, t(".attachment", default: "PDF attachment"), class: form_label_classes %>
<p class="mb-2 text-xs text-imasus-dark-green/55"><%= t(".attachment_hint", default: "Optional. PDF only, up to 10 MB.") %></p>
<%= 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? %>
<div class="mt-3 rounded-2xl border border-imasus-dark-green/10 bg-stone-50 px-4 py-3">
<p class="text-sm font-medium text-imasus-dark-green"><%= @draft.pdf_attachment_filename %></p>
<p class="mt-1 text-xs text-stone-700"><%= number_to_human_size(@draft.pdf_attachment_byte_size) %></p>
<label class="mt-3 inline-flex items-center gap-2 text-sm text-stone-700">
<%= check_box_tag "workshop_email[remove_pdf_attachment]", "1", false, class: "rounded border-imasus-dark-green/20 text-imasus-dark-green focus:ring-imasus-mint" %>
<span><%= t(".remove_attachment", default: "Remove this PDF") %></span>
</label>
</div>
<% end %>
</div>

<div class="flex flex-wrap items-center gap-3">
<%= f.submit t(".preview", default: "Review recipients and preview"),
class: form_primary_button_classes %>
Expand Down
Loading