From 0a1e55e03646ade1b128be0054d437d083d0b8cd Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Fri, 14 Aug 2026 23:54:10 +0300 Subject: [PATCH] feat(email): HTTP API transport via Resend (fallback SMTP). Outbound SMTP ports are often blocked on VPS; EMAIL_TRANSPORT=http_api sends via Resend HTTPS (Brevo optional). OpenSMTPd/SMTP remain available. --- docker/.env.example | 8 +- docker/docker-compose.stage.yml | 4 + src/eventhub.app.src | 1 + src/logic/logic_email.erl | 198 ++++++++++++++++++++++++++++++-- test/unit/logic_email_tests.erl | 45 +++++++- 5 files changed, 242 insertions(+), 14 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index eb07b9c..8c8054d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -20,7 +20,13 @@ ADMIN_MODER_PASSWORD=замените-сильный-пароль-модерат ADMIN_SUPPORT_EMAIL=support@eventhub.local ADMIN_SUPPORT_PASSWORD=замените-сильный-пароль-поддержки -# SMTP (пусто SMTP_HOST = только лог; пароль не коммитить) +# Email: EMAIL_TRANSPORT=auto|smtp|http_api|log (auto: API key → http_api, else SMTP_HOST → smtp, else log) +EMAIL_TRANSPORT=auto +# HTTP API (HTTPS — when outbound SMTP ports blocked). Canon: Resend (Brevo optional). +EMAIL_API_KEY= +EMAIL_API_PROVIDER=resend +# EMAIL_API_URL=https://api.resend.com/emails +# SMTP (пусто SMTP_HOST + нет API key = только лог; пароль не коммитить) SMTP_HOST= SMTP_PORT=587 SMTP_USER= diff --git a/docker/docker-compose.stage.yml b/docker/docker-compose.stage.yml index dcbb916..3bac0fd 100644 --- a/docker/docker-compose.stage.yml +++ b/docker/docker-compose.stage.yml @@ -41,6 +41,10 @@ services: - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD} - CLUSTER_MODE=true - DNS_NAME=eventhub-node + - EMAIL_TRANSPORT=${EMAIL_TRANSPORT:-auto} + - EMAIL_API_KEY=${EMAIL_API_KEY:-} + - EMAIL_API_PROVIDER=${EMAIL_API_PROVIDER:-resend} + - EMAIL_API_URL=${EMAIL_API_URL:-} - SMTP_HOST=${SMTP_HOST:-} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER:-} diff --git a/src/eventhub.app.src b/src/eventhub.app.src index 381f867..e037bc6 100644 --- a/src/eventhub.app.src +++ b/src/eventhub.app.src @@ -13,6 +13,7 @@ trails, cowboy_swagger, ssl, + inets, gen_smtp ]}, {env, [ diff --git a/src/logic/logic_email.erl b/src/logic/logic_email.erl index eb227c3..82341fd 100755 --- a/src/logic/logic_email.erl +++ b/src/logic/logic_email.erl @@ -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">>). diff --git a/test/unit/logic_email_tests.erl b/test/unit/logic_email_tests.erl index 7bcfa88..585ac87 100644 --- a/test/unit/logic_email_tests.erl +++ b/test/unit/logic_email_tests.erl @@ -8,25 +8,35 @@ logic_email_test_() -> {"invite and reset urls", fun test_invite_reset/0}, {"deliver error still ok", fun test_deliver_error/0}, {"html template placeholders filled", fun test_template_fill/0}, - {"booking reminder deliver", fun test_reminder_deliver/0} + {"booking reminder deliver", fun test_reminder_deliver/0}, + {"http_api mock deliver", fun test_http_api_deliver/0}, + {"http_api error still ok", fun test_http_api_error/0} ]}. setup() -> application:unset_env(eventhub, smtp_host), application:unset_env(eventhub, smtp_deliver), + application:unset_env(eventhub, email_transport), + application:unset_env(eventhub, email_api_key), + application:unset_env(eventhub, email_http_deliver), ok. cleanup(_) -> application:unset_env(eventhub, smtp_host), application:unset_env(eventhub, smtp_deliver), + application:unset_env(eventhub, email_transport), + application:unset_env(eventhub, email_api_key), + application:unset_env(eventhub, email_http_deliver), ok. test_log_only() -> application:unset_env(eventhub, smtp_host), + application:set_env(eventhub, email_transport, log), ?assertEqual(ok, logic_email:send_verification_email(<<"a@b.c">>, <<"tok">>)). test_verify_deliver() -> Self = self(), + application:set_env(eventhub, email_transport, smtp), application:set_env(eventhub, smtp_host, "smtp.test"), application:set_env(eventhub, smtp_deliver, fun(From, To, Raw, Opts) -> @@ -50,6 +60,7 @@ test_verify_deliver() -> test_invite_reset() -> Self = self(), + application:set_env(eventhub, email_transport, smtp), application:set_env(eventhub, smtp_host, <<"smtp.test">>), application:set_env(eventhub, smtp_deliver, fun(_From, To, Raw, _Opts) -> @@ -70,6 +81,7 @@ test_invite_reset() -> end. test_deliver_error() -> + application:set_env(eventhub, email_transport, smtp), application:set_env(eventhub, smtp_host, "smtp.test"), application:set_env(eventhub, smtp_deliver, fun(_, _, _, _) -> {error, boom} end), ?assertEqual(ok, logic_email:send_verification_email(<<"a@b.c">>, <<"t">>)), @@ -85,6 +97,7 @@ test_template_fill() -> test_reminder_deliver() -> Self = self(), + application:set_env(eventhub, email_transport, smtp), application:set_env(eventhub, smtp_host, "smtp.test"), application:set_env(eventhub, smtp_deliver, fun(_From, To, Raw, _Opts) -> @@ -102,3 +115,33 @@ test_reminder_deliver() -> after 1000 -> error(timeout) end. + +test_http_api_deliver() -> + Self = self(), + application:set_env(eventhub, email_transport, http_api), + application:set_env(eventhub, email_api_key, <<"test-key">>), + application:set_env(eventhub, email_http_deliver, + fun(Key, From, To, Subject, Plain, Html) -> + Self ! {http, Key, From, To, Subject, Plain, Html}, + ok + end), + ?assertEqual(ok, logic_email:send_verification_email(<<"user@ex.com">>, <<"abc123">>)), + receive + {http, Key, From, To, Subject, Plain, Html} -> + ?assertEqual(<<"test-key">>, Key), + ?assertEqual(<<"noreply@calentiq.com">>, From), + ?assertEqual(<<"user@ex.com">>, To), + ?assert(binary:match(Subject, <<"CalenTIQ">>) =/= nomatch), + ?assert(binary:match(Plain, <<"/verify?token=abc123">>) =/= nomatch), + ?assert(binary:match(Html, <<"/verify?token=abc123">>) =/= nomatch) + after 1000 -> + error(timeout) + end. + +test_http_api_error() -> + application:set_env(eventhub, email_transport, http_api), + application:set_env(eventhub, email_api_key, <<"k">>), + application:set_env(eventhub, email_http_deliver, fun(_, _, _, _, _, _) -> {error, boom} end), + ?assertEqual(ok, logic_email:send_verification_email(<<"a@b.c">>, <<"t">>)), + application:set_env(eventhub, email_http_deliver, fun(_, _, _, _, _, _) -> error(crash) end), + ?assertEqual(ok, logic_email:send_password_reset(<<"a@b.c">>, <<"t">>)).