diff --git a/.gitignore b/.gitignore index f207dbe..255be7a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ npm-debug.log .devbox .bundle .env +.claude 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/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/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 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/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 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/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/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/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 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 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"}, 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 0000000..a078cb5 Binary files /dev/null and b/priv/static/dinomail.gif differ diff --git a/priv/static/events/bounce-04a143bdcf4c8c70896a50bbfbe16d3b.png b/priv/static/events/bounce-04a143bdcf4c8c70896a50bbfbe16d3b.png new file mode 100644 index 0000000..6af6c62 Binary files /dev/null and b/priv/static/events/bounce-04a143bdcf4c8c70896a50bbfbe16d3b.png differ diff --git a/priv/static/events/bounce.png b/priv/static/events/bounce.png new file mode 100644 index 0000000..6af6c62 Binary files /dev/null and b/priv/static/events/bounce.png differ diff --git a/priv/static/events/click-1e1a1d8e3f0127677acd43a82e28c534.png b/priv/static/events/click-1e1a1d8e3f0127677acd43a82e28c534.png new file mode 100644 index 0000000..122a7ca Binary files /dev/null and b/priv/static/events/click-1e1a1d8e3f0127677acd43a82e28c534.png differ diff --git a/priv/static/events/click.png b/priv/static/events/click.png new file mode 100644 index 0000000..122a7ca Binary files /dev/null and b/priv/static/events/click.png differ diff --git a/priv/static/events/deferred-0a4513bcf44d5dfccb05c25d57f7b535.png b/priv/static/events/deferred-0a4513bcf44d5dfccb05c25d57f7b535.png new file mode 100644 index 0000000..70fd825 Binary files /dev/null and b/priv/static/events/deferred-0a4513bcf44d5dfccb05c25d57f7b535.png differ diff --git a/priv/static/events/deferred.png b/priv/static/events/deferred.png new file mode 100644 index 0000000..70fd825 Binary files /dev/null and b/priv/static/events/deferred.png differ diff --git a/priv/static/events/delivered-70dfaeee25f5c40fc172b69efe96b2e9.png b/priv/static/events/delivered-70dfaeee25f5c40fc172b69efe96b2e9.png new file mode 100644 index 0000000..c7fb2c1 Binary files /dev/null and b/priv/static/events/delivered-70dfaeee25f5c40fc172b69efe96b2e9.png differ diff --git a/priv/static/events/delivered.png b/priv/static/events/delivered.png new file mode 100644 index 0000000..c7fb2c1 Binary files /dev/null and b/priv/static/events/delivered.png differ diff --git a/priv/static/events/dropped-b11e0061006dd057cbc9afc5a306eeca.png b/priv/static/events/dropped-b11e0061006dd057cbc9afc5a306eeca.png new file mode 100644 index 0000000..39e8321 Binary files /dev/null and b/priv/static/events/dropped-b11e0061006dd057cbc9afc5a306eeca.png differ diff --git a/priv/static/events/dropped.png b/priv/static/events/dropped.png new file mode 100644 index 0000000..39e8321 Binary files /dev/null and b/priv/static/events/dropped.png differ diff --git a/priv/static/events/open-2b43ed17398812329eaf55ef7cd8d3dc.png b/priv/static/events/open-2b43ed17398812329eaf55ef7cd8d3dc.png new file mode 100644 index 0000000..8002c70 Binary files /dev/null and b/priv/static/events/open-2b43ed17398812329eaf55ef7cd8d3dc.png differ diff --git a/priv/static/events/open.png b/priv/static/events/open.png new file mode 100644 index 0000000..8002c70 Binary files /dev/null and b/priv/static/events/open.png differ diff --git a/priv/static/events/processed-e88377d247406f01f6ba1faff5d7a72a.png b/priv/static/events/processed-e88377d247406f01f6ba1faff5d7a72a.png new file mode 100644 index 0000000..6ac01c3 Binary files /dev/null and b/priv/static/events/processed-e88377d247406f01f6ba1faff5d7a72a.png differ diff --git a/priv/static/events/processed.png b/priv/static/events/processed.png new file mode 100644 index 0000000..6ac01c3 Binary files /dev/null and b/priv/static/events/processed.png differ diff --git a/priv/static/events/spamreport-13032f8a92dd8e9f70545ea780a3f3c5.png b/priv/static/events/spamreport-13032f8a92dd8e9f70545ea780a3f3c5.png new file mode 100644 index 0000000..90aa8fd Binary files /dev/null and b/priv/static/events/spamreport-13032f8a92dd8e9f70545ea780a3f3c5.png differ diff --git a/priv/static/events/spamreport.png b/priv/static/events/spamreport.png new file mode 100644 index 0000000..90aa8fd Binary files /dev/null and b/priv/static/events/spamreport.png differ diff --git a/priv/static/events/unsubscribe-d5bfdc0d56c1e91fb73ddde1cf9d7e6b.png b/priv/static/events/unsubscribe-d5bfdc0d56c1e91fb73ddde1cf9d7e6b.png new file mode 100644 index 0000000..e8ef4bf Binary files /dev/null and b/priv/static/events/unsubscribe-d5bfdc0d56c1e91fb73ddde1cf9d7e6b.png differ diff --git a/priv/static/events/unsubscribe.png b/priv/static/events/unsubscribe.png new file mode 100644 index 0000000..e8ef4bf Binary files /dev/null and b/priv/static/events/unsubscribe.png differ diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000..ad6b5f2 Binary files /dev/null and b/priv/static/favicon.ico differ 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