feat(email): SMTP via gen_smtp and CalenTIQ HTML templates. Refs EventHub/EventHubBack#68

This commit is contained in:
2026-08-14 18:51:09 +03:00
parent 3d7ae0d372
commit 69ee037d45
9 changed files with 369 additions and 6 deletions
+149 -3
View File
@@ -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, "?=">>.