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:
| Value | Transport | When 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. |
smtp | Nodemailer 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
- A service calls
MailService.queueMail()or a convenience method (e.g.,sendVerificationEmail). - The subject is sanitized at the boundary (see next section).
- An
Emailrecord is created withstatus = PENDINGin the database. - The
{ emailId }payload is enqueued onto SQS (MAIL_QUEUE_URLenv var). SqsMailConsumerlong-polls (20s wait, batches of 10), loads theEmailrow, and calls SESSendEmail.- On success: row updated to
SENTwithsentAttimestamp; SQS message deleted. - 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:
| Step | Rule | Why |
|---|---|---|
| 1 | Strip \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. |
| 2 | Collapse runs of whitespace, trim ends | Tabs and double-spaces render as an untidy subject line; normalize to single spaces. |
| 3 | Truncate 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
| Template | Function |
|---|---|
| Branded HTML | generateEmailTemplate() -- responsive layout with AIQLick branding, header, body, buttons (up to 3) |
| Calendar invitation | generateCalendarInvitationEmail() -- event details card, attendee list, RSVP buttons, status badges |
| Interview invitation | generateInterviewInvitationEmail() -- interview-specific calendar template |
| Meeting invitation | generateMeetingInvitationEmail() -- meeting-specific calendar template with RSVP links |
| RSVP confirmation | generateRSVPConfirmationEmail() -- 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.
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<{ filename: string; contentBase64: string; contentType?: string }>
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=REQUESTfor scheduled + rescheduled invites,METHOD=CANCELfor cancellationsUIDderived from recipient email + eventscheduledAtso Google/Outlook/Apple Calendar import updates as the same eventSEQUENCEincremented on reschedules so calendar clients apply the change instead of creating a duplicateATTENDEElines per meeting attendee,URL+DESCRIPTIONcarrying 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 var | Purpose |
|---|---|
MAIL_BACKEND=sqs | Activates SqsMailConsumer. Idle if unset. |
MAIL_TRANSPORT=smtp|ses | Leaf send target. Default ses. Both dev and prod currently set ses. |
MAIL_QUEUE_URL | SQS URL for aiqlick-mail-{env}. Required. |
SES_FROM_ADDRESS | Sender address (e.g. no-reply@aiqlick.com). Falls back to SMTP_FROM for backward compatibility. |
SMTP_HOST / SMTP_PORT | Required when MAIL_TRANSPORT=smtp. Currently send.one.com:465. |
SMTP_USER / SMTP_PASSWORD | Required when MAIL_TRANSPORT=smtp. |
AWS_REGION | Defaults 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:
adminListEmails(input: { limit: 100, search: "<recipient>" })— pull the full delivery history for that recipient.- 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.
- 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.
- 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).
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
| Status | Description |
|---|---|
RECEIVED | Persisted from S3, initial state |
ROUTED | Company resolved successfully |
IGNORED | No company tag in the recipient address |
FAILED | Persisted but company_not_found |
PROCESSING | CV/Job parsing in flight (CV variant) |
COMPLETED | Pipeline finished, downstream rows created |
Processing Pipeline
- SES inbound rule matches the recipient (
cvs-to-s3-{env}orjobs-to-s3-{env}) and writes the raw RFC822 to S3. - S3 EventBridge rule fires on
ObjectCreated, routing to the appropriate SQS queue. - SQS consumer (
SqsInboundEmailConsumer, sharing the sameBaseSqsConsumerplumbing as the mail consumer) downloads the raw bytes, parses headers, resolves the company tag, and writes anInboundEmailrow. - CV variant only — the consumer's
on_persistedcallback immediately invokes_process_pending_cv_emails()which runs CV extraction and createsUser+Talent+CVrecords. Without this hand-off the row would sit inRECEIVEDforever (the IMAP loop that used to do this isn't running in SQS mode — fixed in commit1e0ce9c). - Deduplication — synthetic UID is
sha256(s3-key)[:8]so the(mailbox, folder, uid)unique constraint catches SQS redeliveries.
Configuration
| Env var | Purpose |
|---|---|
INBOUND_EMAIL_BACKEND=sqs | Activates the SQS jobs consumer |
INBOUND_CV_BACKEND=sqs | Activates the SQS CV consumer |
INBOUND_EMAIL_QUEUE_URL | SQS URL for aiqlick-inbound-jobs-{env} |
INBOUND_CV_QUEUE_URL | SQS URL for aiqlick-inbound-cvs-{env} |
INBOUND_EMAIL_S3_BUCKET | S3 bucket name for raw RFC822 (e.g. aiqlick-inbound-emails-dev) |
INBOUND_EMAIL_MAILBOX_ADDRESS | jobs@inbound-dev.aiqlick.com (synthetic mailbox row for FK) |
INBOUND_CV_MAILBOX_ADDRESS | cv@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
}
}