feat(email): HTTP API transport via Resend (fallback SMTP).
CI / test (push) Successful in 7m40s
CI / deploy-ift (push) Successful in 3m29s
CI / e2e-ift (push) Successful in 1m27s
CI / deploy-stage (push) Failing after 3m45s
CI / e2e-stage (push) Has been skipped

Outbound SMTP ports are often blocked on VPS; EMAIL_TRANSPORT=http_api
sends via Resend HTTPS (Brevo optional). OpenSMTPd/SMTP remain available.
This commit is contained in:
2026-08-14 23:54:10 +03:00
parent fb8869fa86
commit 0a1e55e036
5 changed files with 242 additions and 14 deletions
+1
View File
@@ -13,6 +13,7 @@
trails,
cowboy_swagger,
ssl,
inets,
gen_smtp
]},
{env, [
+186 -12
View File
@@ -2,11 +2,13 @@
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
send_booking_reminder/4, send_waitlist_promoted/3]).
%% 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)
%% Email transport (Spec#8 / stage outbound :25 blocked):
%% EMAIL_TRANSPORT = smtp | http_api | log | auto (default auto)
%% auto: EMAIL_API_KEY set → http_api; else SMTP_HOST set → smtp; else log
%% SMTP: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TLS
%% HTTP API: EMAIL_API_KEY, EMAIL_API_PROVIDER=resend|brevo (default resend),
%% optional EMAIL_API_URL; SMTP_FROM as sender
%% Tests: smtp_deliver / email_http_deliver app env mocks
-spec send_verification_email(binary(), binary()) -> ok.
send_verification_email(Email, Token) ->
@@ -20,7 +22,6 @@ send_specialist_invite(Email, Token) ->
send_password_reset(Email, Token) ->
send_kind(reset, Email, abs_url(<<"/reset-password?token=">>, Token)).
%% Reminder before event start (Back#70).
-spec send_booking_reminder(binary(), binary(), calendar:datetime(), binary()) -> ok.
send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
Url = abs_path(EventPath),
@@ -41,6 +42,55 @@ send_kind(Kind, To0, Url) ->
send(to_bin(To0), Subject, Plain, Html).
send(To, Subject, Plain, Html) ->
case transport() of
log ->
logger:info(#{what => email_log_only, to => To, subject => Subject}),
ok;
smtp ->
send_smtp(To, Subject, Plain, Html);
http_api ->
send_http_api(To, Subject, Plain, Html)
end.
transport() ->
case env_bin("EMAIL_TRANSPORT", <<>>) of
<<"smtp">> -> smtp;
<<"http_api">> -> http_api;
<<"log">> -> log;
<<"auto">> -> transport_auto();
<<>> ->
case application:get_env(eventhub, email_transport) of
{ok, smtp} -> smtp;
{ok, http_api} -> http_api;
{ok, log} -> log;
_ -> transport_auto()
end;
_ ->
transport_auto()
end.
transport_auto() ->
case application:get_env(eventhub, email_transport) of
{ok, smtp} -> smtp;
{ok, http_api} -> http_api;
{ok, log} -> log;
_ ->
case api_key() of
<<>> ->
case smtp_relay() of
undefined -> log;
_ -> smtp
end;
_ ->
http_api
end
end.
%%%-------------------------------------------------------------------
%%% SMTP
%%%-------------------------------------------------------------------
send_smtp(To, Subject, Plain, Html) ->
case smtp_relay() of
undefined ->
logger:info(#{what => email_log_only, to => To, subject => Subject}),
@@ -49,11 +99,11 @@ send(To, Subject, Plain, Html) ->
From = from_addr(),
Raw = rfc822(From, To, Subject, Plain, Html),
Opts = smtp_opts(Relay),
safe_deliver(From, To, Raw, Opts)
safe_smtp_deliver(From, To, Raw, Opts)
end.
safe_deliver(From, To, Raw, Opts) ->
Fun = deliver_fun(),
safe_smtp_deliver(From, To, Raw, Opts) ->
Fun = smtp_deliver_fun(),
try Fun(From, To, Raw, Opts) of
{ok, _} -> ok;
ok -> ok;
@@ -70,13 +120,13 @@ safe_deliver(From, To, Raw, Opts) ->
ok
end.
deliver_fun() ->
smtp_deliver_fun() ->
case application:get_env(eventhub, smtp_deliver) of
{ok, Fun} when is_function(Fun, 4) -> Fun;
_ -> fun default_deliver/4
_ -> fun default_smtp_deliver/4
end.
default_deliver(From, To, Raw, Opts) ->
default_smtp_deliver(From, To, Raw, Opts) ->
gen_smtp_client:send({From, [To], Raw}, Opts).
smtp_relay() ->
@@ -108,6 +158,130 @@ tls_mode(<<"always">>) -> always;
tls_mode(<<"never">>) -> never;
tls_mode(_) -> if_available.
%%%-------------------------------------------------------------------
%%% HTTP API (HTTPS transactional — no outbound :25/:587)
%%% Providers: resend (default/canon), brevo (optional)
%%%-------------------------------------------------------------------
send_http_api(To, Subject, Plain, Html) ->
case api_key() of
<<>> ->
logger:warning(#{what => email_api_missing_key, to => To}),
ok;
Key ->
From = from_addr(),
safe_http_deliver(Key, From, To, Subject, Plain, Html)
end.
safe_http_deliver(Key, From, To, Subject, Plain, Html) ->
Fun = http_deliver_fun(),
try Fun(Key, From, To, Subject, Plain, Html) of
ok -> ok;
{ok, _} -> ok;
{error, Reason} ->
logger:warning(#{what => email_api_error, to => To, reason => Reason}),
ok;
Other ->
logger:warning(#{what => email_api_unexpected, to => To, result => Other}),
ok
catch
Class:Reason:Stack ->
logger:warning(#{what => email_api_crash, to => To,
class => Class, reason => Reason, stack => Stack}),
ok
end.
http_deliver_fun() ->
case application:get_env(eventhub, email_http_deliver) of
{ok, Fun} when is_function(Fun, 6) -> Fun;
_ -> fun default_http_deliver/6
end.
default_http_deliver(Key, From, To, Subject, Plain, Html) ->
_ = application:ensure_all_started(inets),
_ = application:ensure_all_started(ssl),
Provider = api_provider(),
{Url, Headers, Body} = http_api_request(Provider, Key, From, To, Subject, Plain, Html),
Request = {binary_to_list(Url), Headers, "application/json", Body},
HttpOpts = [{timeout, 15000}, {ssl, [{verify, verify_peer},
{cacerts, public_key:cacerts_get()}]}],
case httpc:request(post, Request, HttpOpts, [{body_format, binary}]) of
{ok, {{_, Code, _}, _, _Resp}} when Code >= 200, Code < 300 ->
ok;
{ok, {{_, Code, _}, _, Resp}} ->
{error, {http_status, Code, Resp}};
{error, Reason} ->
{error, Reason}
end.
http_api_request(resend, Key, From, To, Subject, Plain, Html) ->
Url = case env_bin("EMAIL_API_URL", <<>>) of
<<>> -> <<"https://api.resend.com/emails">>;
U -> U
end,
FromLine = <<"CalenTIQ <", From/binary, ">">>,
Body = jsx:encode(#{
<<"from">> => FromLine,
<<"to">> => [To],
<<"subject">> => Subject,
<<"html">> => Html,
<<"text">> => Plain
}),
Headers = [
{"authorization", "Bearer " ++ binary_to_list(Key)},
{"accept", "application/json"},
{"content-type", "application/json"}
],
{Url, Headers, Body};
http_api_request(brevo, Key, From, To, Subject, Plain, Html) ->
Url = case env_bin("EMAIL_API_URL", <<>>) of
<<>> -> <<"https://api.brevo.com/v3/smtp/email">>;
U -> U
end,
Body = jsx:encode(#{
<<"sender">> => #{
<<"name">> => <<"CalenTIQ">>,
<<"email">> => From
},
<<"to">> => [#{<<"email">> => To}],
<<"subject">> => Subject,
<<"htmlContent">> => Html,
<<"textContent">> => Plain
}),
Headers = [
{"api-key", binary_to_list(Key)},
{"accept", "application/json"},
{"content-type", "application/json"}
],
{Url, Headers, Body}.
api_provider() ->
case env_bin("EMAIL_API_PROVIDER", <<"resend">>) of
<<"resend">> -> resend;
<<"brevo">> -> brevo;
<<"sendinblue">> -> brevo;
Other ->
case application:get_env(eventhub, email_api_provider) of
{ok, resend} -> resend;
{ok, brevo} -> brevo;
_ when Other =:= <<>> -> resend;
_ ->
logger:warning(#{what => email_api_unknown_provider, provider => Other}),
resend
end
end.
api_key() ->
case application:get_env(eventhub, email_api_key) of
{ok, K} when is_binary(K), K =/= <<>> -> K;
{ok, K} when is_list(K), K =/= "" -> unicode:characters_to_binary(K);
_ -> env_bin("EMAIL_API_KEY", <<>>)
end.
%%%-------------------------------------------------------------------
%%% Shared
%%%-------------------------------------------------------------------
from_addr() ->
env_bin("SMTP_FROM", <<"noreply@calentiq.com">>).