Send record of the day email

This commit is contained in:
Claudio Ortolina
2026-03-05 10:37:04 +00:00
parent 911cf4525a
commit 478832249b
6 changed files with 259 additions and 1 deletions
+6
View File
@@ -119,6 +119,12 @@ config :music_library, MusicLibrary.Mailer,
api_key: "change me", api_key: "change me",
domain: "change me" domain: "change me"
config :music_library, MusicLibrary.RecordsOnThisDayEmail,
from_email: "postmaster@mailgun.fullyforged.com",
to_email: "claudio@fullyforged.com",
mailer: MusicLibrary.Mailer,
base_url: "https://music-library.claudio-ortolina.org"
config :fluxon, :translate_function, &MusicLibraryWeb.CoreComponents.translate_error/1 config :fluxon, :translate_function, &MusicLibraryWeb.CoreComponents.translate_error/1
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
+4 -1
View File
@@ -24,6 +24,7 @@ config :music_library, ErrorTracker.ErrorNotifier,
config :music_library, Oban, config :music_library, Oban,
plugins: [ plugins: [
{Oban.Plugins.Cron, {Oban.Plugins.Cron,
timezone: "Europe/London",
crontab: [ crontab: [
# every 12 hours # every 12 hours
{"0 */12 * * *", MusicLibrary.Worker.ApplyScrobbleRules}, {"0 */12 * * *", MusicLibrary.Worker.ApplyScrobbleRules},
@@ -39,7 +40,9 @@ config :music_library, Oban,
{"0 7 1 * *", MusicLibrary.Worker.RecordGenerateAllEmbeddings}, {"0 7 1 * *", MusicLibrary.Worker.RecordGenerateAllEmbeddings},
{"0 8 1 * *", MusicLibrary.Worker.ArtistRefreshAllMusicBrainzData}, {"0 8 1 * *", MusicLibrary.Worker.ArtistRefreshAllMusicBrainzData},
{"0 9 1 * *", MusicLibrary.Worker.ArtistRefreshAllDiscogsData}, {"0 9 1 * *", MusicLibrary.Worker.ArtistRefreshAllDiscogsData},
{"0 10 1 * *", MusicLibrary.Worker.ArtistRefreshAllWikipediaData} {"0 10 1 * *", MusicLibrary.Worker.ArtistRefreshAllWikipediaData},
# daily at 8 am
{"0 8 * * *", MusicLibrary.Worker.SendRecordsOnThisDayEmail}
]} ]}
] ]
@@ -0,0 +1,156 @@
defmodule MusicLibrary.RecordsOnThisDayEmail do
@moduledoc false
import Swoosh.Email
require Logger
alias MusicLibrary.Assets.Transform
alias MusicLibrary.Collection
alias MusicLibrary.Records.Record
def send(date) do
records = Collection.get_records_on_this_day(date)
if records == [] do
{:ok, :no_records}
else
conf = config()
from_email = Keyword.fetch!(conf, :from_email)
to_email = Keyword.fetch!(conf, :to_email)
mailer = Keyword.fetch!(conf, :mailer)
heading = Calendar.strftime(date, "Records on %-d %B")
email =
new()
|> to(to_email)
|> from({"MusicLibrary", from_email})
|> subject("[MusicLibrary] #{heading}")
|> html_body(build_html(records, date, conf))
case mailer.deliver(email) do
{:ok, _} ->
Logger.info("Records on this day email sent (#{length(records)} records)")
{:ok, :sent}
{:error, reason} ->
Logger.error("Failed to send records on this day email: #{inspect(reason)}")
{:error, reason}
end
end
end
# -- Private --
defp build_html(records, date, conf) do
base_url = Keyword.fetch!(conf, :base_url) |> String.trim_trailing("/")
api_token = Application.get_env(:music_library, MusicLibraryWeb)[:api_token]
heading = date |> Calendar.strftime("Records on %-d %B") |> html_escape()
records_html =
Enum.map_join(records, "\n", fn record -> record_html(record, date, base_url, api_token) end)
"""
<div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: system-ui, -apple-system, sans-serif; background-color: #f4f4f5;">
<div style="background-color: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);">
<h1 style="color: #18181b; font-size: 24px; font-weight: bold; margin: 0 0 24px 0;">
#{heading}
</h1>
#{records_html}
</div>
</div>
"""
end
defp record_html(record, date, base_url, api_token) do
years = Record.released_how_long_ago?(record, date)
cover_url = cover_image_url(record, base_url, api_token)
record_url = record_detail_url(record, base_url)
artist_names = record |> Record.artist_names() |> html_escape()
title = record.title |> html_escape()
years_label = years_ago_label(years)
{years_color, years_weight} = anniversary_style(years)
format = format_label(record.format)
type = type_label(record.type)
purchased_label =
if record.purchased_at do
" · #{Record.format_as_date(record.purchased_at)}"
else
""
end
cover_html =
if cover_url do
~s(<img src="#{html_escape(cover_url)}" width="48" height="48" style="width: 48px; height: 48px; object-fit: cover; border-radius: 4px; display: block;" alt="" />)
else
~s(<div style="width: 48px; height: 48px; background-color: #e4e4e7; border-radius: 4px;"></div>)
end
"""
<a href="#{html_escape(record_url)}" style="display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid #f4f4f5; align-items: center; text-decoration: none; color: inherit;">
#{cover_html}
<div style="min-width: 0; flex: 1;">
<p style="margin: 0; font-size: 13px; color: #52525b; line-height: 1.4;">#{artist_names}</p>
<p style="margin: 2px 0 0 0; font-size: 15px; font-weight: 600; color: #18181b; line-height: 1.4;">#{title}</p>
<p style="margin: 2px 0 0 0; font-size: 12px; color: #71717a; line-height: 1.4;">
<span style="color: #{years_color}; font-weight: #{years_weight};">#{years_label}</span>
· #{format} · #{type}#{purchased_label}
</p>
</div>
</a>
"""
end
defp record_detail_url(record, base_url) do
"#{base_url}/collection/#{record.id}"
end
defp cover_image_url(%{cover_hash: nil}, _base_url, _api_token), do: nil
defp cover_image_url(record, base_url, api_token) do
payload = Transform.new(hash: record.cover_hash, width: 96) |> Transform.encode!()
"#{base_url}/api/assets/#{payload}?token=#{api_token}"
end
defp years_ago_label(nil), do: ""
defp years_ago_label(0), do: "Today"
defp years_ago_label(1), do: "1 year ago"
defp years_ago_label(n), do: "#{n} years ago"
defp anniversary_style(years) when is_integer(years) and years > 0 and rem(years, 10) == 0,
do: {"#b45309", "bold"}
defp anniversary_style(years) when is_integer(years) and years > 0 and rem(years, 5) == 0,
do: {"#6b7280", "bold"}
defp anniversary_style(0), do: {"#dc2626", "bold"}
defp anniversary_style(_), do: {"#71717a", "normal"}
defp format_label(:cd), do: "CD"
defp format_label(:backup), do: "Backup"
defp format_label(:vinyl), do: "Vinyl"
defp format_label(:blu_ray), do: "Blu-ray"
defp format_label(:dvd), do: "DVD"
defp format_label(:multi), do: "Multi"
defp format_label(:digital_download), do: "Download"
defp format_label(:vhs), do: "VHS"
defp format_label(:unknown), do: "Unknown"
defp format_label(_), do: ""
defp type_label(:album), do: "Album"
defp type_label(:ep), do: "EP"
defp type_label(:live), do: "Live"
defp type_label(:compilation), do: "Comp"
defp type_label(:single), do: "Single"
defp type_label(:other), do: "Other"
defp type_label(_), do: ""
defp html_escape(value) do
value |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
end
defp config do
Application.get_env(:music_library, __MODULE__, [])
end
end
@@ -0,0 +1,11 @@
defmodule MusicLibrary.Worker.SendRecordsOnThisDayEmail do
use Oban.Worker, queue: :default, max_attempts: 3
alias MusicLibrary.RecordsOnThisDayEmail
@impl Oban.Worker
def perform(_) do
today = DateTime.now!(MusicLibrary.default_timezone()) |> DateTime.to_date()
RecordsOnThisDayEmail.send(today)
end
end
+8
View File
@@ -122,4 +122,12 @@ defmodule MusicLibraryWeb.Router do
error_tracker_dashboard "/errors" error_tracker_dashboard "/errors"
end end
end end
if Mix.env() == :dev do
scope "/dev" do
pipe_through [:browser, :logged_in]
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end end
@@ -0,0 +1,74 @@
defmodule MusicLibrary.RecordsOnThisDayEmailTest do
use MusicLibrary.DataCase
import Swoosh.TestAssertions
alias MusicLibrary.Fixtures
alias MusicLibrary.Records.Record
alias MusicLibrary.RecordsOnThisDayEmail
setup do
Application.put_env(:music_library, RecordsOnThisDayEmail,
from_email: "test@example.com",
to_email: "recipient@example.com",
mailer: MusicLibrary.Mailer,
base_url: "http://localhost:4000"
)
on_exit(fn ->
Application.delete_env(:music_library, RecordsOnThisDayEmail)
end)
end
describe "send/1" do
test "sends email when records match the date" do
# Create a record with a release date matching March 5
record =
Fixtures.Records.record(%{
release_date: "2020-03-05",
title: "Test Album"
})
date = ~D[2025-03-05]
assert {:ok, :sent} = RecordsOnThisDayEmail.send(date)
assert_email_sent(fn email ->
assert email.subject =~ "Records on 5 March"
html = email.html_body
assert html =~ record.title
assert html =~ Record.artist_names(record)
assert html =~ "5 years ago"
assert html =~ "http://localhost:4000/collection/#{record.id}"
end)
end
test "skips sending when no records match" do
# Create a record with a release date that won't match
Fixtures.Records.record(%{release_date: "2020-01-01"})
date = ~D[2025-06-15]
assert {:ok, :no_records} = RecordsOnThisDayEmail.send(date)
refute_email_sent()
end
test "shows anniversary styling for milestone years" do
# 10 year anniversary (gold)
Fixtures.Records.record(%{
release_date: "2015-07-20",
title: "Gold Anniversary Album"
})
date = ~D[2025-07-20]
assert {:ok, :sent} = RecordsOnThisDayEmail.send(date)
assert_email_sent(fn email ->
html = email.html_body
assert html =~ "Gold Anniversary Album"
assert html =~ "10 years ago"
# Gold color
assert html =~ "#b45309"
end)
end
end
end