0a1e55e036
Outbound SMTP ports are often blocked on VPS; EMAIL_TRANSPORT=http_api sends via Resend HTTPS (Brevo optional). OpenSMTPd/SMTP remain available.
361 lines
11 KiB
Erlang
Executable File
361 lines
11 KiB
Erlang
Executable File
-module(logic_email).
|
|
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
|
|
send_booking_reminder/4, send_waitlist_promoted/3]).
|
|
|
|
%% 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) ->
|
|
send_kind(verify, Email, abs_url(<<"/verify?token=">>, Token)).
|
|
|
|
-spec send_specialist_invite(binary(), binary()) -> ok.
|
|
send_specialist_invite(Email, Token) ->
|
|
send_kind(invite, Email, abs_url(<<"/invites?token=">>, Token)).
|
|
|
|
-spec send_password_reset(binary(), binary()) -> ok.
|
|
send_password_reset(Email, Token) ->
|
|
send_kind(reset, Email, abs_url(<<"/reset-password?token=">>, Token)).
|
|
|
|
-spec send_booking_reminder(binary(), binary(), calendar:datetime(), binary()) -> ok.
|
|
send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
|
|
Url = abs_path(EventPath),
|
|
WhenText = format_utc(StartTime),
|
|
{Subject, Plain, Html} =
|
|
logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
|
|
send(to_bin(Email), Subject, Plain, Html).
|
|
|
|
-spec send_waitlist_promoted(binary(), binary(), binary()) -> ok.
|
|
send_waitlist_promoted(Email, EventTitle, EventPath) ->
|
|
Url = abs_path(EventPath),
|
|
{Subject, Plain, Html} =
|
|
logic_email_templates:render_waitlist_promoted(Url, to_bin(EventTitle)),
|
|
send(to_bin(Email), Subject, Plain, Html).
|
|
|
|
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 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}),
|
|
ok;
|
|
Relay ->
|
|
From = from_addr(),
|
|
Raw = rfc822(From, To, Subject, Plain, Html),
|
|
Opts = smtp_opts(Relay),
|
|
safe_smtp_deliver(From, To, Raw, Opts)
|
|
end.
|
|
|
|
safe_smtp_deliver(From, To, Raw, Opts) ->
|
|
Fun = smtp_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.
|
|
|
|
smtp_deliver_fun() ->
|
|
case application:get_env(eventhub, smtp_deliver) of
|
|
{ok, Fun} when is_function(Fun, 4) -> Fun;
|
|
_ -> fun default_smtp_deliver/4
|
|
end.
|
|
|
|
default_smtp_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.
|
|
|
|
%%%-------------------------------------------------------------------
|
|
%%% 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">>).
|
|
|
|
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>>.
|
|
|
|
abs_path(Path0) ->
|
|
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
|
|
Path = case to_bin(Path0) of
|
|
<<$/, Rest/binary>> -> <<$/, Rest/binary>>;
|
|
Rest -> <<$/, Rest/binary>>
|
|
end,
|
|
<<Base/binary, Path/binary>>.
|
|
|
|
format_utc({{Y, Mo, D}, {H, Mi, _S}}) ->
|
|
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
|
|
[Y, Mo, D, H, Mi])).
|
|
|
|
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, "?=">>.
|