Send record of the day email
This commit is contained in:
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user