diff --git a/docker/.env.example b/docker/.env.example index cfb8e31..0ebeab8 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -19,3 +19,13 @@ ADMIN_MODER_EMAIL=moderator@eventhub.local ADMIN_MODER_PASSWORD=замените-сильный-пароль-модератора ADMIN_SUPPORT_EMAIL=support@eventhub.local ADMIN_SUPPORT_PASSWORD=замените-сильный-пароль-поддержки + +# SMTP (пусто SMTP_HOST = только лог; пароль не коммитить) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@calentiq.com +SMTP_TLS=if_available +PUBLIC_APP_URL=https://stage.calentiq.com + diff --git a/docker/Dockerfile b/docker/Dockerfile index e9b1e69..b7ea778 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -15,6 +15,7 @@ RUN rebar3 get-deps COPY include/ include/ COPY src/ src/ +COPY priv/ priv/ COPY src/config/sys.config ./config/sys.config COPY src/config/vm.args ./config/vm.args diff --git a/docker/docker-compose.stage.yml b/docker/docker-compose.stage.yml index d317006..b124aaa 100644 --- a/docker/docker-compose.stage.yml +++ b/docker/docker-compose.stage.yml @@ -41,6 +41,13 @@ services: - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD} - CLUSTER_MODE=true - DNS_NAME=eventhub-node + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USER=${SMTP_USER:-} + - SMTP_PASS=${SMTP_PASS:-} + - SMTP_FROM=${SMTP_FROM:-noreply@calentiq.com} + - SMTP_TLS=${SMTP_TLS:-if_available} + - PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://stage.calentiq.com} networks: eventhub-net: aliases: diff --git a/priv/email/layout.html b/priv/email/layout.html new file mode 100644 index 0000000..19bbb91 --- /dev/null +++ b/priv/email/layout.html @@ -0,0 +1,49 @@ + + + + + + {{heading}} + + + {{preheader}} + + + + +
+ + + + + + + + + + + + + +
+

CalenTIQ

+

One calendar. Three modes for your time.

+
 
+

{{heading}}

+

{{lead}}

+ + + + +
+ {{cta_label}} +
+

Если кнопка не открывается, скопируйте ссылку:
+ {{cta_url}} +

+
+ {{footnote}} +
+
+ + diff --git a/rebar.config b/rebar.config index 46de3e6..983d8e5 100644 --- a/rebar.config +++ b/rebar.config @@ -10,7 +10,8 @@ {meck, "0.9.2"}, {gun, "2.2.0"}, {prometheus_cowboy, "0.2.0"}, - {cowboy_swagger, "2.8.0"} + {cowboy_swagger, "2.8.0"}, + {gen_smtp, "1.3.0"} ]}. {shell, [ @@ -27,7 +28,7 @@ {profiles, [ {prod, [ {relx, [ - {release, {eventhub, "0.0.1"}, [eventhub, sasl,cowboy, jose, jsx, argon2, runtime_tools, os_mon, prometheus_cowboy]}, + {release, {eventhub, "0.0.1"}, [eventhub, sasl, cowboy, jose, jsx, argon2, gen_smtp, ssl, runtime_tools, os_mon, prometheus_cowboy]}, {include_erts, true}, {extended_start_script, true}, {sys_config, "./src/config/sys.config"} diff --git a/src/eventhub.app.src b/src/eventhub.app.src index 03c11d0..381f867 100644 --- a/src/eventhub.app.src +++ b/src/eventhub.app.src @@ -11,7 +11,9 @@ cowboy, jsx, trails, - cowboy_swagger + cowboy_swagger, + ssl, + gen_smtp ]}, {env, [ {http_port, 8080}, diff --git a/src/logic/logic_email.erl b/src/logic/logic_email.erl index 0cd4d94..a4cee2c 100755 --- a/src/logic/logic_email.erl +++ b/src/logic/logic_email.erl @@ -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), + <>. + +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, "?=">>. diff --git a/src/logic/logic_email_templates.erl b/src/logic/logic_email_templates.erl new file mode 100644 index 0000000..ec534ee --- /dev/null +++ b/src/logic/logic_email_templates.erl @@ -0,0 +1,64 @@ +-module(logic_email_templates). +-export([render/2]). + +%% Kind: verify | invite | reset +-spec render(verify | invite | reset, binary()) -> + {Subject :: binary(), Plain :: binary(), Html :: binary()}. +render(Kind, Url) -> + {Subject, Heading, Lead, Cta, Foot, Pre} = copy(Kind), + Plain = iolist_to_binary([Heading, <<"\n\n">>, Lead, <<"\n\n">>, Url, <<"\n">>]), + Html = fill(layout(), [ + {<<"{{preheader}}">>, Pre}, + {<<"{{heading}}">>, Heading}, + {<<"{{lead}}">>, Lead}, + {<<"{{cta_url}}">>, Url}, + {<<"{{cta_label}}">>, Cta}, + {<<"{{footnote}}">>, Foot} + ]), + {Subject, Plain, Html}. + +copy(verify) -> + {<<"CalenTIQ: подтвердите email">>, + <<"Подтвердите email">>, + <<"Один календарь. Три режима для вашего времени. Нажмите кнопку, чтобы активировать аккаунт.">>, + <<"Подтвердить email">>, + <<"Если вы не регистрировались в CalenTIQ, просто проигнорируйте письмо.">>, + <<"Подтвердите email в CalenTIQ">>}; +copy(invite) -> + {<<"CalenTIQ: приглашение специалиста">>, + <<"Вас пригласили как специалиста">>, + <<"Откройте приглашение, чтобы принять или отклонить его в CalenTIQ.">>, + <<"Открыть приглашение">>, + <<"Письмо отправлено, потому что владелец календаря указал этот адрес.">>, + <<"Приглашение специалиста в CalenTIQ">>}; +copy(reset) -> + {<<"CalenTIQ: сброс пароля">>, + <<"Сброс пароля">>, + <<"Если это были вы — задайте новый пароль по кнопке. Ссылка одноразовая.">>, + <<"Сменить пароль">>, + <<"Если вы не запрашивали сброс, проигнорируйте письмо — пароль не изменится.">>, + <<"Сброс пароля CalenTIQ">>}. + +layout() -> + case read_priv() of + {ok, Bin} -> Bin; + {error, _} -> builtin_layout() + end. + +read_priv() -> + case code:priv_dir(eventhub) of + {error, _} = E -> E; + Dir -> + Path = filename:join([Dir, "email", "layout.html"]), + file:read_file(Path) + end. + +fill(Html, []) -> Html; +fill(Html, [{Needle, Val} | Rest]) -> + fill(binary:replace(Html, Needle, Val, [global]), Rest). + +builtin_layout() -> + <<"" + "

CalenTIQ

{{heading}}

{{lead}}

" + "

{{cta_label}}

" + "

{{cta_url}}

{{footnote}}

">>. diff --git a/test/unit/logic_email_tests.erl b/test/unit/logic_email_tests.erl new file mode 100644 index 0000000..b576647 --- /dev/null +++ b/test/unit/logic_email_tests.erl @@ -0,0 +1,83 @@ +-module(logic_email_tests). +-include_lib("eunit/include/eunit.hrl"). + +logic_email_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"log-only without host", fun test_log_only/0}, + {"mock deliver gets CalenTIQ + verify url", fun test_verify_deliver/0}, + {"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} + ]}. + +setup() -> + application:unset_env(eventhub, smtp_host), + application:unset_env(eventhub, smtp_deliver), + ok. + +cleanup(_) -> + application:unset_env(eventhub, smtp_host), + application:unset_env(eventhub, smtp_deliver), + ok. + +test_log_only() -> + application:unset_env(eventhub, smtp_host), + ?assertEqual(ok, logic_email:send_verification_email(<<"a@b.c">>, <<"tok">>)). + +test_verify_deliver() -> + Self = self(), + application:set_env(eventhub, smtp_host, "smtp.test"), + application:set_env(eventhub, smtp_deliver, + fun(From, To, Raw, Opts) -> + Self ! {smtp, From, To, Raw, Opts}, + {ok, mock} + end), + ?assertEqual(ok, logic_email:send_verification_email(<<"user@ex.com">>, <<"abc123">>)), + receive + {smtp, From, To, Raw, Opts} -> + ?assertEqual(<<"noreply@calentiq.com">>, From), + ?assertEqual(<<"user@ex.com">>, To), + ?assertEqual("smtp.test", proplists:get_value(relay, Opts)), + ?assert(binary:match(Raw, <<"multipart/alternative">>) =/= nomatch), + ?assert(binary:match(Raw, <<"text/html">>) =/= nomatch), + ?assert(binary:match(Raw, <<"CalenTIQ">>) =/= nomatch), + ?assert(binary:match(Raw, <<"/verify?token=abc123">>) =/= nomatch), + ?assert(binary:match(Raw, <<"#FF6A3D">>) =/= nomatch) + after 1000 -> + error(timeout) + end. + +test_invite_reset() -> + Self = self(), + application:set_env(eventhub, smtp_host, <<"smtp.test">>), + application:set_env(eventhub, smtp_deliver, + fun(_From, To, Raw, _Opts) -> + Self ! {smtp, To, Raw}, + ok + end), + ok = logic_email:send_specialist_invite(<<"i@ex.com">>, <<"invtok">>), + receive + {smtp, <<"i@ex.com">>, Raw1} -> + ?assert(binary:match(Raw1, <<"/invites?token=invtok">>) =/= nomatch) + after 1000 -> error(timeout) + end, + ok = logic_email:send_password_reset(<<"r@ex.com">>, <<"rst">>), + receive + {smtp, <<"r@ex.com">>, Raw2} -> + ?assert(binary:match(Raw2, <<"/reset-password?token=rst">>) =/= nomatch) + after 1000 -> error(timeout) + end. + +test_deliver_error() -> + 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">>)), + application:set_env(eventhub, smtp_deliver, fun(_, _, _, _) -> error(crash) end), + ?assertEqual(ok, logic_email:send_password_reset(<<"a@b.c">>, <<"t">>)). + +test_template_fill() -> + {Sub, Plain, Html} = logic_email_templates:render(verify, <<"https://x/verify?token=z">>), + ?assert(binary:match(Sub, <<"CalenTIQ">>) =/= nomatch), + ?assert(binary:match(Plain, <<"https://x/verify?token=z">>) =/= nomatch), + ?assert(binary:match(Html, <<"{{">>) =:= nomatch), + ?assert(binary:match(Html, <<"https://x/verify?token=z">>) =/= nomatch).