src/smtp/z_email.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2022 Marc Worrell
%%
%% @doc Send e-mail to a recipient. Optionally queue low priority messages.

%% Copyright 2009-2022 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(z_email).
-author("Marc Worrell <marc@worrell.nl>").

%% interface functions
-export([
    email_domain/1,
    ensure_domain/2,
    bounce_domain/1,

	get_admin_email/1,
    get_email_from/1,
    format_recipient/2,
    ensure_to_email/2,

	send_admin/3,

	send_page/3,

	send/2,
	send/3,

    send/4,
    send_render/4,
    send_render/5,

    sendq/4,
    sendq_render/4,
    sendq_render/5,

    split_name_email/1,
    combine_name_email/2
]).

-include_lib("zotonic.hrl").


% The email domain depends on the site sending the e-mail
-spec email_domain(z:context()) -> binary().
email_domain(Context) ->
    case z_convert:to_binary( m_config:get_value(site, smtphost, Context) ) of
        <<>> -> z_context:hostname(Context);
        SmtpHost -> SmtpHost
    end.

% Ensure that the sites's domain is attached to the email address.
-spec ensure_domain(binary()|string(), z:context()) -> binary().
ensure_domain(Email, Context) when is_binary(Email) ->
    case binary:match(Email, <<"@">>) of
        {_,_} -> Email;
        nomatch -> <<Email/binary, $@, (email_domain(Context))/binary>>
    end;
ensure_domain(Email, Context) ->
    ensure_domain(z_convert:to_binary(Email), Context).


% Bounces can be forced to a different e-mail server altogether
-spec bounce_domain(z:context()) -> binary().
bounce_domain(Context) ->
    case z_config:get('smtp_bounce_domain') of
        undefined -> email_domain(Context);
        BounceDomain -> z_convert:to_binary(BounceDomain)
    end.

%% @doc Return the email address to be used in emails for the
%% given id. The address will be formatted using the recipient's name.
-spec format_recipient(m_rsc:resource(), z:context()) -> {ok, binary()} | {error, term()}.
format_recipient(Id, Context) ->
    case m_rsc:rid(Id, Context) of
        undefined ->
            {error, enoent};
        RscId ->
            case m_rsc:p(Id, email_raw, Context) of
                undefined ->
                    {error, no_email};
                Email ->
                    {Name, _NameCtx} = z_template:render_to_iolist("_name.tpl", [{id, RscId}], Context),
                    {ok, combine_name_email(iolist_to_binary(Name), Email)}
            end
    end.

%% @doc If the recipient is an resource id, ensure that it is formatted as an email address.
-spec ensure_to_email( #email{}, z:context() ) -> {ok, #email{}} | {error, term()}.
ensure_to_email(#email{ to = undefined }, _Context) ->
    {error, no_recipient};
ensure_to_email(#email{ to = <<>> }, _Context) ->
    {error, no_recipient};
ensure_to_email(#email{ to = "" }, _Context) ->
    {error, no_recipient};
ensure_to_email(#email{ to = Id } = E, Context) when is_integer(Id) ->
    case format_recipient(Id, Context) of
        {ok, To} ->
            {ok, E#email{ to = To }};
        {error, _} = Error ->
            Error
    end;
ensure_to_email(#email{} = E, _Context) ->
    {ok, E}.

%% @doc Fetch the e-mail address of the site administrator
-spec get_admin_email(z:context()) -> binary().
get_admin_email(Context) ->
	case m_config:get_value(zotonic, admin_email, Context) of
		undefined ->
			case m_site:get(admin_email, Context) of
				undefined ->
					case m_rsc:p_no_acl(1, email_raw, Context) of
                        undefined -> wwwadmin_email(Context);
                        <<>> -> wwwadmin_email(Context);
						Email when is_binary(Email) -> Email
					end;
				Email ->
                    z_convert:to_binary(Email)
			end;
		Email ->
            z_convert:to_binary(Email)
	end.

%% @doc Fetch the default From e-mail address. Defaults to noreply@hostname
-spec get_email_from(z:context()) -> binary().
get_email_from(Context) ->
    z_email_server:get_email_from(Context).


wwwadmin_email(Context) ->
    <<"wwwadmin@", (z_context:hostname(Context))/binary>>.

%% @doc Send a simple text message to the administrator
-spec send_admin(iodata(), iodata(), z:context()) ->
          {ok, MsgId::binary()}
        | {error, sender_disabled|term()}.
send_admin(Subject, Message, Context) ->
	Email = get_admin_email(Context),
	Subject1 = iolist_to_binary([ $[, z_context:hostname(Context), "] ", Subject ]),
	Message1 = iolist_to_binary([
		Message,
		"\n\n-- \nYou receive this e-mail because you are registered as the admin of the site ",
		z_context:abs_url(<<"/">>, Context)
	]),
	z_email_server:send(#email{queue=false, to=Email, subject=Subject1, text=Message1}, Context).


%% @doc Send a page to an e-mail address, assumes the correct template "mailing_page.tpl" is available.
%%		 Defaults for these pages are supplied by mod_mailinglist.
send_page(undefined, _Id, _Context) ->
	{error, not_email};
send_page(_Email, undefined, _Context) ->
	{error, not_found};
send_page(Email, Id, Context) when is_integer(Id) ->
	Vars = [
		{id, Id},
		{recipient, Email}
	],
	case m_rsc:is_a(Id, document, Context) of
		false ->
			send_render(Email, {cat, "mailing_page.tpl"}, Vars, Context);
		true ->
			E = #email{
				to=Email,
				html_tpl={cat, "mailing_page.tpl"},
				vars=Vars,
				attachments=[Id]
			},
			send(E, Context)
	end;
send_page(Email, Id, Context) ->
	send_page(Email, m_rsc:rid(Id, Context), Context).


%% @doc Send an email message defined by the email record.
send(#email{} = Email, Context) ->
    case ensure_to_email(Email, Context) of
        {ok, Email1} ->
            z_email_server:send(Email1, Context);
        {error, _} = Error ->
            Error
    end.

send(MsgId, #email{} = Email, Context) ->
	z_email_server:send(MsgId, Email, Context).

%% @doc Send a simple text message to an email address
send(To, Subject, Message, Context) ->
	send(#email{queue=false, to=To, subject=Subject, text=Message}, Context).

%% @doc Queue a simple text message to an email address
sendq(To, Subject, Message, Context) ->
	send(#email{queue=true, to=To, subject=Subject, text=Message}, Context).

%% @doc Send a html message to an email address, render the message using a template.
send_render(To, HtmlTemplate, Vars, Context) ->
    send_render(To, HtmlTemplate, undefined, Vars, Context).

%% @doc Send a html and text message to an email address, render the message using two templates.
send_render(To, HtmlTemplate, TextTemplate, Vars, Context) ->
	send(#email{queue=false, to=To, from=proplists:get_value(email_from, Vars),
	            html_tpl=HtmlTemplate, text_tpl=TextTemplate, vars=Vars}, Context).

%% @doc Queue a html message to an email address, render the message using a template.
sendq_render(To, HtmlTemplate, Vars, Context) ->
    sendq_render(To, HtmlTemplate, undefined, Vars, Context).

%% @doc Queue a html and text message to an email address, render the message using two templates.
sendq_render(To, HtmlTemplate, TextTemplate, Vars, Context) ->
	send(#email{queue=true, to=To, from=proplists:get_value(email_from, Vars),
	            html_tpl=HtmlTemplate, text_tpl=TextTemplate, vars=Vars}, Context).


%% @doc Combine a name and an email address to the format `jan janssen <jan@example.com>'
%% @todo do we need rfc2047:encode/1 call here?
-spec combine_name_email(Name, Email) -> NameEmail when
    Name :: binary() | string() | undefined,
    Email :: binary() | string(),
    NameEmail :: binary().
combine_name_email(undefined, Email) ->
    Email;
combine_name_email(Name, Email) ->
    Name1 = z_string:trim(unicode:characters_to_binary(Name)),
    Email1 = unicode:characters_to_binary(Email),
    smtp_util:combine_rfc822_addresses([{Name1, Email1}]).


%% @doc Split the name and email from the format `jan janssen <jan@example.com>'
-spec split_name_email(String) -> {Name, Email} when
    String :: string() | binary(),
    Name :: binary(),
    Email :: binary().
split_name_email(Email) ->
    Email1 = z_string:trim(rfc2047:decode(unicode:characters_to_binary(Email, utf8))),
    case smtp_util:parse_rfc5322_addresses(Email1) of
        {ok, [{N,E}|_]} ->
            {z_string:trim(unicode:characters_to_binary(b(N), utf8)), unicode:characters_to_binary(b(E), utf8)};
        {error,{1,smtp_rfc5322_parse,["syntax error before: ","'>'"]}} ->
            % Issue parsing emails without domain, add a domain before the final '>' and try again
            Email2 = iolist_to_binary(re:replace(Email1, <<">$">>, <<"@example.com>">>)),
            case smtp_util:parse_rfc5322_addresses(Email2) of
                {ok, [{N,E}|_]} ->
                    N1 = z_string:trim(unicode:characters_to_binary(b(N), utf8)),
                    E1 = unicode:characters_to_binary(re:replace(b(E), <<"@example.com$">>, <<>>), utf8),
                    {N1, E1};
                {error, _} ->
                    {z_string:trim(z_convert:to_binary(Email1)), <<>>}
            end;
        {error, _} ->
            {z_string:trim(z_convert:to_binary(Email1)), <<>>}
    end.

b(undefined) -> <<>>;
b(S) -> S.