Skip to content

Move MJML content to separate template file#955

Open
dthaler wants to merge 4 commits intoorcasound:mainfrom
dthaler:copilot/fix-2
Open

Move MJML content to separate template file#955
dthaler wants to merge 4 commits intoorcasound:mainfrom
dthaler:copilot/fix-2

Conversation

@dthaler
Copy link
Contributor

@dthaler dthaler commented Sep 17, 2025

This PR addresses the issue where email bodies were hardcoded in server/lib/orcasite/notifications/email.ex, making it difficult for non-Elixir-developers to collaborate on email content.

Changes Made

Extracted hardcoded MJML content from the mjml_new_detection_body/1 and mjml_confirmed_candidate_body/1 functions into separate template files:

  • lib/orcasite/notifications/templates/new_detection.mjml.eex (2,902 characters)
  • lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex (2,955 characters)

Implemented template loading infrastructure:

  • Added read_template/1 helper function that uses Path.expand/1 to read template files from the lib directory
  • Updated both email body functions to use read_template/1 instead of heredoc strings
  • Maintained all existing EEx/Handlebars interpolation syntax ({{ variable }} format)

Added comprehensive test coverage in test/orcasite/notifications/email_template_test.exs that verifies:

  • Template files exist and are readable
  • Templates contain valid MJML structure
  • All required variables are present in templates

Benefits

  • Non-developer friendly: Content editors and designers can now modify email templates without touching Elixir code
  • Better collaboration: Email content is separated from application logic
  • Easier maintenance: Templates can be easily compared, version-controlled, and tested with sample data
  • Backward compatible: All existing functionality and variable interpolation is preserved

Summary by CodeRabbit

  • New Features

    • Enhanced email notifications for new detections and confirmed candidates with clearer layout, dynamic details (description, listener count), and quick actions (Listen live, Review report, Start a new bout).
    • Shows a summary table of recent detections when relevant.
    • Unsubscribe section appears only when available; CTAs link to live site and blog.
  • Refactor

    • Email content moved out of code into external templates for consistent presentation.
  • Tests

    • Added tests to ensure templates exist, contain MJML structure, and include key text, variables, and links.

Copilot AI and others added 3 commits September 17, 2025 15:25
Co-authored-by: dthaler <6547784+dthaler@users.noreply.github.com>
Co-authored-by: dthaler <6547784+dthaler@users.noreply.github.com>
@dthaler dthaler requested a review from skanderm as a code owner September 17, 2025 15:58
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 17, 2025

Walkthrough

Replaced inline MJML email strings with external EEx MJML templates loaded at runtime via a new private read_template/1; added two MJML templates and tests validating their presence and key placeholders. Compile and rendering flow (Zappa + EEx) and public APIs unchanged.

Changes

Cohort / File(s) Summary
Email module refactor
server/lib/orcasite/notifications/email.ex
Replaced large inline MJML bodies with a private read_template/1 that loads lib/orcasite/notifications/templates/{name}.mjml.eex; retained Zappa.compile! and EEx.eval_string flow; no public signature changes.
Templates added
server/lib/orcasite/notifications/templates/new_detection.mjml.eex, server/lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex
Added MJML EEx templates containing dynamic placeholders (node, node_name, meta[...], unsubscribe_token, unsubscribe_url) with conditional sections and CTAs (listen/review links).
Tests for templates
server/test/orcasite/notifications/email_template_test.exs
Added ExUnit tests verifying templates exist, are readable, contain <mjml>, and include expected strings and variable placeholders.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Notifier
  participant Email as Notifications.Email
  participant FS as FileSystem
  participant Zappa as MJML Compiler
  participant EEx as EEx Engine
  participant Mailer

  Notifier->>Email: build_email(type, assigns)
  Email->>Email: read_template(name)
  Email->>FS: read templates/{name}.mjml.eex
  FS-->>Email: template string
  Email->>Zappa: compile!(mjml_string)
  Zappa-->>Email: compiled HTML with EEx tags
  Email->>EEx: eval_string(assigns incl. unsubscribe_url)
  EEx-->>Email: final HTML
  Email->>Mailer: deliver(email, HTML)
  Mailer-->>Notifier: result
  note over Email: unsubscribe block rendered only if token present
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

I nibble code like clover leaves,
Templates bloom where strings once weaved—
Read at runtime, blossom bright,
Compile and render through the night.
A hop, a sniff, "Listen now!"—delight.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Move MJML content to separate template file" succinctly describes the PR's primary intent: extracting hardcoded MJML email bodies into external templates and updating the email module to read them at runtime. The PR and file summaries confirm that template files were added and mjml_new_detection_body/1 and mjml_confirmed_candidate_body/1 were updated to load those templates, so the title accurately reflects the main change. The use of the singular "file" is a minor wording mismatch but does not obscure the intent.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a5e61a7 and eb7c76d.

📒 Files selected for processing (1)
  • server/lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (10)
server/lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex (3)

20-21: Add descriptive alt text for accessibility.

Provide alt text for the “don’t miss” image.

-        <mj-image src="https://orcasite.s3.us-west-2.amazonaws.com/email_assets/orcasound_dont_miss.jpg"></mj-image>
+        <mj-image src="https://orcasite.s3.us-west-2.amazonaws.com/email_assets/orcasound_dont_miss.jpg" alt="Don't miss the live orca audio stream"></mj-image>

28-30: Remove stray “ecm” query flag.

?ecm& looks accidental and could pollute analytics.

-          If you miss the concert, <br /> watch the <a href="https://orcasound.net/blog?ecm&utm_source=email&utm_medium=email&utm_campaign=notifications" style="color: #007C89;">Orcasound blog</a> for recordings & bioacoustic analysis!
+          If you miss the concert, <br /> watch the <a href="https://orcasound.net/blog?utm_source=email&utm_medium=email&utm_campaign=notifications" style="color: #007C89;">Orcasound blog</a> for recordings & bioacoustic analysis!

45-47: Stale copyright year; make it dynamic.

Keeps templates future‑proof.

EEx runs in your pipeline; confirm mixing EEx with MJML/Handlebars is OK in prod.

-          Copyright © 2023 Orcasound, All rights reserved. <br />
+          Copyright © <%= Date.utc_today().year %> Orcasound, All rights reserved. <br />
server/lib/orcasite/notifications/email.ex (1)

73-79: Handle MJML compilation errors explicitly.

elem(1) discards error status. Surface failures to avoid silently sending broken HTML.

   def compile_mjml(mjml, assigns) do
     mjml
     |> Zappa.compile!()
     |> EEx.eval_string(Map.to_list(assigns))
-    |> Mjml.to_html()
-    |> elem(1)
+    |> Mjml.to_html()
+    |> case do
+      {:ok, html} -> html
+      {:error, reason} -> raise "MJML render failed: #{inspect(reason)}"
+    end
   end
server/test/orcasite/notifications/email_template_test.exs (3)

6-7: Make test paths independent of CWD.

Resolve from test file dir to avoid failures when run outside app root.

-      template_path = Path.expand("lib/orcasite/notifications/templates/new_detection.mjml.eex")
+      template_path = Path.expand("../lib/orcasite/notifications/templates/new_detection.mjml.eex", __DIR__)

16-17: Same here for confirmed_candidate path.

-      template_path = Path.expand("lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex")
+      template_path = Path.expand("../lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex", __DIR__)

25-38: Strengthen assertions to catch regressions and link issues.

  • Ensure per‑row “Review” link uses notif_meta["candidate_id"].
  • Guard against accidental http:// links in templates.
     test "templates contain required variables" do
       # Test new_detection template variables
-      {:ok, new_detection_content} = File.read(Path.expand("lib/orcasite/notifications/templates/new_detection.mjml.eex"))
+      {:ok, new_detection_content} = File.read(Path.expand("../lib/orcasite/notifications/templates/new_detection.mjml.eex", __DIR__))
       assert String.contains?(new_detection_content, "{{ node_name }}"), "should contain node_name variable"
       assert String.contains?(new_detection_content, "{{ node }}"), "should contain node variable"
       assert String.contains?(new_detection_content, "meta[\"description\"]"), "should contain description variable"
       assert String.contains?(new_detection_content, "{{unsubscribe_url}}"), "should contain unsubscribe_url variable"
+      assert String.contains?(new_detection_content, "reports/{{notif_meta[\"candidate_id\"]}}"), "review link should use notif_meta candidate_id"
       
       # Test confirmed_candidate template variables
-      {:ok, confirmed_candidate_content} = File.read(Path.expand("lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex"))
+      {:ok, confirmed_candidate_content} = File.read(Path.expand("../lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex", __DIR__))
       assert String.contains?(confirmed_candidate_content, "{{ node }}"), "should contain node variable"
       assert String.contains?(confirmed_candidate_content, "meta[\"message\"]"), "should contain message variable"
       assert String.contains?(confirmed_candidate_content, "{{ unsubscribe_url }}"), "should contain unsubscribe_url variable"
+      refute confirmed_candidate_content =~ ~r{\shref="http://}i, "all links should be https"
     end

Optionally add a render smoke test:

test "templates render without errors" do
  assigns = %{
    node: "haro-strait",
    node_name: "Haro Strait",
    unsubscribe_token: "t",
    notifications_since_count: 0,
    meta: %{}
  }
  assert is_binary(Orcasite.Notifications.Email.mjml_new_detection_body(assigns))
  assert is_binary(Orcasite.Notifications.Email.mjml_confirmed_candidate_body(assigns))
end
server/lib/orcasite/notifications/templates/new_detection.mjml.eex (3)

23-27: Avoid non‑portable #if boolean expressions.

node && meta["start_time"] && meta["category"] is not standard Handlebars. Prefer nested #if for compatibility.

-    {{#if node && meta["start_time"] && meta["category"] }}
+    {{#if node}}
+    {{#if meta["start_time"]}}
+    {{#if meta["category"]}}
       <mj-text font-size="20px" font-family="helvetica">
         <a href="https://live.orcasound.net/bouts/new/{{ node }}?time={{ meta["start_time"] }}&category={{ meta["category"] }}&utm_source=email&utm_medium=email&utm_campaign=notifications">Start a new bout</a>
       </mj-text>
-    {{/if}}
+    {{/if}}{{/if}}{{/if}}

29-33: Likely non‑portable comparison in #if.

> 0 may not be supported by your Handlebars engine. Consider relying on truthiness of the count or a precomputed boolean.

-    {{#if notifications_since_count > 0}}
+    {{#if notifications_since_count}}
       <mj-text font-size="20px" font-family="helvetica">
         There have been {{ notifications_since_count }} other detections since the last notification.
       </mj-text>

56-58: Minor: normalize placeholder style.

Use consistent spacing in {{ ... }} for readability.

-      Listen here: <a href="https://live.orcasound.net/listen/{{node}}?utm_source=email&utm_medium=email&utm_campaign=notifications">https://live.orcasound.net/listen/{{ node }}</a>
+      Listen here: <a href="https://live.orcasound.net/listen/{{ node }}?utm_source=email&utm_medium=email&utm_campaign=notifications">https://live.orcasound.net/listen/{{ node }}</a>
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d8ed033 and a5e61a7.

📒 Files selected for processing (4)
  • server/lib/orcasite/notifications/email.ex (3 hunks)
  • server/lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex (1 hunks)
  • server/lib/orcasite/notifications/templates/new_detection.mjml.eex (1 hunks)
  • server/test/orcasite/notifications/email_template_test.exs (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (1)
server/lib/orcasite/notifications/email.ex (1)

31-33: Confirm Handlebars features are supported by your renderer.

Templates use #if with expressions and each as |x|. Ensure Zappa/MJML pipeline (or downstream provider) evaluates these; otherwise, emails may ship with raw {{ }}.

Would you like a quick ExUnit that renders both templates via compile_mjml/2 with minimal assigns to catch runtime issues?

Also applies to: 43-45

Comment on lines +68 to +71
defp read_template(name) do
Path.expand("lib/orcasite/notifications/templates/#{name}.mjml.eex")
|> File.read!()
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make template path robust to CWD/release layout.

Hardcoding "lib/..." depends on process CWD. Resolve relative to this module instead.

-  defp read_template(name) do
-    Path.expand("lib/orcasite/notifications/templates/#{name}.mjml.eex")
-    |> File.read!()
-  end
+  defp read_template(name) do
+    Path.expand("templates/#{name}.mjml.eex", __DIR__)
+    |> File.read!()
+  end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defp read_template(name) do
Path.expand("lib/orcasite/notifications/templates/#{name}.mjml.eex")
|> File.read!()
end
defp read_template(name) do
Path.expand("templates/#{name}.mjml.eex", __DIR__)
|> File.read!()
end
🤖 Prompt for AI Agents
In server/lib/orcasite/notifications/email.ex around lines 68–71, the template
path is hardcoded to "lib/..." which depends on the process CWD; change it to
build the path relative to this module file (use __DIR__ or similar) and join
into the templates folder so it works in releases/other CWDs—i.e. construct the
full path from the current file directory plus "templates/#{name}.mjml.eex"
(using Path.join/1 or Path.expand with __DIR__), then read that file with
File.read!().

Comment on lines +46 to +48
{{#if notif_meta["candidate_id"] }}
<a href="https://live.orcasound.net/reports/{{meta["candidate_id"]}}?utm_source=email&utm_medium=email&utm_campaign=notifications">Review</a>
{{/if}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Wrong variable in Review link inside the loop.

Links will point to the outer meta id, not the per‑row notif_meta id.

-              <a href="https://live.orcasound.net/reports/{{meta["candidate_id"]}}?utm_source=email&utm_medium=email&utm_campaign=notifications">Review</a>
+              <a href="https://live.orcasound.net/reports/{{notif_meta["candidate_id"]}}?utm_source=email&utm_medium=email&utm_campaign=notifications">Review</a>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{{#if notif_meta["candidate_id"] }}
<a href="https://live.orcasound.net/reports/{{meta["candidate_id"]}}?utm_source=email&utm_medium=email&utm_campaign=notifications">Review</a>
{{/if}}
{{#if notif_meta["candidate_id"] }}
<a href="https://live.orcasound.net/reports/{{notif_meta["candidate_id"]}}?utm_source=email&utm_medium=email&utm_campaign=notifications">Review</a>
{{/if}}
🤖 Prompt for AI Agents
In server/lib/orcasite/notifications/templates/new_detection.mjml.eex around
lines 46 to 48, the Review link uses the outer meta["candidate_id"] instead of
the per-row notif_meta["candidate_id"]; change the URL interpolation to use
notif_meta["candidate_id"] (so the href becomes
.../reports/{{notif_meta["candidate_id"]}}?...), ensuring it matches the
existing {{#if notif_meta["candidate_id"] }} guard and preserves the utm query
params and quoting style.

dthaler

This comment was marked as resolved.

…e.mjml.eex

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR refactors email notification templates by extracting hardcoded MJML content from Elixir functions into separate template files, making email content more accessible to non-developers while maintaining existing functionality.

  • Extracted MJML email templates from Elixir code into dedicated .mjml.eex files
  • Added template loading infrastructure with read_template/1 helper function
  • Implemented comprehensive test coverage for template existence and content validation

Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
server/lib/orcasite/notifications/email.ex Replaced hardcoded MJML strings with calls to read_template() function
server/lib/orcasite/notifications/templates/new_detection.mjml.eex New template file containing MJML for new detection notifications
server/lib/orcasite/notifications/templates/confirmed_candidate.mjml.eex New template file containing MJML for confirmed candidate notifications
server/test/orcasite/notifications/email_template_test.exs Added test suite to verify template files exist and contain required content

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

<td style="padding: 0 0 0 15px;">{{ notif_meta["description"] }}</td>
<td style="padding: 0 0 0 15px;">
{{#if notif_meta["candidate_id"] }}
<a href="https://live.orcasound.net/reports/{{meta["candidate_id"]}}?utm_source=email&utm_medium=email&utm_campaign=notifications">Review</a>
Copy link

Copilot AI Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable reference is incorrect. It should use notif_meta["candidate_id"] instead of meta["candidate_id"] since this is inside the {{#each notifications_since as |notif_meta|}} loop.

Suggested change
<a href="https://live.orcasound.net/reports/{{meta["candidate_id"]}}?utm_source=email&utm_medium=email&utm_campaign=notifications">Review</a>
<a href="https://live.orcasound.net/reports/{{notif_meta["candidate_id"]}}?utm_source=email&utm_medium=email&utm_campaign=notifications">Review</a>

Copilot uses AI. Check for mistakes.
@paulcretu
Copy link
Member

Good idea and I don't have any major issues with this PR (though there are some AI comments that might be worth addressing), but I think we should take this opportunity to try out phoenix_swoosh instead. I'll take a look as part of #954 and in the meantime leave this PR open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants