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_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).