From ec0dd50b3a776bf91309765081f0505dea9330cd Mon Sep 17 00:00:00 2001 From: marinac-dev Date: Sat, 25 Jan 2025 13:45:07 +0100 Subject: [PATCH 1/2] refactor: Improve Disposable and make it more readable Signed-off-by: marinac-dev --- .formatter.exs | 3 +- .tool-versions | 2 + README.md | 7 +- lib/disposable.ex | 113 ++++++++++++++++++------- lib/disposable/disposable_exception.ex | 3 + mix.exs | 4 +- 6 files changed, 96 insertions(+), 36 deletions(-) create mode 100644 .tool-versions create mode 100644 lib/disposable/disposable_exception.ex diff --git a/.formatter.exs b/.formatter.exs index d2cda26..0a70dc0 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,5 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + line_length: 120 ] diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..45fea67 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 27.0 +elixir 1.18.0-otp-27 diff --git a/README.md b/README.md index 73a1d3e..9ec0d03 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,17 @@ Disposable is an Elixir library for checking if an email address is from a disposable email service. It provides a fast, memory-efficient way to validate email domains against a known list of disposable email providers. With over 169.000 domains in the list, Disposable is a reliable tool for preventing users from signing up with temporary email addresses. +## Note + +- Requires Elixir 1.18 or later + ## Features - Fast in-memory checking of email domains - Easy to use API - Configurable disposable domains list - Ability to reload domains without application restart +- Built-in list of 169.000+ disposable email domains ## Installation @@ -19,7 +24,7 @@ The package can be installed by adding `disposable` to your list of dependencies ```elixir def deps do [ - {:disposable, "~> 0.1.3"} + {:disposable, "~> 0.1.4"} ] end ``` diff --git a/lib/disposable.ex b/lib/disposable.ex index b9e8d53..5eb05c9 100644 --- a/lib/disposable.ex +++ b/lib/disposable.ex @@ -1,51 +1,103 @@ defmodule Disposable do - use Agent + @moduledoc """ + Provides functionality to check if an email address belongs to a disposable email service. + Uses an Agent process to maintain an in-memory cache of disposable domains for efficient lookups. + """ + use Agent require Logger - @moduledoc """ - Checks if an email address is from a disposable email service. - Uses an Agent to keep domains in memory for faster checking. - """ + @typedoc "A disposable email" + @type email :: String.t() + @typedoc "A disposable domain name" + @type domain :: String.t() + + @doc """ + Starts the Disposable domain cache Agent. + The Agent is registered under the current module name and initialized with + domains loaded from the configured file path. + """ + @spec start_link(keyword()) :: Agent.on_start() def start_link(_opts) do Agent.start_link(&load_domains/0, name: __MODULE__) end @doc """ - Checks if an email address is from a disposable email service. + Checks if an email address belongs to a disposable email service. + + Returns `false` if the email is invalid or if the Agent process is not running. ## Examples - iex> Disposable.check("test@example.com") + iex> Disposable.check("user@example.com") false iex> Disposable.check("test@alltempmail.com") true - """ - def check(email) do - if Process.whereis(__MODULE__) do - domain = get_domain(email) + @spec check(email()) :: boolean() | {:error, :not_running} | {:error, :invalid_email} | no_return() + def check(email) when is_binary(email) do + with {:ok, pid} <- ensure_running(), + {:ok, domain} <- extract_domain(email) do + Agent.get(pid, &MapSet.member?(&1, domain)) + else + {:error, :not_running} -> + Logger.error("Disposable email Agent is not running") + raise Disposable.Exception, message: "Disposable email Agent is not running" - if domain do - Agent.get(__MODULE__, &MapSet.member?(&1, domain)) - else + {:error, :invalid_email} -> false - end - else - Logger.error("Disposable E-mail Agent is not running.") - false end end - defp get_domain(email) do + @doc """ + Reloads the disposable domains from the configured file into memory. + + Useful for updating the domain list without restarting the application. + """ + @spec reload() :: :ok + def reload do + data = load_domains() + Agent.update(__MODULE__, fn _state -> data end) + end + + @doc """ + Load domains from a URL into memory. + """ + def load_url(url) do + case Disposable.Http.get(url) do + {:ok, 200, _headers, body} -> + data = to_string(body) |> String.split("\n") + + parsed = + data + |> Stream.map(&String.trim/1) + |> MapSet.new() + + Agent.update(__MODULE__, fn _state -> parsed end) + end + end + + # Private Functions + + @spec ensure_running() :: {:ok, pid()} | {:error, :not_running} + defp ensure_running do + case Process.whereis(__MODULE__) do + pid when is_pid(pid) -> {:ok, pid} + nil -> {:error, :not_running} + end + end + + @spec extract_domain(email()) :: {:ok, domain()} | {:error, :invalid_email} + defp extract_domain(email) do case String.split(email, "@") do - [_local_part, domain] -> String.downcase(domain) - _ -> nil + [_local_part, domain] -> {:ok, String.downcase(domain)} + _invalid -> {:error, :invalid_email} end end + @spec load_domains() :: MapSet.t() defp load_domains do domains_file() |> File.stream!() @@ -53,20 +105,17 @@ defmodule Disposable do |> MapSet.new() end + @spec domains_file() :: String.t() defp domains_file do default_path = Application.app_dir(:disposable, "priv/domains.txt") - case Application.get_env(:disposable, :disposable_domains_file) do - nil -> default_path - path -> if File.exists?(path), do: path, else: default_path - end + Application.get_env(:disposable, :disposable_domains_file) + |> determine_file_path(default_path) end - @doc """ - Reloads the domains from the file into memory. - Useful for updating the list without restarting the application. - """ - def reload do - Agent.update(__MODULE__, fn _state -> load_domains() end) - end + @spec determine_file_path(String.t() | nil, String.t()) :: String.t() + defp determine_file_path(nil, default_path), do: default_path + + defp determine_file_path(path, default_path), + do: if(File.exists?(path), do: path, else: default_path) end diff --git a/lib/disposable/disposable_exception.ex b/lib/disposable/disposable_exception.ex new file mode 100644 index 0000000..beee408 --- /dev/null +++ b/lib/disposable/disposable_exception.ex @@ -0,0 +1,3 @@ +defmodule Disposable.Exception do + defexception [:message] +end diff --git a/mix.exs b/mix.exs index ceef97e..dc8ee4e 100644 --- a/mix.exs +++ b/mix.exs @@ -1,14 +1,14 @@ defmodule Disposable.MixProject do use Mix.Project - @version "0.1.3" + @version "0.1.4" @source_url "https://github.com/marinac-dev/disposable" def project do [ app: :disposable, version: @version, - elixir: "~> 1.16", + elixir: "~> 1.18", start_permanent: Mix.env() == :prod, deps: deps(), description: description(), From 3d2a990a168c178ba5da48283191780c036dd46a Mon Sep 17 00:00:00 2001 From: marinac-dev Date: Sat, 25 Jan 2025 13:45:34 +0100 Subject: [PATCH 2/2] feat(http-client): Add http client to re-load file at runtime Signed-off-by: marinac-dev --- lib/application.ex | 6 ++---- lib/disposable/http.ex | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 lib/disposable/http.ex diff --git a/lib/application.ex b/lib/application.ex index 19dacf1..b5d026d 100644 --- a/lib/application.ex +++ b/lib/application.ex @@ -8,12 +8,10 @@ defmodule Disposable.Application do @impl true def start(_type, _args) do children = [ - # List all child processes to be supervised - Disposable + {Disposable, []} ] - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options + # See https://hexdocs.pm/elixir/Supervisor.html for other strategies and supported options opts = [strategy: :one_for_one, name: Disposable.Supervisor] Supervisor.start_link(children, opts) end diff --git a/lib/disposable/http.ex b/lib/disposable/http.ex new file mode 100644 index 0000000..4d7e943 --- /dev/null +++ b/lib/disposable/http.ex @@ -0,0 +1,23 @@ +defmodule Disposable.Http do + @default_timeout 15_000 + + def get(url, headers \\ [], timeout \\ @default_timeout) do + request_headers = Enum.map(headers, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) + + opts = [ + timeout: timeout, + ssl: [verify: :verify_none], + autoredirect: false + ] + + case :httpc.request(:get, {String.to_charlist(url), request_headers}, opts, []) do + {:ok, {{_version, status_code, _reason}, response_headers, body}} -> + headers_map = Enum.into(response_headers, %{}, fn {k, v} -> {List.to_string(k), List.to_string(v)} end) + + {:ok, status_code, headers_map, body} + + {:error, reason} -> + {:error, reason} + end + end +end