Transactional email is sent via Nodemailer through Zoho SMTP. All email templates are written in HTML (no templating engine — pure TypeScript functions returning strings).
Transport Configuration
// src/mail/mailer.ts
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, // smtppro.zoho.eu
port: Number(process.env.SMTP_PORT ?? 587),
secure: process.env.SMTP_SECURE === "true", // false for port 587 (STARTTLS)
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
From address: EMAIL_FROM env var, e.g. PCMR.gr <[email protected]>
The transporter is verified at API startup via transporter.verify() — failures are caught and logged (.catch(() => {})) without crashing the server, since SMTP might be temporarily unavailable.
MailService Methods
send(options) — sends email, logs success/failure, throws on error. Use when email delivery failure should be surfaced to the caller.
sendSilently(options) — fire-and-forget, never throws. Use for non-critical notifications (build updates, staff notifications) where a delivery failure should not break the operation.
Most email calls in the codebase use sendSilently — the user's operation should succeed even if email fails.
Base Template
All templates use baseTemplate() from src/mail/templates/base.template.ts:
- Dark theme: background
#07060d, surface#0f0e1a, purple accent#800080 - Max width: 600px, centered, responsive
- Components:
heading(),paragraph(),mutedParagraph(),ctaButton(),infoRow(),infoTable(),divider(),statusChip()
All Email Templates
Customer-facing templates (15 total)
| Template | Subject (Greek) | Sent by | Trigger |
|---|---|---|---|
quoteCreatedTemplate | "Η προσφορά σας είναι έτοιμη" | OrdersService | Quote published to customer, or order created with known user |
awaitingPaymentTemplate | "...έτοιμη για πληρωμή" | OrdersService | paymentStatus → awaiting_payment |
paymentConfirmedTemplate | "Η πληρωμή σας επιβεβαιώθηκε" | PaymentsService | Payment verified |
buildStartedTemplate | "Η κατασκευή...ξεκίνησε" | BuildsService | buildStatus → in_progress |
readyToShipTemplate | "...έτοιμο για αποστολή" | OrdersService | fulfillmentStatus → ready |
shippedTemplate | "...απεστάλη" | OrdersService | fulfillmentStatus → shipped; includes tracking number + carrier |
deliveredTemplate | "...παραδόθηκε" | OrdersService | fulfillmentStatus → completed |
cancelledTemplate | "...ακυρώθηκε" | OrdersService | orderStatus → cancelled |
refundedTemplate | "Επιστροφή χρημάτων" | OrdersService | paymentStatus → refunded |
orderClaimedTemplate | "...συνδέθηκε με τον λογαριασμό σας" | ClaimService | Order claimed via claim code |
warrantyExpiringTemplate | "Η εγγύησή σας λήγει σε N ημέρες" | WarrantyNotificationsService | Daily cron, 30-day window |
verifyEmailTemplate | "Verify your email" | Better Auth | Account registration |
resetPasswordTemplate | "Reset your password" | Better Auth | Password reset request |
Staff-facing templates
| Template | Sent by | Trigger |
|---|---|---|
quoteAcceptedStaffTemplate | PortalOrdersService | Customer accepts quote via portal |
| Waitlist notification | WaitlistService | New waitlist entry |
sendSilently Pattern
// Correct — used throughout the codebase
this.mailService.sendSilently({
to: user.email,
subject: 'Order update',
html: readyToShipTemplate({ order, user }),
});
// no await, no try/catch needed
// Wrong — would break the update if email fails
await this.mailService.send({ ... });
The only place where email delivery failure should propagate is during auth flows (Better Auth's sendVerificationEmail / sendResetPassword) where a failed delivery means the user can't complete the flow.
Warranty Expiry Cron
WarrantyNotificationsService runs daily at 09:00 Athens time:
@Cron('0 9 * * *', { timeZone: 'Europe/Athens' })
async sendExpiryNotifications() {
const warranties = await this.prisma.orderWarranty.findMany({
where: {
active: true,
endDate: {
gte: new Date(),
lte: addDays(new Date(), 30),
},
},
take: 500,
});
for (const warranty of warranties) {
const daysLeft = differenceInDays(warranty.endDate, new Date());
await this.mailService.sendSilently({
to: warranty.order.user.email,
html: warrantyExpiringTemplate({ warranty, daysLeft }),
});
await sleep(200); // 200ms between sends for Zoho rate limits
}
}
Cap: 500 warranties per run. If more than 500 are expiring within 30 days, the remainder will be picked up on the next daily run (assuming they still fall within the 30-day window).
Urgency color: The email template changes the urgency color at ≤7 days remaining.
SMTP Details
| Setting | Value |
|---|---|
| Host | smtppro.zoho.eu |
| Port | 587 |
| Secure | false (STARTTLS, not SSL on connect) |
| Auth | Zoho SMTP user + password |
| Rate limit | 200ms delay between bulk sends |
Required Environment Variables
| Variable | Notes |
|---|---|
SMTP_HOST | smtppro.zoho.eu |
SMTP_PORT | 587 |
SMTP_USER | Full Zoho email address |
SMTP_PASS | Zoho account password (rotate if compromised) |
SMTP_SECURE | false for port 587 |
EMAIL_FROM | Display name + email, e.g. PCMR.gr <[email protected]> |
STAFF_NOTIFY_EMAIL | Staff email for quote-accepted notifications |
WAITLIST_NOTIFY_EMAIL | Comma-separated list for waitlist notifications |