%% @author Atilla Erdodi <atilla@maximonster.com>
%% @copyright 2010-2026 Maximonster Interactive Things
%% @doc Email server. Queues, renders and sends e-mails.
%% @end
%% Copyright 2010-2026 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_ok/2,
get_email_from/1,
reply_to_message_id/2
]).
-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).
% Default port for TLS email delivery
-define(SMTP_PORT_TLS, 587).
% Default port for plain text email delivery
-define(SMTP_PORT_PLAIN_TEXT, 25).
-record(state, {smtp_relay, smtp_relay_opts, smtp_relay_tls_options, smtp_no_mx_lookups,
smtp_verp_as_from, smtp_bcc, override,
sending=[], delete_sent_after, poll_ref, smtp_max_senders}).
-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, generate an unique message-id for it.
-spec send(Email, Context) -> {ok, SentEmailId} | {error, Reason} when
Email :: #email{},
SentEmailId :: binary(),
Context :: z:context(),
Reason :: env_backup | sender_disabled.
send(#email{} = Email, Context) ->
send(generate_message_id(), Email, Context).
%% @doc Send an email using a predefined unique id. Emails are only sent
%% for enabled senders and non-backup environments.
-spec send(EmailId, Email, Context) -> {ok, SentEmailId} | {error, Reason} when
EmailId :: binary() | string(),
Email :: #email{},
SentEmailId :: binary(),
Context :: z:context(),
Reason :: env_backup | sender_disabled.
send(EmailId, #email{} = Email, Context) ->
case m_site:environment(Context) of
backup ->
{error, env_backup};
_Env ->
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
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. Disabled users 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 z_config:get(admin_email) =:= 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_ok(Recipient, Context) ->
RecipientEmail = recipient_email_address(Recipient),
case z_notifier:first( #email_is_recipient_ok{ recipient = RecipientEmail }, Context) of
undefined -> true;
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(term()) -> {ok, term()} | {ok, term(), timeout() | hibernate} | ignore | {stop, term()}.
%% {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}.
%% {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(term(), term()) -> {noreply, term()} | {noreply, term(), timeout() | hibernate} | {stop, term(), term()}.
%% {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_emails(Id, Email#email.to, Email, Context, State1)
end,
State3 = case z_utils:is_empty(Email#email.cc) of
true -> State2;
false -> send_emails(<<Id/binary, "+cc">>, Email#email.cc, Email, Context, State2)
end,
State4 = case z_utils:is_empty(Email#email.bcc) of
true -> State3;
false -> send_emails(<<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(#{
in => zotonic_core,
text => <<"[smtp] Could not handle bounced messages">>,
result => error,
reason => Reason,
src => Peer,
bounce_email => BounceEmail
}),
ok
end,
{noreply, State};
handle_cast({delivery_report, What, OptRecipient, <<"<", MsgId/binary>>, OptStatusMessage}, State) ->
MsgId1 = binary:replace(MsgId, <<">">>, <<>>),
handle_cast({delivery_report, What, OptRecipient, MsgId1, OptStatusMessage}, State);
handle_cast({delivery_report, What, OptRecipient, MsgIdHeader, OptStatusMessage}, State) ->
{MsgId, Domain} = case binstr:split(z_convert:to_binary(MsgIdHeader), <<"@">>) of
[MId, ED] -> {MId, ED};
[MId] -> {MId, undefined}
end,
% 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);
_ when is_binary(Domain), Domain =/= <<>> ->
% We got a delivery report, 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;
_ ->
ignore
end,
{noreply, State};
%% @doc Trap unknown casts
handle_cast(Message, State) ->
{stop, {unknown_cast, Message}, State}.
-spec handle_info(term(), term()) -> {noreply, term()} | {noreply, term(), timeout() | hibernate} | {stop, term(), term()}.
%% {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(term(), term()) -> ok.
%% @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(term(), term(), term()) -> {ok, term()}.
%% @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(#{
in => zotonic_core,
text => <<"[smtp] Permanent failure sending email">>,
result => error,
reason => permanent_failure,
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(#{
in => zotonic_core,
text => <<"[smtp] Temporary failure sending email">>,
result => error,
reason => temporary_failure,
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(#{
in => zotonic_core,
text => <<"[smtp] Success sending email">>,
result => ok,
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(#{
in => zotonic_core,
text => <<"[smtp] Success sending email">>,
result => ok,
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, SmtpRelayTLSOpts} =
case SmtpRelay of
true ->
{
[{relay, z_config:get(smtp_host, "localhost")},
{port, z_config:get(smtp_port, ?SMTP_PORT_PLAIN_TEXT)}
]
++ case {z_config:get(smtp_username),
z_config:get(smtp_password)} of
{undefined, undefined} ->
[];
{User, Pass} ->
[{auth, always},
{username, User},
{password, Pass}]
end
++ case z_config:get(smtp_ssl, false) of
true -> [ {tls, always} ];
false -> []
end,
z_config:get(smtp_relay_tls_options, [])
};
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_relay_tls_options=SmtpRelayTLSOpts,
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.
%% @doc Generate a reply email address for the message-id.
%% Used when reply_to in the email record is set to 'message_id'.
%% Returns an RFC822-formatted mailbox using the address
%% "reply+MessageId@site_email_domain" (optionally with a display-name).
-spec reply_to_message_id(MessageId, Context) -> ReplyToEmail when
MessageId :: binary(),
Context :: z:context(),
ReplyToEmail :: binary().
reply_to_message_id(MessageId, Context) when is_binary(MessageId) ->
ReplyTo = <<"reply+", MessageId/binary>>,
combine_name_email(<<>>, ReplyTo, Context).
% The 'From' is either the VERP (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),
if
State#state.smtp_verp_as_from =:= true ->
combine_name_email(FromName, VERP, Context);
FromEmail =:= <<>> ->
combine_name_email(FromName, get_email_from(Context), Context);
true ->
combine_name_email(FromName, FromEmail, Context)
end.
combine_name_email(Name, <<>>, Context) ->
combine_name_email(Name, get_email_from(Context), Context);
combine_name_email(<<>>, Email, Context) ->
Name = case m_site:title(Context) of
<<>> -> z_context:hostname(Context);
Title -> Title
end,
combine_name_email(Name, Email, Context);
combine_name_email(Name, Email, Context) ->
Email1 = ensure_domain(Email, Context),
z_email:combine_name_email(Name, Email1).
ensure_domain(Email, Context) ->
case binary:match(Email, <<"@">>) of
nomatch ->
Domain = z_email:email_domain(Context),
<<Email/binary, "@", Domain/binary>>;
{_, _} ->
Email
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
%% =========================
%% @doc Send an email to a single or a list of recipients. The list must be a list
%% of email addresses (and not rescource ids), otherwise the list is assumed to be a string.
send_emails(_Id, [], _Email, _Context, State) ->
State;
send_emails(_Id, undefined, _Email, _Context, State) ->
State;
send_emails(_Id, <<>>, _Email, _Context, State) ->
State;
send_emails(Id, Recipient, Email, Context, State) when is_binary(Recipient) ->
send_email(Id, Recipient, Email, Context, State);
send_emails(Id, Recipient, Email, Context, State) when is_integer(Recipient) ->
% The sender must have access to the email address of the recipient.
send_emails(Id, m_rsc:p(Recipient, <<"email_raw">>, Context), Email, Context, State);
send_emails(Id, [ Recipient | _ ] = Recipients, Email, Context, State) when not is_integer(Recipient) ->
{_, State1} = lists:foldl(
fun
(undefined, Acc) -> Acc;
(<<>>, Acc) -> Acc;
("", Acc) -> Acc;
(Rcpt, {N, StateAcc}) ->
% Ensure the message-id is unique per recipient
Id1 = case N of
1 -> Id;
_ -> <<Id/binary, "-", (integer_to_binary(N))/binary>>
end,
StateAcc1 = send_emails(Id1, Rcpt, Email, Context, StateAcc),
{N+1, StateAcc1}
end,
{1, State},
Recipients),
State1;
send_emails(Id, Recipient, Email, Context, State) ->
send_email(Id, Recipient, Email, Context, State).
%% @doc Send an email to a single recipient
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(#{
in => zotonic_core,
text => <<"[smtp] Dropping email to invalid address">>,
result => error,
reason => illegal_address,
recipient => Recipient
}),
%% delete email from the queue and notify the system
delete_email(illegal_address, Id, Recipient, Email, Context),
State
end;
false ->
?LOG_NOTICE(#{
in => zotonic_core,
text => <<"[smtp] Dropping email from disabled sender">>,
result => error,
reason => sender_disabled,
recipient => Recipient,
sender => z_acl:user(Context)
}),
delete_email(sender_disabled, Id, Recipient, Email, Context),
State
end;
{error, Template} ->
?LOG_WARNING(#{
in => zotonic_core,
text => <<"[smtp] Delayed sending email because template is not available">>,
result => error,
reason => template_missing,
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_INFO,
mailer_status = blocked,
mailer_message = <<"Not sending email, recipient is blocked by us - check email status.">>,
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(illegal_address, Id, Recipient, Email, Context) ->
delete_emailq(Id),
z_notifier:notify(#email_failed{
message_nr = Id,
recipient = Recipient,
is_final = true,
status = <<"Malformed email address">>,
reason = illegal_address
}, Context),
LogEmail = #log_email{
severity = ?LOG_LEVEL_ERROR,
mailer_status = error,
props = [ {reason, illegal_address} ],
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(sender_disabled, Id, Recipient, Email, Context) ->
delete_emailq(Id),
LogEmail = #log_email{
severity = ?LOG_LEVEL_INFO,
mailer_status = error,
props = [ {reason, sender_disabled} ],
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),
m_config:get_value(site, email_override_exceptions, Context),
State),
RecipientEmail = recipient_email_address(Recipient1),
case is_recipient_ok(RecipientEmail, Context) of
true ->
SmtpOpts = smtp_options(RecipientEmail, State, Context),
BccSmtpOpts = case z_utils:is_empty(State#state.smtp_bcc) of
true ->
[];
false ->
{_BccName, BccEmail} = z_email:split_name_email(State#state.smtp_bcc),
smtp_options(BccEmail, State, Context)
end,
MessageId = message_id(Id, Context),
VERP = bounce_email(MessageId, Context),
From = get_email_from(Email#email.from, VERP, State, Context),
ContextCsp = z_context:set_csp_nonce(Context),
SenderPid = proc_lib:spawn_link(
fun() ->
spawned_email_sender(
Id, MessageId, Recipient, RecipientEmail, <<"<", VERP/binary, ">">>,
From, State#state.smtp_bcc, Email, SmtpOpts, BccSmtpOpts,
RetryCt, ContextCsp)
end),
{relay, Relay} = proplists:lookup(relay, SmtpOpts),
State#state{
sending=[
#email_sender{id=Id, sender_pid=SenderPid, domain=Relay} | State#state.sending
]};
false ->
?LOG_NOTICE(#{
in => zotonic_core,
text => <<"[smtp] Dropping email to address blocked by Zotonic module (#is_recipient_ok)">>,
result => error,
reason => blocked,
email => RecipientEmail
}),
drop_blocked_email(Id, RecipientEmail, Email, Context),
State
end.
smtp_options(RecipientEmail, State, Context) ->
[_RcptLocalName, RecipientDomain] = binary:split(RecipientEmail, <<"@">>),
RelayOptions = case relay_site_options(State, Context) of
{true, RelayOpts} ->
RelayOpts;
false ->
[{relay, z_convert:to_list(RecipientDomain)}]
end,
Options = [ {no_mx_lookups, State#state.smtp_no_mx_lookups},
{hostname, client_smtphost(Context)},
{timeout, ?SMTP_CONNECT_TIMEOUT} | RelayOptions ],
case proplists:is_defined(tls_options, Options) of
true ->
Options;
false ->
%% It is needed to have access to the mx-record to do the name
%% verification. It looks like this validation can be done inside
%% gen_smtp only.
[ {tls_options, [ tls_versions(), {verify, verify_none} ]} | Options ]
end.
client_smtphost(Context) ->
case z_convert:to_binary(m_config:get_value(site, client_smtphost, Context)) of
<<>> ->
z_email:email_domain(Context);
SmtpHost ->
SmtpHost
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) ->
Relay = proplists:get_value(relay, State#state.smtp_relay_opts),
RelayOpts = [
{tls_options, lists:merge3([tls_versions()], State#state.smtp_relay_tls_options, tls_certificate_check:options(Relay))}
| State#state.smtp_relay_opts
],
{true, RelayOpts};
relay_site_options(_State, Context) ->
case m_config:get_boolean(site, smtp_relay, Context) of
true ->
SSL = m_config:get_boolean(site, smtp_relay_ssl, Context),
SmtpHost = case z_convert:to_binary( m_config:get_value(site, smtp_relay_host, Context) ) of
<<>> -> "localhost";
SHost -> z_convert:to_list(SHost)
end,
DefaultPort = case SSL of
true -> ?SMTP_PORT_TLS;
false -> ?SMTP_PORT_PLAIN_TEXT
end,
Port = case z_convert:to_binary( m_config:get_value(site, smtp_relay_port, Context) ) of
<<>> ->
DefaultPort;
SPort ->
try
z_convert:to_integer(SPort)
catch
_:_ -> DefaultPort
end
end,
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,
TLS = case SSL of
true ->
[
{tls, always}
];
false ->
[]
end,
{true, [
{relay, SmtpHost},
{port, Port},
{tls_options, [ tls_versions() | tls_certificate_check:options(SmtpHost) ]}
] ++ Creds ++ TLS};
false ->
false
end.
tls_versions() ->
{versions, ['tlsv1.2', 'tlsv1.3']}.
spawned_email_sender(Id, MessageId, Recipient, RecipientEmail, VERP, From,
Bcc, Email, SmtpOpts, BccSmtpOpts, RetryCt, Context) ->
z_context:logger_md(Context),
case encode_email(Id, Email, <<"<", MessageId/binary, ">">>, From, Context) of
{ok, EncodedMail} ->
spawned_email_sender_loop(Id, MessageId, Recipient, RecipientEmail, VERP, From,
Bcc, Email, EncodedMail, SmtpOpts, BccSmtpOpts, RetryCt, Context);
{error, _Reason} = Error ->
Error
end.
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 => <<"[smtp] 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{
mailer_status = sending,
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 ->
{ok, <<"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 => <<"[smtp] 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, OptDelayMinutes} ->
%% 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),
update_emailq_delay(Id, OptDelayMinutes),
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 = message(Message),
mailer_host = Host
}
}, Context),
% delete email from the queue and notify the system
delete_emailq(Id)
end;
{error, Reason} ->
?LOG_ERROR(#{
text => <<"[smtp] 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 => <<"[smtp] 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, Context);
smtp ->
send_blocking_smtp(MsgId, VERP, RecipientEmail, EncodedMail, SmtpOpts, Context);
{error, _} = Error ->
Error;
{error, _, _} = Error ->
Error
end.
send_blocking_smtp(MsgId, VERP, RecipientEmail, EncodedMail, SmtpOpts, Context) ->
{relay, Relay} = proplists:lookup(relay, SmtpOpts),
?LOG_INFO(#{
text => <<"[smtp] Sending email">>,
in => zotonic_core,
recipient => RecipientEmail,
message_id => MsgId,
relay => Relay,
port => proplists:get_value(port, SmtpOpts)
}),
IsFallbackPlainText = z_config:get(smtp_plaintext_fallback),
case catch 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>>}}
when IsFallbackPlainText ->
% Don't ask ...
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts, Context);
{error, Failure, {_FailureType, _Host, {error, Reason}}}
when IsFallbackPlainText,
(Failure =:= send orelse Failure =:= retries_exceeded),
(Reason =:= closed orelse Reason =:= timeout orelse Reason =:= einval) ->
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts, Context);
{error, _Failure, {_FailureType, _Host, <<"554 TLS handshake failure", _/binary>>}}
when IsFallbackPlainText ->
% Seen with yahoo.com addresses and OTP 26
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts, Context);
{error, _} = Error ->
Error;
{error, _, _} = Error ->
Error;
{'EXIT', _} when IsFallbackPlainText ->
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts, Context);
{'EXIT', {Reason, _Stack}} ->
{error, Reason}
end.
send_blocking_no_tls(VERP, RecipientEmail, EncodedMail, SmtpOpts, Context) ->
SmtpOpts1 = [
{tls, never}
| proplists:delete(tls, proplists:delete(tls_options, SmtpOpts))
],
SmtpOpts2 = [
{port, plaintext_port(Context)}
| proplists:delete(port, SmtpOpts1)
],
?LOG_NOTICE(#{
text => <<"[smtp] SMTP closed or timeout error, retrying without TLS">>,
in => zotonic_core,
recipient => RecipientEmail,
relay => proplists:get_value(relay, SmtpOpts2),
port => proplists:get_value(port, SmtpOpts2)
}),
case gen_smtp_client:send_blocking({VERP, [RecipientEmail], EncodedMail}, SmtpOpts2) of
Receipt when is_binary(Receipt) ->
{ok, Receipt};
{error, _} = Error ->
Error;
{error, _, _} = Error ->
Error
end.
plaintext_port(Context) ->
DefaultPort = case z_config:get(smtp_relay) of
true ->
z_config:get(smtp_port, ?SMTP_PORT_PLAIN_TEXT);
false ->
?SMTP_PORT_PLAIN_TEXT
end,
case m_config:get_boolean(site, smtp_relay, Context) of
true ->
case z_convert:to_binary( m_config:get_value(site, smtp_relay_port, Context) ) of
<<>> ->
DefaultPort;
SPort ->
try
z_convert:to_integer(SPort)
catch
_:_ -> DefaultPort
end
end;
false ->
DefaultPort
end.
-spec is_retry_possible(Reason, FailureType, Message) -> {true, DelayMinutes} | false when
Reason :: retries_exceeded | term(),
FailureType :: temporary_failure | permanent_failure,
Message :: binary() | undefined,
DelayMinutes :: undefined | non_neg_integer().
is_retry_possible(_Reason, _FailureType, auth_failed) ->
{true, undefined}; % proxy - could be temporary
is_retry_possible(retries_exceeded, _FailureType, _Message) ->
{true, undefined};
is_retry_possible(_Reason, permanent_failure, Message) when is_binary(Message) ->
case is_proxy_issue(Message) of
true -> {true, undefined};
false ->
% If it is spam block then we retry after some time.
% Also apply a random delay to avoid thundering herd.
case check_spam_block(Message) of
blacklist -> {true, random_delay(12*60,14*60)}; % Retry after 12 hours
gmail -> {true, random_delay(2*60,3*60)}; % Retry after 2 hours
false -> false
end
end;
is_retry_possible(_Reason, permanent_failure, _Message) ->
false;
is_retry_possible(_Reason, _FailureType, _Message) ->
{true, undefined}.
random_delay(From, To) ->
z_ids:number(To - From + 1) + From - 1.
% Check for issue with proxy (https://github.com/zotonic/zotonic/issues/3508):
% 554 5.7.0 Your message could not be sent. The limit on the number of allowed outgoing
% messages was exceeded. Try again later.
is_proxy_issue(Message) ->
binary:match(Message, <<"Try again later.">>) =/= nomatch.
% Spamcop blocks for approx 12-24 hours:
% 550 5.7.1 5.7.1 Service unavailable; client [...] blocked using bl.spamcop.net
%
% Gmail blocks for approx 2 hours:
% 550-5.7.1 [IP-address] Gmail has detected that this message is
% 550-5.7.1 likely unsolicited mail. To reduce the amount of spam sent to Gmail,
% 550-5.7.1 this message has been blocked. For more information, go to
% 550 5.7.1 https://support.google.com/mail/?p=UnsolicitedMessageError [hex-code] - gsmtp
check_spam_block(Message) ->
Blocks = [
{<<"blocked using bl.spamcop.net">>, blacklist},
{<<"https://support.google.com/mail/?p=UnsolicitedMessageError">>, gmail},
{<<"To reduce the amount of spam sent to Gmail">>, gmail}
],
check_spam_block_1(Blocks, Message).
check_spam_block_1([], _Message) ->
false;
check_spam_block_1([{Pattern, Type} | Rest], Message) ->
case binary:match(Message, Pattern) of
{_,_} -> Type;
nomatch -> check_spam_block_1(Rest, Message)
end.
encode_email(_Id, #email{raw=Raw}, _MessageId, _From, _Context) when is_list(Raw); is_binary(Raw) ->
{ok, 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} ->
case re:run(Html, "<title>(.*?)</title>", [dotall, caseless, {capture, all_but_first, binary}]) of
{match, [Title]} -> z_string:trim(z_string:line(z_html:unescape(Title)));
nomatch -> <<>>
end;
{_Html, Sub} -> Sub
end,
Headers = [{<<"From">>, From},
{<<"To">>, ensure_brackets(Email#email.to, Context)},
{<<"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),
try
case build_and_encode_mail(Headers2, Text, Html, Email#email.attachments, Context) of
{ok, _} = Ok -> Ok;
{error, _} = Error -> Error
end
catch
E:R:S ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] Error encoding email">>,
result => E,
reason => R,
stack => S,
headers => Headers2
}),
{error, R}
end;
encode_email(Id, #email{body=Body} = Email, MessageId, From, Context) when is_tuple(Body) ->
Headers = [{<<"From">>, From},
{<<"To">>, ensure_brackets(Email#email.to, Context)},
{<<"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)
],
try
Encoded = mimemail:encode({BodyType, BodySubtype, MailHeaders, BodyParams, BodyParts}, opt_dkim(Context)),
{ok, Encoded}
catch
E:R:S ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] Error encoding email">>,
result => E,
reason => R,
stack => S,
headers => MailHeaders
}),
{error, R}
end;
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, Context)},
{<<"Message-Id">>, MessageId}
| Email#email.headers ],
Headers2 = add_reply_to(Id, Email, add_cc(Email, Headers), Context),
try
Encoded = iolist_to_binary([ encode_headers(Headers2), "\r\n\r\n", Body ]),
{ok, Encoded}
catch
E:R:S ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] Error encoding email">>,
result => E,
reason => R,
stack => S,
headers => Headers2
}),
{error, R}
end.
ensure_brackets(UserId, Context) when is_integer(UserId) ->
{Name, _} = z_template:render_to_iolist("_name.tpl", [ {id, UserId} ], Context),
Email = m_rsc:p(UserId, <<"email_raw">>, Context),
z_email:combine_name_email(iolist_to_binary(Name), z_convert:to_binary(Email));
ensure_brackets(Email, _Context) when is_binary(Email) ->
case binary:match(Email, <<"<">>) of
{_,_} ->
Email;
nomatch ->
[ Name | _ ] = binary:split(Email, <<"@">>),
z_email:combine_name_email(Name, Email)
end;
ensure_brackets([Email|_] = Es, Context) when not is_integer(Email) ->
Es1 = lists:map(fun(E) -> ensure_brackets(E, Context) end, Es),
iolist_to_binary(lists:join($,, Es1));
ensure_brackets(Email, Context) ->
ensure_brackets(z_convert:to_binary(Email), Context).
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_to_message_id(Id, Context)} | Headers];
add_reply_to(_Id, #email{ reply_to = ReplyTo }, Headers, Context) ->
{Name, Email} = z_email:split_name_email(ReplyTo),
ReplyTo1 = combine_name_email(Name, Email, Context),
[{<<"Reply-To">>, ReplyTo1} | Headers].
-spec build_and_encode_mail(Headers, Text, Html, Attachments, Context) -> {ok, Encoded} | {error, Reason} when
Headers :: list(),
Text :: binary(),
Html :: binary(),
Attachments :: [ m_rsc:resource_id() | #upload{} ],
Context :: z:context(),
Encoded :: binary(),
Reason :: attachment_encoding_failed.
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 => []
},
% Ensure text part
Parts = if
Text =/= <<>> ->
[{<<"text">>, <<"plain">>, [], Params, expand_cr(Text)}];
Html =/= <<>> ->
ContentHtml = case binary:split(Html, <<"<!--content-->">>) of
[ _, MDH ] -> MDH;
_ -> Html
end,
[{<<"text">>, <<"plain">>, [], Params,
expand_cr(z_convert:to_binary(z_markdown:to_markdown(ContentHtml, [no_html, no_tables])))}];
true ->
[]
end,
% Optionally add html part
Parts1 = if
Html =/= <<>> ->
z_email_embed:embed_images(Parts ++ [{<<"text">>, <<"html">>, [], Params, Html}], Context);
true ->
Parts
end,
% Encode, with or without attachments
if
Attachment =:= [] ->
Encoded = 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,
{ok, Encoded};
true ->
AttsEncoded = [ encode_attachment(Att, Context) || Att <- Attachment ],
case lists:any(fun({error, _}) -> true; (_) -> false end, AttsEncoded) of
true ->
{error, attachment_encoding_failed};
false ->
Encoded = mimemail:encode({<<"multipart">>, <<"mixed">>,
Headers1,
#{},
[ {<<"multipart">>, <<"alternative">>, [], #{}, Parts1} | AttsEncoded ]
}, opt_dkim(Context)),
{ok, Encoded}
end
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, Reason} = Error ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] Error fetching attachment file">>,
result => error,
reason => Reason,
attachment_id => AttId,
filename => Filename,
user_id => z_acl:user(Context)
}),
Error
end;
#{ <<"preview_filename">> := Filename } when is_binary(Filename), Filename =/= <<>> ->
case z_file_request:lookup_file(Filename, Context) of
{ok, FInfo} ->
Mime = z_media_identify:guess_mime(Filename),
Upload = #upload{
data = z_file_request:content_data(FInfo, identity),
mime = Mime,
filename = filename:basename(Filename)
},
encode_attachment(Upload, Context);
{error, Reason} = Error ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] Error fetching attachment file">>,
result => error,
reason => Reason,
attachment_id => AttId,
filename => Filename,
user_id => z_acl:user(Context)
}),
Error
end;
#{} ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] Attachment has no file to send">>,
result => error,
reason => enoent,
attachment_id => AttId,
user_id => z_acl:user(Context)
}),
{error, enoent};
undefined ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] Attachment has no medium record">>,
result => error,
reason => no_medium,
attachment_id => AttId,
user_id => z_acl:user(Context)
}),
{error, no_medium}
end;
false ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] No access to attachment">>,
result => error,
reason => eacces,
attachment_id => AttId,
user_id => z_acl:user(Context)
}),
{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 ->
?LOG_ERROR(#{
in => zotonic_core,
text => <<"[smtp] Attachment tempfile is not a valid tempfile">>,
result => error,
reason => upload_not_tempfile,
tmpfile => TmpFile,
filename => Filename,
user_id => z_acl:user(Context)
}),
{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/utf8, R/binary>>, Acc) -> expand_cr(R, <<Acc/binary, C/utf8>>);
expand_cr(<<Invalid, R/binary>>, Acc) ->
?LOG_WARNING(#{
in => zotonic_core,
text => <<"[smtp] Dropping invalid UTF-8 byte in expand_cr">>,
result => error,
reason => invalid_utf8,
invalid_byte => Invalid,
accumulator => Acc
}),
expand_cr(R, Acc).
check_override(undefined, _SiteOverride, _Exceptions, _State) -> undefined;
check_override("", _SiteOverride, _Exceptions, _State) -> undefined;
check_override(<<>>, _SiteOverride, _Exceptions, _State) -> undefined;
check_override(EmailAddr, SiteOverride, Exceptions, #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 ->
case is_override_exception(EmailAddr, Exceptions) of
true ->
EmailAddr;
false ->
z_email:combine_name_email(
iolist_to_binary([EmailAddr, " (override)"]),
UseOverride)
end
end.
is_override_exception(_Email, undefined) -> false;
is_override_exception(_Email, <<>>) -> false;
is_override_exception(_Email, "") -> false;
is_override_exception(Email, OverrideExceptions) ->
Es = split_override_exceptions(z_string:to_lower(OverrideExceptions)),
Email1 = z_string:to_lower(
unicode:characters_to_binary(
re:replace(Email, <<"\\+.*@">>, <<"@">>), utf8)),
lists:any(fun(E) -> is_match_exception(Email1, E) end, Es).
is_match_exception(Email, <<"@", Domain/binary>>) ->
case binary:split(Email, <<"@">>) of
[ _, Domain ] -> true;
_ -> false
end;
is_match_exception(Email, Exception) ->
Email =:= Exception.
split_override_exceptions(S) ->
binary:split(S, [ <<" ">>, <<"\t">>, <<"\n">>, <<";">>, <<",">> ], [ global, trim_all ]).
optional_render(undefined, undefined, _Vars, _Context) ->
<<>>;
optional_render(Text, undefined, _Vars, _Context) ->
case unicode:characters_to_binary(Text) of
Bin when is_binary(Bin) -> Bin;
{incomplete, Encoded, _Rest} ->
?LOG_WARNING(#{
in => zotonic_core,
text => <<"[smtp] failed to fully encode text to binary (incomplete)">>,
result => error,
reason => invalid_utf8,
original_text => Text,
partial_encoded => Encoded
}),
<<>>;
{error, Encoded, _Rest} ->
?LOG_WARNING(#{
in => zotonic_core,
text => <<"[smtp] failed to encode text to binary (error)">>,
result => error,
reason => invalid_utf8,
original_text => Text,
partial_encoded => Encoded
}),
<<>>
end;
optional_render(undefined, Template, Vars, Context) ->
{Output, _RenderState} = z_template:render_to_iolist(Template, Vars, Context),
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(#{
in => zotonic_core,
text => <<"[smtp] Could not mark message as sent">>,
result => error,
reason => Reason,
message_id => id
}),
{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(#{
in => zotonic_core,
text => <<"[smtp] Could not delete message">>,
message_id => Id,
reason => NotOk
}),
{error, NotOk};
{aborted, Reason} ->
?LOG_NOTICE(#{
in => zotonic_core,
text => <<"[smtp] Could not delete message">>,
result => error,
reason => Reason,
message_id => Id
}),
{error, Reason}
end.
%% @doc Set the next delay for the given message
update_emailq_delay(_Id, undefined) ->
% Automatic
ok;
update_emailq_delay(Id, DelayMinutes) ->
Next = inc_timestamp(os:timestamp(), DelayMinutes),
Tr = fun() ->
case mnesia:read(email_queue, Id) of
[QEmail] ->
if
QEmail#email_queue.retry_on < Next ->
mnesia:write(QEmail#email_queue{ retry_on = Next });
true ->
ok
end;
[] ->
{error, notfound}
end
end,
case mnesia:transaction(Tr) of
{atomic, Result} ->
Result;
{aborted, Reason} ->
?LOG_NOTICE(#{
in => zotonic_core,
text => <<"[smtp] Could not set delay for message">>,
result => error,
message_id => id,
reason => Reason,
delay => DelayMinutes
}),
{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(#{
in => zotonic_core,
text => <<"[smtp] Could not delete sent messages">>,
result => error,
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:notify(#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(#{
in => zotonic_core,
text => <<"[smtp] Could not delete failed messages">>,
result => error,
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, excluding the ones in backup mode
begin
Context = z_context:depickle_site(QEmail#email_queue.pickled_context),
case m_site:environment(Context) of
backup -> false;
_Env -> maps:find(Context, StatusSites) =:= {ok, running}
end
end
]),
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(#{
in => zotonic_core,
text => <<"[smtp] Could not fetch next messages to be sent">>,
result => error,
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(_) -> z_config:get(smtp_max_senders, ?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.