CODEX // oaoisme wiki

§ unswayed-backend · Authentication

Transactional emails

updated 2026-06-03

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 when MAIL_HOST is 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 from MAIL_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 legacy emails/otp view: a Bootstrap 5.3.2 CDN shell, body { padding: 20px }, and the Cloudinary RALLi-logo footer (the shared shell()).
    • 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.
  • renderWelcomeEmail({ name, userType }) reproduces emails/account_creation: the Hi {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/catch in verifyOtp; 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.