Current section
Files
Jump to
Current section
Files
lib/extract/email.ex
defmodule Text.Extract.Email do
@moduledoc """
Phase 2 validator for email-address candidates.
Takes a scanner candidate span and validates it as an email address
per RFC 5322 §3.4.1 (dot-atom local part) and RFC 6531 (SMTPUTF8 /
EAI — internationalised local parts) when the `:eai` option is on.
The host part is validated identically to URL hosts: per-label IDNA
via `Unicode.IDNA.to_ascii/2`, then TLD lookup against the bundled
IANA list.
Returns a structured `%{}` record on success or `{:error, reason}` on
rejection.
"""
alias Text.Extract.{Boundary, Tld}
@typedoc "Parsed email record."
@type email_record :: %{
email: String.t(),
ascii: String.t(),
span: {non_neg_integer(), non_neg_integer()},
local: String.t(),
host: String.t(),
ascii_host: String.t()
}
@typedoc "Reasons for rejecting an email candidate."
@type reason ::
:empty
| :no_local
| :no_host
| :invalid_local
| :invalid_host
| :idna_failed
| :invalid_tld
# RFC 5322 atext (excluding `@` and `.` which are structural). EAI
# mode (RFC 6531) additionally accepts any non-ASCII codepoint.
@atext_ascii ~c"!#$%&'*+-/=?^_`{|}~" ++
Enum.to_list(?A..?Z) ++
Enum.to_list(?a..?z) ++
Enum.to_list(?0..?9)
@doc """
Validates an email candidate span.
### Arguments
* `candidate` is the candidate substring as emitted by
`Text.Extract.Scanner.scan/1`.
* `span` is the `{start_byte, length_bytes}` tuple positioning
`candidate` within the original source text — preserved through
to the returned record's `:span` field.
### Options
* `:eai` — when `true`, allow non-ASCII codepoints in the local part
per RFC 6531 SMTPUTF8. Default `true`.
* `:tld_mode` — `:iana` (default) or `:any`.
* `:strict_idn` — when `true`, IDNA uses STD3 ASCII rules. Default
`false`.
### Returns
* `{:ok, record}` on success.
* `{:error, reason}` on rejection.
### Examples
iex> {:ok, r} = Text.Extract.Email.validate("alice@example.com", {0, 17})
iex> {r.local, r.host, r.span}
{"alice", "example.com", {0, 17}}
iex> Text.Extract.Email.validate("alice@example.fake", {0, 18})
{:error, :invalid_tld}
"""
@spec validate(String.t(), {non_neg_integer(), non_neg_integer()}, keyword()) ::
{:ok, email_record()} | {:error, reason()}
def validate(candidate, {start, _len} = _span, options \\ [])
when is_binary(candidate) do
raw = Boundary.shrink(candidate)
if raw == "" do
{:error, :empty}
else
do_validate(raw, {start, byte_size(raw)}, options)
end
end
defp do_validate(raw, span, options) do
eai? = Keyword.get(options, :eai, true)
tld_mode = Keyword.get(options, :tld_mode, :iana)
strict = Keyword.get(options, :strict_idn, false)
with [local, host] <- split_at_at(raw),
:ok <- check_local(local, eai?),
:ok <- check_host(host),
{:ok, ascii_host} <- idna_host(host, strict),
:ok <- check_tld(ascii_host, tld_mode) do
{:ok,
%{
email: raw,
ascii: local <> "@" <> ascii_host,
span: span,
local: local,
host: host,
ascii_host: ascii_host
}}
else
:error -> {:error, :no_local}
{:error, _} = err -> err
end
end
# RFC 5322 / 6531: an email address is `local-part "@" domain`. We
# split on the *last* `@` to allow `@` in the local part (quoted-
# string form, which we do not otherwise validate but should not
# mis-split).
defp split_at_at(raw) do
case String.split(raw, "@") do
[_one] ->
:error
parts ->
[List.last(parts) | Enum.reverse(tl(Enum.reverse(parts)))]
|> case do
[host | local_parts] -> [Enum.reverse(local_parts) |> Enum.join("@"), host]
end
end
end
# RFC 5322 dot-atom: 1*atext *("." 1*atext) — at least one atext
# char, optionally followed by `.`-separated runs of atext. RFC 6531
# extends `atext` to include any non-ASCII codepoint.
defp check_local("", _eai), do: {:error, :no_local}
defp check_local(local, eai?) do
parts = String.split(local, ".")
cond do
Enum.any?(parts, &(&1 == "")) -> {:error, :invalid_local}
Enum.all?(parts, &valid_atext?(&1, eai?)) -> :ok
true -> {:error, :invalid_local}
end
end
defp valid_atext?(<<>>, _eai), do: false
defp valid_atext?(part, eai?) do
part
|> String.to_charlist()
|> Enum.all?(fn c ->
cond do
c in @atext_ascii -> true
eai? and c >= 0x80 -> true
true -> false
end
end)
end
defp check_host(""), do: {:error, :no_host}
defp check_host(host) do
labels = String.split(host, ".")
cond do
length(labels) < 2 ->
{:error, :no_host}
Enum.any?(labels, &(&1 == "")) ->
{:error, :invalid_host}
Enum.any?(labels, &(String.starts_with?(&1, "-") or String.ends_with?(&1, "-"))) ->
{:error, :invalid_host}
true ->
:ok
end
end
defp idna_host(host, strict) do
options = [
transitional: false,
check_hyphens: true,
check_bidi: true,
check_joiners: true,
use_std3_ascii_rules: strict
]
case Unicode.IDNA.to_ascii(host, options) do
{:ok, ascii} -> {:ok, String.downcase(ascii)}
{:error, _} -> {:error, :idna_failed}
end
end
defp check_tld(ascii_host, mode) do
tld = ascii_host |> String.split(".") |> List.last()
if Tld.tld?(tld, mode), do: :ok, else: {:error, :invalid_tld}
end
end