Skip to main content

Mail & Email

The platform handles email in two directions: outbound transactional emails delivered via SQS + Amazon SES, and inbound email ingestion for job applications and CV submissions via SES inbound → S3 → SQS.

The Bull/Nodemailer/IMAP-polling architecture was retired in May 2026 — see the AWS-native migration for the full context.

Outbound Email

Architecture

The MailModule is registered as @Global(), making MailService available to all modules without explicit imports. The consumer extends a shared BaseSqsConsumer that owns the long-poll loop, batch receive, visibility-extension on failure, and poison-pill handling — SqsMailConsumer only implements parsePayload and handle.

Transport selection — MAIL_TRANSPORT

SqsMailConsumer dispatches the actual send to one of two transports, selected by the MAIL_TRANSPORT env var read at boot:

ValueTransportWhen to use
ses (default)AWS SES SendEmail (SDK call)When SES production access is granted and you want SES's deliverability + DKIM signing in the sender domain. Currently active on both dev and prod.
smtpNodemailer SMTP via send.one.com:465 (creds in SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASSWORD)When SES is sandboxed or denied and you need to send to arbitrary recipients (maintained as a fallback).

The queue layer is the same in both modes — SQS still owns retry, idempotency, and DLQ. Only the leaf send call swaps. Logs make the active transport explicit:

[SqsMailConsumer] Mail transport=smtp host=send.one.com:465 from=no-reply@aiqlick.com

[SqsMailConsumer] Mail send OK (smtp) (618ms) {"emailId":"…","recipient":"…","messageId":"<…@aiqlick.com>","transport":"smtp"}

Flipping back to SES once production access lands is a pure env change — set MAIL_TRANSPORT=ses, stop + remove + run the container so --env-file is re-read, no code change needed.

Delivery Flow

  1. A service calls MailService.queueMail() or a convenience method (e.g., sendVerificationEmail).
  2. The subject is sanitized at the boundary (see next section).
  3. An Email record is created with status = PENDING in the database.
  4. The { emailId } payload is enqueued onto SQS (MAIL_QUEUE_URL env var).
  5. SqsMailConsumer long-polls (20s wait, batches of 10), loads the Email row, and calls SES SendEmail.
  6. On success: row updated to SENT with sentAt timestamp; SQS message deleted.
  7. On failure: row updated to FAILED; SQS visibility timer reset to 30s for redrive (max 3 attempts → DLQ).

Idempotency

Before each SES call, the consumer checks email.status === 'SENT' and skips if true — guards against double-sends when SQS redelivers a message after a successful SES send but before the delete. The DLQ (aiqlick-mail-*-dlq) collects unrecoverable failures with 14-day retention; a CloudWatch alarm fires when DLQ depth > 0.

Subject sanitization

Every call to MailService.queueMail(recipient, subject, content, …) runs the subject through a single sanitizer before the Email row is created, so callers don't each have to reimplement the same guards:

StepRuleWhy
1Strip \r / \n (replace with a single space)SMTP header-injection defense. Nodemailer rejects headers containing newlines, which would silently fail the whole send. A user who files a ticket with Subject: …\r\nBcc: attacker@evil.com cannot smuggle a BCC.
2Collapse runs of whitespace, trim endsTabs and double-spaces render as an untidy subject line; normalize to single spaces.
3Truncate to 200 chars with an ellipsis ()Past ~200 chars SMTP relays line-fold subjects in unpredictable ways and many email clients truncate mid-word. 200 is a pragmatic cap that keeps the common-case intact (recruiter invites, support replies) while protecting against a 5k-char CV filename-as-subject.

If the cleaned subject differs from the original, MailService logs a WARN with the before/after lengths and whether the newline branch fired — so a regression where some upstream caller starts producing bad subjects shows up in logs without a separate monitor.

The sanitizer is internal to MailService — callers pass whatever subject they want and receive the sanitized value in the persisted Email.subject. There is no raw escape hatch; the sanitizer is mandatory.

Email Templates

TemplateFunction
Branded HTMLgenerateEmailTemplate() -- responsive layout with AIQLick branding, header, body, buttons (up to 3)
Calendar invitationgenerateCalendarInvitationEmail() -- event details card, attendee list, RSVP buttons, status badges
Interview invitationgenerateInterviewInvitationEmail() -- interview-specific calendar template
Meeting invitationgenerateMeetingInvitationEmail() -- meeting-specific calendar template with RSVP links
RSVP confirmationgenerateRSVPConfirmationEmail() -- confirms attendee response

Convenience Methods

16 purpose-built methods cover all transactional email types:

  • Auth: sendVerificationEmail (OTP), sendPasswordResetEmail (24h TTL, SUP-00006), sendTwoFactorCode (10-min PIN)
  • Onboarding: sendInvitationEmail, sendActivationInvitationEmail (7-day link), sendPasswordSetupEmail, sendTalentOnboardingEmail (branded welcome for talent invited by a company)
  • Team: sendTeamMemberInvitationEmail (branded company invitation with inviter name, company name, and role)
  • Messaging: sendMessageNotificationEmail (new message alert with sender name, message preview, and conversation link)
  • Recruitment: sendHighMatchTalentNotification (match score alert)
  • Scheduling: sendInterviewSlotSelectionEmail, sendInterviewConfirmationEmail, sendMeetingInvitationEmail, sendMeetingRescheduledEmail, sendMeetingCancelledEmail, sendMeetingReminderEmail

Timezone Formatting

All dates/times in outbound emails must use timeZone: 'Europe/Stockholm' in Intl.DateTimeFormat options. The platform stores dates in UTC, but users are in European timezones — emails are formatted in CET/CEST to match what users see in the UI.

The canonical constant is defined in calendar-invitation.template.ts:

const EMAIL_TIMEZONE = 'Europe/Stockholm';

Every scheduling email method (sendInterviewSlotSelectionEmail, sendInterviewConfirmationEmail, sendMeetingInvitationEmail, etc.) must pass this timezone to all Intl.DateTimeFormat calls. Omitting it causes times to render in the server's system timezone (UTC on EC2), producing 1–2 hour offsets.

warning

When adding new email methods with date formatting, always include timeZone: 'Europe/Stockholm' in every Intl.DateTimeFormat call. This was the root cause of SUP-00042.

Attachments (SUP-00136, 2026-04-21)

MailService.queueMail(recipient, subject, content, ipAddress?, country?, attachments?) accepts an optional array of attachments:

attachments?: Array&lt;{ filename: string; contentBase64: string; contentType?: string }&gt;

The Email model gained an attachments Json? column that stores the array as-is. MailProcessor decodes each entry's base64 payload into a Buffer and passes the list to nodemailer.sendMail({ attachments: ... }).

The first consumer is the interview invitation email, which now ships an RFC 5545 .ics calendar attachment built by src/common/mail/utils/ics.util.ts:

  • METHOD=REQUEST for scheduled + rescheduled invites, METHOD=CANCEL for cancellations
  • UID derived from recipient email + event scheduledAt so Google/Outlook/Apple Calendar import updates as the same event
  • SEQUENCE incremented on reschedules so calendar clients apply the change instead of creating a duplicate
  • ATTENDEE lines per meeting attendee, URL + DESCRIPTION carrying the Jitsi join link and any reschedule reason

See MailService.buildInterviewIcsAttachment() for the call site; sendInterviewInvitationEmail, sendInterviewRescheduledEmail, and sendInterviewCancelledEmail all flow through it.

Configuration

Env varPurpose
MAIL_BACKEND=sqsActivates SqsMailConsumer. Idle if unset.
MAIL_TRANSPORT=smtp|sesLeaf send target. Default ses. Both dev and prod currently set ses.
MAIL_QUEUE_URLSQS URL for aiqlick-mail-{env}. Required.
SES_FROM_ADDRESSSender address (e.g. no-reply@aiqlick.com). Falls back to SMTP_FROM for backward compatibility.
SMTP_HOST / SMTP_PORTRequired when MAIL_TRANSPORT=smtp. Currently send.one.com:465.
SMTP_USER / SMTP_PASSWORDRequired when MAIL_TRANSPORT=smtp.
AWS_REGIONDefaults to eu-north-1.

Monitor delivery via the Email table (queryable through the admin GraphQL API), the SQS console (aiqlick-mail-* and *-dlq depths), or X-Ray traces (each send call shows up as a subsegment when XRAY_ENABLED=true — SES hops trace into the SDK call, SMTP hops do not).

The Joi validation schema in app.module.ts accepts both SMTP_* and SES-only configurations — only the keys for the active transport are required. All REDIS_* keys were removed during the AWS-native migration and are no longer read.

SES production status

Case 177763885000822 requesting SES production access for the eu-north-1 account was granted (aws sesv2 get-account reports ReviewDetails.Status = GRANTED, ProductionAccessEnabled: true, quota 50,000/24h at 14/sec).

Both dev and prod environments have been successfully cut over to run MAIL_TRANSPORT=ses — outbound mail flows natively through Amazon SES. The old SMTP Nodemailer configuration is maintained as a fallback in the codebase but is not actively used.

Delivery metrics, DKIM signatures, and bounce/complaint suppression lists are active and managed via the AWS SES console in the Stockholm (eu-north-1) region.

Troubleshooting outbound delivery

Inspecting a FAILED email

adminListEmails(input: AdminListEmailsInput) returns { id, recipient, subject, status, createdAt } and supports search, status, startDate, endDate. Filter by status: "FAILED" or search: "<recipient or subject>" to narrow down.

adminGetEmail(emailId) returns richer fields — content, subject, recipient, status, sentAt, createdAt, ipAddress, country — but does not expose the SMTP error message. To see the actual failure reason you have to read Email.errorMessage and Email.retryCount from the database. See RDS Port-Forwarding for the SSM tunnel command.

Transient SMTP/SES burst vs code bug

When a batch of emails shows status: FAILED clustered in time, check whether the failure is transient (SMTP/SES side) or persistent (our code, the recipient, or our sender reputation):

Diagnostic checklist:

  1. adminListEmails(input: { limit: 100, search: "&lt;recipient&gt;" }) — pull the full delivery history for that recipient.
  2. Count FAILED vs SENT around the incident window. A dense cluster (10+ failures in <2 min) followed by a clean SENT run for the same address is the transient signature.
  3. If failures are spread across the day with no clean run afterwards, the address is persistently broken (hard bounce, spam flag) or the sender method has a code bug — escalate to an engineer.
  4. If FAILED emails span multiple recipients simultaneously, suspect SES-wide throttling, a credentials issue, or sender reputation damage — check SES sending statistics in the AWS console (eu-north-1).
Reference incident — 2026-04-21 19:51 UTC

22 emails to usman.idrees@qualipro.se failed inside a ~2-minute burst during a bulk "move to TEST + assignTester + reply" operation on 11 tickets: 11× You have been set as tester on SUP-XXX followed by 11× Reply on SUP-XXX. Two and a half hours later, Status updated on SUP-XXX emails to the same address all SENT, and every subsequent tester/reply email has SENT successfully since. Textbook transient 4xx throttle — no code fix was needed. If we want to reduce the blast radius of bursts like this, the remediation is inter-message backoff on bulk operations and/or a scheduled re-queue for FAILED emails that hit an SMTP 4xx — tracked as a separate ticket, not tied to the original bug that triggered the investigation.

Inbound Email

Architecture (post-migration)

Inbound email processing runs in background-tasks. The 30s IMAP polling loop was retired in May 2026 in favour of an event-driven SES → S3 → SQS path with sub-5s latency.

The receiving subdomain is separate from the apex (inbound.aiqlick.com for prod, inbound-dev.aiqlick.com for dev) so the apex MX records (which still point to one.com mail) are unaffected — info@aiqlick.com and other normal mailboxes keep working untouched.

Company Routing

Emails are routed to companies via plus-addressing:

jobs+{company-slug}@inbound.aiqlick.com    # creates a Job record
cv+{company-slug}@inbound.aiqlick.com # creates a User/Talent/CV record

The tag is extracted, normalized (lowercase, spaces to hyphens, special characters stripped), and matched against Company.slug. If no company matches, the email is stored but not linked.

Processing Statuses

StatusDescription
RECEIVEDPersisted from S3, initial state
ROUTEDCompany resolved successfully
IGNOREDNo company tag in the recipient address
FAILEDPersisted but company_not_found
PROCESSINGCV/Job parsing in flight (CV variant)
COMPLETEDPipeline finished, downstream rows created

Processing Pipeline

  1. SES inbound rule matches the recipient (cvs-to-s3-{env} or jobs-to-s3-{env}) and writes the raw RFC822 to S3.
  2. S3 EventBridge rule fires on ObjectCreated, routing to the appropriate SQS queue.
  3. SQS consumer (SqsInboundEmailConsumer, sharing the same BaseSqsConsumer plumbing as the mail consumer) downloads the raw bytes, parses headers, resolves the company tag, and writes an InboundEmail row.
  4. CV variant only — the consumer's on_persisted callback immediately invokes _process_pending_cv_emails() which runs CV extraction and creates User + Talent + CV records. Without this hand-off the row would sit in RECEIVED forever (the IMAP loop that used to do this isn't running in SQS mode — fixed in commit 1e0ce9c).
  5. Deduplication — synthetic UID is sha256(s3-key)[:8] so the (mailbox, folder, uid) unique constraint catches SQS redeliveries.

Configuration

Env varPurpose
INBOUND_EMAIL_BACKEND=sqsActivates the SQS jobs consumer
INBOUND_CV_BACKEND=sqsActivates the SQS CV consumer
INBOUND_EMAIL_QUEUE_URLSQS URL for aiqlick-inbound-jobs-{env}
INBOUND_CV_QUEUE_URLSQS URL for aiqlick-inbound-cvs-{env}
INBOUND_EMAIL_S3_BUCKETS3 bucket name for raw RFC822 (e.g. aiqlick-inbound-emails-dev)
INBOUND_EMAIL_MAILBOX_ADDRESSjobs@inbound-dev.aiqlick.com (synthetic mailbox row for FK)
INBOUND_CV_MAILBOX_ADDRESScv@inbound-dev.aiqlick.com

Data Model

InboundMailbox (IMAP config), InboundEmail (content and processing state), InboundEmailAttachment (S3 keys and hashes), InboundEmailParseAttempt (parse status and output).

query {
inboundEmails(filter: { companySlug: "acme-corp", processingStatus: "COMPLETED", limit: 20 }) {
items { id subject fromAddress processingStatus company { name } }
total hasMore
}
}