Current section

Files

Jump to
mail_address lib mail_address.ex
Raw

lib/mail_address.ex

# MailAddress - RFC5321 Mail Address Handling
# (c) Copyright 2018, John Vinters
# Licence: MIT, see file COPYING for details.
defmodule MailAddress do
@moduledoc """
Functions to handle RFC5321 Mail Addresses.
The library implements functions for handling email addresses as specified
mostly by RFC5321. A large chunk of the address syntax is implemented, with
a few exceptions:
* Handling of general address literals in domains (IPv4 and IPv6
address literals are supported).
* Handling of internationalized addresses (UTF8, punycode etc).
## Creating Addresses
Addresses may be created a number of ways:
* `%MailAddress{}` - this will create a null address.
* Calling `new/3` - this will directly assign a local and domain part.
* Calling `MailAddress.Parser.parse/2` - this will parse a string into
an address.
### Examples
iex> %MailAddress{}
#MailAddress<>
iex> {:ok, addr} = MailAddress.new("test", "example.org")
iex> addr
#MailAddress<test@example.org>
iex> {:ok, addr, ""} = MailAddress.Parser.parse("test@example.org")
iex> addr
#MailAddress<test@example.org>
## Modifying Addresses
Addresses can be modified by a number of functions, which return a new
address with the appropriate update:
* `set_domain/3` - updates domain.
* `set_local_part/3` - updates local part of address.
## Querying Addresses
Addresses can be queryied for their components:
* `address_literal?/1` - checks if the address has an address literal domain set.
* `address_literal/1` - returns address literal domain (or `nil` if none).
* `domain?/1` - checks if the address has a domain set.
* `domain/1` - returns the address domain.
* `local_part?/1` - checks if the address has a local part set.
* `local_part/1` - returns the address local part.
* `needs_quoting?/1` - checks if the address local part needs quoting.
* `null?/1` - returns true if the address is null (no local or domain parts).
## Comparing and Encoding Addresses
* `domains_equal?/2` - compares address domains.
* `encode/2` - encodes address as string, taking care of quoting etc.
* `equal?/2` - compares two addresses.
* `local_parts_equal?/2` - compares address local parts.
## Parsing Addresses
The module MailAddress.Parser contains parsing code.
* `MailAddress.Parser.parse/2` - parses a string into an address.
* `MailAddress.Parser.valid?/2` - determines if address has valid syntax.
## Specifying Options
The `MailAddress.Options` struct is used to store options for configuring
the library. Checks are applied after every change/creation operation.
## Protocols
The library implements the `Inspect` and `String.Chars` protocols for
`MailAddress` structs.
The `Inspect` protocol is used in the `iex` shell and by `inspect/2` to
pretty-print the `MailAddress` struct contents.
The `String.Chars` protocol enables a `MailAddress` struct to be directly
converted into an encoded string.
"""
alias MailAddress.CharSet
@typedoc "Error return type - a tuple containing `:error` and a reason string."
@type error :: {:error, String.t()}
@typedoc "Represents an IPv4 or IPv6 address."
@type ip_address :: :inet.ip4_address() | :inet.ip6_address()
@typedoc "Success return type - a tuple containing `:ok` and a `MailAddress` struct."
@type success :: {:ok, %__MODULE__{}}
@typedoc "The `MailAddress` struct."
@type t :: %__MODULE__{
address_literal: nil | ip_address(),
local_part: String.t(),
domain: String.t(),
needs_quoting: boolean
}
@doc """
Address struct.
The struct *SHOULD* *be* *treated* *as* *opaque*
and not tampered with directly as it may change, and the `needs_quoting`
field is cached.
Callers should use the appropriate functions to get/set fields which
ensures that everything remains in-sync and valid.
"""
defstruct address_literal: nil,
local_part: "",
domain: "",
needs_quoting: false
defimpl Inspect, for: MailAddress do
import Inspect.Algebra
def inspect(%MailAddress{} = addr, opts) do
str = MailAddress.encode(addr, false)
insp = color(str, :string, opts)
concat(["#MailAddress<", insp, ">"])
end
end
defimpl String.Chars, for: MailAddress do
def to_string(%MailAddress{} = addr) do
MailAddress.encode(addr, true)
end
end
defmodule Options do
@moduledoc "Contains struct to hold configuration."
@typedoc "The `MailAddress.Options` struct."
@type t :: %__MODULE__{
allow_address_literal: boolean,
allow_localhost: boolean,
allow_null: boolean,
allow_null_local_part: boolean,
downcase_domain: boolean,
max_address_length: pos_integer,
max_domain_length: pos_integer,
max_local_part_length: pos_integer,
require_brackets: boolean,
require_domain: boolean
}
@doc """
Holds the configuration options for handling addresses.
* `:allow_address_literal` - if `true`, allows domain part to be an
address literal. Defaults to `false`.
* `:allow_localhost` - if `true`, allows domain part to be "localhost".
Defaults to `false`.
* `:allow_null` - if `true` allows address to be null. Defaults to `false`.
* `:allow_null_local_part` - if `true` allows address to have an empty
local part. Defaults to `false`.
* `:downcase_domain` - if `true` downcases domain automatically. Defaults to
`false`.
* `:max_address_length` - the maximum total length in characters.
Defaults to 256 (from RFC5321).
* `:max_domain_length` - the maximum domain length in characters.
Defaults to 255 (from RFC5321).
* `:max_local_part_length` - the maximum local part length in characters.
Defaults to 64 (from RFC5321).
* `:require_brackets` - if `true`, insists that address must be surrounded
by angle brackets '<' and '>'. If `false` the brackets are optional
and any parsing will stop when either the end of string,
or a space after the last valid domain character is reached.
Defaults to `false`.
* `:require_domain` - if `true` then the address must have a domain
component unless it is a null address. Defaults to `true`.
"""
defstruct allow_address_literal: false,
allow_localhost: false,
allow_null: false,
allow_null_local_part: false,
downcase_domain: false,
max_address_length: 256,
max_domain_length: 255,
max_local_part_length: 64,
require_brackets: false,
require_domain: true
end
@doc """
Returns the decoded address literal domain (if any), or nil otherwise.
## Examples
iex> MailAddress.address_literal(%MailAddress{})
nil
iex> {:ok, addr} = MailAddress.new("test", "[192.168.0.1]", %MailAddress.Options{allow_address_literal: true})
iex> MailAddress.address_literal(addr)
{192, 168, 0, 1}
"""
@spec address_literal(MailAddress.t()) :: String.t()
def address_literal(%MailAddress{address_literal: a}), do: a
@doc """
Checks whether address has an address literal domain part.
## Examples
iex> MailAddress.address_literal?(%MailAddress{})
false
iex> {:ok, addr} = MailAddress.new("test", "[192.168.0.1]", %MailAddress.Options{allow_address_literal: true})
iex> MailAddress.address_literal?(addr)
true
"""
@spec address_literal?(MailAddress.t()) :: boolean
def address_literal?(%MailAddress{address_literal: nil}), do: false
def address_literal?(%MailAddress{}), do: true
@doc """
Applies checks and optional domain downcasing to given address using
passed options.
This function is automatically called as required by other functions
in the package, so doesn't normally need to be called unless you
are messing with the `MailAddress` struct directly (which isn't
a good idea).
If successful, returns `{:ok, new_address}`, otherwise returns
`{:error, error_message}`.
"""
@spec check(MailAddress.t(), Options.t()) :: {:ok, MailAddress.t()} | error()
def check(%MailAddress{} = addr, %MailAddress.Options{} = options) do
with :ok <- check_domain(addr, options),
:ok <- check_domain_length(addr, options),
:ok <- check_domain_address_literal(addr, options),
:ok <- check_local_part_length(addr, options),
:ok <- check_length(addr, options),
:ok <- check_null(addr, options),
{:ok, addr} <- check_needs_quoting(addr),
{:ok, addr} <- check_downcase(addr, options),
do: {:ok, addr}
end
# checks the domain isn't null (as long as entire address isn't null).
@spec check_domain(MailAddress.t(), Options.t()) :: :ok | error()
defp check_domain(%MailAddress{domain: ""} = addr, %Options{require_domain: true}) do
if byte_size(addr.local_part) == 0, do: :ok, else: {:error, "domain expected"}
end
defp check_domain(%MailAddress{} = addr, %Options{allow_localhost: false}) do
if domains_equal?(addr, "localhost") do
{:error, "domain can't be localhost"}
else
:ok
end
end
defp check_domain(%MailAddress{}, %MailAddress.Options{}), do: :ok
# checks the domain isn't an address literal (if configured to do so).
@spec check_domain_address_literal(MailAddress.t(), Options.t()) :: :ok | error()
defp check_domain_address_literal(%MailAddress{address_literal: nil}, %Options{
allow_address_literal: false
}),
do: :ok
defp check_domain_address_literal(%MailAddress{}, %Options{allow_address_literal: false}) do
{:error, "domain can't be an address literal"}
end
defp check_domain_address_literal(%MailAddress{}, %Options{}), do: :ok
# checks domain length is OK.
@spec check_domain_length(MailAddress.t(), MailAddress.Options.t()) :: :ok | error()
defp check_domain_length(%MailAddress{domain: dom}, %MailAddress.Options{} = options) do
max_length = options.max_domain_length
if byte_size(dom) > max_length do
{:error, "domain too long (must be <= #{max_length} characters)"}
else
:ok
end
end
# downcases the domain part if required.
@spec check_downcase(MailAddress.t(), Options.t()) :: {:ok, MailAddress.t()} | error()
defp check_downcase(%MailAddress{domain: dom} = addr, %Options{downcase_domain: true}) do
{:ok, %{addr | domain: String.downcase(dom)}}
end
defp check_downcase(%MailAddress{} = addr, %MailAddress.Options{}), do: {:ok, addr}
# checks overall length is OK.
@spec check_length(MailAddress.t(), MailAddress.Options.t()) :: :ok | error()
defp check_length(%MailAddress{local_part: loc, domain: dom}, %MailAddress.Options{} = options) do
max_length = options.max_address_length
if byte_size(loc) + 1 + byte_size(dom) > max_length do
{:error, "address too long (must be <= #{max_length} characters)"}
else
:ok
end
end
# checks a given local part contains only valid characters.
# returns either `:ok` or `{:error, error_message}`.
@spec check_local_part(String.t()) :: :ok | error()
defp check_local_part(local) when is_binary(local) do
local
|> :binary.bin_to_list()
|> Enum.reduce_while(:ok, fn ch, acc ->
case CharSet.qpair?(ch) do
true ->
{:cont, acc}
false ->
{:halt, {:error, "invalid character #{CharSet.format(ch)} in address local part"}}
end
end)
end
# checks local part length is OK.
@spec check_local_part_length(MailAddress.t(), MailAddress.Options.t()) :: :ok | error()
defp check_local_part_length(
%MailAddress{domain: dom, local_part: loc},
%MailAddress.Options{} = options
) do
max_length = options.max_local_part_length
len = byte_size(loc)
cond do
len > max_length ->
{:error, "local part too long (must be <= #{max_length} characters"}
len == 0 && !options.allow_null_local_part && byte_size(dom) > 0 ->
{:error, "local part can't be null"}
true ->
:ok
end
end
# checks to see if address needs quoting
@spec check_needs_quoting(MailAddress.t()) :: {:ok, MailAddress.t()} | error()
defp check_needs_quoting(%MailAddress{local_part: "", domain: ""} = addr),
do: {:ok, %{addr | needs_quoting: false}}
defp check_needs_quoting(%MailAddress{local_part: ""} = addr),
do: {:ok, %{addr | needs_quoting: true}}
defp check_needs_quoting(%MailAddress{local_part: <<?.::size(8), _rest::binary>>} = addr),
do: {:ok, %{addr | needs_quoting: true}}
defp check_needs_quoting(%MailAddress{local_part: local} = addr) do
{needs_quoting, last_dot} =
local
|> :binary.bin_to_list()
|> Enum.reduce({false, false}, fn ch, {nq, ld} = acc ->
is_dot = ch == ?.
cond do
nq -> acc
is_dot && ld -> {true, ld}
is_dot -> {nq, true}
!CharSet.atext?(ch) -> {true, ld}
true -> {nq, false}
end
end)
{:ok, %{addr | needs_quoting: needs_quoting || last_dot}}
end
# checks the address isn't null.
@spec check_null(MailAddress.t(), Options.t()) :: :ok | error()
defp check_null(%MailAddress{local_part: "", domain: ""}, %Options{allow_null: false}) do
{:error, "address can't be null"}
end
defp check_null(%MailAddress{}, %MailAddress.Options{}), do: :ok
@doc """
Returns the domain part of the address.
## Examples
iex> MailAddress.domain(%MailAddress{})
""
iex> {:ok, addr} = MailAddress.new("test", "example.org")
iex> MailAddress.domain(addr)
"example.org"
"""
@spec domain(MailAddress.t()) :: String.t()
def domain(%MailAddress{domain: d}), do: d
@doc """
Checks whether address has a domain part.
## Examples
iex> MailAddress.domain?(%MailAddress{})
false
iex> {:ok, addr} = MailAddress.new("test", "example.org")
iex> MailAddress.domain?(addr)
true
"""
@spec domain?(MailAddress.t()) :: boolean
def domain?(%MailAddress{domain: ""}), do: false
def domain?(%MailAddress{}), do: true
@doc """
Compares domain of given address with `domain` (case-insensitively).
Returns `true` if the domains are the same, or `false` otherwise.
## Examples
iex> {:ok, addr_1} = MailAddress.new("test", "example.org")
iex> {:ok, addr_2} = MailAddress.new("another", "example.org")
iex> {:ok, addr_3} = MailAddress.new("test", "localhost", %MailAddress.Options{allow_localhost: true})
iex> MailAddress.domains_equal?(addr_1, "example.org")
true
iex> MailAddress.domains_equal?(addr_2, "EXAMPLE.ORG")
true
iex> MailAddress.domains_equal?(addr_1, "something_else")
false
iex> MailAddress.domains_equal?(addr_1, addr_2)
true
iex> MailAddress.domains_equal?(addr_1, %MailAddress{})
false
iex> MailAddress.domains_equal?(addr_3, "localhost")
true
iex> MailAddress.domains_equal?(addr_3, "[127.0.0.1]")
true
iex> MailAddress.domains_equal?(addr_3, "[IPv6:::1]")
true
"""
@spec domains_equal?(MailAddress.t(), String.t() | MailAddress.t()) :: boolean
def domains_equal?(%MailAddress{domain: d1} = addr, domain) when is_binary(domain) do
String.downcase(d1) == String.downcase(domain)
|| (localhost?(addr) && localhost_string?(domain))
end
def domains_equal?(%MailAddress{domain: d1} = a1, %MailAddress{domain: d2} = a2) do
String.downcase(d1) == String.downcase(d2)
|| (localhost?(a1) && localhost?(a2))
end
@doc """
Returns address safely encoded, optionally (and by default) bracketed.
## Examples
iex> MailAddress.encode(%MailAddress{}, false)
""
iex> MailAddress.encode(%MailAddress{}, true)
"<>"
iex> {:ok, addr, ""} = MailAddress.Parser.parse("test@example.org")
iex> MailAddress.encode(addr, true)
"<test@example.org>"
iex> {:ok, addr, ""} = MailAddress.Parser.parse("\\\"@test\\\"@example.org")
iex> MailAddress.encode(addr, true)
"<\\"\\\\@test\\\"@example.org>"
"""
@spec encode(MailAddress.t(), boolean) :: String.t()
def encode(_, bracket \\ true)
def encode(%MailAddress{} = addr, true) do
enc = encode(addr, false)
<<?<::size(8), enc::binary, ?>::size(8)>>
end
def encode(%MailAddress{local_part: "", domain: ""}, false),
do: <<>>
def encode(%MailAddress{needs_quoting: false} = addr, false),
do: <<addr.local_part::binary, ?@::size(8), addr.domain::binary>>
def encode(%MailAddress{needs_quoting: true} = addr, false) do
local =
addr.local_part
|> :binary.bin_to_list()
|> Enum.flat_map(fn ch ->
case CharSet.atext?(ch) do
true -> [ch]
false -> [?\\, ch]
end
end)
|> :binary.list_to_bin()
<<?"::size(8), local::binary, ?"::size(8), ?@::size(8), addr.domain::binary>>
end
@doc """
Checks whether `addr_1` and `addr_2` are the same.
The local parts are compared case sensitively, whilst the domain parts
are compare case insensitively.
## Examples
iex> {:ok, addr_1} = MailAddress.new("test", "example.org")
iex> {:ok, addr_2} = MailAddress.new("test", "ExAmPlE.ORG")
iex> MailAddress.equal?(addr_1, addr_2)
true
iex> {:ok, addr_3} = MailAddress.new("fred", "ExAmPlE.ORG")
iex> MailAddress.equal?(addr_1, addr_3)
false
"""
@spec equal?(MailAddress.t(), MailAddress.t()) :: boolean
def equal?(%MailAddress{} = addr_1, %MailAddress{} = addr_2) do
local_parts_equal?(addr_1, addr_2) && domains_equal?(addr_1, addr_2)
end
@doc """
Returns the local part of the address.
## Examples
iex> {:ok, addr} = MailAddress.new("test", "example.org")
iex> MailAddress.local_part(addr)
"test"
"""
@spec local_part(MailAddress.t()) :: String.t()
def local_part(%MailAddress{local_part: l}), do: l
@doc """
Checks whether address has local part set.
## Examples
iex> MailAddress.local_part?(%MailAddress{})
false
iex> {:ok, addr} = MailAddress.new("test", "example.org")
iex> MailAddress.local_part?(addr)
true
"""
@spec local_part?(MailAddress.t()) :: boolean
def local_part?(%MailAddress{local_part: ""}), do: false
def local_part?(%MailAddress{}), do: true
@doc """
Compares address local parts (case-sensitively).
The second parameter may be either a string or a `MailAddress` struct.
Returns `true` if the local parts are the same, or `false` otherwise.
## Examples
iex> {:ok, addr_1} = MailAddress.new("test", "example.org")
iex> {:ok, addr_2} = MailAddress.new("test", "example.com")
iex> MailAddress.local_parts_equal?(addr_1, addr_2)
true
iex> MailAddress.local_parts_equal?(addr_1, "test")
true
iex> MailAddress.local_parts_equal?(addr_2, "TEST")
false
"""
@spec local_parts_equal?(MailAddress.t(), MailAddress.t()) :: boolean
def local_parts_equal?(%MailAddress{local_part: l1}, local_part) when is_binary(local_part),
do: l1 == local_part
def local_parts_equal?(%MailAddress{local_part: l1}, %MailAddress{local_part: l2}),
do: l1 == l2
@doc """
Checks whether domain part of address is 'localhost', or the domain
is an address literal and is [127.0.0.1] or [IPv6:::1].
## Examples
iex> {:ok, addr_1} = MailAddress.new("test", "example.org")
iex> MailAddress.localhost?(addr_1)
false
iex> {:ok, addr_2} = MailAddress.new("test", "localhost", %MailAddress.Options{allow_localhost: true})
iex> MailAddress.localhost?(addr_2)
true
iex> {:ok, addr_3} = MailAddress.new("test", "[127.0.0.1]", %MailAddress.Options{allow_address_literal: true, allow_localhost: true})
iex> MailAddress.localhost?(addr_3)
true
iex> {:ok, addr_4} = MailAddress.new("test", "[192.168.0.1]", %MailAddress.Options{allow_address_literal: true, allow_localhost: true})
iex> MailAddress.localhost?(addr_4)
false
iex> {:ok, addr_5} = MailAddress.new("test", "[IPv6:::1]", %MailAddress.Options{allow_address_literal: true, allow_localhost: true})
iex> MailAddress.localhost?(addr_5)
true
"""
@spec localhost?(MailAddress.t()) :: boolean
def localhost?(%MailAddress{domain: "localhost"}), do: true
def localhost?(%MailAddress{address_literal: {127, 0, 0, 1}}), do: true
def localhost?(%MailAddress{address_literal: {0, 0, 0, 0, 0, 0, 0, 1}}), do: true
def localhost?(%MailAddress{}), do: false
@doc """
Checks to see if the given string is "localhost" or equivalent
([127.0.0.1] or [IPv6:::1]).
## Examples:
iex> MailAddress.localhost_string?("test")
false
iex> MailAddress.localhost_string?("LOCALHOST")
true
iex> MailAddress.localhost_string?("[127.0.0.1]")
true
iex> MailAddress.localhost_string?("[127.0.0.1")
false
iex> MailAddress.localhost_string?("[192.168.0.1]")
false
iex> MailAddress.localhost_string?("[IPv6:::1]")
true
"""
@spec localhost_string?(String.t) :: boolean
def localhost_string?(<<?[::size(8), _rest::binary>> = str) do
case MailAddress.Parser.Domain.parse(str) do
{:ok, _, _, {127, 0, 0, 1}} -> true
{:ok, _, _, {0, 0, 0, 0, 0, 0, 0, 1}} -> true
_ -> false
end
end
def localhost_string?(str) do
String.downcase(str) == "localhost"
end
@doc """
Checks whether the local part of the given address needs quoting.
The `needs_quoting` flag on the address is updated when the address
is changed, so calling this function is inexpensive.
"""
@spec needs_quoting?(MailAddress.t()) :: boolean
def needs_quoting?(%MailAddress{needs_quoting: nq}), do: nq
@doc """
Creates a new `MailAddress` setting both local and domain parts at
the same time using the provided (or default) `options`.
NOTE: the local part isn't parsed - it is just checked to ensure that
it only contains valid characters. This means that the local part
should be raw rather than quoted form.
Returns either `{:ok, new_address}` or `{:error, error_reason}`.
## Examples
iex> {:ok, addr} = MailAddress.new("test", "example.org")
iex> addr
#MailAddress<test@example.org>
iex> {:ok, addr} = MailAddress.new("@test", "example.org")
iex> addr
#MailAddress<\"\\\@test\"@example.org>
iex> MailAddress.new("test", "example.org!")
{:error, "invalid domain"}
"""
@spec new(String.t(), String.t(), Options.t()) :: {:ok, MailAddress.t()} | error()
def new(local, domain, %MailAddress.Options{} = options \\ %MailAddress.Options{})
when is_binary(local) and is_binary(domain) do
with :ok <- check_local_part(local),
{:ok, parsed_domain, "", literal} <- MailAddress.Parser.Domain.parse(domain) do
%MailAddress{address_literal: literal, local_part: local, domain: parsed_domain}
|> check(options)
else
{:ok, _, _, _} ->
{:error, "invalid domain"}
{:error, _} = err ->
err
end
end
@doc """
Checks whether the address in null (no local part and no domain).
## Examples
iex> MailAddress.null?(%MailAddress{})
true
iex> {:ok, addr} = MailAddress.new("test", "example.org")
iex> MailAddress.null?(addr)
false
iex> {:ok, addr} = MailAddress.new("", "", %MailAddress.Options{allow_null: true})
iex> MailAddress.null?(addr)
true
"""
@spec null?(MailAddress.t()) :: boolean
def null?(%MailAddress{local_part: "", domain: ""}), do: true
def null?(%MailAddress{}), do: false
@doc """
Sets the domain part of the address using the provided (or default)
options.
Returns either `{:ok, new_address}` or `{:error, error_reason}`.
## Examples
iex> {:ok, addr} = MailAddress.set_domain(%MailAddress{}, "test", %MailAddress.Options{allow_null_local_part: true})
iex> MailAddress.domain(addr)
"test"
iex> {:ok, addr} = MailAddress.new("test", "example.com")
iex> MailAddress.domain(addr)
"example.com"
iex> {:ok, addr} = MailAddress.set_domain(addr, "example.org")
iex> MailAddress.domain(addr)
"example.org"
"""
@spec set_domain(MailAddress.t(), String.t(), Options.t()) :: {:ok, MailAddress.t()} | error()
def set_domain(
%MailAddress{} = addr,
domain,
%MailAddress.Options{} = options \\ %MailAddress.Options{}
)
when is_binary(domain) do
case MailAddress.Parser.Domain.parse(domain) do
{:ok, parsed_domain, "", literal} ->
%{addr | address_literal: literal, domain: parsed_domain}
|> check(options)
{:ok, _, _, _} ->
{:error, "invalid domain"}
{:error, _} = err ->
err
end
end
@doc """
Sets the local part of the address using the provided (or default)
options.
NOTE: the local part isn't parsed - it is just checked to ensure that
it only contains valid characters, consequently it should be in raw
unquoted format.
Returns either `{:ok, new_address}` or `{:error, error_reason}`.
## Examples
iex> {:ok, addr} = MailAddress.set_local_part(%MailAddress{}, "test", %MailAddress.Options{require_domain: false})
iex> MailAddress.local_part(addr)
"test"
iex> MailAddress.set_domain(%MailAddress{}, "test", %MailAddress.Options{allow_null_local_part: false})
{:error, "local part can't be null"}
iex> {:ok, addr} = MailAddress.new("test", "example.org")
iex> MailAddress.local_part(addr)
"test"
iex> {:ok, addr} = MailAddress.set_local_part(addr, "other")
iex> MailAddress.local_part(addr)
"other"
"""
@spec set_local_part(MailAddress.t(), String.t(), Options.t()) ::
{:ok, MailAddress.t()} | error()
def set_local_part(
%MailAddress{} = addr,
local,
%MailAddress.Options{} = options \\ %MailAddress.Options{}
)
when is_binary(local) do
with :ok <- check_local_part(local) do
%{addr | local_part: local}
|> check(options)
end
end
end