diff --git a/config/config.exs b/config/config.exs
index 1ece3730..8bf81299 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -119,6 +119,12 @@ config :music_library, MusicLibrary.Mailer,
api_key: "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
# Import environment specific config. This must remain at the bottom
diff --git a/config/prod.exs b/config/prod.exs
index 1f2d3749..9c7ad263 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -24,6 +24,7 @@ config :music_library, ErrorTracker.ErrorNotifier,
config :music_library, Oban,
plugins: [
{Oban.Plugins.Cron,
+ timezone: "Europe/London",
crontab: [
# every 12 hours
{"0 */12 * * *", MusicLibrary.Worker.ApplyScrobbleRules},
@@ -39,7 +40,9 @@ config :music_library, Oban,
{"0 7 1 * *", MusicLibrary.Worker.RecordGenerateAllEmbeddings},
{"0 8 1 * *", MusicLibrary.Worker.ArtistRefreshAllMusicBrainzData},
{"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}
]}
]
diff --git a/lib/music_library/records_on_this_day_email.ex b/lib/music_library/records_on_this_day_email.ex
new file mode 100644
index 00000000..107623c4
--- /dev/null
+++ b/lib/music_library/records_on_this_day_email.ex
@@ -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)
+
+ """
+
+
+
+ #{heading}
+
+ #{records_html}
+
+
+ """
+ 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(
)
+ else
+ ~s()
+ end
+
+ """
+
+ #{cover_html}
+
+
#{artist_names}
+
#{title}
+
+ #{years_label}
+ · #{format} · #{type}#{purchased_label}
+
+
+
+ """
+ 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
diff --git a/lib/music_library/worker/send_records_on_this_day_email.ex b/lib/music_library/worker/send_records_on_this_day_email.ex
new file mode 100644
index 00000000..bf32a3dd
--- /dev/null
+++ b/lib/music_library/worker/send_records_on_this_day_email.ex
@@ -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
diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex
index 1b3525e7..2f57b2a8 100644
--- a/lib/music_library_web/router.ex
+++ b/lib/music_library_web/router.ex
@@ -122,4 +122,12 @@ defmodule MusicLibraryWeb.Router do
error_tracker_dashboard "/errors"
end
end
+
+ if Mix.env() == :dev do
+ scope "/dev" do
+ pipe_through [:browser, :logged_in]
+
+ forward "/mailbox", Plug.Swoosh.MailboxPreview
+ end
+ end
end
diff --git a/test/music_library/records_on_this_day_email_test.exs b/test/music_library/records_on_this_day_email_test.exs
new file mode 100644
index 00000000..a8ee5e8a
--- /dev/null
+++ b/test/music_library/records_on_this_day_email_test.exs
@@ -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