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]).
%% Kind: verify | invite | reset
%% Non-ASCII binaries MUST use /utf8 (OTP truncates codepoints to 8 bits otherwise).
-spec render(verify | invite | reset, binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
render(Kind, Url) ->
@@ -12,28 +13,28 @@ render(Kind, Url) ->
-spec render_reminder(binary(), binary(), binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
render_reminder(Url, Title, WhenText) ->
Subject = <<"CalenTIQ: напоминание о записи">>,
Heading = <<"Напоминание о записи">>,
Subject = <<"CalenTIQ: напоминание о записи"/utf8>>,
Heading = <<"Напоминание о записи"/utf8>>,
Lead = iolist_to_binary([
<<"Скоро: «">>, Title, <<"» — ">>, WhenText, <<" (UTC).">>
<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText, <<" (UTC).">>
]),
Cta = <<"Открыть запись">>,
Foot = <<"Вы получили письмо, потому что записаны на это событие в CalenTIQ.">>,
Pre = <<"Напоминание о записи в CalenTIQ">>,
Cta = <<"Открыть запись"/utf8>>,
Foot = <<"Вы получили письмо, потому что записаны на это событие в CalenTIQ."/utf8>>,
Pre = <<"Напоминание о записи в CalenTIQ"/utf8>>,
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
-spec render_waitlist_promoted(binary(), binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
render_waitlist_promoted(Url, Title) ->
Subject = <<"CalenTIQ: место в листе ожидания">>,
Heading = <<"Для вас освободилось место">>,
Subject = <<"CalenTIQ: место в листе ожидания"/utf8>>,
Heading = <<"Для вас освободилось место"/utf8>>,
Lead = iolist_to_binary([
<<"Вы были в листе ожидания на «">>, Title,
<<"». Мы записали вас — откройте событие в CalenTIQ.">>
<<"Вы были в листе ожидания на «"/utf8>>, Title,
<<"». Мы записали вас — откройте событие в CalenTIQ."/utf8>>
]),
Cta = <<"Открыть событие">>,
Foot = <<"Если запись вам не нужна, отмените её в приложении.">>,
Pre = <<"Место из листа ожидания CalenTIQ">>,
Cta = <<"Открыть событие"/utf8>>,
Foot = <<"Если запись вам не нужна, отмените её в приложении."/utf8>>,
Pre = <<"Место из листа ожидания CalenTIQ"/utf8>>,
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}.
copy(verify) ->
{<<"CalenTIQ: подтвердите email">>,
<<"Подтвердите email">>,
<<"Один календарь. Три режима для вашего времени. Нажмите кнопку, чтобы активировать аккаунт.">>,
<<"Подтвердить email">>,
<<"Если вы не регистрировались в CalenTIQ, просто проигнорируйте письмо.">>,
<<"Подтвердите email в CalenTIQ">>};
{<<"CalenTIQ: подтвердите email"/utf8>>,
<<"Подтвердите email"/utf8>>,
<<"Один календарь. Три режима для вашего времени. Нажмите кнопку, чтобы активировать аккаунт."/utf8>>,
<<"Подтвердить email"/utf8>>,
<<"Если вы не регистрировались в CalenTIQ, просто проигнорируйте письмо."/utf8>>,
<<"Подтвердите email в CalenTIQ"/utf8>>};
copy(invite) ->
{<<"CalenTIQ: приглашение специалиста">>,
<<"Вас пригласили как специалиста">>,
<<"Откройте приглашение, чтобы принять или отклонить его в CalenTIQ.">>,
<<"Открыть приглашение">>,
<<"Письмо отправлено, потому что владелец календаря указал этот адрес.">>,
<<"Приглашение специалиста в CalenTIQ">>};
{<<"CalenTIQ: приглашение специалиста"/utf8>>,
<<"Вас пригласили как специалиста"/utf8>>,
<<"Откройте приглашение, чтобы принять или отклонить его в CalenTIQ."/utf8>>,
<<"Открыть приглашение"/utf8>>,
<<"Письмо отправлено, потому что владелец календаря указал этот адрес."/utf8>>,
<<"Приглашение специалиста в CalenTIQ"/utf8>>};
copy(reset) ->
{<<"CalenTIQ: сброс пароля">>,
<<"Сброс пароля">>,
<<"Если это были вы — задайте новый пароль по кнопке. Ссылка одноразовая.">>,
<<"Сменить пароль">>,
<<"Если вы не запрашивали сброс, проигнорируйте письмо — пароль не изменится.">>,
<<"Сброс пароля CalenTIQ">>}.
{<<"CalenTIQ: сброс пароля"/utf8>>,
<<"Сброс пароля"/utf8>>,
<<"Если это были вы — задайте новый пароль по кнопке. Ссылка одноразовая."/utf8>>,
<<"Сменить пароль"/utf8>>,
<<"Если вы не запрашивали сброс, проигнорируйте письмо — пароль не изменится."/utf8>>,
<<"Сброс пароля CalenTIQ"/utf8>>}.
layout() ->
case read_priv() of
+4 -4
View File
@@ -49,14 +49,14 @@ notify_specialist_invite(UserId, Invite) ->
%% In-app / WS: напоминание о записи (Back#70)
notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) ->
NTitle = <<"Напоминание о записи">>,
Body = iolist_to_binary([<<"Скоро: «">>, Title, <<"» — ">>, WhenText, <<" (UTC)">>]),
NTitle = <<"Напоминание о записи"/utf8>>,
Body = iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText, <<" (UTC)">>]),
_ = core_notification:create(UserId, event_reminder, NTitle, Body),
broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
notify_waitlist_promoted(UserId, #{title := Title} = Data) ->
NTitle = <<"Место из листа ожидания">>,
Body = iolist_to_binary([<<"Вас записали на «">>, Title, <<"».">>]),
NTitle = <<"Место из листа ожидания"/utf8>>,
Body = iolist_to_binary([<<"Вас записали на «"/utf8>>, Title, <<"»."/utf8>>]),
_ = core_notification:create(UserId, waitlist_promoted, NTitle, Body),
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,
token = Token} = Inv) when UserId =/= <<>> ->
Title = <<"Приглашение стать специалистом">>,
Body = <<"Вас пригласили в календарь ", CalId/binary>>,
Title = <<"Приглашение стать специалистом"/utf8>>,
Body = <<"Вас пригласили в календарь "/utf8, CalId/binary>>,
_ = core_notification:create(UserId, specialist_invite, Title, Body),
logic_notification:notify_specialist_invite(UserId, Inv),
maybe_email(Inv, Token);
+5 -1
View File
@@ -91,9 +91,13 @@ test_deliver_error() ->
test_template_fill() ->
{Sub, Plain, Html} = logic_email_templates:render(verify, <<"https://x/verify?token=z">>),
?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(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() ->
Self = self(),