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