feat(email): SMTP via gen_smtp and CalenTIQ HTML templates. Refs EventHub/EventHubBack#68
This commit is contained in:
@@ -11,7 +11,9 @@
|
||||
cowboy,
|
||||
jsx,
|
||||
trails,
|
||||
cowboy_swagger
|
||||
cowboy_swagger,
|
||||
ssl,
|
||||
gen_smtp
|
||||
]},
|
||||
{env, [
|
||||
{http_port, 8080},
|
||||
|
||||
+149
-3
@@ -1,11 +1,157 @@
|
||||
-module(logic_email).
|
||||
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2]).
|
||||
|
||||
%% SMTP via env (variant A / Back#68):
|
||||
%% SMTP_HOST empty → log-only (local/tests)
|
||||
%% SMTP_PORT (default 587), SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TLS
|
||||
%% PUBLIC_APP_URL — base for deep links (default https://stage.calentiq.com)
|
||||
%% Tests: application:set_env(eventhub, smtp_deliver, Fun)
|
||||
|
||||
-spec send_verification_email(binary(), binary()) -> ok.
|
||||
send_verification_email(Email, Token) ->
|
||||
io:format("Sending verification email to ~s with token ~s~n", [Email, Token]).
|
||||
send_kind(verify, Email, abs_url(<<"/verify?token=">>, Token)).
|
||||
|
||||
-spec send_specialist_invite(binary(), binary()) -> ok.
|
||||
send_specialist_invite(Email, Token) ->
|
||||
io:format("Sending specialist invite email to ~s with token ~s~n", [Email, Token]).
|
||||
send_kind(invite, Email, abs_url(<<"/invites?token=">>, Token)).
|
||||
|
||||
-spec send_password_reset(binary(), binary()) -> ok.
|
||||
send_password_reset(Email, Token) ->
|
||||
io:format("Sending password reset email to ~s with token ~s~n", [Email, Token]).
|
||||
send_kind(reset, Email, abs_url(<<"/reset-password?token=">>, Token)).
|
||||
|
||||
send_kind(Kind, To0, Url) ->
|
||||
{Subject, Plain, Html} = logic_email_templates:render(Kind, Url),
|
||||
send(to_bin(To0), Subject, Plain, Html).
|
||||
|
||||
send(To, Subject, Plain, Html) ->
|
||||
case smtp_relay() of
|
||||
undefined ->
|
||||
logger:info(#{what => email_log_only, to => To, subject => Subject}),
|
||||
ok;
|
||||
Relay ->
|
||||
From = from_addr(),
|
||||
Raw = rfc822(From, To, Subject, Plain, Html),
|
||||
Opts = smtp_opts(Relay),
|
||||
safe_deliver(From, To, Raw, Opts)
|
||||
end.
|
||||
|
||||
safe_deliver(From, To, Raw, Opts) ->
|
||||
Fun = deliver_fun(),
|
||||
try Fun(From, To, Raw, Opts) of
|
||||
{ok, _} -> ok;
|
||||
ok -> ok;
|
||||
{error, Reason} ->
|
||||
logger:warning(#{what => smtp_send_error, to => To, reason => Reason}),
|
||||
ok;
|
||||
Other ->
|
||||
logger:warning(#{what => smtp_send_unexpected, to => To, result => Other}),
|
||||
ok
|
||||
catch
|
||||
Class:Reason:Stack ->
|
||||
logger:warning(#{what => smtp_send_crash, to => To,
|
||||
class => Class, reason => Reason, stack => Stack}),
|
||||
ok
|
||||
end.
|
||||
|
||||
deliver_fun() ->
|
||||
case application:get_env(eventhub, smtp_deliver) of
|
||||
{ok, Fun} when is_function(Fun, 4) -> Fun;
|
||||
_ -> fun default_deliver/4
|
||||
end.
|
||||
|
||||
default_deliver(From, To, Raw, Opts) ->
|
||||
gen_smtp_client:send({From, [To], Raw}, Opts).
|
||||
|
||||
smtp_relay() ->
|
||||
case app_or_env(smtp_host, "SMTP_HOST") of
|
||||
<<>> -> undefined;
|
||||
Host -> binary_to_list(Host)
|
||||
end.
|
||||
|
||||
smtp_opts(Relay) ->
|
||||
Port = env_int("SMTP_PORT", 587),
|
||||
Tls = tls_mode(env_bin("SMTP_TLS", <<"if_available">>)),
|
||||
Base = [
|
||||
{relay, Relay},
|
||||
{port, Port},
|
||||
{tls, Tls},
|
||||
{hostname, "calentiq.com"}
|
||||
],
|
||||
User = env_bin("SMTP_USER", <<>>),
|
||||
Pass = env_bin("SMTP_PASS", <<>>),
|
||||
case User of
|
||||
<<>> -> Base;
|
||||
_ ->
|
||||
[{username, binary_to_list(User)},
|
||||
{password, binary_to_list(Pass)},
|
||||
{auth, always} | Base]
|
||||
end.
|
||||
|
||||
tls_mode(<<"always">>) -> always;
|
||||
tls_mode(<<"never">>) -> never;
|
||||
tls_mode(_) -> if_available.
|
||||
|
||||
from_addr() ->
|
||||
env_bin("SMTP_FROM", <<"noreply@calentiq.com">>).
|
||||
|
||||
abs_url(PathPrefix, Token) ->
|
||||
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
|
||||
TokenB = to_bin(Token),
|
||||
<<Base/binary, PathPrefix/binary, TokenB/binary>>.
|
||||
|
||||
strip_slash(Url) ->
|
||||
case binary:last(Url) of
|
||||
$/ -> binary:part(Url, 0, byte_size(Url) - 1);
|
||||
_ -> Url
|
||||
end.
|
||||
|
||||
rfc822(From, To, Subject, Plain, Html) ->
|
||||
Bound = <<"eh-alt-01">>,
|
||||
iolist_to_binary([
|
||||
<<"From: CalenTIQ <">>, From, <<">\r\n">>,
|
||||
<<"To: ">>, To, <<"\r\n">>,
|
||||
<<"Subject: ">>, encode_subject(Subject), <<"\r\n">>,
|
||||
<<"MIME-Version: 1.0\r\n">>,
|
||||
<<"Content-Type: multipart/alternative; boundary=\"">>, Bound, <<"\"\r\n">>,
|
||||
<<"\r\n">>,
|
||||
<<"--">>, Bound, <<"\r\n">>,
|
||||
<<"Content-Type: text/plain; charset=utf-8\r\n">>,
|
||||
<<"Content-Transfer-Encoding: 8bit\r\n\r\n">>,
|
||||
Plain, <<"\r\n">>,
|
||||
<<"--">>, Bound, <<"\r\n">>,
|
||||
<<"Content-Type: text/html; charset=utf-8\r\n">>,
|
||||
<<"Content-Transfer-Encoding: 8bit\r\n\r\n">>,
|
||||
Html, <<"\r\n">>,
|
||||
<<"--">>, Bound, <<"--\r\n">>
|
||||
]).
|
||||
|
||||
app_or_env(AppKey, EnvName) ->
|
||||
case application:get_env(eventhub, AppKey) of
|
||||
{ok, Val} when Val =/= undefined, Val =/= <<>>, Val =/= "" -> to_bin(Val);
|
||||
_ -> env_bin(EnvName, <<>>)
|
||||
end.
|
||||
|
||||
env_bin(Name, Default) ->
|
||||
case os:getenv(Name) of
|
||||
false -> Default;
|
||||
"" -> Default;
|
||||
S -> unicode:characters_to_binary(S)
|
||||
end.
|
||||
|
||||
env_int(Name, Default) ->
|
||||
case os:getenv(Name) of
|
||||
false -> Default;
|
||||
"" -> Default;
|
||||
S ->
|
||||
try list_to_integer(S) of
|
||||
N -> N
|
||||
catch
|
||||
_:_ -> Default
|
||||
end
|
||||
end.
|
||||
|
||||
to_bin(B) when is_binary(B) -> B;
|
||||
to_bin(L) when is_list(L) -> unicode:characters_to_binary(L).
|
||||
|
||||
encode_subject(Bin) ->
|
||||
<<"=?UTF-8?B?", (base64:encode(Bin))/binary, "?=">>.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-module(logic_email_templates).
|
||||
-export([render/2]).
|
||||
|
||||
%% Kind: verify | invite | reset
|
||||
-spec render(verify | invite | reset, binary()) ->
|
||||
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
|
||||
render(Kind, Url) ->
|
||||
{Subject, Heading, Lead, Cta, Foot, Pre} = copy(Kind),
|
||||
Plain = iolist_to_binary([Heading, <<"\n\n">>, Lead, <<"\n\n">>, Url, <<"\n">>]),
|
||||
Html = fill(layout(), [
|
||||
{<<"{{preheader}}">>, Pre},
|
||||
{<<"{{heading}}">>, Heading},
|
||||
{<<"{{lead}}">>, Lead},
|
||||
{<<"{{cta_url}}">>, Url},
|
||||
{<<"{{cta_label}}">>, Cta},
|
||||
{<<"{{footnote}}">>, Foot}
|
||||
]),
|
||||
{Subject, Plain, Html}.
|
||||
|
||||
copy(verify) ->
|
||||
{<<"CalenTIQ: подтвердите email">>,
|
||||
<<"Подтвердите email">>,
|
||||
<<"Один календарь. Три режима для вашего времени. Нажмите кнопку, чтобы активировать аккаунт.">>,
|
||||
<<"Подтвердить email">>,
|
||||
<<"Если вы не регистрировались в CalenTIQ, просто проигнорируйте письмо.">>,
|
||||
<<"Подтвердите email в CalenTIQ">>};
|
||||
copy(invite) ->
|
||||
{<<"CalenTIQ: приглашение специалиста">>,
|
||||
<<"Вас пригласили как специалиста">>,
|
||||
<<"Откройте приглашение, чтобы принять или отклонить его в CalenTIQ.">>,
|
||||
<<"Открыть приглашение">>,
|
||||
<<"Письмо отправлено, потому что владелец календаря указал этот адрес.">>,
|
||||
<<"Приглашение специалиста в CalenTIQ">>};
|
||||
copy(reset) ->
|
||||
{<<"CalenTIQ: сброс пароля">>,
|
||||
<<"Сброс пароля">>,
|
||||
<<"Если это были вы — задайте новый пароль по кнопке. Ссылка одноразовая.">>,
|
||||
<<"Сменить пароль">>,
|
||||
<<"Если вы не запрашивали сброс, проигнорируйте письмо — пароль не изменится.">>,
|
||||
<<"Сброс пароля CalenTIQ">>}.
|
||||
|
||||
layout() ->
|
||||
case read_priv() of
|
||||
{ok, Bin} -> Bin;
|
||||
{error, _} -> builtin_layout()
|
||||
end.
|
||||
|
||||
read_priv() ->
|
||||
case code:priv_dir(eventhub) of
|
||||
{error, _} = E -> E;
|
||||
Dir ->
|
||||
Path = filename:join([Dir, "email", "layout.html"]),
|
||||
file:read_file(Path)
|
||||
end.
|
||||
|
||||
fill(Html, []) -> Html;
|
||||
fill(Html, [{Needle, Val} | Rest]) ->
|
||||
fill(binary:replace(Html, Needle, Val, [global]), Rest).
|
||||
|
||||
builtin_layout() ->
|
||||
<<"<!DOCTYPE html><html><body style=\"font-family:Arial,sans-serif;color:#324A5F\">"
|
||||
"<p><b>CalenTIQ</b></p><h1>{{heading}}</h1><p>{{lead}}</p>"
|
||||
"<p><a href=\"{{cta_url}}\" style=\"background:#FF6A3D;color:#fff\">{{cta_label}}</a></p>"
|
||||
"<p>{{cta_url}}</p><p>{{footnote}}</p></body></html>">>.
|
||||
Reference in New Issue
Block a user