Transactional Email

In the previous chapter we set up Hikyaku and built email helpers. Now let's wire emails into real application flows — registration confirmation, password reset, and comment notifications.

Registration confirmation

When a user registers, send a confirmation email with a time-limited token:

-module(blog_accounts).
-export([register_user/1, confirm_user/1]).

register_user(Params) ->
    CS = user:registration_changeset(#{}, Params),
    case blog_repo:insert(CS) of
        {ok, User} ->
            Token = generate_token(maps:get(id, User), <<"confirm">>, 24),
            blog_emails:confirmation(User, Token),
            {ok, User};
        {error, CS1} ->
            {error, CS1}
    end.

confirm_user(Token) ->
    case verify_token(Token, <<"confirm">>) of
        {ok, UserId} ->
            {ok, User} = blog_repo:get(user, UserId),
            CS = kura_changeset:cast(user, User,
                #{<<"confirmed_at">> => calendar:universal_time()},
                [confirmed_at]),
            blog_repo:update(CS);
        {error, _} ->
            {error, invalid_token}
    end.

The email helper:

-module(blog_emails).
-export([welcome/1, confirmation/2, password_reset/2, comment_notification/2]).

confirmation(#{email := Email, username := Username}, Token) ->
    ConfirmUrl = <<"https://myblog.com/confirm?token=", Token/binary>>,
    E0 = hikyaku_email:new(),
    E1 = hikyaku_email:from(E0, {<<"Nova Blog">>, <<"noreply@myblog.com">>}),
    E2 = hikyaku_email:to(E1, Email),
    E3 = hikyaku_email:subject(E2, <<"Confirm your email">>),
    E4 = hikyaku_email:text_body(E3,
        <<"Hi ", Username/binary, ",\n\n",
          "Click the link below to confirm your email:\n\n",
          ConfirmUrl/binary, "\n\n",
          "This link expires in 24 hours.">>),
    E5 = hikyaku_email:html_body(E4,
        <<"<h1>Confirm your email</h1>",
          "<p>Hi ", Username/binary, ",</p>",
          "<p><a href=\"", ConfirmUrl/binary, "\">Click here to confirm</a></p>",
          "<p>This link expires in 24 hours.</p>">>),
    hikyaku_mailer:deliver(blog_mailer, E5).

Password reset

request_password_reset(Email) ->
    case blog_repo:get_by(user, [{email, Email}]) of
        {ok, User} ->
            Token = generate_token(maps:get(id, User), <<"reset">>, 1),
            blog_emails:password_reset(User, Token),
            ok;
        {error, not_found} ->
            %% Don't reveal whether the email exists
            ok
    end.

reset_password(Token, NewPassword) ->
    case verify_token(Token, <<"reset">>) of
        {ok, UserId} ->
            {ok, User} = blog_repo:get(user, UserId),
            CS = user:password_changeset(User, #{<<"password">> => NewPassword}),
            blog_repo:update(CS);
        {error, _} ->
            {error, invalid_token}
    end.
password_reset(#{email := Email, username := Username}, Token) ->
    ResetUrl = <<"https://myblog.com/reset-password?token=", Token/binary>>,
    E0 = hikyaku_email:new(),
    E1 = hikyaku_email:from(E0, {<<"Nova Blog">>, <<"noreply@myblog.com">>}),
    E2 = hikyaku_email:to(E1, Email),
    E3 = hikyaku_email:subject(E2, <<"Reset your password">>),
    E4 = hikyaku_email:text_body(E3,
        <<"Hi ", Username/binary, ",\n\n",
          "Click below to reset your password:\n\n",
          ResetUrl/binary, "\n\n",
          "This link expires in 1 hour.\n",
          "If you didn't request this, ignore this email.">>),
    hikyaku_mailer:deliver(blog_mailer, E4).

Comment notifications

Notify post authors when someone comments, triggered from PubSub:

-module(blog_notification_worker).
-behaviour(gen_server).

-export([start_link/0, init/1, handle_info/2, handle_cast/2, handle_call/3]).

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

init([]) ->
    nova_pubsub:join(comments),
    {ok, #{}}.

handle_info({nova_pubsub, comments, _Sender, "comment_created", Comment}, State) ->
    PostId = maps:get(post_id, Comment),
    {ok, Post} = blog_repo:get(post, PostId),
    Post1 = blog_repo:preload(post, Post, [author]),
    Comment1 = blog_repo:preload(comment, Comment, [author]),

    %% Don't notify if the author commented on their own post
    PostAuthorId = maps:get(id, maps:get(author, Post1)),
    CommentAuthorId = maps:get(user_id, Comment1),
    case PostAuthorId =/= CommentAuthorId of
        true -> blog_emails:comment_notification(Post1, Comment1);
        false -> ok
    end,
    {noreply, State};

handle_info(_Info, State) ->
    {noreply, State}.

handle_cast(_Msg, State) -> {noreply, State}.
handle_call(_Req, _From, State) -> {reply, ok, State}.

Add this worker to your supervisor to start automatically.

Testing email delivery

Use the test adapter to capture emails in tests. Configure blog_mailer to use the test adapter in your test environment, with self() as the receiving process:

%% In test config or test setup
%% Override blog_mailer to use the test adapter:
config() ->
    #{adapter => hikyaku_adapter_test,
      pid => self()}.
%% In your test
test_registration_sends_email(_Config) ->
    {ok, _User} = blog_accounts:register_user(#{
        <<"username">> => <<"testuser">>,
        <<"email">> => <<"test@example.com">>,
        <<"password">> => <<"password123">>
    }),
    receive
        {hikyaku_email, Email} ->
            <<"Confirm your email">> = hikyaku_email:get_subject(Email),
            ok
    after 1000 ->
        ct:fail("No email received")
    end.

Token generation helpers

generate_token(UserId, Purpose, ExpiryHours) ->
    Payload = #{user_id => UserId, purpose => Purpose,
                expires_at => erlang:system_time(second) + ExpiryHours * 3600},
    base64:encode(term_to_binary(Payload)).

verify_token(Token, ExpectedPurpose) ->
    try
        Payload = binary_to_term(base64:decode(Token)),
        #{user_id := UserId, purpose := Purpose, expires_at := ExpiresAt} = Payload,
        Now = erlang:system_time(second),
        case Purpose =:= ExpectedPurpose andalso ExpiresAt > Now of
            true -> {ok, UserId};
            false -> {error, expired}
        end
    catch _:_ ->
        {error, invalid}
    end.

Warning

This is a simplified token implementation for illustration. In production, use cryptographically signed tokens (e.g. HMAC-SHA256) and store token hashes in the database for revocation.


With email integrated, let's ensure everything works with proper Unit Testing.