From 09147805a7ac5a2a558b75f57dfe48cce4e3ec8d Mon Sep 17 00:00:00 2001 From: Andrei Roman Date: Thu, 9 Apr 2026 17:22:02 +0200 Subject: [PATCH 1/9] Add phoenix_live_view, esbuild, and auctionet_single_sign_on_plug deps Co-Authored-By: Claude Sonnet 4.6 --- config/config.exs | 16 ++++++++++++++-- config/dev.exs | 8 ++++++-- config/runtime.exs | 4 ++++ mix.exs | 5 +++++ mix.lock | 5 +++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/config/config.exs b/config/config.exs index 8df89ef..fbac55c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,8 +12,11 @@ config :ex_gridhook, # Configures the endpoint config :ex_gridhook, ExGridhookWeb.Endpoint, url: [host: "localhost"], - secret_key_base: System.get_env("SECRET_KEY_BASE") || "no secret", - pubsub_server: ExGridhook.PubSub + secret_key_base: + System.get_env("SECRET_KEY_BASE") || + "dev_secret_key_base_at_least_64_bytes_long_do_not_use_in_production_xxxxxxxxxxx", + pubsub_server: ExGridhook.PubSub, + live_view: [signing_salt: "gridhook_lv"] config :ex_gridhook, revision: {:system, "HEROKU_SLUG_COMMIT", "some revision"} @@ -39,6 +42,15 @@ config :honeybadger, origin: System.get_env("HONEYBADGER_ORIGIN", "https://api.honeybadger.io"), breadcrumbs_enabled: true +config :esbuild, + version: "0.17.11", + default: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index 2b89f19..eeea022 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -11,7 +11,9 @@ config :ex_gridhook, ExGridhookWeb.Endpoint, debug_errors: true, code_reloader: true, check_origin: false, - watchers: [] + watchers: [ + esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} + ] # ## SSL Support # @@ -33,7 +35,9 @@ config :ex_gridhook, ExGridhookWeb.Endpoint, config :ex_gridhook, ExGridhookWeb.Endpoint, live_reload: [ patterns: [ - ~r{priv/gettext/.*(po)$} + ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, + ~r{priv/gettext/.*(po)$}, + ~r{lib/ex_gridhook_web/(live|components|controllers)/.*(ex|heex)$} ] ] diff --git a/config/runtime.exs b/config/runtime.exs index d82a8a0..46c3b76 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -12,6 +12,10 @@ if config_env() == :prod do config :logger, level: System.get_env("LOG_LEVEL") || :info + config :ex_gridhook, + sso_secret_key: System.get_env("SSO_SECRET_KEY"), + sso_request_url: System.get_env("SSO_REQUEST_URL") + # Configure the database config :ex_gridhook, ExGridhook.Repo, adapter: Ecto.Adapters.Postgres, diff --git a/mix.exs b/mix.exs index 1d9e094..6e2ada2 100644 --- a/mix.exs +++ b/mix.exs @@ -34,13 +34,17 @@ defmodule ExGridhook.Mixfile do [ {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, {:ecto_sql, ">= 0.0.0"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, {:gettext, ">= 0.0.0"}, {:honeybadger, ">= 0.0.0"}, {:jason, ">= 0.0.0"}, + {:auctionet_single_sign_on_plug, ">= 0.0.0", + github: "barsoom/auctionet_single_sign_on_plug"}, {:phoenix, "~> 1.7.0"}, {:phoenix_ecto, ">= 0.0.0"}, {:phoenix_html, ">= 0.0.0"}, {:phoenix_live_reload, ">= 0.0.0", only: :dev}, + {:phoenix_live_view, "~> 0.20"}, {:phoenix_pubsub, ">= 2.0.0"}, {:phoenix_view, "~> 2.0"}, {:plug_cowboy, ">= 1.0.0"}, @@ -61,6 +65,7 @@ defmodule ExGridhook.Mixfile do [ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], + "assets.deploy": ["esbuild default --minify", "phx.digest"], test: ["ecto.create --quiet", "ecto.migrate", "test"] ] end diff --git a/mix.lock b/mix.lock index 44c27eb..93807ca 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "auctionet_single_sign_on_plug": {:git, "https://github.com/barsoom/auctionet_single_sign_on_plug.git", "9dcab03061a85397ecbe981c8459cd7cf258173d", []}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, @@ -9,6 +10,7 @@ "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, @@ -16,6 +18,8 @@ "honeybadger": {:hex, :honeybadger, "0.25.0", "18ccd1a6cc9efd7a723cb8db0b2a853eea2d8d4a77ede8fbe498d16dbd5350d7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: true]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.0.0 and < 2.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:process_tree, "~> 0.2.1", [hex: :process_tree, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c39f02a1d86c34d7c915c4a188e9eaef953b3a1ec5cc02c4585f7697b523c6d5"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, @@ -25,6 +29,7 @@ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, From b27cf2781c45aa3fdeb69a634dd17a36e708f6de Mon Sep 17 00:00:00 2001 From: Andrei Roman Date: Thu, 9 Apr 2026 17:22:08 +0200 Subject: [PATCH 2/9] Extend Event context with query functions needed for the UI Adds email filter, newest/oldest time, distinct mailer_actions via recursive SQL, remove_by_email for GDPR, and event type metadata. Adds total_events, increment, and decrement to EventsData. Co-Authored-By: Claude Sonnet 4.6 --- lib/ex_gridhook/event.ex | 84 ++++++++++++++++++++++++++++++++++ lib/ex_gridhook/events_data.ex | 14 ++++++ 2 files changed, 98 insertions(+) diff --git a/lib/ex_gridhook/event.ex b/lib/ex_gridhook/event.ex index 4d029ec..88f1d84 100644 --- a/lib/ex_gridhook/event.ex +++ b/lib/ex_gridhook/event.ex @@ -2,12 +2,28 @@ defmodule ExGridhook.Event do @moduledoc false use Ecto.Schema + import Ecto.Query alias ExGridhook.Event alias ExGridhook.EventsData alias ExGridhook.Repo alias Ecto.Multi @timestamps_opts [type: :utc_datetime] + @event_types ~w(processed dropped delivered deferred bounce open click spamreport unsubscribe) + + @event_descriptions %{ + "processed" => "Message has been received and is ready to be delivered.", + "dropped" => + "You may see the following drop reasons: invalid SMTPAPI header, spam content (if spam checker app enabled), unsubscribed address, bounced address, spam reporting address, invalid.", + "delivered" => "Message has been successfully delivered to the receiving server.", + "deferred" => "Recipient's email server temporarily rejected message.", + "bounce" => "Receiving server could not or would not accept message.", + "open" => "Recipient has opened the HTML message.", + "click" => "Recipient clicked on a link within the message.", + "spamreport" => "Recipient marked message as spam.", + "unsubscribe" => "Recipient clicked on message's subscription management link." + } + schema "events" do field(:email, :string) field(:name, :string) @@ -23,6 +39,74 @@ defmodule ExGridhook.Event do timestamps(inserted_at: :created_at) end + def event_types, do: @event_types + def event_description(name), do: Map.get(@event_descriptions, name, "No description") + + def total_events, do: EventsData.total_events() + + def newest_time do + Repo.one(from e in __MODULE__, select: max(e.happened_at)) + end + + def oldest_time do + Repo.one(from e in __MODULE__, select: min(e.happened_at)) + end + + # Uses a recursive SQL query to efficiently find distinct mailer actions on large tables. + # See: http://zogovic.com/post/44856908222/optimizing-postgresql-query-for-distinct-values + def mailer_actions do + sql = """ + WITH RECURSIVE t(n) AS ( + SELECT MIN(mailer_action) FROM events + UNION + SELECT (SELECT mailer_action FROM events WHERE mailer_action > n ORDER BY mailer_action LIMIT 1) + FROM t WHERE n IS NOT NULL + ) + SELECT n FROM t; + """ + + %{rows: rows} = Repo.query!(sql) + rows |> List.flatten() |> Enum.reject(&is_nil/1) + end + + def query_by_user_identifier(user_identifier) do + from(e in __MODULE__, where: e.user_identifier == ^user_identifier) + end + + def with_email_if_present(query, email) when email in [nil, ""], do: query + + def with_email_if_present(query, email) do + downcased = String.downcase(email) + from(e in query, where: e.email == ^downcased) + end + + def with_name_if_present(query, name) when name in [nil, ""], do: query + def with_name_if_present(query, name), do: from(e in query, where: e.name == ^name) + + def with_mailer_action_if_present(query, action) when action in [nil, ""], do: query + + def with_mailer_action_if_present(query, action), + do: from(e in query, where: e.mailer_action == ^action) + + def with_associated_record_if_present(query, record) when record in [nil, ""], do: query + + def with_associated_record_if_present(query, record) do + from(e in query, where: fragment("? @> ?", e.associated_records, ^[record])) + end + + def recent_first(query), do: from(e in query, order_by: [desc: e.happened_at, desc: e.id]) + + def paginate(query, page, per) do + from(e in query, limit: ^per, offset: ^((page - 1) * per)) + end + + def remove_by_email(email) do + downcased = String.downcase(email) + {count, _} = Repo.delete_all(from e in __MODULE__, where: e.email == ^downcased) + EventsData.decrement(count) + count + end + def create_event_data(attributes \\ %{}) do category = Map.get(attributes, "category") event = Map.get(attributes, "event") diff --git a/lib/ex_gridhook/events_data.ex b/lib/ex_gridhook/events_data.ex index c0d8472..667a920 100644 --- a/lib/ex_gridhook/events_data.ex +++ b/lib/ex_gridhook/events_data.ex @@ -2,10 +2,24 @@ defmodule ExGridhook.EventsData do @moduledoc false use Ecto.Schema + import Ecto.Query + alias ExGridhook.Repo schema "events_data" do field(:total_events, :integer) timestamps() end + + def total_events do + Repo.one(from e in __MODULE__, select: e.total_events, limit: 1) || 0 + end + + def increment(count) do + Repo.update_all(__MODULE__, inc: [total_events: count]) + end + + def decrement(count) do + Repo.update_all(__MODULE__, inc: [total_events: -count]) + end end From f3979ed07d4b4c9fecac8e209e442dc66a334bd1 Mon Sep 17 00:00:00 2001 From: Andrei Roman Date: Thu, 9 Apr 2026 17:22:45 +0200 Subject: [PATCH 3/9] Add LiveView events UI, porting gridlook into ex_gridhook Replaces the plain-text root route with a LiveView page that replicates gridlook's event browsing UI: stats, filter form (email, name, mailer_action, associated_record), paginated event table with gravatar and expandable data rows, and prev/next navigation. Includes layouts with CUSTOM_HTML_HEADER support, CSS converted from gridlook's SCSS, LiveView JS, and static assets (event PNGs, dinomail). Co-Authored-By: Claude Sonnet 4.6 --- assets/js/app.js | 8 + lib/ex_gridhook_web.ex | 44 +++++- .../components/core_components.ex | 4 + lib/ex_gridhook_web/components/layouts.ex | 5 + .../components/layouts/app.html.heex | 14 ++ .../components/layouts/root.html.heex | 15 ++ lib/ex_gridhook_web/endpoint.ex | 1 + lib/ex_gridhook_web/live/events_live.ex | 133 ++++++++++++++++ .../live/events_live.html.heex | 138 +++++++++++++++++ priv/static/assets/app.css | 143 ++++++++++++++++++ priv/static/dinomail.gif | Bin 0 -> 6801 bytes ...ounce-04a143bdcf4c8c70896a50bbfbe16d3b.png | Bin 0 -> 1280 bytes priv/static/events/bounce.png | Bin 0 -> 1280 bytes ...click-1e1a1d8e3f0127677acd43a82e28c534.png | Bin 0 -> 1864 bytes priv/static/events/click.png | Bin 0 -> 1864 bytes ...erred-0a4513bcf44d5dfccb05c25d57f7b535.png | Bin 0 -> 1226 bytes priv/static/events/deferred.png | Bin 0 -> 1226 bytes ...vered-70dfaeee25f5c40fc172b69efe96b2e9.png | Bin 0 -> 1203 bytes priv/static/events/delivered.png | Bin 0 -> 1203 bytes ...opped-b11e0061006dd057cbc9afc5a306eeca.png | Bin 0 -> 977 bytes priv/static/events/dropped.png | Bin 0 -> 977 bytes .../open-2b43ed17398812329eaf55ef7cd8d3dc.png | Bin 0 -> 1142 bytes priv/static/events/open.png | Bin 0 -> 1142 bytes ...essed-e88377d247406f01f6ba1faff5d7a72a.png | Bin 0 -> 1155 bytes priv/static/events/processed.png | Bin 0 -> 1155 bytes ...eport-13032f8a92dd8e9f70545ea780a3f3c5.png | Bin 0 -> 869 bytes priv/static/events/spamreport.png | Bin 0 -> 869 bytes ...cribe-d5bfdc0d56c1e91fb73ddde1cf9d7e6b.png | Bin 0 -> 1181 bytes priv/static/events/unsubscribe.png | Bin 0 -> 1181 bytes priv/static/favicon.ico | Bin 0 -> 345 bytes 30 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 assets/js/app.js create mode 100644 lib/ex_gridhook_web/components/core_components.ex create mode 100644 lib/ex_gridhook_web/components/layouts.ex create mode 100644 lib/ex_gridhook_web/components/layouts/app.html.heex create mode 100644 lib/ex_gridhook_web/components/layouts/root.html.heex create mode 100644 lib/ex_gridhook_web/live/events_live.ex create mode 100644 lib/ex_gridhook_web/live/events_live.html.heex create mode 100644 priv/static/assets/app.css create mode 100644 priv/static/dinomail.gif create mode 100644 priv/static/events/bounce-04a143bdcf4c8c70896a50bbfbe16d3b.png create mode 100644 priv/static/events/bounce.png create mode 100644 priv/static/events/click-1e1a1d8e3f0127677acd43a82e28c534.png create mode 100644 priv/static/events/click.png create mode 100644 priv/static/events/deferred-0a4513bcf44d5dfccb05c25d57f7b535.png create mode 100644 priv/static/events/deferred.png create mode 100644 priv/static/events/delivered-70dfaeee25f5c40fc172b69efe96b2e9.png create mode 100644 priv/static/events/delivered.png create mode 100644 priv/static/events/dropped-b11e0061006dd057cbc9afc5a306eeca.png create mode 100644 priv/static/events/dropped.png create mode 100644 priv/static/events/open-2b43ed17398812329eaf55ef7cd8d3dc.png create mode 100644 priv/static/events/open.png create mode 100644 priv/static/events/processed-e88377d247406f01f6ba1faff5d7a72a.png create mode 100644 priv/static/events/processed.png create mode 100644 priv/static/events/spamreport-13032f8a92dd8e9f70545ea780a3f3c5.png create mode 100644 priv/static/events/spamreport.png create mode 100644 priv/static/events/unsubscribe-d5bfdc0d56c1e91fb73ddde1cf9d7e6b.png create mode 100644 priv/static/events/unsubscribe.png create mode 100644 priv/static/favicon.ico diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..4c5c6be --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,8 @@ +import { Socket } from "phoenix" +import { LiveSocket } from "phoenix_live_view" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken } }) + +liveSocket.connect() +window.liveSocket = liveSocket diff --git a/lib/ex_gridhook_web.ex b/lib/ex_gridhook_web.ex index 8060df0..bcb48f6 100644 --- a/lib/ex_gridhook_web.ex +++ b/lib/ex_gridhook_web.ex @@ -6,7 +6,7 @@ defmodule ExGridhookWeb do This can be used in your application as: use ExGridhookWeb, :controller - use ExGridhookWeb, :view + use ExGridhookWeb, :live_view The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused @@ -17,7 +17,7 @@ defmodule ExGridhookWeb do and import those modules here. """ - def static_paths, do: ~w(css fonts images js favicon.ico robots.txt) + def static_paths, do: ~w(assets events dinomail.gif favicon.ico robots.txt) def controller do quote do @@ -30,6 +30,45 @@ defmodule ExGridhookWeb do end end + def live_view do + quote do + use Phoenix.LiveView, layout: {ExGridhookWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + import Phoenix.HTML + use PhoenixHTMLHelpers + + import Phoenix.LiveView.Helpers + import ExGridhookWeb.CoreComponents + + unquote(verified_routes()) + end + end + def verified_routes do quote do use Phoenix.VerifiedRoutes, @@ -45,6 +84,7 @@ defmodule ExGridhookWeb do import Plug.Conn import Plug.BasicAuth import Phoenix.Controller + import Phoenix.LiveView.Router end end diff --git a/lib/ex_gridhook_web/components/core_components.ex b/lib/ex_gridhook_web/components/core_components.ex new file mode 100644 index 0000000..0d8eea4 --- /dev/null +++ b/lib/ex_gridhook_web/components/core_components.ex @@ -0,0 +1,4 @@ +defmodule ExGridhookWeb.CoreComponents do + @moduledoc false + use Phoenix.Component +end diff --git a/lib/ex_gridhook_web/components/layouts.ex b/lib/ex_gridhook_web/components/layouts.ex new file mode 100644 index 0000000..44b2d94 --- /dev/null +++ b/lib/ex_gridhook_web/components/layouts.ex @@ -0,0 +1,5 @@ +defmodule ExGridhookWeb.Layouts do + use ExGridhookWeb, :html + + embed_templates "layouts/*" +end diff --git a/lib/ex_gridhook_web/components/layouts/app.html.heex b/lib/ex_gridhook_web/components/layouts/app.html.heex new file mode 100644 index 0000000..1934419 --- /dev/null +++ b/lib/ex_gridhook_web/components/layouts/app.html.heex @@ -0,0 +1,14 @@ +
+
+

Gridlook

+
+ <%= @inner_content %> + +
+ diff --git a/lib/ex_gridhook_web/components/layouts/root.html.heex b/lib/ex_gridhook_web/components/layouts/root.html.heex new file mode 100644 index 0000000..e7b50e1 --- /dev/null +++ b/lib/ex_gridhook_web/components/layouts/root.html.heex @@ -0,0 +1,15 @@ + + + + + + + Gridlook + + + + + <%= Phoenix.HTML.raw(System.get_env("CUSTOM_HTML_HEADER", "")) %> + <%= @inner_content %> + + diff --git a/lib/ex_gridhook_web/endpoint.ex b/lib/ex_gridhook_web/endpoint.ex index edfbd00..6198f68 100644 --- a/lib/ex_gridhook_web/endpoint.ex +++ b/lib/ex_gridhook_web/endpoint.ex @@ -1,6 +1,7 @@ defmodule ExGridhookWeb.Endpoint do use Phoenix.Endpoint, otp_app: :ex_gridhook + socket("/live", Phoenix.LiveView.Socket) socket("/socket", ExGridhookWeb.UserSocket) # Serve at "/" the static files from "priv/static" directory. diff --git a/lib/ex_gridhook_web/live/events_live.ex b/lib/ex_gridhook_web/live/events_live.ex new file mode 100644 index 0000000..c5eb90d --- /dev/null +++ b/lib/ex_gridhook_web/live/events_live.ex @@ -0,0 +1,133 @@ +defmodule ExGridhookWeb.EventsLive do + use ExGridhookWeb, :live_view + + alias ExGridhook.Event + alias ExGridhook.Repo + + @per_page 100 + + def mount(_params, _session, socket) do + {:ok, + assign(socket, + mailer_actions: Event.mailer_actions(), + total_count: Event.total_events(), + newest_time: Event.newest_time(), + oldest_time: Event.oldest_time() + )} + end + + def handle_params(params, _uri, socket) do + email = clean(params["email"]) + name = clean(params["name"]) + mailer_action = clean(params["mailer_action"]) + associated_record = clean(params["associated_record"]) + page = max(String.to_integer(params["page"] || "1"), 1) + + events = + Event + |> Event.with_email_if_present(email) + |> Event.with_name_if_present(name) + |> Event.with_mailer_action_if_present(mailer_action) + |> Event.with_associated_record_if_present(associated_record) + |> Event.recent_first() + |> Event.paginate(page, @per_page) + |> Repo.all() + + {:noreply, + assign(socket, + email: email, + name: name, + mailer_action: mailer_action, + associated_record: associated_record, + page: page, + events: events + )} + end + + def handle_event("filter", params, socket) do + filter_params = + params + |> Map.take(["email", "name", "mailer_action", "associated_record"]) + |> Enum.reject(fn {_k, v} -> v == "" end) + |> Map.new() + + {:noreply, push_patch(socket, to: build_path(filter_params))} + end + + def handle_event("clear_filters", _params, socket) do + {:noreply, push_patch(socket, to: "/")} + end + + # Build a URL path merging current filters with given overrides. + # Overrides use atom keys for easy calling from templates (no map literal syntax). + def filter_path(assigns, overrides \\ []) do + base = [ + {"email", assigns[:email]}, + {"name", assigns[:name]}, + {"mailer_action", assigns[:mailer_action]}, + {"associated_record", assigns[:associated_record]} + ] + + merged = + overrides + |> Enum.map(fn {k, v} -> {to_string(k), v} end) + |> then(&Enum.into(&1, Map.new(base))) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + + build_path(merged) + end + + defp build_path(params) do + query = URI.encode_query(params) + if query == "", do: "/", else: "/?#{query}" + end + + defp clean(nil), do: nil + defp clean(""), do: nil + defp clean(value), do: String.trim(value) + + def filtered?(email, name, mailer_action) do + Enum.any?([email, name, mailer_action], &(not is_nil(&1))) + end + + def format_time(nil), do: "-" + + def format_time(%DateTime{} = dt) do + Calendar.strftime(dt, "%-d %b %Y at %H:%M:%S UTC") + end + + def format_number(n) when is_integer(n) do + n + |> Integer.to_string() + |> String.graphemes() + |> Enum.reverse() + |> Enum.chunk_every(3) + |> Enum.intersperse([","]) + |> List.flatten() + |> Enum.reverse() + |> Enum.join() + end + + def format_number(n), do: to_string(n) + + def inspect_value(value) when is_map(value) do + value + |> Enum.map_join("\n", fn {k, v} -> + v_str = if is_binary(v), do: v, else: inspect(v) + "#{k} = #{v_str}" + end) + end + + def inspect_value(value) when is_list(value), do: Enum.join(value, "\n") + def inspect_value(value), do: inspect(value) + + def smtp_id(event) do + get_in(event.data || %{}, ["smtp-id"]) + end + + def gravatar_url(smtp_id) do + hash = Base.encode16(:crypto.hash(:md5, smtp_id), case: :lower) + "//www.gravatar.com/avatar/#{hash}?s=40&r=any&default=identicon&forcedefault=1" + end +end diff --git a/lib/ex_gridhook_web/live/events_live.html.heex b/lib/ex_gridhook_web/live/events_live.html.heex new file mode 100644 index 0000000..b0d7929 --- /dev/null +++ b/lib/ex_gridhook_web/live/events_live.html.heex @@ -0,0 +1,138 @@ +
+

Total events: <%= format_number(@total_count) %>

+

Newest event: <%= format_time(@newest_time) %>

+

Oldest event: <%= format_time(@oldest_time) %>

+
+ +
+

+ + +

+ +

+ + +

+ +

+ + + + This is a cached list that is not updated automatically. + +

+ + <%= if @associated_record do %> +

+ + + + For example: Item:123 + +

+ <% end %> + +

+
+ +<%= if filtered?(@email, @name, @mailer_action) do %> + +<% end %> + +<%= if @events != [] do %> +

+ <%= if @page > 1 do %> + <.link patch={filter_path(assigns, page: @page - 1)}>← Newer + <% end %> +   + <.link patch={filter_path(assigns, page: @page + 1)}>Older → +

+ + + <%= for event <- @events do %> + + + + + + + + + + + + <% end %> +
<%= format_time(event.happened_at) %> + <%= if event.mailer_action do %> + + <.link patch={filter_path(assigns, mailer_action: event.mailer_action, page: nil)}> + <%= event.mailer_action %> + + +   + <% end %> + <.link + patch={filter_path(assigns, name: event.name, page: nil)} + title={Event.event_description(event.name)} + class="tooltip" + ><%= event.name %> +
+ <%= if id = smtp_id(event) do %> + + <% end %> + + <%= if Map.has_key?(event.unique_args || %{}, "outbound_id") do %> + <%= if campaign_id = (event.unique_args || %{})["campaign_id"] do %> + + Generated by a campaign on outbound.io + + <% end %> + <% end %> + <%= if map_size(event.data || %{}) > 0 do %> +
<%= inspect_value(event.data) %>
+ <% end %> + <%= if (event.associated_records || []) != [] do %> +

Associated records

+
<%= inspect_value(event.associated_records) %>
+ <% end %> + <%= if map_size(event.unique_args || %{}) > 0 do %> +

Unique arguments

+
<%= inspect_value(event.unique_args) %>
+ <% end %> +
+ +
+ +

+ <%= if @page > 1 do %> + <.link patch={filter_path(assigns, page: @page - 1)}>← Newer + <% end %> +   + <.link patch={filter_path(assigns, page: @page + 1)}>Older → +

+<% else %> +

+ <%= if @page > 1 do %> + <.link patch={filter_path(assigns, page: @page - 1)}>← Newer + No more events + <.link patch={filter_path(assigns, page: @page - 1)}>← Newer + <% else %> + No events :( + <% end %> +

+<% end %> diff --git a/priv/static/assets/app.css b/priv/static/assets/app.css new file mode 100644 index 0000000..6b170e5 --- /dev/null +++ b/priv/static/assets/app.css @@ -0,0 +1,143 @@ +body { + margin: 0px; +} + +.content { + font-family: 'Helvetica Neue', Helvetica, sans-serif; + font-size: 13px; + padding: 15px 20px; + background: #f9f9fa; + color: #2d3238; +} + +.content header { + margin-bottom: 30px; +} + +.content footer { + margin-top: 50px; +} + +.content .main-title { + font-size: 35px; + margin: 0; +} + +.content .main-title a { + color: inherit; + text-decoration: none; + border-bottom: 1px solid #ccc; +} + +.content .stats { + margin-top: -10px; +} + +.content .stats .stat { + margin: 5px 0; +} + +.content h2 { + font-weight: normal; +} + +.content b, +.content strong, +.content label { + font-weight: 600; +} + +.content .filter-form { + margin: 30px 0; +} + +.content .filter-form-email { + margin: 0 10px; + padding: 3px 5px; + width: 20em; +} + +.content .events-table { + width: 900px; + margin: 30px 0; + border-collapse: collapse; +} + +.content .events-table tr.main-data td { + border-top: 1px solid #ccc; + padding-top: 22px; +} + +.content .events-table tr.extra-data td { + vertical-align: top; + padding: 15px 0; +} + +.content .events-table tr.extra-data td.identifier-image { + padding-right: 10px; +} + +.content .events-table tr.extra-data td.event-image { + padding-left: 10px; + text-align: right; +} + +.content .events-table tr.extra-data h4 { + margin-bottom: 5px; +} + +.content .events-table tr.extra-data pre.data-block { + margin: 5px 0; + word-break: break-all; + white-space: pre-wrap; + font-family: inherit; + font-size: inherit; +} + +.content .events-table tr.extra-data pre.data-block:first-child { + margin-top: 0; +} + +.content .events-table td.email { + font-weight: bold; +} + +.content .events-table td.event { + white-space: nowrap; + text-align: right; +} + +.content .tooltip { + display: inline; + position: relative; +} + +.content .tooltip:hover:before { + bottom: 26px; + color: #000; + content: attr(title); + padding: 5px 15px; + position: absolute; + width: 200px; + white-space: normal; + text-align: left; + font-weight: bold; + background: #fff; + border: 1px solid #ccc; +} + +.content .help-block { + display: block; + margin-top: 5px; + margin-bottom: 10px; + color: #404040; +} + +.content .no-more-events { + margin-top: 10px; + margin-bottom: 10px; +} + +img.dinomail { + display: block; +} diff --git a/priv/static/dinomail.gif b/priv/static/dinomail.gif new file mode 100644 index 0000000000000000000000000000000000000000..a078cb5b4973e7b4e2cd389de6d1f0ba640141fe GIT binary patch literal 6801 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 CX-_o( literal 0 HcmV?d00001 diff --git a/priv/static/events/bounce-04a143bdcf4c8c70896a50bbfbe16d3b.png b/priv/static/events/bounce-04a143bdcf4c8c70896a50bbfbe16d3b.png new file mode 100644 index 0000000000000000000000000000000000000000..6af6c627a27c2bb1e441e3042604530a59b20ee2 GIT binary patch literal 1280 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 literal 0 HcmV?d00001 diff --git a/priv/static/events/deferred.png b/priv/static/events/deferred.png new file mode 100644 index 0000000000000000000000000000000000000000..70fd825b95409bf15e511cc1648b9a3f78d725a5 GIT binary patch literal 1226 zcmV;*1U37KP)rb0y7{(vSRG=IrYT^e!m>9q9qly23nwXeqrpZ)dT+mHK+;E#)7!z=DEQ1B691`4+ zFhPeC10$Tup({|%6zDjW!zdI642m!grN_(XN}){yizSfk3vcoZ?{h!beccy*pS}bE z;Hh#D;2?neO1^$${aSWD(sK&-4f@en_;Ov%_zk1Rd6-sh`v(2!>r;Tllq}RXX|dr1 z7FMi#hCcM8uTKHaBwR&SUJ+~n%(8iY#fC?Zt-FSO?57X?=<8E}Q*l2bGq(_>RrRpj z?XUu%;oP#tXQ*c%`{_eJ`uY^$)-@nvH*sK4L4k~&VA~rXCM3NLqGcZ6d>|sJjQ31&@(iF z?m+|EdWTV|YQZl#`Jj1fW-criE0!H_!!_&Nr>>&D87-$h7~mPn zA&I}lZ^rw!mnS8!*WyrgKP^B@zq%j7#^Xecf^Y(?GUQe9mp># zMa;P*ydM#@1=p<0kZq{dbfd969gF`6Z1e~?W(BZHc<-ulukJ_ju^gw^7UJj^-$6Gz zi^eYfHq;%1sA}j!L2)J0v-8|=U8(Lwjiv|cvNS9!1?DK*de@Q_FkSTzk}P zh6wO_93LM07Fzu@RP6&hQs35(szxnJlo~f&*R|Y7m9i9z#c}`#_X{|hcZPe^P|L^R z^(lPtSu{EZjHqqt+ZEMKy=cDk4a{0OWI_&T^xzy9&}IpIAK|G{LoLq)52ic7d!Iz1 zwQmB|8r`laS8Fj)@CIyC0yb%Et#@G`6X;Bn!=nX;3q5sesO7=j{C0>S$&2qEJ&yb1 zi_r8Mc7?iY6w|k!hjUrLqLL`(fMJ1-R5?6KK!1xh57baQnxm>`i6S$Woa6U$0qL4K=)KKdcKp}Z4JUsl}&K>bC5xYGhl z#hyAf)OsF)_vJ5ncJr}>N?wq>y!k1;DtS%v`W9T1nj~>;tyK=~7g)XH!8R>m%=4~M zL#^i!?)~!TSIYF4G#?dMR|!1+b7#0m4Yi)H;odJlNdR9iOJ#Dvi0iGlsuUJ;0B3>`8~C~iGXMYp07*qoM6N<$f;w169smFU literal 0 HcmV?d00001 diff --git a/priv/static/events/delivered-70dfaeee25f5c40fc172b69efe96b2e9.png b/priv/static/events/delivered-70dfaeee25f5c40fc172b69efe96b2e9.png new file mode 100644 index 0000000000000000000000000000000000000000..c7fb2c1a4e12cd1f872d5dfadf469eea093f7f82 GIT binary patch literal 1203 zcmV;k1WfyhP)T8Bqq%!)JRvGU`k?XrAet3Q$wrOiqJ10UilRi#JWjZyz!=2DwLKI zB2B4UA{0eTM5(n+L1@HU49SH?N_AVCznk5eIe&c5VGo3XEZSsCggo%@o^y8g%rDP* z2WDeQDe-@ijjCxAb`y3J*1{SUzObhez&Ub>oQD>c$S2)zZrsK?%E1~XrK}A&J3 z^Kk&blV5AN{L3(6+7QX^-@iWv;J}*^d**$ofJ?$XL~FHJKPAXhmY^B{ zd~R7*@3vGDA4Yk6l*-_$Q-k#s?GMiR6#z%a#>QqJG}$+7+wN^?X+Z;zAo730NhaZ? zJgldrlyF_Qhwk&%=;&z9dGF*YhcWijlWlFMJ3Du5B?x^{C7nI>fjvEW3qcUz&b>T1 z1m@@Gkw*#D5HWQCXmpke1<5!M7nhgOzWuR;pY~7;0jolT_+ARBR0@Sc0Ujin6p)rg zwM6$x0PvjxK?)%-Gc$u*lhf$l)j@L`rGS-zpscF>w=fv$N=Vb}wpt9}C3@kOs_r$wI=m)L2_+3vF7dCM;|oMhHtGnwp#ORA)P` z|M(L=8~GB!(kvPtNkUpSqKK0PxCE`a#Lo<0Y7lWWZ*9S$gT1(V?K%d}eTr8O_hR3^ZrzTzex1a~ zrLR#~F2Yi2md#~(Y)P}XhB|)yIHjhm`nd{pVNL_P%IWsp^t(keeRBrrX-rK`0TRe&v)H|R7k-<(sgKQA_Z1|h20;8+&AehgLFp^yayi6g_L7e4 zrNJb?7}KdnWKq76MJcxL=)g9DRIr}s!**OmjDc|uE`+uyC&Qru@y@YhFj_IMSSyLx zbaB4>;sTOhoj7v%MRaeEeNYYUNU0q!R0N%#G6un`Yo_!dwIuHYkxw*Oj`j_fH zeptye#-w7l*6?zXUh(rINj7_NGAqv$rr%?d6@C+1vF`|Si5zYIf0ncf`wyD#T1}oc R=8ymY002ovPDHLkV1jH_ExP~! literal 0 HcmV?d00001 diff --git a/priv/static/events/delivered.png b/priv/static/events/delivered.png new file mode 100644 index 0000000000000000000000000000000000000000..c7fb2c1a4e12cd1f872d5dfadf469eea093f7f82 GIT binary patch literal 1203 zcmV;k1WfyhP)T8Bqq%!)JRvGU`k?XrAet3Q$wrOiqJ10UilRi#JWjZyz!=2DwLKI zB2B4UA{0eTM5(n+L1@HU49SH?N_AVCznk5eIe&c5VGo3XEZSsCggo%@o^y8g%rDP* z2WDeQDe-@ijjCxAb`y3J*1{SUzObhez&Ub>oQD>c$S2)zZrsK?%E1~XrK}A&J3 z^Kk&blV5AN{L3(6+7QX^-@iWv;J}*^d**$ofJ?$XL~FHJKPAXhmY^B{ zd~R7*@3vGDA4Yk6l*-_$Q-k#s?GMiR6#z%a#>QqJG}$+7+wN^?X+Z;zAo730NhaZ? zJgldrlyF_Qhwk&%=;&z9dGF*YhcWijlWlFMJ3Du5B?x^{C7nI>fjvEW3qcUz&b>T1 z1m@@Gkw*#D5HWQCXmpke1<5!M7nhgOzWuR;pY~7;0jolT_+ARBR0@Sc0Ujin6p)rg zwM6$x0PvjxK?)%-Gc$u*lhf$l)j@L`rGS-zpscF>w=fv$N=Vb}wpt9}C3@kOs_r$wI=m)L2_+3vF7dCM;|oMhHtGnwp#ORA)P` z|M(L=8~GB!(kvPtNkUpSqKK0PxCE`a#Lo<0Y7lWWZ*9S$gT1(V?K%d}eTr8O_hR3^ZrzTzex1a~ zrLR#~F2Yi2md#~(Y)P}XhB|)yIHjhm`nd{pVNL_P%IWsp^t(keeRBrrX-rK`0TRe&v)H|R7k-<(sgKQA_Z1|h20;8+&AehgLFp^yayi6g_L7e4 zrNJb?7}KdnWKq76MJcxL=)g9DRIr}s!**OmjDc|uE`+uyC&Qru@y@YhFj_IMSSyLx zbaB4>;sTOhoj7v%MRaeEeNYYUNU0q!R0N%#G6un`Yo_!dwIuHYkxw*Oj`j_fH zeptye#-w7l*6?zXUh(rINj7_NGAqv$rr%?d6@C+1vF`|Si5zYIf0ncf`wyD#T1}oc R=8ymY002ovPDHLkV1jH_ExP~! literal 0 HcmV?d00001 diff --git a/priv/static/events/dropped-b11e0061006dd057cbc9afc5a306eeca.png b/priv/static/events/dropped-b11e0061006dd057cbc9afc5a306eeca.png new file mode 100644 index 0000000000000000000000000000000000000000..39e8321e64f79ce199eb39f0248c69823a34b733 GIT binary patch literal 977 zcmV;?11|iDP)iIP z#xR3)mp!GUD{O4gAJNH)s~abDn4T0uM3y48d2^~Q_E*XzNg;1;>eW4pRH3^SW zECy~^H>n)aksRsWfs)J-_8O$*k&>iGloFB>i0M;~i)kg&cd%hOXG<%CP)NaFG0@(& zCGg~djmiOCAVX#rh@Pc)z-01)EVG?FXf9uMiu7MPKQq|#>0a>N+w4$pH^`l@K`$f% zb7Ueg8;rU6V6df~N!!`9o3pO1=>!8`p(vp*L$5j@6!1qXPp`$O@!1D zLi&eKSo@B>kNKRuYSFuFfZ}y6uwhuYgLXwzaD^F`IYFKNMwlRQg9vj5 z$1HDjlda#^(|Tw+=Bm>F^7%g%fmm1!#;Ma!WbACZkZ?HB4k@3HHs2cb-CY*=_%103 z0>E7Ay_VH-d=(lTNNU`|g|awj-U^|3{!`W$f6EJJPX}qfw?iuRY={R*IM>-%RDKg@ zm#h@$E_hF@){)~Qj({YA*|X%UDQDIirlV_s%&6MjDY)G0FeKlJNgQ?h4)0LB5zHkE z{rnW?R!4Q+i(%cYqlS#b>Z=#N>z9ajK9+ zvo~8{Vy(a`Rqd}ZBn;&QLEwD#n~qH&;63-ItMbU@s~^;}j!8%(K@>mAka`~hF^C;y z5}UunUTCb0SX)p~AXUtCPCIt(R!3xLiUlzUtH3@LMu}!CVgNIWpz$W!D-t!CjRj)8 z^b2JWMUJ;KkT@?TQP&U$77f-u507Ev^N;U0x}rnxEmWrb00000NkvXXu0mjfGO5kp literal 0 HcmV?d00001 diff --git a/priv/static/events/dropped.png b/priv/static/events/dropped.png new file mode 100644 index 0000000000000000000000000000000000000000..39e8321e64f79ce199eb39f0248c69823a34b733 GIT binary patch literal 977 zcmV;?11|iDP)iIP z#xR3)mp!GUD{O4gAJNH)s~abDn4T0uM3y48d2^~Q_E*XzNg;1;>eW4pRH3^SW zECy~^H>n)aksRsWfs)J-_8O$*k&>iGloFB>i0M;~i)kg&cd%hOXG<%CP)NaFG0@(& zCGg~djmiOCAVX#rh@Pc)z-01)EVG?FXf9uMiu7MPKQq|#>0a>N+w4$pH^`l@K`$f% zb7Ueg8;rU6V6df~N!!`9o3pO1=>!8`p(vp*L$5j@6!1qXPp`$O@!1D zLi&eKSo@B>kNKRuYSFuFfZ}y6uwhuYgLXwzaD^F`IYFKNMwlRQg9vj5 z$1HDjlda#^(|Tw+=Bm>F^7%g%fmm1!#;Ma!WbACZkZ?HB4k@3HHs2cb-CY*=_%103 z0>E7Ay_VH-d=(lTNNU`|g|awj-U^|3{!`W$f6EJJPX}qfw?iuRY={R*IM>-%RDKg@ zm#h@$E_hF@){)~Qj({YA*|X%UDQDIirlV_s%&6MjDY)G0FeKlJNgQ?h4)0LB5zHkE z{rnW?R!4Q+i(%cYqlS#b>Z=#N>z9ajK9+ zvo~8{Vy(a`Rqd}ZBn;&QLEwD#n~qH&;63-ItMbU@s~^;}j!8%(K@>mAka`~hF^C;y z5}UunUTCb0SX)p~AXUtCPCIt(R!3xLiUlzUtH3@LMu}!CVgNIWpz$W!D-t!CjRj)8 z^b2JWMUJ;KkT@?TQP&U$77f-u507Ev^N;U0x}rnxEmWrb00000NkvXXu0mjfGO5kp literal 0 HcmV?d00001 diff --git a/priv/static/events/open-2b43ed17398812329eaf55ef7cd8d3dc.png b/priv/static/events/open-2b43ed17398812329eaf55ef7cd8d3dc.png new file mode 100644 index 0000000000000000000000000000000000000000..8002c704b9a85837c0a405aa83303721be638bde GIT binary patch literal 1142 zcmV-+1d02JP)oNqb^<;jl0lz;f=|>F!9Qms8QoWHluL_Hd*RG*zDE_j4{Hfz;Mf8NMOhS z1oo7Dggfp_loZt%b&Q5tA)EZu@|-yjcQi|7~1=Y9sh3Whjsf^Je3vNp(^?q+4&wl zJGJtKrkd46f!ccs>Zk|m3yrPyi8Q6v=*o`Dk#wikCX`|j3&?c z%#+cl-IBSZ+5Wvz8P7o-YK1Byaj9$mN))*n-WNWTvz(FavL>qPpxRLlmDNeY9;i=m z;v$@~3$nX-XZWmSI4h2TCgMziiunO5+eR`6RIVK=$H6;Y=l47(BfH#_XM9$6$+AOS zOBzE3{ah>(Tpy8kCy0Yx;{3-6FRxMNW~hvA1RKm)=5SUV>=LK$Y${o$HkCr9SfSFp zur54NB-7K=0YSrAc8H6e;=b*K6g;3pi=YyJhDz?h>tS)2o125r=L-bRaF!k7V&|6R zf;IXtotQskNG-!!a|2%8xD$TAA0s0pfxsEgii2I`gef8QG2Pi~ zK1@zd!t3=00%tfY4t9|f#(1-SaY|-BcqXR&_|{T~qNAtqV|^nuO<||_-#6{iFHT7N1d}s>tN(Yn2Zqrl*0BcD7ys}TDZ;iHUv`i5%x zEN3{&4so&5V2sxxk3&C7@=X@4%P4i`M$3D{hN=yLWiMDA%rJ~X#lQGC1- zZ8q1RQC7WT9Id!LPr0(9+=y1j%%{h7gl{5LK3XuroO1J^kW$3?_tc+f4#`gDwx6uG*Ia zm1}5fb6|7yF0k{Bx3}E3IEc9a&3BDBYiWQs;zxJy7;M_;a^NgG#Kq3%UwrKm=~yuL z5f2kj5YN7{KCC4;EE+2bIh%umf}SD&xdVKwZv1zqr^kRD&pS1#s5L^Up3URD+xLgR{#J207*qo IM6N<$f?Dw?dH?_b literal 0 HcmV?d00001 diff --git a/priv/static/events/open.png b/priv/static/events/open.png new file mode 100644 index 0000000000000000000000000000000000000000..8002c704b9a85837c0a405aa83303721be638bde GIT binary patch literal 1142 zcmV-+1d02JP)oNqb^<;jl0lz;f=|>F!9Qms8QoWHluL_Hd*RG*zDE_j4{Hfz;Mf8NMOhS z1oo7Dggfp_loZt%b&Q5tA)EZu@|-yjcQi|7~1=Y9sh3Whjsf^Je3vNp(^?q+4&wl zJGJtKrkd46f!ccs>Zk|m3yrPyi8Q6v=*o`Dk#wikCX`|j3&?c z%#+cl-IBSZ+5Wvz8P7o-YK1Byaj9$mN))*n-WNWTvz(FavL>qPpxRLlmDNeY9;i=m z;v$@~3$nX-XZWmSI4h2TCgMziiunO5+eR`6RIVK=$H6;Y=l47(BfH#_XM9$6$+AOS zOBzE3{ah>(Tpy8kCy0Yx;{3-6FRxMNW~hvA1RKm)=5SUV>=LK$Y${o$HkCr9SfSFp zur54NB-7K=0YSrAc8H6e;=b*K6g;3pi=YyJhDz?h>tS)2o125r=L-bRaF!k7V&|6R zf;IXtotQskNG-!!a|2%8xD$TAA0s0pfxsEgii2I`gef8QG2Pi~ zK1@zd!t3=00%tfY4t9|f#(1-SaY|-BcqXR&_|{T~qNAtqV|^nuO<||_-#6{iFHT7N1d}s>tN(Yn2Zqrl*0BcD7ys}TDZ;iHUv`i5%x zEN3{&4so&5V2sxxk3&C7@=X@4%P4i`M$3D{hN=yLWiMDA%rJ~X#lQGC1- zZ8q1RQC7WT9Id!LPr0(9+=y1j%%{h7gl{5LK3XuroO1J^kW$3?_tc+f4#`gDwx6uG*Ia zm1}5fb6|7yF0k{Bx3}E3IEc9a&3BDBYiWQs;zxJy7;M_;a^NgG#Kq3%UwrKm=~yuL z5f2kj5YN7{KCC4;EE+2bIh%umf}SD&xdVKwZv1zqr^kRD&pS1#s5L^Up3URD+xLgR{#J207*qo IM6N<$f?Dw?dH?_b literal 0 HcmV?d00001 diff --git a/priv/static/events/processed-e88377d247406f01f6ba1faff5d7a72a.png b/priv/static/events/processed-e88377d247406f01f6ba1faff5d7a72a.png new file mode 100644 index 0000000000000000000000000000000000000000..6ac01c3688acede5606403a76ae8cba1d27e4711 GIT binary patch literal 1155 zcmV-}1bq96P)bKvxudRQ`ZVcKIshrJ840LGj^-?(!D>~l^X_1xn=edtGD)$m;j`_V7N z_MSep*d6$~t^pZ`im=O^j7sabm;*5G0(!?L6?kW#bLyz)9{1@(Kl-YM?~F~x)$4!Y zhs)Q{(cO!SwjNmPn~-<3Og7FbD1pm0g`2Zb;GKQWsiU5I+@}xy=&Kt3Msy;2`p41Q z`K!W(4u>#oMoGn&h>S~uIXNAD149bDv(Gto)K@n*A*nnQn=&?IZF~d*ql|bYD$pu0 z3qtN0z9VW6x_U>@Wc&FJ&bMDi&AC>T9j`*nJ_}ynVN&3oea@*XJY4}pf&rmWoyJHabEnWBI?8To@)6kB}+C~N5**{tT9Smjz z!avg?;*^BYWG!q1ZI~~f+2f2FYVUXnHoqQ?)~uz%$<`y{qy(cygT;z7 zYN+LVKnZ{GH4~aUui;FieaSqN_7cL1by)wY1Y2JlSd%kOY$(xwm4lcR*gbYd%lOj;PTB&>_m&$p?eRtfhK82tSFw)^_}n)G`8ZXX|? zRBvzZY%edbLQhZ6QV$Q0F9el>YC-KhytBVzi(x151$u$MAW#r2Fbcv1;q&m${)+7eOJJfNq3IHy-upQIS;6l~ zL^)GKt^9^_NAY9hA8o*@6dmraRq>KShzO^KTJA{hD*oay!~`HHQHS8Y5`xSUR>lzd z%$_`>hFZzpMN|KG40tS7kCh302o2#`?s29PeE%002ovPDHLkV1kuHF=_w+ literal 0 HcmV?d00001 diff --git a/priv/static/events/processed.png b/priv/static/events/processed.png new file mode 100644 index 0000000000000000000000000000000000000000..6ac01c3688acede5606403a76ae8cba1d27e4711 GIT binary patch literal 1155 zcmV-}1bq96P)bKvxudRQ`ZVcKIshrJ840LGj^-?(!D>~l^X_1xn=edtGD)$m;j`_V7N z_MSep*d6$~t^pZ`im=O^j7sabm;*5G0(!?L6?kW#bLyz)9{1@(Kl-YM?~F~x)$4!Y zhs)Q{(cO!SwjNmPn~-<3Og7FbD1pm0g`2Zb;GKQWsiU5I+@}xy=&Kt3Msy;2`p41Q z`K!W(4u>#oMoGn&h>S~uIXNAD149bDv(Gto)K@n*A*nnQn=&?IZF~d*ql|bYD$pu0 z3qtN0z9VW6x_U>@Wc&FJ&bMDi&AC>T9j`*nJ_}ynVN&3oea@*XJY4}pf&rmWoyJHabEnWBI?8To@)6kB}+C~N5**{tT9Smjz z!avg?;*^BYWG!q1ZI~~f+2f2FYVUXnHoqQ?)~uz%$<`y{qy(cygT;z7 zYN+LVKnZ{GH4~aUui;FieaSqN_7cL1by)wY1Y2JlSd%kOY$(xwm4lcR*gbYd%lOj;PTB&>_m&$p?eRtfhK82tSFw)^_}n)G`8ZXX|? zRBvzZY%edbLQhZ6QV$Q0F9el>YC-KhytBVzi(x151$u$MAW#r2Fbcv1;q&m${)+7eOJJfNq3IHy-upQIS;6l~ zL^)GKt^9^_NAY9hA8o*@6dmraRq>KShzO^KTJA{hD*oay!~`HHQHS8Y5`xSUR>lzd z%$_`>hFZzpMN|KG40tS7kCh302o2#`?s29PeE%002ovPDHLkV1kuHF=_w+ literal 0 HcmV?d00001 diff --git a/priv/static/events/spamreport-13032f8a92dd8e9f70545ea780a3f3c5.png b/priv/static/events/spamreport-13032f8a92dd8e9f70545ea780a3f3c5.png new file mode 100644 index 0000000000000000000000000000000000000000..90aa8fd6a4b7523073d55acd7f4c3f3e5b73b983 GIT binary patch literal 869 zcmeAS@N?(olHy`uVBq!ia0vp^(m<@t!3-pWlpB{bFfi^9@Ck7Ra)}2b{QN>ZJc8WZ z0$g1DoSb|d9K0MHJZx-mRH3JT(AnvLu~Ds_UW2}Vi-AF_wsxC=L8p;XuZhWIGqdUD z=5sAA7nztWvbJ7qWVG1CWU-mqVk@g9mX^zHZC4r^t~51WYiqmK-hP9;{6-CpO^%M6 z9UXT%JMXo!+NYzl&%j`xo!vekp96t`2SY**NlG4)mOiYleb~(Ga8%S0Q`4iauE)&H zk6BtCcXT}N>3Q7C>qL6`$$)^9K|v?8vQD|UobvEEm6vzEs_J56J>{bkGUFJJy()v8D9*FV~@;jxR$<4v2M z1O`6Yy7lRf9Zz@ee7`oxLXCr`ey)qm?@{r23s zcVQmy;)C8_y7a-*=)<*ZAAO8J`Wk<_bLVq_$>%_mFTo~XLQK9qdh|8S^lP~3w}ld+y2b+{F(3lE8AJA{LEWm{K=LC z`2{mDv2k(p35$qI$S5eQ>O^G}*LO{wzhc9-UEhEHnpd*W3>fDYo-U3d5|VchTKhRU ziZnd@e?~T8#~IfppG3JluN)G+adT&wFn4ltj?NX;ge{wbgsynhuuPn3$#l&9i0F^n z5BoXSq=vnRX}wQ^!pW7VFXlhF zDm?i>gU(%<)-5z-2na|qd?T;7kslS~k5-G8_u~kI)*yf8j z@4EHHU*BWC%CM2umvf2Q?C|PSe|DW$bqw0Dz|b&k>Q%;&y~>P%2bvLp+wC6nf3)n_D*BV@SE>YyfS~fsp2j{`-&Lpf af8cu=yLEx&nyvpqnZ?u9&t;ucLK6UBw00r@ literal 0 HcmV?d00001 diff --git a/priv/static/events/spamreport.png b/priv/static/events/spamreport.png new file mode 100644 index 0000000000000000000000000000000000000000..90aa8fd6a4b7523073d55acd7f4c3f3e5b73b983 GIT binary patch literal 869 zcmeAS@N?(olHy`uVBq!ia0vp^(m<@t!3-pWlpB{bFfi^9@Ck7Ra)}2b{QN>ZJc8WZ z0$g1DoSb|d9K0MHJZx-mRH3JT(AnvLu~Ds_UW2}Vi-AF_wsxC=L8p;XuZhWIGqdUD z=5sAA7nztWvbJ7qWVG1CWU-mqVk@g9mX^zHZC4r^t~51WYiqmK-hP9;{6-CpO^%M6 z9UXT%JMXo!+NYzl&%j`xo!vekp96t`2SY**NlG4)mOiYleb~(Ga8%S0Q`4iauE)&H zk6BtCcXT}N>3Q7C>qL6`$$)^9K|v?8vQD|UobvEEm6vzEs_J56J>{bkGUFJJy()v8D9*FV~@;jxR$<4v2M z1O`6Yy7lRf9Zz@ee7`oxLXCr`ey)qm?@{r23s zcVQmy;)C8_y7a-*=)<*ZAAO8J`Wk<_bLVq_$>%_mFTo~XLQK9qdh|8S^lP~3w}ld+y2b+{F(3lE8AJA{LEWm{K=LC z`2{mDv2k(p35$qI$S5eQ>O^G}*LO{wzhc9-UEhEHnpd*W3>fDYo-U3d5|VchTKhRU ziZnd@e?~T8#~IfppG3JluN)G+adT&wFn4ltj?NX;ge{wbgsynhuuPn3$#l&9i0F^n z5BoXSq=vnRX}wQ^!pW7VFXlhF zDm?i>gU(%<)-5z-2na|qd?T;7kslS~k5-G8_u~kI)*yf8j z@4EHHU*BWC%CM2umvf2Q?C|PSe|DW$bqw0Dz|b&k>Q%;&y~>P%2bvLp+wC6nf3)n_D*BV@SE>YyfS~fsp2j{`-&Lpf af8cu=yLEx&nyvpqnZ?u9&t;ucLK6UBw00r@ literal 0 HcmV?d00001 diff --git a/priv/static/events/unsubscribe-d5bfdc0d56c1e91fb73ddde1cf9d7e6b.png b/priv/static/events/unsubscribe-d5bfdc0d56c1e91fb73ddde1cf9d7e6b.png new file mode 100644 index 0000000000000000000000000000000000000000..e8ef4bf46db34c7cd616bb7bb2458356ef35781c GIT binary patch literal 1181 zcmV;O1Y-M%P)-|hQ?^oS3Zp`XBcqliwU`MK^9QBbk`l%e0}C|)=Ms^` z23u{Y(>sY|2!#BW^8$~q1dbnafM&<9JC1wzeLr*0rpTU1w*HXs_S*CPem?Kd`~BQK z&yC>#;D2Kwd?Ebt371#q;qS)3&M(5D zLq$ysChfqm+4{)Phko=`4Bxdc3wed5m;x{%28Ya3F#TMed0oFg89R-Mz^?js80p zMW~~mbDXCS{phP4F7EIT;~>sm{25>D&%x&hj-YQ~5H{Yxs2#nxCufCy>Zs=&=jlT~ z`YMJe@5w;#kQohadYr#hiSz@9k+eG0~nqFdPXK@z&iWXQO`Nf(}#ZaRSZx3 zcpr>HtnKVUgI13V71hYh$wy*JD$3Nqzz$%<0(1>e%J9rO`_xg-InL9Ee)Lrg-@GFg zHwXU0wHw`N?dU>%TPM_&b@=vpkz{-@w*VH46?dm0!!zsbQ%61LI8Pt?(N{72{p3&4 zX&gbL?oXLtTlK>5Dhkg0h=iSck&?O}J-z)hJhRR|b=22X)#F@N4vIF#;n?a`_(r9| zkpMrnAVuKy(D1meyV2fdLS5VM_fgYw1DdKv6qTOEwms>1JuXRxXV%%5bf-&EvT`{L z(P0?>Fb4MYc)~qv?D0k-W`uA2;3H@aw@}@zpFve)2Q*ijQT&q{hw=+$cxIjb3#Up@ z@lr5Gx4r@UwhgeyhG9DDMZ!I6>`^oG2HuR{juw3%DjV7#%H{gsps8*_S$VAt&#aeg zu1KHCyd@e_v7xYpuaK}x$b0svk+kMrCRNrIq`NZ%<=1Bj(!2P!QS}Z-wUTm0Iy`R zRI6R-jZvYYmMVN z8E#_uVnNU=F`Fk|dut2+3kR1igDoi>#t>gj>aJn7#a?g7QrHs1<#1<#r-z5fCU=; v8hZ-CkNyY*DFU^?L}vNJvheTHV;KGqos%fg5qfGi00000NkvXXu0mjfW@bq~ literal 0 HcmV?d00001 diff --git a/priv/static/events/unsubscribe.png b/priv/static/events/unsubscribe.png new file mode 100644 index 0000000000000000000000000000000000000000..e8ef4bf46db34c7cd616bb7bb2458356ef35781c GIT binary patch literal 1181 zcmV;O1Y-M%P)-|hQ?^oS3Zp`XBcqliwU`MK^9QBbk`l%e0}C|)=Ms^` z23u{Y(>sY|2!#BW^8$~q1dbnafM&<9JC1wzeLr*0rpTU1w*HXs_S*CPem?Kd`~BQK z&yC>#;D2Kwd?Ebt371#q;qS)3&M(5D zLq$ysChfqm+4{)Phko=`4Bxdc3wed5m;x{%28Ya3F#TMed0oFg89R-Mz^?js80p zMW~~mbDXCS{phP4F7EIT;~>sm{25>D&%x&hj-YQ~5H{Yxs2#nxCufCy>Zs=&=jlT~ z`YMJe@5w;#kQohadYr#hiSz@9k+eG0~nqFdPXK@z&iWXQO`Nf(}#ZaRSZx3 zcpr>HtnKVUgI13V71hYh$wy*JD$3Nqzz$%<0(1>e%J9rO`_xg-InL9Ee)Lrg-@GFg zHwXU0wHw`N?dU>%TPM_&b@=vpkz{-@w*VH46?dm0!!zsbQ%61LI8Pt?(N{72{p3&4 zX&gbL?oXLtTlK>5Dhkg0h=iSck&?O}J-z)hJhRR|b=22X)#F@N4vIF#;n?a`_(r9| zkpMrnAVuKy(D1meyV2fdLS5VM_fgYw1DdKv6qTOEwms>1JuXRxXV%%5bf-&EvT`{L z(P0?>Fb4MYc)~qv?D0k-W`uA2;3H@aw@}@zpFve)2Q*ijQT&q{hw=+$cxIjb3#Up@ z@lr5Gx4r@UwhgeyhG9DDMZ!I6>`^oG2HuR{juw3%DjV7#%H{gsps8*_S$VAt&#aeg zu1KHCyd@e_v7xYpuaK}x$b0svk+kMrCRNrIq`NZ%<=1Bj(!2P!QS}Z-wUTm0Iy`R zRI6R-jZvYYmMVN z8E#_uVnNU=F`Fk|dut2+3kR1igDoi>#t>gj>aJn7#a?g7QrHs1<#1<#r-z5fCU=; v8hZ-CkNyY*DFU^?L}vNJvheTHV;KGqos%fg5qfGi00000NkvXXu0mjfW@bq~ literal 0 HcmV?d00001 diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ad6b5f27b53649e6b473f8006621b44f539ab73b GIT binary patch literal 345 zcmV-f0jBQ=J2NvS08c!cZsz&sQTlP1C8U&Er@eSEWckvdNmQZ!loY z8!JU;Z{yiz97Vyhd{}C;xiaXDTzwk@`TRP~M32AEb$-J=y7f4n6f@fQgL2E^(8{1# zSN_rbQ>P4c?MpG#y4n@OjAWnR0hl;?hbpIsK#D5>34rb8o`eH&&=>p#z@DvVXpZI% rfErW)Aa~ln2k>Yc{PVxid6d5Z?PX5iudMU!00000NkvXXu0mjf^P7=k literal 0 HcmV?d00001 From fdcdcb53cc32a229aef7fd71f48c24901e09188f Mon Sep 17 00:00:00 2001 From: Andrei Roman Date: Thu, 9 Apr 2026 17:22:58 +0200 Subject: [PATCH 4/9] Add SSO authentication for the web UI Uses auctionet_single_sign_on_plug for JWT-based SSO. Auth is skipped in dev when SSO_SECRET_KEY is not set. Production requires SSO_SECRET_KEY and SSO_REQUEST_URL env vars. Co-Authored-By: Claude Sonnet 4.6 --- lib/ex_gridhook_web/auth/jwt_plug.ex | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 lib/ex_gridhook_web/auth/jwt_plug.ex diff --git a/lib/ex_gridhook_web/auth/jwt_plug.ex b/lib/ex_gridhook_web/auth/jwt_plug.ex new file mode 100644 index 0000000..9e19f66 --- /dev/null +++ b/lib/ex_gridhook_web/auth/jwt_plug.ex @@ -0,0 +1,24 @@ +defmodule ExGridhookWeb.Auth.JwtPlug do + @moduledoc """ + Thin wrapper around AuctionetSingleSignOnPlug that reads config from + application env. Skips authentication in dev when SSO_SECRET_KEY is not set. + """ + + def init(opts), do: opts + + def call(conn, _opts) do + secret_key = Application.get_env(:ex_gridhook, :sso_secret_key) + + if is_nil(secret_key) do + conn + else + opts = + AuctionetSingleSignOnPlug.init( + sso_secret_key: secret_key, + sso_request_url: Application.get_env(:ex_gridhook, :sso_request_url) + ) + + AuctionetSingleSignOnPlug.call(conn, opts) + end + end +end From be0d14e4a10513892d1df449c50c408e79fcafae Mon Sep 17 00:00:00 2001 From: Andrei Roman Date: Thu, 9 Apr 2026 17:23:08 +0200 Subject: [PATCH 5/9] Wire up LiveView route and add GDPR personal data deletion endpoint Replaces GET / with the EventsLive LiveView behind SSO auth. Adds DELETE /api/v1/personal_data for GDPR email deletion (basic auth). Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/api_controller.ex | 65 +++++++++++++++++++ lib/ex_gridhook_web/router.ex | 22 ++++++- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 lib/ex_gridhook_web/controllers/api_controller.ex diff --git a/lib/ex_gridhook_web/controllers/api_controller.ex b/lib/ex_gridhook_web/controllers/api_controller.ex new file mode 100644 index 0000000..7aeb2fc --- /dev/null +++ b/lib/ex_gridhook_web/controllers/api_controller.ex @@ -0,0 +1,65 @@ +defmodule ExGridhookWeb.ApiController do + use ExGridhookWeb, :controller + + alias ExGridhook.{Event, Repo} + + def events(conn, params) do + user_identifier = params["user_identifier"] || params["user_id"] + + if is_nil(user_identifier) || user_identifier == "" do + conn + |> put_status(400) + |> json(%{error: "You have to specify user_identifier."}) + else + page = String.to_integer(params["page"] || "1") + per = String.to_integer(params["per_page"] || "25") + + events = + Event.query_by_user_identifier(user_identifier) + |> Event.with_name_if_present(params["name"]) + |> Event.with_mailer_action_if_present(params["mailer_action"]) + |> Event.with_associated_record_if_present(params["associated_record"]) + |> Event.recent_first() + |> Event.paginate(page, per) + |> Repo.all() + + json(conn, Enum.map(events, &serialize_event/1)) + end + end + + def event(conn, %{"id" => id}) do + case Repo.get(Event, id) do + nil -> conn |> put_status(404) |> json(%{error: "Not found."}) + event -> json(conn, serialize_event(event)) + end + end + + def remove_personal_data(conn, params) do + email = params["email"] + + if is_nil(email) || email == "" do + conn + |> put_status(400) + |> json(%{error: "You have to specify email."}) + else + count = Event.remove_by_email(email) + json(conn, %{removed: count}) + end + end + + # NOTE: If you add, remove or rename keys, also change `GridlookEvent` in the Auctionet core repo to match. + defp serialize_event(event) do + %{ + id: event.id, + category: event.category, + data: event.data, + email: event.email, + happened_at: event.happened_at, + mailer_action: event.mailer_action, + name: event.name, + unique_args: event.unique_args, + user_identifier: event.user_identifier, + associated_records: event.associated_records + } + end +end diff --git a/lib/ex_gridhook_web/router.ex b/lib/ex_gridhook_web/router.ex index b475bde..72056b9 100644 --- a/lib/ex_gridhook_web/router.ex +++ b/lib/ex_gridhook_web/router.ex @@ -5,20 +5,30 @@ defmodule ExGridhookWeb.Router do pipeline :browser do plug(:accepts, ["html"]) plug(:fetch_session) - plug(:fetch_flash) + plug(:fetch_live_flash) + plug(:put_root_layout, {ExGridhookWeb.Layouts, :root}) plug(:protect_from_forgery) plug(:put_secure_browser_headers) end + pipeline :jwt_auth do + plug(ExGridhookWeb.Auth.JwtPlug) + end + pipeline :api do plug(:accepts, ["json"]) plug(:authenticate_with_basic_auth) end + scope "/", ExGridhookWeb do + pipe_through([:browser, :jwt_auth]) + + live("/", EventsLive) + end + scope "/", ExGridhookWeb do pipe_through(:browser) - get("/", RootController, :index) get("/revision", RootController, :revision) get("/boom", RootController, :boom) end @@ -29,6 +39,14 @@ defmodule ExGridhookWeb.Router do resources("/", EventController, only: [:create]) end + scope "/api/v1", ExGridhookWeb do + pipe_through(:api) + + get("/events", ApiController, :events) + get("/events/:id", ApiController, :event) + delete("/personal_data", ApiController, :remove_personal_data) + end + defp authenticate_with_basic_auth(conn, _) do basic_auth(conn, Application.get_env(:ex_gridhook, :basic_auth_config)) end From 0e58cd86ff1a1c40878936f415b963fcc6595d2c Mon Sep 17 00:00:00 2001 From: Andrei Roman Date: Thu, 9 Apr 2026 17:23:15 +0200 Subject: [PATCH 6/9] Add mix tasks for event retention cleanup and GDPR deletion mix remove_events: deletes events beyond NUMBER_OF_MONTHS_TO_KEEP_EVENTS_FOR, SavedSearchMailer#build events older than 2 months, and all campaign events. mix remove_personal_data EMAIL: deletes all events for an email address and decrements the EventsData counter. Co-Authored-By: Claude Sonnet 4.6 --- lib/mix/tasks/remove_events.ex | 63 +++++++++++++++++++++++++++ lib/mix/tasks/remove_personal_data.ex | 28 ++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 lib/mix/tasks/remove_events.ex create mode 100644 lib/mix/tasks/remove_personal_data.ex diff --git a/lib/mix/tasks/remove_events.ex b/lib/mix/tasks/remove_events.ex new file mode 100644 index 0000000..ccce71c --- /dev/null +++ b/lib/mix/tasks/remove_events.ex @@ -0,0 +1,63 @@ +defmodule Mix.Tasks.RemoveEvents do + @moduledoc """ + Removes old events based on the NUMBER_OF_MONTHS_TO_KEEP_EVENTS_FOR environment variable. + + Deletes: + - Events older than NUMBER_OF_MONTHS_TO_KEEP_EVENTS_FOR months (general limit) + - SavedSearchMailer#build events older than 2 months + - All campaign events (those with campaign_id in unique_args) + + Usage: + mix remove_events + """ + + use Mix.Task + + import Ecto.Query + alias ExGridhook.{Event, EventsData, Repo} + + @shortdoc "Remove old events according to retention policy" + + def run(_args) do + Mix.Task.run("app.start") + + months = System.get_env("NUMBER_OF_MONTHS_TO_KEEP_EVENTS_FOR") + + if is_nil(months) || months == "" do + Mix.shell().info("Skipping: NUMBER_OF_MONTHS_TO_KEEP_EVENTS_FOR is not set.") + else + general_limit = months_ago(String.to_integer(months)) + saved_search_limit = months_ago(2) + + remove_events( + from(e in Event, where: e.happened_at < ^general_limit), + "Deleted events older than #{general_limit}" + ) + + remove_events( + from(e in Event, + where: + e.mailer_action == "SavedSearchMailer#build" and e.happened_at < ^saved_search_limit + ), + "Deleted SavedSearchMailer#build events older than 2 months" + ) + + remove_events( + from(e in Event, where: like(fragment("unique_args::text"), "%campaign_id%")), + "Deleted campaign events" + ) + end + end + + defp remove_events(query, message) do + {count, _} = Repo.delete_all(query) + EventsData.decrement(count) + Mix.shell().info(message) + end + + defp months_ago(months) do + DateTime.utc_now() + |> DateTime.add(-months * 30 * 24 * 60 * 60, :second) + |> DateTime.truncate(:second) + end +end diff --git a/lib/mix/tasks/remove_personal_data.ex b/lib/mix/tasks/remove_personal_data.ex new file mode 100644 index 0000000..34b2b03 --- /dev/null +++ b/lib/mix/tasks/remove_personal_data.ex @@ -0,0 +1,28 @@ +defmodule Mix.Tasks.RemovePersonalData do + @moduledoc """ + Removes all events for a given email address (GDPR deletion). + + Usage: + mix remove_personal_data EMAIL + + Example: + mix remove_personal_data user@example.com + """ + + use Mix.Task + + alias ExGridhook.Event + + @shortdoc "Remove all events for a given email (GDPR)" + + def run([email]) do + Mix.Task.run("app.start") + + count = Event.remove_by_email(email) + Mix.shell().info("Removed #{count} event(s) for #{email}.") + end + + def run(_) do + Mix.raise("Usage: mix remove_personal_data EMAIL") + end +end From db144eebe1f0b73bfac076a266cb58f088de91c5 Mon Sep 17 00:00:00 2001 From: Andrei Roman Date: Thu, 9 Apr 2026 17:23:21 +0200 Subject: [PATCH 7/9] Update root controller test for new LiveView route GET / now renders the Gridlook LiveView instead of a plain-text response. Co-Authored-By: Claude Sonnet 4.6 --- test/ex_gridhook_web/controllers/root_controller_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ex_gridhook_web/controllers/root_controller_test.exs b/test/ex_gridhook_web/controllers/root_controller_test.exs index b4ea11a..467283d 100644 --- a/test/ex_gridhook_web/controllers/root_controller_test.exs +++ b/test/ex_gridhook_web/controllers/root_controller_test.exs @@ -1,10 +1,10 @@ defmodule ExGridhookWeb.RootControllerTest do use ExGridhookWeb.ConnCase - test "GET /" do + test "GET / renders the events UI" do conn = get(build_conn(), "/") - assert response(conn, 200) =~ "handles incoming sendgrid events" + assert response(conn, 200) =~ "Gridlook" end test "GET /revision without built_from_revision file " do From 45d965f2df1ae6152761d9588c6f15e32c309318 Mon Sep 17 00:00:00 2001 From: Andrei Roman Date: Thu, 9 Apr 2026 17:23:28 +0200 Subject: [PATCH 8/9] Ignore Claude Code settings file Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f207dbe..1d205b3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ npm-debug.log .devbox .bundle .env +.claude/settings.json From 8061f0b6b0b97cd410094edac94cf04300cc452f Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 9 Apr 2026 17:25:05 +0200 Subject: [PATCH 9/9] Update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1d205b3..255be7a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,4 @@ npm-debug.log .devbox .bundle .env -.claude/settings.json +.claude