Skip to main content

Email

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)

TemplateSubject (Greek)Sent byTrigger
quoteCreatedTemplate"Η προσφορά σας είναι έτοιμη"OrdersServiceQuote published to customer, or order created with known user
awaitingPaymentTemplate"...έτοιμη για πληρωμή"OrdersServicepaymentStatus → awaiting_payment
paymentConfirmedTemplate"Η πληρωμή σας επιβεβαιώθηκε"PaymentsServicePayment verified
buildStartedTemplate"Η κατασκευή...ξεκίνησε"BuildsServicebuildStatus → in_progress
readyToShipTemplate"...έτοιμο για αποστολή"OrdersServicefulfillmentStatus → ready
shippedTemplate"...απεστάλη"OrdersServicefulfillmentStatus → shipped; includes tracking number + carrier
deliveredTemplate"...παραδόθηκε"OrdersServicefulfillmentStatus → completed
cancelledTemplate"...ακυρώθηκε"OrdersServiceorderStatus → cancelled
refundedTemplate"Επιστροφή χρημάτων"OrdersServicepaymentStatus → refunded
orderClaimedTemplate"...συνδέθηκε με τον λογαριασμό σας"ClaimServiceOrder claimed via claim code
warrantyExpiringTemplate"Η εγγύησή σας λήγει σε N ημέρες"WarrantyNotificationsServiceDaily cron, 30-day window
verifyEmailTemplate"Verify your email"Better AuthAccount registration
resetPasswordTemplate"Reset your password"Better AuthPassword reset request

Staff-facing templates

TemplateSent byTrigger
quoteAcceptedStaffTemplatePortalOrdersServiceCustomer accepts quote via portal
Waitlist notificationWaitlistServiceNew 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

SettingValue
Hostsmtppro.zoho.eu
Port587
Securefalse (STARTTLS, not SSL on connect)
AuthZoho SMTP user + password
Rate limit200ms delay between bulk sends

Required Environment Variables

VariableNotes
SMTP_HOSTsmtppro.zoho.eu
SMTP_PORT587
SMTP_USERFull Zoho email address
SMTP_PASSZoho account password (rotate if compromised)
SMTP_SECUREfalse for port 587
EMAIL_FROMDisplay name + email, e.g. PCMR.gr <[email protected]>
STAFF_NOTIFY_EMAILStaff email for quote-accepted notifications
WAITLIST_NOTIFY_EMAILComma-separated list for waitlist notifications