fix(email): encode Cyrillic binaries with /utf8.
CI / test (push) Successful in 7m11s
CI / deploy-ift (push) Successful in 3m12s
CI / e2e-ift (push) Successful in 1m26s
CI / deploy-stage (push) Failing after 3m33s
CI / e2e-stage (push) Has been skipped

OTP truncates non-ASCII in <<"...">> to 8-bit bytes, garbling Russian
in mail and in-app notification copy.
This commit is contained in:
2026-08-15 00:32:33 +03:00
parent 0a1e55e036
commit e86d603f30
4 changed files with 43 additions and 38 deletions
+32 -31
View File
@@ -2,6 +2,7 @@
-export([render/2, render_reminder/3, render_waitlist_promoted/2]). -export([render/2, render_reminder/3, render_waitlist_promoted/2]).
%% Kind: verify | invite | reset %% Kind: verify | invite | reset
%% Non-ASCII binaries MUST use /utf8 (OTP truncates codepoints to 8 bits otherwise).
-spec render(verify | invite | reset, binary()) -> -spec render(verify | invite | reset, binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}. {Subject :: binary(), Plain :: binary(), Html :: binary()}.
render(Kind, Url) -> render(Kind, Url) ->
@@ -12,28 +13,28 @@ render(Kind, Url) ->
-spec render_reminder(binary(), binary(), binary()) -> -spec render_reminder(binary(), binary(), binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}. {Subject :: binary(), Plain :: binary(), Html :: binary()}.
render_reminder(Url, Title, WhenText) -> render_reminder(Url, Title, WhenText) ->
Subject = <<"CalenTIQ: напоминание о записи">>, Subject = <<"CalenTIQ: напоминание о записи"/utf8>>,
Heading = <<"Напоминание о записи">>, Heading = <<"Напоминание о записи"/utf8>>,
Lead = iolist_to_binary([ Lead = iolist_to_binary([
<<"Скоро: «">>, Title, <<"» — ">>, WhenText, <<" (UTC).">> <<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText, <<" (UTC).">>
]), ]),
Cta = <<"Открыть запись">>, Cta = <<"Открыть запись"/utf8>>,
Foot = <<"Вы получили письмо, потому что записаны на это событие в CalenTIQ.">>, Foot = <<"Вы получили письмо, потому что записаны на это событие в CalenTIQ."/utf8>>,
Pre = <<"Напоминание о записи в CalenTIQ">>, Pre = <<"Напоминание о записи в CalenTIQ"/utf8>>,
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre). assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
-spec render_waitlist_promoted(binary(), binary()) -> -spec render_waitlist_promoted(binary(), binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}. {Subject :: binary(), Plain :: binary(), Html :: binary()}.
render_waitlist_promoted(Url, Title) -> render_waitlist_promoted(Url, Title) ->
Subject = <<"CalenTIQ: место в листе ожидания">>, Subject = <<"CalenTIQ: место в листе ожидания"/utf8>>,
Heading = <<"Для вас освободилось место">>, Heading = <<"Для вас освободилось место"/utf8>>,
Lead = iolist_to_binary([ Lead = iolist_to_binary([
<<"Вы были в листе ожидания на «">>, Title, <<"Вы были в листе ожидания на «"/utf8>>, Title,
<<"». Мы записали вас — откройте событие в CalenTIQ.">> <<"». Мы записали вас — откройте событие в CalenTIQ."/utf8>>
]), ]),
Cta = <<"Открыть событие">>, Cta = <<"Открыть событие"/utf8>>,
Foot = <<"Если запись вам не нужна, отмените её в приложении.">>, Foot = <<"Если запись вам не нужна, отмените её в приложении."/utf8>>,
Pre = <<"Место из листа ожидания CalenTIQ">>, Pre = <<"Место из листа ожидания CalenTIQ"/utf8>>,
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre). assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre) -> assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre) ->
@@ -49,26 +50,26 @@ assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre) ->
{Subject, Plain, Html}. {Subject, Plain, Html}.
copy(verify) -> copy(verify) ->
{<<"CalenTIQ: подтвердите email">>, {<<"CalenTIQ: подтвердите email"/utf8>>,
<<"Подтвердите email">>, <<"Подтвердите email"/utf8>>,
<<"Один календарь. Три режима для вашего времени. Нажмите кнопку, чтобы активировать аккаунт.">>, <<"Один календарь. Три режима для вашего времени. Нажмите кнопку, чтобы активировать аккаунт."/utf8>>,
<<"Подтвердить email">>, <<"Подтвердить email"/utf8>>,
<<"Если вы не регистрировались в CalenTIQ, просто проигнорируйте письмо.">>, <<"Если вы не регистрировались в CalenTIQ, просто проигнорируйте письмо."/utf8>>,
<<"Подтвердите email в CalenTIQ">>}; <<"Подтвердите email в CalenTIQ"/utf8>>};
copy(invite) -> copy(invite) ->
{<<"CalenTIQ: приглашение специалиста">>, {<<"CalenTIQ: приглашение специалиста"/utf8>>,
<<"Вас пригласили как специалиста">>, <<"Вас пригласили как специалиста"/utf8>>,
<<"Откройте приглашение, чтобы принять или отклонить его в CalenTIQ.">>, <<"Откройте приглашение, чтобы принять или отклонить его в CalenTIQ."/utf8>>,
<<"Открыть приглашение">>, <<"Открыть приглашение"/utf8>>,
<<"Письмо отправлено, потому что владелец календаря указал этот адрес.">>, <<"Письмо отправлено, потому что владелец календаря указал этот адрес."/utf8>>,
<<"Приглашение специалиста в CalenTIQ">>}; <<"Приглашение специалиста в CalenTIQ"/utf8>>};
copy(reset) -> copy(reset) ->
{<<"CalenTIQ: сброс пароля">>, {<<"CalenTIQ: сброс пароля"/utf8>>,
<<"Сброс пароля">>, <<"Сброс пароля"/utf8>>,
<<"Если это были вы — задайте новый пароль по кнопке. Ссылка одноразовая.">>, <<"Если это были вы — задайте новый пароль по кнопке. Ссылка одноразовая."/utf8>>,
<<"Сменить пароль">>, <<"Сменить пароль"/utf8>>,
<<"Если вы не запрашивали сброс, проигнорируйте письмо — пароль не изменится.">>, <<"Если вы не запрашивали сброс, проигнорируйте письмо — пароль не изменится."/utf8>>,
<<"Сброс пароля CalenTIQ">>}. <<"Сброс пароля CalenTIQ"/utf8>>}.
layout() -> layout() ->
case read_priv() of case read_priv() of
+4 -4
View File
@@ -49,14 +49,14 @@ notify_specialist_invite(UserId, Invite) ->
%% In-app / WS: напоминание о записи (Back#70) %% In-app / WS: напоминание о записи (Back#70)
notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) -> notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) ->
NTitle = <<"Напоминание о записи">>, NTitle = <<"Напоминание о записи"/utf8>>,
Body = iolist_to_binary([<<"Скоро: «">>, Title, <<"» — ">>, WhenText, <<" (UTC)">>]), Body = iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText, <<" (UTC)">>]),
_ = core_notification:create(UserId, event_reminder, NTitle, Body), _ = core_notification:create(UserId, event_reminder, NTitle, Body),
broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}). broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
notify_waitlist_promoted(UserId, #{title := Title} = Data) -> notify_waitlist_promoted(UserId, #{title := Title} = Data) ->
NTitle = <<"Место из листа ожидания">>, NTitle = <<"Место из листа ожидания"/utf8>>,
Body = iolist_to_binary([<<"Вас записали на «">>, Title, <<"».">>]), Body = iolist_to_binary([<<"Вас записали на «"/utf8>>, Title, <<"»."/utf8>>]),
_ = core_notification:create(UserId, waitlist_promoted, NTitle, Body), _ = core_notification:create(UserId, waitlist_promoted, NTitle, Body),
broadcast_to_user(UserId, waitlist_promoted, Data#{user_id => UserId}). broadcast_to_user(UserId, waitlist_promoted, Data#{user_id => UserId}).
+2 -2
View File
@@ -187,8 +187,8 @@ do_create(OwnerId, CalendarId, UserId, Email, Opts) ->
notify_invite(#specialist_invite{invitee_user_id = UserId, calendar_id = CalId, notify_invite(#specialist_invite{invitee_user_id = UserId, calendar_id = CalId,
token = Token} = Inv) when UserId =/= <<>> -> token = Token} = Inv) when UserId =/= <<>> ->
Title = <<"Приглашение стать специалистом">>, Title = <<"Приглашение стать специалистом"/utf8>>,
Body = <<"Вас пригласили в календарь ", CalId/binary>>, Body = <<"Вас пригласили в календарь "/utf8, CalId/binary>>,
_ = core_notification:create(UserId, specialist_invite, Title, Body), _ = core_notification:create(UserId, specialist_invite, Title, Body),
logic_notification:notify_specialist_invite(UserId, Inv), logic_notification:notify_specialist_invite(UserId, Inv),
maybe_email(Inv, Token); maybe_email(Inv, Token);
+5 -1
View File
@@ -91,9 +91,13 @@ test_deliver_error() ->
test_template_fill() -> test_template_fill() ->
{Sub, Plain, Html} = logic_email_templates:render(verify, <<"https://x/verify?token=z">>), {Sub, Plain, Html} = logic_email_templates:render(verify, <<"https://x/verify?token=z">>),
?assert(binary:match(Sub, <<"CalenTIQ">>) =/= nomatch), ?assert(binary:match(Sub, <<"CalenTIQ">>) =/= nomatch),
%% OTP truncates Cyrillic in <<"...">> without /utf8 — guard real UTF-8 text
?assert(binary:match(Sub, <<"подтвердите"/utf8>>) =/= nomatch),
?assert(binary:match(Plain, <<"Подтвердите"/utf8>>) =/= nomatch),
?assert(binary:match(Plain, <<"https://x/verify?token=z">>) =/= nomatch), ?assert(binary:match(Plain, <<"https://x/verify?token=z">>) =/= nomatch),
?assert(binary:match(Html, <<"{{">>) =:= nomatch), ?assert(binary:match(Html, <<"{{">>) =:= nomatch),
?assert(binary:match(Html, <<"https://x/verify?token=z">>) =/= nomatch). ?assert(binary:match(Html, <<"https://x/verify?token=z">>) =/= nomatch),
?assert(binary:match(Html, <<"Подтвердите"/utf8>>) =/= nomatch).
test_reminder_deliver() -> test_reminder_deliver() ->
Self = self(), Self = self(),