%% @author Atilla Erdodi <atilla@maximonster.com>
%% @copyright 2010-2021 Maximonster Interactive Things
%% @doc Email server. Queues, renders and sends e-mails.
%% Copyright 2010-2021 Maximonster Interactive Things
%%
%% 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_server).
-author("Atilla Erdodi <atilla@maximonster.com>").
-author("Marc Worrell <marc@worrell.nl>").
-behaviour(gen_server).
%% gen_server exports
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
%% interface functions
-export([
start_link/0,
is_bounce_email_address/1,
bounced/2,
delivery_report/4,
generate_message_id/0,
send/2,
send/3,
poll/0,
tempfile/0,
is_tempfile/1,
is_tempfile_deletable/1,
is_sender_enabled/2,
is_sender_enabled/3,
is_recipient_blocked/2,
get_email_from/1
]).
-include_lib("zotonic.hrl").
-include_lib("stdlib/include/qlc.hrl").
% Maximum times we retry to send a message before we mark it as failed.
-define(MAX_RETRY, 10).
% Max number of e-mails being sent at the same time
-define(EMAIL_MAX_SENDING, 30).
% Max number of connections per (relay) domain.
-define(EMAIL_MAX_DOMAIN, 5).
% Extension of files with queued copies of tmpfile attachments
-define(TMPFILE_EXT, ".mailspool").
% Timeout (in msec) for the connect to external SMTP server (default is 5000)
-define(SMTP_CONNECT_TIMEOUT, 15000).
-record(state, {smtp_relay, smtp_relay_opts, smtp_no_mx_lookups,
smtp_verp_as_from, smtp_bcc, override,
sending=[], delete_sent_after, poll_ref}).
-record(email_queue, {id, retry_on=inc_timestamp(os:timestamp(), 1), retry=0,
recipient, email, created=os:timestamp(), sent,
pickled_context}).
-record(email_sender, {id, sender_pid, domain, is_connected=false}).
-type delivery_type() :: permanent_failure | temporary_failure | sent | received | relayed.
-export_type([ delivery_type/0 ]).
%%====================================================================
%% API
%%====================================================================
%% @doc Starts the server
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
start_link([]).
%% @doc Starts the server
-spec start_link(list()) -> {ok, pid()} | ignore | {error, term()}.
start_link(Args) when is_list(Args) ->
gen_server:start_link({local, ?MODULE}, ?MODULE, Args, []).
%% @doc Check if the received e-mail address is a bounce address
is_bounce_email_address(<<"noreply+",_/binary>>) -> true;
is_bounce_email_address("noreply+"++_) -> true;
is_bounce_email_address(_) -> false.
%% @doc Handle a bounce
bounced(Peer, NoReplyEmail) ->
gen_server:cast(?MODULE, {bounced, Peer, NoReplyEmail}).
%% @doc Handle a delivery report from an outside service like mailgun
-spec delivery_report( delivery_type(), binary() | undefined, binary(), binary() | undefined ) -> ok.
delivery_report(What, OptRecipient, MsgIdHeader, OptStatusMessage) ->
gen_server:cast(?MODULE, {delivery_report, What, OptRecipient, MsgIdHeader, OptStatusMessage}).
%% @doc Generate a new message id
-spec generate_message_id() -> binary().
generate_message_id() ->
z_ids:random_id('az09', 20).
%% @doc Send an email
send(#email{} = Email, Context) ->
send(generate_message_id(), Email, Context).
%% @doc Send an email using a predefined unique id.
send(EmailId, #email{} = Email, Context) ->
case is_sender_enabled(Email, Context) of
true ->
EmailId1 = z_convert:to_binary(EmailId),
Email1 = copy_attachments(Email),
Context1 = z_context:depickle(z_context:pickle(Context)),
gen_server:cast(?MODULE, {send, EmailId1, Email1, Context1}),
{ok, EmailId1};
false ->
{error, sender_disabled}
end.
%% @doc Return the filename for a tempfile that can be used for the emailer
tempfile() ->
z_tempfile:tempfile(?TMPFILE_EXT).
%% @doc Check if a file is a tempfile of the emailer
is_tempfile(File) ->
z_tempfile:is_tempfile(File)
andalso z_convert:to_list(filename:extension(File)) =:= ?TMPFILE_EXT.
%% @doc Return the max age of a tempfile
is_tempfile_deletable(undefined) ->
false;
is_tempfile_deletable(File) ->
case is_tempfile(File) of
true ->
case filelib:last_modified(File) of
0 ->
false;
Modified when is_tuple(Modified) ->
ModifiedSecs = calendar:datetime_to_gregorian_seconds(Modified),
NowSecs = calendar:datetime_to_gregorian_seconds(calendar:local_time()),
NowSecs > max_tempfile_age() + ModifiedSecs
end;
false ->
true
end.
%% @doc Max tempfile age in seconds
max_tempfile_age() ->
max_tempfile_age(?MAX_RETRY, 0) + 24*3600.
max_tempfile_age(0, Acc) -> Acc;
max_tempfile_age(N, Acc) -> max_tempfile_age(N-1, period(N) + Acc).
%% @doc Check if the sender is allowed to send email. If an user is disabled they are only
%% allowed to send mail to themselves or to the admin.
is_sender_enabled(#email{} = Email, Context) ->
is_sender_enabled(z_acl:user(Context), Email#email.to, Context).
is_sender_enabled(undefined, _RecipientEmail, _Context) ->
true;
is_sender_enabled(1, _RecipientEmail, _Context) ->
true;
is_sender_enabled(Id, RecipientEmail, Context) when is_list(RecipientEmail) ->
is_sender_enabled(Id, z_convert:to_binary(RecipientEmail), Context);
is_sender_enabled(Id, RecipientEmail, Context) when is_integer(Id) ->
(m_rsc:exists(Id, Context) andalso z_convert:to_bool(m_rsc:p_no_acl(Id, is_published, Context)))
orelse recipient_is_user_or_admin(Id, RecipientEmail, Context).
recipient_is_user_or_admin(Id, RecipientEmail, Context) ->
m_config:get_value(zotonic, admin_email, Context) =:= RecipientEmail
orelse m_rsc:p_no_acl(1, email_raw, Context) =:= RecipientEmail
orelse m_rsc:p_no_acl(Id, email_raw, Context) =:= RecipientEmail
orelse lists:any(fun(Idn) ->
proplists:get_value(key, Idn) =:= RecipientEmail
end,
m_identity:get_rsc_by_type(Id, email, Context)).
is_recipient_blocked(Recipient, Context) ->
RecipientEmail = recipient_email_address(Recipient),
case z_notifier:first( #email_is_blocked{ recipient = RecipientEmail }, Context) of
undefined -> false;
true -> true;
false -> false
end.
recipient_email_address(Recipient) ->
Recipient2 = z_string:trim(z_string:line(z_convert:to_binary(Recipient))),
{_RcptName, RecipientEmail} = z_email:split_name_email(Recipient2),
z_string:to_lower(RecipientEmail).
%% @doc Force a poll to send new email
-spec poll() -> ok.
poll() ->
?MODULE ! poll,
ok.
%%====================================================================
%% gen_server callbacks
%%====================================================================
%% @spec init(Args) -> {ok, State} |
%% {ok, State, Timeout} |
%% ignore |
%% {stop, Reason}
%% @doc Initiates the server.
init(_Args) ->
ok = create_email_queue(),
State = update_config(#state{
poll_ref = timer:send_after(30000, poll)
}),
process_flag(trap_exit, true),
{ok, State}.
%% @spec handle_call(Request, From, State) -> {reply, Reply, State} |
%% {reply, Reply, State, Timeout} |
%% {noreply, State} |
%% {noreply, State, Timeout} |
%% {stop, Reason, Reply, State} |
%% {stop, Reason, State}
handle_call({is_sending_allowed, Pid, Relay}, _From, State) ->
DomainWorkers = length(lists:filter(
fun(#email_sender{domain=Domain, is_connected=IsConnected}) ->
IsConnected andalso Relay =:= Domain
end,
State#state.sending)),
case DomainWorkers < email_max_domain(Relay) of
true ->
Workers = [
case E#email_sender.sender_pid of
Pid -> E#email_sender{is_connected=true};
_ -> E
end
|| E <- State#state.sending
],
{reply, ok, State#state{sending=Workers}};
false ->
{reply, {error, wait}, State}
end;
%% @doc Trap unknown calls
handle_call(Message, _From, State) ->
{stop, {unknown_call, Message}, State}.
%% @spec handle_cast(Msg, State) -> {noreply, State} |
%% {noreply, State, Timeout} |
%% {stop, Reason, State}
%% @doc Send an e-mail.
handle_cast({send, Id, #email{} = Email, Context}, State) ->
z_context:logger_md(Context),
State1 = update_config(State),
State2 = case z_utils:is_empty(Email#email.to) of
true -> State1;
false -> send_email(Id, Email#email.to, Email, Context, State1)
end,
State3 = case z_utils:is_empty(Email#email.cc) of
true -> State2;
false -> send_email(<<Id/binary, "+cc">>, Email#email.cc, Email, Context, State2)
end,
State4 = case z_utils:is_empty(Email#email.bcc) of
true -> State3;
false -> send_email(<<Id/binary, "+bcc">>, Email#email.bcc, Email, Context, State3)
end,
{noreply, State4};
%%@ doc Handle a bounced email
handle_cast({bounced, Peer, BounceEmail}, State) ->
% Fetch the MsgId from the bounce address
[BounceLocalName,Domain] = binary:split(z_convert:to_binary(BounceEmail), <<"@">>),
<<"noreply+", MsgId/binary>> = BounceLocalName,
% Find the original message in our database of recent sent e-mail
TrFun = fun()->
case mnesia:read(email_queue, MsgId) of
[ QEmail ] ->
mnesia:delete_object(QEmail),
{(QEmail#email_queue.email)#email.to, QEmail#email_queue.pickled_context};
[] ->
mnesia:abort(notfound)
end
end,
case mnesia:transaction(TrFun) of
{atomic, {Recipient, PickledContext}} ->
Context = z_context:depickle(PickledContext),
z_notifier:notify(#email_bounced{
message_nr=MsgId,
recipient=Recipient
}, Context),
z_notifier:notify(#zlog{
user_id=z_acl:user(Context),
props=#log_email{
severity = ?LOG_LEVEL_ERROR,
message_nr = MsgId,
mailer_status = bounce,
mailer_host = z_convert:ip_to_list(Peer),
envelop_to = BounceEmail,
envelop_from = "<>",
to_id = z_acl:user(Context),
props = []
}}, Context);
{aborted, notfound} ->
% We got a bounce, but we don't have the message anymore.
% Custom bounce domains make this difficult to process.
case z_sites_dispatcher:get_site_for_hostname(Domain) of
{ok, Host} ->
Context = z_context:new(Host),
z_notifier:notify(#email_bounced{
message_nr=MsgId,
recipient=undefined
}, Context),
z_notifier:notify(#zlog{
user_id=undefined,
props=#log_email{
severity = ?LOG_LEVEL_WARNING,
message_nr = MsgId,
mailer_status = bounce,
mailer_host = z_convert:ip_to_list(Peer),
envelop_to = BounceEmail,
envelop_from = "<>",
props = []
}}, Context);
undefined ->
ignore
end;
{aborted, Reason} ->
?LOG_WARNING(#{
text => <<"Could not handle bounced messages">>,
in => zotonic_core,
src => Peer,
bounce_email => BounceEmail,
reason => Reason
}),
ok
end,
{noreply, State};
handle_cast({delivery_report, What, OptRecipient, MsgIdHeader, OptStatusMessage}, State) ->
[ MsgId, Domain ] = binstr:split(z_convert:to_binary(MsgIdHeader), <<"@">>),
% Find the original message in our database of recent sent e-mail
TrFun = fun()->
[QEmail] = mnesia:read(email_queue, MsgId),
{(QEmail#email_queue.email)#email.to, QEmail#email_queue.pickled_context}
end,
case mnesia:transaction(TrFun) of
{atomic, {Recipient, PickledContext}} ->
Context = z_context:depickle(PickledContext),
handle_delivery_report(What, MsgId, Recipient, OptStatusMessage, Context);
_ ->
% We got a bounce, but we don't have the message anymore.
% Custom bounce domains make this difficult to process.
case z_sites_dispatcher:get_site_for_hostname(Domain) of
{ok, Host} ->
Context = z_context:new(Host),
handle_delivery_report(What, MsgId, OptRecipient, OptStatusMessage, Context);
undefined ->
ignore
end
end,
{noreply, State};
%% @doc Trap unknown casts
handle_cast(Message, State) ->
{stop, {unknown_cast, Message}, State}.
%% @spec handle_info(Info, State) -> {noreply, State} |
%% {noreply, State, Timeout} |
%% {stop, Reason, State}
%% @doc Poll the database queue for any retrys.
handle_info(poll, State) ->
_ = timer:cancel(State#state.poll_ref),
{IsSending, State1} = poll_queued(State),
Time = case IsSending of
false -> 10000;
true -> 2000
end,
State2 = State1#state{
poll_ref = timer:send_after(Time, poll)
},
z_utils:flush_message(poll),
{noreply, State2};
%% @doc Spawned process has crashed. Clear it from the sending list.
handle_info({'EXIT', Pid, _Reason}, State) ->
{noreply, remove_worker(Pid, State)};
%% @doc Handling all non call/cast messages
handle_info(_Info, State) ->
{noreply, State}.
%% @spec terminate(Reason, State) -> void()
%% @doc This function is called by a gen_server when it is about to
%% terminate. It should be the opposite of Module:init/1 and do any necessary
%% cleaning up. When it returns, the gen_server terminates with Reason.
%% The return value is ignored.
terminate(_Reason, _State) ->
ok.
%% @spec code_change(OldVsn, State, Extra) -> {ok, NewState}
%% @doc Convert process state when code is changed
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%====================================================================
%% support functions
%%====================================================================
handle_delivery_report(permanent_failure, MsgId, Recipient, OptMessage, Context) ->
?LOG_WARNING(#{
text => <<"Permanent failure sending email">>,
in => zotonic_core,
recipient => Recipient,
message_id => MsgId,
message => OptMessage
}),
z_notifier:notify(#email_failed{
message_nr = MsgId,
recipient = Recipient,
is_final = true,
reason = bounce,
status = OptMessage
}, Context),
z_notifier:notify(#zlog{
user_id = z_acl:user(Context),
props = #log_email{
severity = ?LOG_LEVEL_ERROR,
message_nr = MsgId,
mailer_status = bounce,
mailer_message = OptMessage,
envelop_to = Recipient,
envelop_from = "<>",
to_id = z_acl:user(Context),
props = []
}
}, Context),
% delete email from the queue and notify the system
delete_emailq(MsgId);
handle_delivery_report(temporary_failure, MsgId, Recipient, OptMessage, Context) ->
?LOG_WARNING(#{
text => <<"Temporary failure sending email">>,
in => zotonic_core,
recipient => Recipient,
message_id => MsgId,
message => OptMessage
}),
z_notifier:notify(#email_failed{
message_nr = MsgId,
recipient = Recipient,
is_final = false,
reason = retry,
status = OptMessage
}, Context),
z_notifier:notify(#zlog{
user_id = z_acl:user(Context),
props = #log_email{
severity = ?LOG_LEVEL_WARNING,
message_nr = MsgId,
mailer_status = retry,
mailer_message = OptMessage,
envelop_to = Recipient,
envelop_from = "<>",
to_id = z_acl:user(Context),
props = []
}
}, Context);
handle_delivery_report(Status, MsgId, Recipient, OptMessage, Context)
when Status =:= sent; Status =:= relayed ->
?LOG_NOTICE(#{
text => <<"Success sending email">>,
in => zotonic_core,
recipient => Recipient,
message_id => MsgId,
state => Status
}),
z_notifier:notify(#email_sent{
message_nr = MsgId,
recipient = Recipient,
is_final = false
}, Context),
z_notifier:notify(#zlog{
user_id = z_acl:user(Context),
props = #log_email{
severity = ?LOG_LEVEL_INFO,
message_nr = MsgId,
mailer_status = Status,
mailer_message = OptMessage,
envelop_to = Recipient,
envelop_from = "<>",
to_id = z_acl:user(Context),
props = []
}
}, Context);
handle_delivery_report(received, MsgId, Recipient, OptMessage, Context) ->
?LOG_NOTICE(#{
text => <<"Success sending email">>,
in => zotonic_core,
recipient => Recipient,
message_id => MsgId,
status => received
}),
z_notifier:notify(#email_sent{
message_nr = MsgId,
recipient = Recipient,
is_final = true
}, Context),
z_notifier:notify(#zlog{
user_id = z_acl:user(Context),
props = #log_email{
severity = ?LOG_LEVEL_INFO,
message_nr = MsgId,
mailer_status = received,
mailer_message = OptMessage,
envelop_to = Recipient,
envelop_from = "<>",
to_id = z_acl:user(Context),
props = []
}
}, Context);
handle_delivery_report(_What, _MsgId, _Recipient, _OptMessage, _Context) ->
ok.
%% @doc Create the email queue in mnesia
create_email_queue() ->
TabDef = [
{type, set},
{record_name, email_queue},
{attributes, record_info(fields, email_queue)}
| case application:get_env(mnesia, dir) of
{ok, _} -> [ {disc_copies, [node()]} ];
undefined -> []
end
],
case mnesia:create_table(email_queue, TabDef) of
{atomic, ok} -> ok;
{aborted, {already_exists, email_queue}} -> ok
end.
%% @doc Refetch the emailer configuration so that we adapt to any config changes.
update_config(State) ->
SmtpRelay = z_config:get(smtp_relay),
SmtpRelayOpts =
case SmtpRelay of
true ->
[{relay, z_config:get(smtp_host, "localhost")},
{port, z_config:get(smtp_port, 25)},
{ssl, z_config:get(smtp_ssl, false)}]
++ case {z_config:get(smtp_username),
z_config:get(smtp_password)} of
{undefined, undefined} ->
[];
{User, Pass} ->
[{auth, always},
{username, User},
{password, Pass}]
end;
false ->
[]
end,
SmtpNoMxLookups = z_config:get(smtp_no_mx_lookups),
SmtpVerpAsFrom = z_config:get(smtp_verp_as_from),
SmtpBcc = z_config:get(smtp_bcc),
Override = z_config:get(email_override),
DeleteSentAfter = z_config:get(smtp_delete_sent_after),
State#state{smtp_relay=SmtpRelay,
smtp_relay_opts=SmtpRelayOpts,
smtp_no_mx_lookups=SmtpNoMxLookups,
smtp_verp_as_from=SmtpVerpAsFrom,
smtp_bcc=SmtpBcc,
override=Override,
delete_sent_after=DeleteSentAfter}.
%% @doc Get the bounce email address. Can be overridden per site in config setting site.bounce_email_override.
-spec bounce_email(binary(), z:context()) -> binary().
bounce_email(MessageId, Context) when is_binary(MessageId) ->
case m_config:get_value(site, bounce_email_override, Context) of
undefined ->
case z_config:get(smtp_bounce_email_override) of
undefined -> <<"noreply+", MessageId/binary>>;
VERP -> z_convert:to_binary(VERP)
end;
VERP ->
z_convert:to_binary(VERP)
end.
-spec reply_email(binary(), z:context()) -> binary().
reply_email(MessageId, Context) when is_binary(MessageId) ->
EmailDomain = z_email:email_domain(Context),
<<"reply+",MessageId/binary, $@, EmailDomain/binary>>.
% The 'From' is either the message id (and bounce domain) or the set from.
get_email_from(EmailFrom, VERP, State, Context) ->
From = case z_convert:to_binary(EmailFrom) of
<<>> -> get_email_from(Context);
L -> L
end,
{FromName, FromEmail} = z_email:split_name_email(From),
case State#state.smtp_verp_as_from of
true ->
z_email:combine_name_email(FromName, VERP);
_ when FromEmail =:= <<>> ->
z_email:combine_name_email(FromName, get_email_from(Context));
_ ->
z_email:combine_name_email(FromName, FromEmail)
end.
% When the 'From' is not the VERP then the 'From' is derived from the site
-spec get_email_from( z:context() ) -> binary().
get_email_from(Context) ->
%% Let the default be overruled by the config setting
case z_convert:to_binary( m_config:get_value(site, email_from, Context) ) of
<<>> ->
EmailDomain = z_email:email_domain(Context),
<<"noreply@", EmailDomain/binary>>;
EmailFrom ->
EmailFrom
end.
% Unique message-id, depends on bounce domain
message_id(MessageId, Context) when is_binary(MessageId) ->
BounceDomain = z_email:bounce_domain(Context),
<<MessageId/binary, $@, BounceDomain/binary>>.
%% @doc Remove a worker Pid from the server state.
remove_worker(Pid, State) ->
Filtered = lists:filter(fun(#email_sender{sender_pid=P}) -> P =/= Pid end, State#state.sending),
State#state{sending=Filtered}.
%% =========================
%% SENDING related functions
%% =========================
% Send an email
send_email(Id, Recipient, Email, Context, State) ->
QEmail = #email_queue{id=Id,
recipient=Recipient,
email=Email,
retry_on=inc_timestamp(os:timestamp(), 0),
sent=undefined,
pickled_context=z_context:pickle(Context)},
QEmailTransFun = fun() -> mnesia:write(QEmail) end,
{atomic, ok} = mnesia:transaction(QEmailTransFun),
case Email#email.queue orelse length(State#state.sending) > ?EMAIL_MAX_SENDING of
true -> State;
false -> spawn_send(Id, Recipient, Email, QEmail#email_queue.retry, Context, State)
end.
spawn_send(Id, Recipient, Email, RetryCt, Context, State) ->
case lists:keyfind(Id, #email_sender.id, State#state.sending) =/= false of
false ->
spawn_send_check_email(Id, Recipient, Email, RetryCt, Context, State);
_ ->
%% Is already being sent. Do nothing, it will retry later
State
end.
spawn_send_check_email(Id, Recipient, Email, RetryCt, Context, State) ->
case check_templates(Email, Context) of
ok ->
case is_sender_enabled(Email, Context) of
true ->
case is_valid_email(Recipient, Context) of
true ->
spawn_send_checked(Id, Recipient, Email, RetryCt, Context, State);
false ->
?LOG_NOTICE(#{
text => <<"Dropping email to invalid address">>,
in => zotonic_core,
recipient => Recipient
}),
%% delete email from the queue and notify the system
delete_email(illegal_address, Id, Recipient, Email, Context),
State
end;
false ->
?LOG_NOTICE(#{
text => <<"Dropping email from disabled sender">>,
in => zotonic_core,
recipient => Recipient,
sender => z_acl:user(Context)
}),
delete_email(sender_disabled, Id, Recipient, Email, Context),
State
end;
{error, Template} ->
?LOG_WARNING(#{
text => <<"Delayed sending email because template is not available">>,
in => zotonic_core,
template => Template
}),
State
end.
check_templates(#email{ text_tpl = Tpl1, html_tpl = Tpl2 }, Context) ->
check_templates_1([ Tpl1, Tpl2 ], Context).
check_templates_1([], _Context) ->
ok;
check_templates_1([ undefined | Ts ], Context) ->
check_templates_1(Ts, Context);
check_templates_1([ {cat, T} | Ts ], Context) ->
check_templates_1([ T | Ts ], Context);
check_templates_1([ T | Ts ], Context) ->
case z_module_indexer:find(template, T, Context) of
{ok, _} -> check_templates_1(Ts, Context);
{error, _} -> {error, T}
end.
drop_blocked_email(Id, Recipient, Email, Context) ->
delete_emailq(Id),
LogEmail = #log_email{
severity = ?LOG_LEVEL_ERROR,
mailer_status = error,
mailer_message = <<"Recipient blocked by Zotonic module (#email_is_blocked)">>,
props = [{reason, recipient_blocked}],
message_nr = Id,
envelop_to = Recipient,
envelop_from = <<>>,
to_id = proplists:get_value(recipient_id, Email#email.vars),
from_id = z_acl:user(Context),
content_id = proplists:get_value(id, Email#email.vars),
other_id = proplists:get_value(list_id, Email#email.vars),
message_template = Email#email.html_tpl
},
z_notifier:notify(#zlog{user_id=z_acl:user(Context), props=LogEmail}, Context).
delete_email(Error, Id, Recipient, Email, Context) ->
delete_emailq(Id),
z_notifier:first(#email_failed{
message_nr = Id,
recipient = Recipient,
is_final = true,
status = case Error of
illegal_address -> <<"Malformed email address">>;
sender_disabled -> <<"Sender disabled">>
end,
reason=Error
}, Context),
LogEmail = #log_email{
severity = ?LOG_LEVEL_ERROR,
mailer_status = error,
props=[{reason, Error}],
message_nr = Id,
envelop_to = Recipient,
envelop_from = <<>>,
to_id = proplists:get_value(recipient_id, Email#email.vars),
from_id = z_acl:user(Context),
content_id = proplists:get_value(id, Email#email.vars),
other_id = proplists:get_value(list_id, Email#email.vars),
message_template = Email#email.html_tpl
},
z_notifier:notify(#zlog{user_id=z_acl:user(Context), props=LogEmail}, Context).
% Start a worker, prevent too many workers per domain.
spawn_send_checked(Id, Recipient, Email, RetryCt, Context, State) ->
Recipient1 = check_override(Recipient, m_config:get_value(site, email_override, Context), State),
RecipientEmail = recipient_email_address(Recipient1),
case is_recipient_blocked(RecipientEmail, Context) of
false ->
[_RcptLocalName, RecipientDomain] = binary:split(RecipientEmail, <<"@">>),
SmtpOpts = [
{no_mx_lookups, State#state.smtp_no_mx_lookups},
{hostname, z_convert:to_list(z_email:email_domain(Context))},
{timeout, ?SMTP_CONNECT_TIMEOUT},
{tls_options, [{versions, ['tlsv1.2']}]}
] ++ case relay_site_options(State, Context) of
{true, RelayOpts} -> RelayOpts;
false -> [{relay, z_convert:to_list(RecipientDomain)}]
end,
BccSmtpOpts = case z_utils:is_empty(State#state.smtp_bcc) of
true ->
[];
false ->
{_BccName, BccEmail} = z_email:split_name_email(State#state.smtp_bcc),
[_BccLocalName, BccDomain] = binary:split(BccEmail, <<"@">>),
[
{no_mx_lookups, State#state.smtp_no_mx_lookups},
{hostname, z_convert:to_list(z_email:email_domain(Context))},
{timeout, ?SMTP_CONNECT_TIMEOUT},
{tls_options, [{versions, ['tlsv1.2']}]}
] ++ case relay_site_options(State, Context) of
{true, BccRelayOpts} -> BccRelayOpts;
false -> [{relay, z_convert:to_list(BccDomain)}]
end
end,
MessageId = message_id(Id, Context),
VERP = bounce_email(MessageId, Context),
From = get_email_from(Email#email.from, VERP, State, Context),
SenderPid = erlang:spawn_link(
fun() ->
spawned_email_sender(
Id, MessageId, Recipient, RecipientEmail, <<"<", VERP/binary, ">">>,
From, State#state.smtp_bcc, Email, SmtpOpts, BccSmtpOpts,
RetryCt, Context)
end),
{relay, Relay} = proplists:lookup(relay, SmtpOpts),
State#state{
sending=[
#email_sender{id=Id, sender_pid=SenderPid, domain=Relay} | State#state.sending
]};
true ->
?LOG_NOTICE(#{
text => <<"[smtp] Dropping email to blocked address">>,
in => zotonic_core,
result => error,
reason => blocked,
email => RecipientEmail
}),
drop_blocked_email(Id, RecipientEmail, Email, Context),
State
end.
%% @doc Fetch the SMTP relay options, if the Zotonic system is configured to use a relay
%% then that relay is always used. Otherwise the relay configuration of the site is used.
relay_site_options(#state{ smtp_relay = true } = State, _Context) ->
{true, State#state.smtp_relay_opts};
relay_site_options(_State, Context) ->
case m_config:get_boolean(site, smtp_relay, Context) of
true ->
SmtpHost = case z_convert:to_binary( m_config:get_value(site, smtp_relay_host, Context) ) of
<<>> -> "localhost";
SHost -> z_convert:to_list(SHost)
end,
Port = case z_convert:to_binary( m_config:get_value(site, smtp_relay_port, Context) ) of
<<>> -> 25;
SPort ->
try
z_convert:to_integer(SPort)
catch
_:_ -> 25
end
end,
SSL = m_config:get_boolean(site, smtp_relay_ssl, Context),
Creds = case z_convert:to_binary( m_config:get_value(site, smtp_relay_username, Context) ) of
<<>> ->
[];
Username ->
[
{auth, always},
{username, z_convert:to_list(Username)},
{password, z_convert:to_list(m_config:get_value(site, smtp_relay_password, Context))}
]
end,
{true, [
{relay, SmtpHost},
{port, Port},
{ssl, SSL}
] ++ Creds};
false ->
false
end.
spawned_email_sender(Id, MessageId, Recipient, RecipientEmail, VERP, From,
Bcc, Email, SmtpOpts, BccSmtpOpts, RetryCt, Context) ->
z_context:logger_md(Context),
EncodedMail = encode_email(Id, Email, <<"<", MessageId/binary, ">">>, From, Context),
spawned_email_sender_loop(Id, MessageId, Recipient, RecipientEmail, VERP, From,
Bcc, Email, EncodedMail, SmtpOpts, BccSmtpOpts, RetryCt, Context).
spawned_email_sender_loop(Id, MessageId, Recipient, RecipientEmail, VERP, From,
Bcc, Email, EncodedMail, SmtpOpts, BccSmtpOpts, RetryCt, Context) ->
{relay, Relay} = proplists:lookup(relay, SmtpOpts),
case gen_server:call(?MODULE, {is_sending_allowed, self(), Relay}) of
{error, wait} ->
?LOG_INFO(#{
text => <<"Delaying email send: too many parallel senders for relay">>,
in => zotonic_core,
recipient => RecipientEmail,
message_id => Id,
relay => Relay
}),
timer:sleep(1000),
spawned_email_sender(Id, MessageId, Recipient, RecipientEmail, VERP, From,
Bcc, Email, SmtpOpts, BccSmtpOpts, RetryCt, Context);
ok ->
LogEmail = #log_email{
message_nr=Id,
envelop_to=RecipientEmail,
envelop_from=VERP,
to_id=proplists:get_value(recipient_id, Email#email.vars),
from_id=z_acl:user(Context),
content_id=proplists:get_value(id, Email#email.vars),
other_id=proplists:get_value(list_id, Email#email.vars), %% Supposed to contain the mailinglist id
message_template=Email#email.html_tpl
},
z_notifier:notify(#zlog{
user_id=LogEmail#log_email.from_id,
props=LogEmail#log_email{severity=?LOG_LEVEL_INFO, mailer_status=sending}
}, Context),
%% use the unique id as 'envelope sender' (VERP)
SendResult = case z_config:get(smtp_is_blackhole, false) of
true -> <<"Blackhole - zotonic config smtp_is_blackhole is set.">>;
false -> send_blocking(Id, VERP, RecipientEmail, EncodedMail, SmtpOpts, Context)
end,
case SendResult of
{error, Reason, {FailureType, Host, Message}} ->
?LOG_ERROR(#{
text => <<"Error sending email">>,
in => zotonic_core,
recipient => RecipientEmail,
relay => Relay,
result => error,
reason => Reason,
failure_type => FailureType,
host => Host,
message => Message
}),
case is_retry_possible(Reason, FailureType, Message) of
true ->
%% do nothing, it will retry later
z_notifier:notify(#email_failed{
message_nr=Id,
recipient=Recipient,
is_final=false,
reason=retry,
retry_ct=RetryCt,
status=Message
}, Context),
z_notifier:notify(#zlog{
user_id=LogEmail#log_email.from_id,
props=LogEmail#log_email{
severity = ?LOG_LEVEL_WARNING,
mailer_status = retry,
mailer_message = message(Message),
mailer_host = Host
}
}, Context),
ok;
false ->
% permanent failure, something is wrong with the receiving server or the recipient
z_notifier:notify(#email_failed{
message_nr=Id,
recipient=Recipient,
is_final=true,
reason=smtphost,
retry_ct=RetryCt,
status=Message
}, Context),
z_notifier:notify(#zlog{
user_id=LogEmail#log_email.from_id,
props=LogEmail#log_email{
severity = ?LOG_LEVEL_ERROR,
mailer_status = bounce,
mailer_message = z_convert:to_binary(Message),
mailer_host = Host
}
}, Context),
% delete email from the queue and notify the system
delete_emailq(Id)
end;
{error, Reason} ->
?LOG_ERROR(#{
text => <<"Error sending email">>,
in => zotonic_core,
recipient => RecipientEmail,
result => error,
reason => Reason
}),
% Returned when the options are not ok
z_notifier:notify(#email_failed{
message_nr=Id,
recipient=Recipient,
is_final=true,
reason=error,
retry_ct=RetryCt
}, Context),
z_notifier:notify(#zlog{
user_id=LogEmail#log_email.from_id,
props=LogEmail#log_email{
severity = ?LOG_LEVEL_ERROR,
mailer_status = error,
props = [{reason, z_convert:to_binary(Reason)}]
}
}, Context),
%% delete email from the queue and notify the system
delete_emailq(Id);
{ok, Receipt} when is_binary(Receipt) ->
Receipt1 = z_string:trim(Receipt),
?LOG_NOTICE(#{
text => <<"Sent email">>,
in => zotonic_core,
result => ok,
recipient => RecipientEmail,
message => Receipt1
}),
z_notifier:notify(#email_sent{
message_nr=Id,
recipient=Recipient,
is_final=false
}, Context),
z_notifier:notify(#zlog{
user_id=LogEmail#log_email.from_id,
props=LogEmail#log_email{
severity = ?LOG_LEVEL_INFO,
mailer_status = sent,
mailer_message = Receipt1
}
}, Context),
%% email accepted by relay
mark_sent(Id),
%% async send a copy for debugging if necessary
case z_utils:is_empty(Bcc) of
true ->
ok;
false ->
catch send_blocking(Id, VERP, Bcc, EncodedMail, BccSmtpOpts, Context)
end
end
end.
message(Message) ->
try
z_convert:to_binary(Message)
catch
_:_ ->
z_convert:to_binary( io_lib:format("~p", [Message]) )
end.
%% --- Send email using email notification, fall back to gen_smtp sending
send_blocking(MsgId, VERP, RecipientEmail, EncodedMail, SmtpOpts, Context) ->
case z_notifier:first(
#email_send_encoded{
message_nr = MsgId,
from = VERP,
to = RecipientEmail,
encoded = EncodedMail,
options = SmtpOpts
},
Context)
of
{ok, Receipt} ->
{ok, Receipt};
undefined ->
send_blocking_smtp(MsgId, VERP, RecipientEmail, EncodedMail, SmtpOpts);
smtp ->
send_blocking_smtp(MsgId, VERP, RecipientEmail, EncodedMail, SmtpOpts);
{error, _} = Error ->
Error;
{error, _, _} = Error ->
Error
end.
send_blocking_smtp(MsgId, VERP, RecipientEmail, EncodedMail, SmtpOpts) ->
{relay, Relay} = proplists:lookup(relay, SmtpOpts),
?LOG_INFO(#{
text => <<"Sending email">>,
in => zotonic_core,
recipient => RecipientEmail,
message_id => MsgId,
relay => Relay
}),
case gen_smtp_client:send_blocking({VERP, [RecipientEmail], EncodedMail}, SmtpOpts) of
Receipt when is_binary(Receipt) ->
{ok, Receipt};
{error, no_more_hosts, {permanent_failure, _Host, <<"ign Root ", _/binary>>}} ->
% Don't ask ...
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts);
{error, retries_exceeded, {_FailureType, _Host, {error, closed}}} ->
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts);
{error, retries_exceeded, {_FailureType, _Host, {error, timeout}}} ->
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts);
{error, _} = Error ->
Error;
{error, _, _} = Error ->
Error
end.
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts) ->
?LOG_NOTICE(#{
text => <<"Bounce error, retrying without TLS">>,
in => zotonic_core,
recipient => RecipientEmail,
relay => proplists:get_value(relay, SmtpOpts)
}),
SmtpOpts1 = [
{tls, never}
| proplists:delete(tls, SmtpOpts)
],
case gen_smtp_client:send_blocking({VERP, [RecipientEmail], EncodedMail}, SmtpOpts1) of
Receipt when is_binary(Receipt) ->
{ok, Receipt};
{error, _} = Error ->
Error;
{error, _, _} = Error ->
Error
end.
is_retry_possible(_Reason, _FailureType, auth_failed) -> true; % proxy - could be temporary
is_retry_possible(retries_exceeded, _FailureType, _Message) -> true;
is_retry_possible(_Reason, permanent_failure, _Message) -> false;
is_retry_possible(_Reason, __FailureType, _Message) -> true.
encode_email(_Id, #email{raw=Raw}, _MessageId, _From, _Context) when is_list(Raw); is_binary(Raw) ->
z_convert:to_binary(Raw);
encode_email(Id, #email{body=undefined} = Email, MessageId, From, Context) ->
%% Optionally render the text and html body
Vars = [{email_to, Email#email.to}, {email_from, From} | Email#email.vars],
ContextRender = set_recipient_prefs(Vars, Context),
Text = optional_render(Email#email.text, Email#email.text_tpl, Vars, ContextRender),
Html = optional_render(Email#email.html, Email#email.html_tpl, Vars, ContextRender),
%% Fetch the subject from the title of the HTML part or from the Email record
Subject = case {Html, Email#email.subject} of
{[], undefined} ->
<<>>;
{_Html, undefined} ->
{match, [_, {Start,Len}|_]} = re:run(Html, "<title>(.*?)</title>", [dotall, caseless]),
z_string:trim(z_string:line(z_html:unescape(lists:sublist(Html, Start+1, Len))));
{_Html, Sub} ->
Sub
end,
Headers = [{<<"From">>, From},
{<<"To">>, ensure_brackets(Email#email.to)},
{<<"Subject">>, drop_non_printable(iolist_to_binary(Subject))},
{<<"Date">>, date(Context)},
{<<"MIME-Version">>, <<"1.0">>},
{<<"Message-Id">>, MessageId}
| Email#email.headers ],
Headers2 = add_reply_to(Id, Email, add_cc(Email, Headers), Context),
build_and_encode_mail(Headers2, Text, Html, Email#email.attachments, Context);
encode_email(Id, #email{body=Body} = Email, MessageId, From, Context) when is_tuple(Body) ->
Headers = [{<<"From">>, From},
{<<"To">>, ensure_brackets(Email#email.to)},
{<<"Message-Id">>, MessageId}
| Email#email.headers ],
Headers2 = add_reply_to(Id, Email, add_cc(Email, Headers), Context),
{BodyType, BodySubtype, BodyHeaders, BodyParams, BodyParts} = Body,
MailHeaders = [
{z_convert:to_binary(H), z_convert:to_binary(V)} || {H,V} <- (Headers2 ++ BodyHeaders)
],
mimemail:encode({BodyType, BodySubtype, MailHeaders, BodyParams, BodyParts}, opt_dkim(Context));
encode_email(Id, #email{body=Body} = Email, MessageId, From, Context) when is_list(Body); is_binary(Body) ->
Headers = [{<<"From">>, From},
{<<"To">>, ensure_brackets(Email#email.to)},
{<<"Message-Id">>, MessageId}
| Email#email.headers ],
Headers2 = add_reply_to(Id, Email, add_cc(Email, Headers), Context),
iolist_to_binary([ encode_headers(Headers2), "\r\n\r\n", Body ]).
ensure_brackets(Email) when is_binary(Email) ->
case binary:match(Email, <<"<">>) of
{_,_} ->
Email;
nomatch ->
[ Name | _ ] = binary:split(Email, <<"@">>),
<<Name/binary, " <", Email/binary, $>>>
end;
ensure_brackets(Email) ->
ensure_brackets(z_convert:to_binary(Email)).
date(Context) ->
iolist_to_binary(z_datetime:format("r", z_context:set_language(en, Context))).
% Replace all control and non-utf8 characters in the text with spaces.
drop_non_printable(B) ->
drop_non_printable(B, <<>>).
drop_non_printable(<<>>, Acc) ->
Acc;
drop_non_printable(<<C/utf8, Rest/binary>>, Acc) when C >= 32 ->
drop_non_printable(Rest, <<Acc/binary, C/utf8>>);
drop_non_printable(<<_, Rest/binary>>, Acc) ->
drop_non_printable(Rest, <<Acc/binary, " ">>).
add_cc(#email{cc = undefined}, Headers) -> Headers;
add_cc(#email{cc = []}, Headers) -> Headers;
add_cc(#email{cc = <<>>}, Headers) -> Headers;
add_cc(#email{cc = Cc}, Headers) ->
Headers ++ [{<<"Cc">>, Cc}].
add_reply_to(_Id, #email{reply_to=undefined}, Headers, _Context) -> Headers;
add_reply_to(_Id, #email{reply_to = <<>>}, Headers, _Context) ->
[{<<"Reply-To">>, <<"<>">>} | Headers];
add_reply_to(Id, #email{reply_to = message_id}, Headers, Context) ->
[{<<"Reply-To">>, reply_email(Id, Context)} | Headers];
add_reply_to(_Id, #email{reply_to=ReplyTo}, Headers, Context) ->
{Name, Email} = z_email:split_name_email(ReplyTo),
ReplyTo1 = z_email:combine_name_email(Name, z_email:ensure_domain(Email, Context)),
[{<<"Reply-To">>, ReplyTo1} | Headers].
build_and_encode_mail(Headers, Text, Html, Attachment, Context) ->
Headers1 = [
{z_convert:to_binary(H), z_convert:to_binary(V)} || {H,V} <- Headers
],
Params = #{
transfer_encoding => <<"quoted-printable">>,
content_type_params => [ {<<"charset">>, <<"utf-8">>} ],
disposition => <<"inline">>,
disposition_params => []
},
HtmlBin = z_convert:to_binary(Html),
Parts = case z_utils:is_empty(Text) of
true ->
case z_utils:is_empty(Html) of
true ->
[];
false ->
ContentHtml = case binary:split(HtmlBin, <<"<!--content-->">>) of
[ _, MDH ] -> MDH;
_ -> HtmlBin
end,
[{<<"text">>, <<"plain">>, [], Params,
expand_cr(z_convert:to_binary(z_markdown:to_markdown(ContentHtml, [no_html])))}]
end;
false ->
[{<<"text">>, <<"plain">>, [], Params,
expand_cr(z_convert:to_binary(Text))}]
end,
Parts1 = case z_utils:is_empty(HtmlBin) of
true ->
Parts;
false ->
z_email_embed:embed_images(Parts ++ [{<<"text">>, <<"html">>, [], Params, HtmlBin}], Context)
end,
case Attachment of
[] ->
case Parts1 of
[{T,ST,[],Ps,SubParts}] -> mimemail:encode({T,ST,Headers1,Ps,SubParts}, opt_dkim(Context));
_MultiPart -> mimemail:encode({<<"multipart">>, <<"alternative">>, Headers1, #{}, Parts1}, opt_dkim(Context))
end;
_ ->
AttsEncoded = [ encode_attachment(Att, Context) || Att <- Attachment ],
AttsEncodedOk = lists:filter(fun({error, _}) -> false; (_) -> true end, AttsEncoded),
mimemail:encode({<<"multipart">>, <<"mixed">>,
Headers1,
#{},
[ {<<"multipart">>, <<"alternative">>, [], #{}, Parts1} | AttsEncodedOk ]
}, opt_dkim(Context))
end.
encode_attachment(AttId, Context) when is_integer(AttId) ->
case z_acl:rsc_visible(AttId, Context) of
true ->
case m_media:get(AttId, Context) of
#{ <<"filename">> := Filename, <<"mime">> := Mime } when is_binary(Filename), Filename =/= <<>> ->
case z_file_request:lookup_file(Filename, Context) of
{ok, FInfo} ->
Upload = #upload{
data = z_file_request:content_data(FInfo, identity),
mime = Mime,
filename = filename:basename(Filename)
},
encode_attachment(Upload, Context);
{error, _} = Error ->
Error
end;
#{} ->
{error, enoent};
undefined ->
{error, no_medium}
end;
false ->
{error, eacces}
end;
encode_attachment(#upload{mime=undefined, data=undefined, tmpfile=TmpFile, filename=Filename} = Att, Context) ->
case z_tempfile:is_tempfile(TmpFile) of
true ->
case z_media_identify:identify(TmpFile, Filename, Context) of
{ok, Ps} ->
Mime = maps:get(<<"mime">>, Ps, <<"application/octet-stream">>),
encode_attachment(Att#upload{mime = Mime}, Context);
{error, _} ->
encode_attachment(Att#upload{mime= <<"application/octet-stream">>}, Context)
end;
false ->
{error, upload_not_tempfile}
end;
encode_attachment(#upload{mime=undefined, filename=Filename} = Att, Context) ->
Mime = z_media_identify:guess_mime(Filename),
encode_attachment(Att#upload{mime=Mime}, Context);
encode_attachment(#upload{} = Att, _Context) ->
Data = case Att#upload.data of
undefined ->
{ok, FileData} = file:read_file(Att#upload.tmpfile),
FileData;
AttData ->
AttData
end,
[Type, Subtype] = binstr:split(z_convert:to_binary(Att#upload.mime), <<"/">>, 2),
{
Type, Subtype,
[],
#{
transfer_encoding => <<"base64">>,
disposition => <<"attachment">>,
disposition_params => [ {<<"filename">>, filename(Att)} ]
},
Data
}.
filename(#upload{filename=undefined, tmpfile=undefined}) ->
<<"untitled">>;
filename(#upload{filename=undefined, tmpfile=Tmpfile}) ->
z_convert:to_binary(filename:basename(z_convert:to_list(Tmpfile)));
filename(#upload{filename=Filename}) ->
z_convert:to_binary(Filename).
% Make sure that loose \n characters are expanded to \r\n
expand_cr(B) -> expand_cr(B, <<>>).
expand_cr(<<>>, Acc) -> Acc;
expand_cr(<<13, 10, R/binary>>, Acc) -> expand_cr(R, <<Acc/binary, 13, 10>>);
expand_cr(<<10, R/binary>>, Acc) -> expand_cr(R, <<Acc/binary, 13, 10>>);
expand_cr(<<13, R/binary>>, Acc) -> expand_cr(R, <<Acc/binary, 13, 10>>);
expand_cr(<<C, R/binary>>, Acc) -> expand_cr(R, <<Acc/binary, C>>).
check_override(EmailAddr, _SiteOverride, _State) when EmailAddr =:= undefined; EmailAddr =:= []; EmailAddr =:= <<>> ->
undefined;
check_override(EmailAddr, SiteOverride, #state{override=ZotonicOverride}) ->
UseOverride = case z_utils:is_empty(ZotonicOverride) of
true -> SiteOverride;
false -> ZotonicOverride
end,
case z_utils:is_empty(UseOverride) of
true ->
EmailAddr;
false ->
z_email:combine_name_email(
iolist_to_binary([EmailAddr, " (override)"]),
UseOverride)
end.
optional_render(undefined, undefined, _Vars, _Context) ->
[];
optional_render(Text, undefined, _Vars, _Context) ->
Text;
optional_render(undefined, Template, Vars, Context) ->
{Output, _RenderState} = z_template:render_to_iolist(Template, Vars, Context),
binary_to_list(iolist_to_binary(Output)).
set_recipient_prefs(Vars, Context) ->
case proplists:get_value(recipient_id, Vars) of
UserId when is_integer(UserId) ->
z_notifier:foldl(#user_context{id=UserId}, Context, Context);
_Other ->
Context
end.
%% @doc Mark email as sent by adding the 'sent' timestamp.
%% This will schedule it for deletion as well.
mark_sent(Id) ->
Tr = fun() ->
case mnesia:read(email_queue, Id) of
[QEmail] -> mnesia:write(QEmail#email_queue{sent=os:timestamp()});
[] -> {error, notfound}
end
end,
case mnesia:transaction(Tr) of
{atomic, Result} ->
Result;
{aborted, Reason} ->
?LOG_NOTICE(#{
text => <<"Could not mark message as sent">>,
in => zotonic_core,
message_id => id,
reason => Reason
}),
{error, Reason}
end.
%% @doc Deletes a message from the queue.
delete_emailq(Id) ->
Tr = fun()->
case mnesia:read(email_queue, Id) of
[ QEmail ] ->
mnesia:delete_object(QEmail);
[] ->
mnesia:abort(notfound)
end
end,
case mnesia:transaction(Tr) of
{atomic, ok} ->
ok;
{atomic, NotOk} ->
?LOG_NOTICE(#{
text => <<"Could not delete message">>,
in => zotonic_core,
message_id => Id,
reason => NotOk
}),
{error, NotOk};
{aborted, Reason} ->
?LOG_NOTICE(#{
text => <<"Could not delete message">>,
in => zotonic_core,
message_id => Id,
reason => Reason
}),
{error, Reason}
end.
%%
%% QUEUEING related functions
%%
%% Delete sent messages - notify that they were succesful
delete_sent_messages(StatusSites, State) ->
Now = os:timestamp(),
DelTransFun = fun() ->
DelQuery = qlc:q([QEmail || QEmail <- mnesia:table(email_queue),
QEmail#email_queue.sent =/= undefined andalso
timer:now_diff(
inc_timestamp(QEmail#email_queue.sent, State#state.delete_sent_after),
Now) < 0
]),
DelQueryRes = qlc:e(DelQuery),
[
begin
Site = z_context:depickle_site(QEmail#email_queue.pickled_context),
case maps:find(Site, StatusSites) of
{ok, running} ->
mnesia:delete_object(QEmail),
{QEmail#email_queue.id,
QEmail#email_queue.recipient,
QEmail#email_queue.pickled_context};
{ok, _} ->
false;
error ->
mnesia:delete_object(QEmail),
false
end
end
|| QEmail <- DelQueryRes
]
end,
case mnesia:transaction(DelTransFun) of
{atomic, NotifyList} ->
lists:foreach(
fun
({Id, Recipient, PickledContext}) ->
z_notifier:notify(#email_sent{
message_nr=Id,
recipient=Recipient,
is_final=true
}, z_context:depickle(PickledContext));
(false) ->
ok
end,
NotifyList);
{aborted, Reason} ->
?LOG_NOTICE(#{
text => <<"Could not delete sent messages">>,
in => zotonic_core,
reason => Reason
}),
ok
end.
%% Delete all messages with too high retry count - notify that they failed
delete_failed_messages(StatusSites) ->
SetFailTransFun = fun() ->
PollQuery = qlc:q([
QEmail || QEmail <- mnesia:table(email_queue),
QEmail#email_queue.sent =:= undefined,
QEmail#email_queue.retry > ?MAX_RETRY
]),
PollQueryRes = qlc:e(PollQuery),
[
begin
Site = z_context:depickle_site(QEmail#email_queue.pickled_context),
case maps:find(Site, StatusSites) of
{ok, running} ->
mnesia:delete_object(QEmail),
{QEmail#email_queue.id,
QEmail#email_queue.recipient,
QEmail#email_queue.retry,
QEmail#email_queue.pickled_context};
{ok, _} ->
false;
error ->
mnesia:delete_object(QEmail),
false
end
end
|| QEmail <- PollQueryRes
]
end,
case mnesia:transaction(SetFailTransFun) of
{atomic, NotifyList} ->
lists:foreach(
fun
({Id, Recipient, RetryCt, PickledContext}) ->
z_notifier:first(#email_failed{
message_nr=Id,
recipient=Recipient,
is_final=true,
reason=retry,
retry_ct=RetryCt,
status= <<"Retries exceeded">>
}, z_context:depickle(PickledContext));
(false) ->
ok
end,
NotifyList);
{aborted, Reason} ->
?LOG_NOTICE(#{
text => <<"Could not delete failed messages">>,
in => zotonic_core,
reason => Reason
}),
ok
end.
%% Fetch a batch of messages for sending
send_next_batch(MaxListSize, _StatusSites, State) when MaxListSize =< 0 ->
{false, State};
send_next_batch(MaxListSize, StatusSites, State) ->
Now = os:timestamp(),
FetchTransFun =
fun() ->
Q = qlc:q([
QEmail || QEmail <- mnesia:table(email_queue),
% 1. Not sent yet
QEmail#email_queue.sent =:= undefined,
% 2. Not currently sending
proplists:get_value(QEmail#email_queue.id, State#state.sending) =:= undefined,
% 3. Eligible for retry
timer:now_diff(QEmail#email_queue.retry_on, Now) < 0,
% 4. With a running site
maps:find(
z_context:depickle_site(QEmail#email_queue.pickled_context),
StatusSites) =:= {ok, running}
]),
QCursor = qlc:cursor(Q),
QFound = qlc:next_answers(QCursor, MaxListSize),
ok = qlc:delete_cursor(QCursor),
QFound
end,
case mnesia:transaction(FetchTransFun) of
{atomic, []} ->
{false, State};
{atomic, Ms} ->
%% send the fetched messages
State2 = update_config(State),
State3 = lists:foldl(
fun(QEmail, St) ->
update_retry(QEmail),
spawn_send( QEmail#email_queue.id,
QEmail#email_queue.recipient,
QEmail#email_queue.email,
QEmail#email_queue.retry,
z_context:depickle(QEmail#email_queue.pickled_context),
St)
end,
State2,
Ms),
{true, State3};
{aborted, Reason} ->
?LOG_NOTICE(#{
text => <<"Could not fetch next messages to be sent">>,
in => zotonic_core,
reason => Reason
}),
{false, State}
end.
%% @doc Fetch a new batch of queued e-mails. Deletes failed messages.
poll_queued(State) ->
StatusSites = z_sites_manager:get_sites(),
delete_sent_messages(StatusSites, State),
delete_failed_messages(StatusSites),
send_next_batch(?EMAIL_MAX_SENDING - length(State#state.sending), StatusSites, State).
%% @doc Sets the next retry time for an e-mail.
update_retry(QEmail=#email_queue{retry=Retry}) ->
Period = period(Retry),
Tr = fun()->
mnesia:write(QEmail#email_queue{retry=Retry+1,
retry_on=inc_timestamp(os:timestamp(), Period)})
end,
mnesia:transaction(Tr).
period(0) -> 10;
period(1) -> 60;
period(2) -> 12 * 60;
period(_) -> 24 * 60. % Retry every day for extreme cases
%% @doc Increases a timestamp (as returned by now/0) with a value provided in minutes
inc_timestamp({MegaSec, Sec, MicroSec}, MinToAdd) when is_integer(MinToAdd) ->
Sec2 = Sec + (MinToAdd * 60),
Sec3 = Sec2 rem 1000000,
MegaSec2 = MegaSec + Sec2 div 1000000,
{MegaSec2, Sec3, MicroSec}.
is_valid_email(Recipient, Context) ->
case z_context:site(Context) of
zotonic_site_testsandbox -> true;
_ -> is_valid_email(Recipient)
end.
%% @doc Check if an e-mail address is valid
is_valid_email(Recipient) ->
Recipient1 = z_string:trim(z_string:line(z_convert:to_binary(Recipient))),
{_RcptName, RecipientEmail} = z_email:split_name_email(Recipient1),
z_email_utils:is_email(RecipientEmail).
email_max_domain(Domain) ->
email_max_domain_1(lists:reverse(binary:split(z_convert:to_binary(Domain), <<".">>, [global]))).
%% Some mail providers have problems handling more than two connections
email_max_domain_1([<<"net">>, <<"upcmail">> | _]) -> 2;
email_max_domain_1([<<"nl">>, <<"timing">> | _]) -> 2;
email_max_domain_1(_) -> ?EMAIL_MAX_DOMAIN.
%% @doc Simple header encoding.
encode_header({Header, Value}) when is_list(Header)->
encode_header({list_to_binary(Header), Value});
encode_header({Header, [V|_] = Vs}) when is_list(V); is_binary(V); is_tuple(V) ->
Hdr = lists:map(fun ({K, Value}) when is_list(K); is_binary(K) ->
[ K, "=", filter_ascii(Value) ];
({K, Value}) when is_atom(K) ->
[ atom_to_list(K), "=", filter_ascii(Value) ];
(Value) when is_list(Value); is_binary(Value) ->
filter_ascii(Value)
end,
Vs),
[ Header, ": ", lists:join(";\r\n ", Hdr) ];
encode_header({Header, Value})
when Header =:= <<"To">>;
Header =:= <<"From">>;
Header =:= <<"Reply-To">>;
Header =:= <<"Cc">>;
Header =:= <<"Bcc">>;
Header =:= <<"Date">>;
Header =:= <<"Content-Type">>;
Header =:= <<"Mime-Version">>;
Header =:= <<"MIME-Version">>;
Header =:= <<"Content-Transfer-Encoding">> ->
[ Header, ": ", filter_ascii(Value) ];
encode_header({Header, Value}) when is_binary(Header)->
% Encode all other headers according to rfc2047
[ Header, ": ", rfc2047:encode(Value) ];
encode_header({Header, Value}) when is_atom(Header) ->
[ atom_to_list(Header), ": ", rfc2047:encode(Value) ].
encode_headers(Headers) ->
iolist_to_binary([
lists:join("\r\n", lists:map(fun encode_header/1, Headers))
]).
filter_ascii(Value) when is_list(Value) ->
lists:filter(fun(H) -> H >= 32 andalso H =< 126 end, Value);
filter_ascii(Value) when is_binary(Value) ->
<< <<C>> || <<C>> <= Value, C >= 32, C =< 126 >>.
%% @doc Copy all tempfiles in the #mail attachments, to prevent automatic deletion while
%% the email is queued.
copy_attachments(#email{attachments=[]} = Email) ->
Email;
copy_attachments(#email{attachments=Atts} = Email) ->
Atts1 = [ copy_attachment(Att) || Att <- Atts ],
Email#email{attachments=Atts1}.
copy_attachment(#upload{tmpfile=File} = Upload) when is_binary(File) ->
copy_attachment(Upload#upload{tmpfile=binary_to_list(File)});
copy_attachment(#upload{tmpfile=File} = Upload) when is_list(File) ->
case filename:extension(File) of
?TMPFILE_EXT ->
Upload;
_Other ->
case z_tempfile:is_tempfile(File) of
true ->
NewFile = tempfile(),
{ok, _Size} = file:copy(File, NewFile),
Upload#upload{tmpfile=NewFile};
false ->
Upload
end
end;
copy_attachment(Att) ->
Att.
opt_dkim(Context) ->
case z_notifier:first(#email_dkim_options{}, Context) of
undefined -> [];
Options when is_list(Options) -> Options
end.