§ unswayed-backend · Authentication
Transactional emails
Transactional emails
The backend sends two transactional emails today — the OTP email (email verification + password reset) and the welcome ("account creation") email. Both are deliberately reproduced from the legacy PHP/Laravel backend's Blade views so the product looks identical to what users already know, and both go through one swappable port so tests never touch SMTP.
The port: NotificationService
Auth code never talks to a mailer directly — it depends on the abstract
NotificationService:
abstract sendOtp(to, code, purpose): Promise<void>
abstract sendWelcome(to, name, userType): Promise<void>
Two adapters implement it, chosen at boot by notificationServiceFactory:
LogNotificationService— logs the email instead of sending it. Used whenMAIL_HOSTis unset, so local/dev and tests run end-to-end without SMTP.SmtpNotificationService— renders the templates and sends over SMTP (MailerService/ nodemailer). The sender display name is pinned to "Ralli Technologies" (the legacy name), with the address fromMAIL_FROM_ADDRESS.
In e2e tests a capturing fake records every sendOtp/sendWelcome so a test
can read back the delivered OTP code or assert a welcome was sent.
The templates (pure functions)
src/notifications/templates/ holds plain render functions — no I/O, trivially
unit-tested — each returning { subject, html, text }:
renderOtpEmail({ code, purpose, ucn? })reproduces the legacyemails/otpview: a Bootstrap 5.3.2 CDN shell,body { padding: 20px }, and the Cloudinary RALLi-logo footer (the sharedshell()).- Email verification is byte-for-byte legacy: subject "Your Email Verification
OTP.", body "This is your OTP
{otp}Please Verify your email.", with an optional UCN (Unique Candidate Number) line that only renders when a UCN is supplied — exactly the legacy@if($ucn)guard. The new schema has no UCN field yet, so the line stays hidden. - Password reset reuses the same shell with reset wording (subject "Your Password Reset OTP."). This is a deliberate, owner-approved divergence — the legacy reused the verification copy for resets.
- Email verification is byte-for-byte legacy: subject "Your Email Verification
OTP.", body "This is your OTP
renderWelcomeEmail({ name, userType })reproducesemails/account_creation: theHi {name},greeting, the "Welcome to Unswayed" heading, and an applicant- or employer-specific paragraph.
Every interpolated value (OTP, UCN, name) is HTML-escaped via the shared
escapeHtml helper, so a malicious name can't inject markup.
When the welcome email is sent
Once the user verifies their email: AuthService.verifyOtp sends it right
after setVerified. (The legacy sent it at account creation / profile completion;
sending on verification was an explicit product choice.)
The greeting name is derived from the profile via UsersService.findByIdWithProfile:
an applicant's firstName middleName lastName, or — since the new schema stores no
person name for employers — the employer's company name.
Gotcha — it's best-effort. The welcome send is awaited inside a
try/catchinverifyOtp; a failure is logged and swallowed so an SMTP outage can never fail the verification itself. This mirrors the legacy behaviour, where the email was dispatched onto a queue (fire-and-forget) rather than sent inline.
Source of truth
Legacy originals live in RALLi-Technologies/backend: app/Mail/OtpEmail.php,
app/Mail/AccountCreationEmail.php, and resources/views/emails/{otp,account_creation}.blade.php.
See ADR-0019 in docs/DECISIONS.md.