-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), <>. 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, <>. 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, "?=">>.