/opt/canhelp/apps/api/src/lib
Edit: /opt/canhelp/apps/api/src/lib/email.ts (52708B)
import { Resend } from 'resend'
import nodemailer from 'nodemailer'
import { db } from '../db.js'
import { settings as settingsTable, emailTemplates } from '@canhelp/db'
import { inArray, eq } from 'drizzle-orm'
import { logActivity } from './activity.js'
// ─── Config cache (TTL = 60s) ─────────────────────────────────────────────
type SmtpConfig = {
host: string; port: number; username: string; password: string
fromName: string; fromEmail: string; encryption: 'none' | 'tls' | 'ssl'
}
type GeneralConfig = { siteName: string; siteUrl: string }
function firstNonEmpty(...values: Array
): string {
for (const value of values) {
const normalized = value?.trim()
if (normalized) return normalized
}
return ''
}
function envValue(...keys: string[]): string {
return firstNonEmpty(...keys.map((key) => process.env[key]))
}
let _smtpCache: SmtpConfig | null = null
let _generalCache: GeneralConfig | null = null
let _cacheAt = 0
const CACHE_TTL = 60_000
async function loadConfig(): Promise<{ smtp: SmtpConfig; general: GeneralConfig }> {
if (Date.now() - _cacheAt < CACHE_TTL && _smtpCache && _generalCache) {
return { smtp: _smtpCache, general: _generalCache }
}
const rows = await db
.select()
.from(settingsTable)
.where(
inArray(settingsTable.key, [
'smtp.host', 'smtp.port', 'smtp.username', 'smtp.password',
'smtp.fromName', 'smtp.fromEmail', 'smtp.encryption',
'general.siteName', 'general.siteUrl',
]),
)
.catch(() => [])
const kv: Record = {}
for (const r of rows) kv[r.key] = r.value
const smtpHost = firstNonEmpty(kv['smtp.host'], envValue('SMTP_HOST', 'MAIL_HOST', 'EMAIL_HOST'))
const smtpPortRaw = firstNonEmpty(kv['smtp.port'], envValue('SMTP_PORT', 'MAIL_PORT', 'EMAIL_PORT'))
const smtpUsername = firstNonEmpty(kv['smtp.username'], envValue('SMTP_USERNAME', 'SMTP_USER', 'MAIL_USERNAME', 'MAIL_USER', 'EMAIL_USER'))
const smtpPassword = firstNonEmpty(kv['smtp.password'], envValue('SMTP_PASSWORD', 'SMTP_PASS', 'MAIL_PASSWORD', 'MAIL_PASS', 'EMAIL_PASSWORD', 'EMAIL_PASS'))
const smtpFromName = firstNonEmpty(kv['smtp.fromName'], envValue('SMTP_FROM_NAME', 'MAIL_FROM_NAME', 'EMAIL_FROM_NAME'), 'CanHelp')
const smtpFromEmail = firstNonEmpty(kv['smtp.fromEmail'], envValue('SMTP_FROM_EMAIL', 'MAIL_FROM_EMAIL', 'EMAIL_FROM', 'EMAIL_FROM_ADDRESS', 'FROM_EMAIL'), 'noreply@canhelp.com')
const smtpEncryption = firstNonEmpty(kv['smtp.encryption'], envValue('SMTP_ENCRYPTION', 'MAIL_ENCRYPTION', 'EMAIL_ENCRYPTION')) as SmtpConfig['encryption'] | ''
const siteName = firstNonEmpty(kv['general.siteName'], envValue('SITE_NAME'), 'CanHelp')
const siteUrl = firstNonEmpty(kv['general.siteUrl'], envValue('WEB_URL', 'NEXT_PUBLIC_APP_URL'), 'http://localhost:3000')
_smtpCache = {
host: smtpHost,
port: Number(smtpPortRaw || 587),
username: smtpUsername,
password: smtpPassword,
fromName: smtpFromName,
fromEmail: smtpFromEmail,
encryption: smtpEncryption || 'tls',
}
_generalCache = {
siteName,
siteUrl,
}
_cacheAt = Date.now()
return { smtp: _smtpCache, general: _generalCache }
}
async function logEmailEvent(opts: {
event: 'email.sent' | 'email.failed' | 'email.dev'
to: string
subject: string
provider: 'smtp' | 'resend' | 'console' | 'none'
error?: unknown
}) {
await logActivity({
event: opts.event,
userEmail: opts.to,
details: {
to: opts.to,
subject: opts.subject,
provider: opts.provider,
error: opts.error instanceof Error ? opts.error.message : opts.error ? String(opts.error) : null,
},
})
}
/** Invalidate in-memory config cache (call after saving settings) */
export function invalidateEmailCache() {
_cacheAt = 0
}
// ─── Template loader ──────────────────────────────────────────────────────
async function loadTemplate(key: string): Promise<{ subject: string; body: string } | null> {
const [row] = await db
.select()
.from(emailTemplates)
.where(eq(emailTemplates.key, key))
.catch(() => [])
return row ?? null
}
function renderTemplate(tpl: string, vars: Record): string {
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`)
}
// ─── Core send ────────────────────────────────────────────────────────────
async function send(to: string, subject: string, html: string) {
const { smtp } = await loadConfig()
// 1. Custom SMTP via nodemailer
if (smtp.host) {
try {
const secure = smtp.encryption === 'ssl'
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port,
secure,
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
...(smtp.encryption === 'tls' ? { requireTLS: true } : {}),
tls: { rejectUnauthorized: process.env.NODE_ENV !== 'development' },
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 15_000,
})
await transporter.sendMail({
from: `"${smtp.fromName}" <${smtp.fromEmail}>`,
to,
subject,
html,
})
await logEmailEvent({ event: 'email.sent', to, subject, provider: 'smtp' })
return
} catch (err) {
console.error('[Email] SMTP error:', err)
await logEmailEvent({ event: 'email.failed', to, subject, provider: 'smtp', error: err })
// fall through to Resend
}
}
// 2. Resend fallback
const resendApiKey = process.env.RESEND_API_KEY?.trim()
if (resendApiKey) {
const resend = new Resend(resendApiKey)
const from = envValue('FROM_EMAIL', 'SMTP_FROM_EMAIL', 'MAIL_FROM_EMAIL', 'EMAIL_FROM', 'EMAIL_FROM_ADDRESS') || 'noreply@canhelp.com'
try {
const result = await resend.emails.send({ from, to, subject, html })
if ((result as any)?.error) {
throw new Error(typeof (result as any).error === 'string' ? (result as any).error : JSON.stringify((result as any).error))
}
await logEmailEvent({ event: 'email.sent', to, subject, provider: 'resend' })
} catch (err) {
console.error('[Email] Resend error:', err)
await logEmailEvent({ event: 'email.failed', to, subject, provider: 'resend', error: err })
throw err
}
return
}
// 3. Dev console fallback
if (process.env.NODE_ENV === 'production') {
const err = new Error('No email provider configured. Set smtp.* in admin settings or SMTP_HOST/SMTP_PORT/SMTP_USERNAME/SMTP_PASSWORD in environment.')
await logEmailEvent({ event: 'email.failed', to, subject, provider: 'none', error: err })
throw err
}
console.log(`[Email DEV]\nTo: ${to}\nSubject: ${subject}\n${html.replace(/<[^>]+>/g, '').trim()}`)
await logEmailEvent({ event: 'email.dev', to, subject, provider: 'console' })
}
// ─── Helper: render saved template OR use the built-in HTML fallback ──────
async function sendWithTemplate(
templateKey: string,
to: string,
vars: Record,
fallbackSubject: string,
fallbackBody: string,
locale: string = 'el',
) {
const { general } = await loadConfig()
const allVars: Record = { ...vars, siteName: general.siteName, siteUrl: general.siteUrl }
const tpl = (await loadTemplate(`${templateKey}_${locale}`)) ?? (await loadTemplate(templateKey))
if (tpl && tpl.body.trim()) {
const subject = renderTemplate(tpl.subject || fallbackSubject, allVars)
const htmlBody = renderTemplate(tpl.body, allVars)
const html = await layout(subject, htmlBody)
await send(to, subject, html)
} else {
const html = await layout(fallbackSubject, fallbackBody)
await send(to, fallbackSubject, html)
}
}
async function layout(title: string, body: string) {
const { general } = await loadConfig()
return `
${title}
`
}
function btn(text: string, href: string) {
return `${text}`
}
// ─── Email templates ─────────────────────────────────────────────────────────
type Locale = 'el' | 'en' | 'ru' | 'uk'
export async function emailWelcome(opts: { to: string; name: string; role: string; locale?: Locale }) {
const { to, name, role, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const isSpecialist = role === 'specialist'
const copy = {
el: {
subject: `Καλωσόρισες στο ${general.siteName}!`,
greeting: `Γεια ${name},`,
body: isSpecialist
? `Ο λογαριασμός σου ως Ειδικός είναι έτοιμος.`
: `Ο λογαριασμός σου είναι έτοιμος. Δημοσίευσε την πρώτη σου εργασία!`,
btnText: isSpecialist ? 'Βρες εργασίες →' : 'Δημοσίευσε εργασία →',
},
en: {
subject: `Welcome to ${general.siteName}!`,
greeting: `Hi ${name},`,
body: isSpecialist
? `Your Specialist account is ready.`
: `Your account is ready. Post your first task!`,
btnText: isSpecialist ? 'Find tasks →' : 'Post a task →',
},
ru: {
subject: `Добро пожаловать в ${general.siteName}!`,
greeting: `Привет, ${name}!`,
body: isSpecialist
? `Ваш аккаунт Специалиста готов.`
: `Ваш аккаунт готов. Опубликуйте первый заказ!`,
btnText: isSpecialist ? 'Найти заказы →' : 'Опубликовать заказ →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.subject}
${copy.greeting}
${copy.body}
${btn(copy.btnText, isSpecialist ? `${WEB}/tasks` : `${WEB}/tasks/new`)}
`
await sendWithTemplate('registration_success', to, { userName: name, link: WEB }, copy.subject, fallbackBody, locale)
}
export async function emailVerification(opts: { to: string; name: string; link: string; locale?: Locale }) {
const { to, name, link, locale = 'el' } = opts
const { general } = await loadConfig()
const copy = {
el: {
subject: `Επιβεβαίωση email — ${general.siteName}`,
title: 'Επιβεβαίωση email',
greeting: `Γεια ${name},`,
body: 'Παρακαλώ επιβεβαιώστε τη διεύθυνση email σας:',
btnText: 'Επιβεβαίωση email →',
note: 'Ο σύνδεσμος λήγει σε 24 ώρες.',
},
en: {
subject: `Email verification — ${general.siteName}`,
title: 'Verify your email',
greeting: `Hi ${name},`,
body: 'Please verify your email address:',
btnText: 'Verify email →',
note: 'The link expires in 24 hours.',
},
ru: {
subject: `Подтверждение email — ${general.siteName}`,
title: 'Подтверждение email',
greeting: `Привет, ${name}!`,
body: 'Пожалуйста, подтвердите ваш адрес электронной почты:',
btnText: 'Подтвердить email →',
note: 'Ссылка действительна 24 часа.',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, link)}
${copy.note}
`
await sendWithTemplate('email_verification', to, { userName: name, link }, copy.subject, fallbackBody, locale)
}
export async function emailPasswordReset(opts: { to: string; name: string; link: string; locale?: Locale }) {
const { to, name, link, locale = 'el' } = opts
const { general } = await loadConfig()
const copy = {
el: {
subject: `Επαναφορά κωδικού — ${general.siteName}`,
title: 'Επαναφορά κωδικού',
greeting: `Γεια ${name},`,
body: 'Λάβαμε αίτημα επαναφοράς κωδικού. Πατήστε το κουμπί:',
btnText: 'Επαναφορά κωδικού →',
note: 'Αν δεν κάνατε αυτή την αίτηση, αγνοήστε αυτό το email.',
},
en: {
subject: `Password reset — ${general.siteName}`,
title: 'Password reset',
greeting: `Hi ${name},`,
body: 'We received a request to reset your password. Click the button below:',
btnText: 'Reset password →',
note: 'If you did not request this, please ignore this email.',
},
ru: {
subject: `Сброс пароля — ${general.siteName}`,
title: 'Сброс пароля',
greeting: `Привет, ${name}!`,
body: 'Мы получили запрос на сброс пароля. Нажмите кнопку:',
btnText: 'Сбросить пароль →',
note: 'Если вы не делали этот запрос, просто проигнорируйте это письмо.',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, link)}
${copy.note}
`
await sendWithTemplate('password_reset', to, { userName: name, link }, copy.subject, fallbackBody, locale)
}
export async function emailNewOffer(opts: {
to: string; customerName: string; taskTitle: string; taskId: string
specialistName: string; price: number; locale?: Locale
}) {
const { to, customerName, taskTitle, taskId, specialistName, price, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const link = `${WEB}/tasks/${taskId}`
const copy = {
el: {
subject: `Νέα προσφορά: ${taskTitle}`,
title: 'Νέα προσφορά για την εργασία σου',
greeting: `Γεια ${customerName},`,
body: `Ο/Η ${specialistName} έκανε προσφορά ${price}€ για:`,
btnText: 'Δες την προσφορά →',
},
en: {
subject: `New offer: ${taskTitle}`,
title: 'New offer for your task',
greeting: `Hi ${customerName},`,
body: `${specialistName} submitted an offer of ${price}€ for:`,
btnText: 'View offer →',
},
ru: {
subject: `Новое предложение: ${taskTitle}`,
title: 'Новое предложение на ваш заказ',
greeting: `Привет, ${customerName}!`,
body: `${specialistName} прислал(а) предложение ${price}€ на:`,
btnText: 'Посмотреть →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, link)}
`
await sendWithTemplate('new_offer', to,
{ userName: customerName, taskTitle, taskId, specialistName, price: `${price}€`, link },
copy.subject, fallbackBody, locale)
}
export async function emailNewTask(opts: {
to: string; specialistName: string; taskTitle: string; taskId: string; customerName: string; locale?: Locale
}) {
const { to, specialistName, taskTitle, taskId, customerName, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const link = `${WEB}/tasks/${taskId}`
const copy = {
el: {
subject: `Νέα εργασία: ${taskTitle}`,
title: 'Νέα εργασία διαθέσιμη',
greeting: `Γεια ${specialistName},`,
body: `Ο πελάτης ${customerName} δημοσίευσε νέα εργασία:`,
btnText: 'Δες την εργασία →',
},
en: {
subject: `New task: ${taskTitle}`,
title: 'New task available',
greeting: `Hi ${specialistName},`,
body: `${customerName} posted a new task:`,
btnText: 'View task →',
},
ru: {
subject: `Новый заказ: ${taskTitle}`,
title: 'Доступен новый заказ',
greeting: `Привет, ${specialistName}!`,
body: `${customerName} опубликовал(а) новый заказ:`,
btnText: 'Смотреть заказ →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, link)}
`
await sendWithTemplate('new_task', to,
{ userName: specialistName, taskTitle, taskId, customerName, link },
copy.subject, fallbackBody, locale)
}
export async function emailOfferAccepted(opts: {
to: string; specialistName: string; taskTitle: string; taskId: string; price: number; locale?: Locale
}) {
const { to, specialistName, taskTitle, price, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Αποδοχή προσφοράς: ${taskTitle}`,
title: 'Η προσφορά σου έγινε αποδεκτή! 🎉',
greeting: `Γεια ${specialistName},`,
body: `Ο πελάτης αποδέχτηκε την προσφορά σου ${price}€ για:`,
btnText: 'Άνοιξε τη συνομιλία →',
},
en: {
subject: `Offer accepted: ${taskTitle}`,
title: 'Your offer was accepted! 🎉',
greeting: `Hi ${specialistName},`,
body: `The customer accepted your offer of ${price}€ for:`,
btnText: 'Open the chat →',
},
ru: {
subject: `Предложение принято: ${taskTitle}`,
title: 'Ваше предложение принято! 🎉',
greeting: `Привет, ${specialistName}!`,
body: `Заказчик принял ваше предложение ${price}€ на:`,
btnText: 'Открыть чат →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const body = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, `${WEB}/chat`)}
`
await sendWithTemplate('offer_accepted', to,
{ userName: specialistName, taskTitle, price: String(opts.price), link: `${WEB}/chat` },
copy.subject, body, locale)
}
export async function emailNewMessage(opts: {
to: string; recipientName: string; senderName: string; preview: string; roomId: string; locale?: Locale
}) {
const { to, recipientName, senderName, preview, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Νέο μήνυμα από ${senderName}`,
title: 'Νέο μήνυμα',
greeting: `Γεια ${recipientName},`,
body: `Έλαβες μήνυμα από τον/την ${senderName}:`,
btnText: 'Απάντησε →',
},
en: {
subject: `New message from ${senderName}`,
title: 'New message',
greeting: `Hi ${recipientName},`,
body: `You received a message from ${senderName}:`,
btnText: 'Reply →',
},
ru: {
subject: `Новое сообщение от ${senderName}`,
title: 'Новое сообщение',
greeting: `Привет, ${recipientName}!`,
body: `Вам написал(а) ${senderName}:`,
btnText: 'Ответить →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const body = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, `${WEB}/chat`)}
`
await sendWithTemplate('new_message', to,
{ userName: recipientName, senderName, preview, link: `${WEB}/chat` },
copy.subject, body, locale)
}
export async function emailOfferDeclined(opts: {
to: string; specialistName: string; taskTitle: string; taskId: string; locale?: Locale
}) {
const { to, specialistName, taskTitle, taskId, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const link = `${WEB}/tasks/${taskId}`
const copy = {
el: {
subject: `Η προσφορά σας απορρίφθηκε: ${taskTitle}`,
title: 'Η προσφορά σας απορρίφθηκε',
greeting: `Γεια ${specialistName},`,
body: `Δυστυχώς ο πελάτης απέρριψε την προσφορά σας για:`,
btnText: 'Δες άλλες εργασίες →',
},
en: {
subject: `Your offer was declined: ${taskTitle}`,
title: 'Your offer was declined',
greeting: `Hi ${specialistName},`,
body: `Unfortunately, the customer declined your offer for:`,
btnText: 'Browse more tasks →',
},
ru: {
subject: `Предложение отклонено: ${taskTitle}`,
title: 'Ваше предложение отклонено',
greeting: `Привет, ${specialistName}!`,
body: `К сожалению, заказчик отклонил ваше предложение на:`,
btnText: 'Найти другие заказы →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, `${WEB}/tasks`)}
`
await sendWithTemplate('offer_declined', to,
{ userName: specialistName, taskTitle, taskId, link },
copy.subject, fallbackBody, locale)
}
export async function emailOfferOtherAccepted(opts: {
to: string; specialistName: string; taskTitle: string; taskId: string; locale?: Locale
}) {
const { to, specialistName, taskTitle, taskId, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Επιλέχθηκε άλλος ειδικός: ${taskTitle}`,
title: 'Επιλέχθηκε άλλος ειδικός',
greeting: `Γεια ${specialistName},`,
body: `Ο πελάτης επέλεξε άλλον ειδικό για την εργασία:`,
btnText: 'Δες άλλες εργασίες →',
},
en: {
subject: `Another specialist was chosen: ${taskTitle}`,
title: 'Another specialist was chosen',
greeting: `Hi ${specialistName},`,
body: `The customer chose another specialist for the task:`,
btnText: 'Browse more tasks →',
},
ru: {
subject: `Выбран другой исполнитель: ${taskTitle}`,
title: 'Выбран другой исполнитель',
greeting: `Привет, ${specialistName}!`,
body: `Заказчик выбрал другого исполнителя для задания:`,
btnText: 'Найти другие заказы →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, `${WEB}/tasks`)}
`
await sendWithTemplate('offer_other_accepted', to,
{ userName: specialistName, taskTitle, taskId, link: `${WEB}/tasks` },
copy.subject, fallbackBody, locale)
}
export async function emailTaskUpdated(opts: {
to: string; specialistName: string; taskTitle: string; taskId: string; locale?: Locale
}) {
const { to, specialistName, taskTitle, taskId, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const link = `${WEB}/tasks/${taskId}`
const copy = {
el: {
subject: `Η εργασία ενημερώθηκε: ${taskTitle}`,
title: 'Η εργασία ενημερώθηκε',
greeting: `Γεια ${specialistName},`,
body: `Ο πελάτης ενημέρωσε τη λεπτομέρειες της εργασίας:`,
btnText: 'Δες την εργασία →',
},
en: {
subject: `Task updated: ${taskTitle}`,
title: 'Task details updated',
greeting: `Hi ${specialistName},`,
body: `The customer updated the details of the task:`,
btnText: 'View task →',
},
ru: {
subject: `Задание обновлено: ${taskTitle}`,
title: 'Задание обновлено',
greeting: `Привет, ${specialistName}!`,
body: `Заказчик обновил детали задания:`,
btnText: 'Смотреть заказ →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, link)}
`
await sendWithTemplate('task_updated', to,
{ userName: specialistName, taskTitle, taskId, link },
copy.subject, fallbackBody, locale)
}
export async function emailTaskDeadlineReminder(opts: {
to: string; ownerName: string; taskTitle: string; taskId: string; locale?: Locale
}) {
const { to, ownerName, taskTitle, taskId, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const link = `${WEB}/tasks/${taskId}`
const copy = {
el: {
subject: `Λήξη εργασίας αύριο: ${taskTitle}`,
title: 'Υπενθύμιση προθεσμίας',
greeting: `Γεια ${ownerName},`,
body: `Η εργασία σας λήγει σε 24 ώρες. Αλλάξτε την ημερομηνία ή θα αποσυρθεί αυτόματα από τις δημοσιευμένες εργασίες.`,
btnText: 'Επεξεργασία εργασίας →',
},
en: {
subject: `Task expiring tomorrow: ${taskTitle}`,
title: 'Deadline reminder',
greeting: `Hi ${ownerName},`,
body: `Your task expires in 24 hours. Update the deadline or it will be automatically unpublished.`,
btnText: 'Edit task →',
},
ru: {
subject: `Задание истекает завтра: ${taskTitle}`,
title: 'Напоминание о дедлайне',
greeting: `Привет, ${ownerName}!`,
body: `Ваше задание истекает через 24 часа. Обновите дедлайн, иначе задание будет автоматически снято с публикации.`,
btnText: 'Редактировать задание →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, link)}
`
await sendWithTemplate('deadline_reminder', to,
{ userName: ownerName, taskTitle, taskId, link },
copy.subject, fallbackBody, locale)
}
export async function emailTaskArchived(opts: {
to: string; ownerName: string; taskTitle: string; taskId: string; locale?: Locale
}) {
const { to, ownerName, taskTitle, taskId, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const link = `${WEB}/tasks/${taskId}`
const copy = {
el: {
subject: `Η εργασία αποσύρθηκε αυτόματα: ${taskTitle}`,
title: 'Η εργασία αποσύρθηκε',
greeting: `Γεια ${ownerName},`,
body: `Η εργασία σας έληξε και μεταφέρθηκε αυτόματα σε πρόχειρο. Αλλάξτε την ημερομηνία και δημοσιεύστε την ξανά.`,
btnText: 'Επεξεργασία εργασίας →',
},
en: {
subject: `Task unpublished automatically: ${taskTitle}`,
title: 'Task moved to draft',
greeting: `Hi ${ownerName},`,
body: `Your task has expired and was automatically moved to draft. Update the deadline to republish it.`,
btnText: 'Edit task →',
},
ru: {
subject: `Задание снято с публикации: ${taskTitle}`,
title: 'Задание переведено в черновик',
greeting: `Привет, ${ownerName}!`,
body: `Ваше задание истекло и было автоматически переведено в черновик. Обновите дедлайн, чтобы опубликовать его снова.`,
btnText: 'Редактировать задание →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
${btn(copy.btnText, link)}
`
await sendWithTemplate('task_archived', to,
{ userName: ownerName, taskTitle, taskId, link },
copy.subject, fallbackBody, locale)
}
// ─── Plan lifecycle emails ────────────────────────────────────────────────────
export async function emailPlanActivated(opts: {
to: string; name: string; planName: string; price: string; expiresAt: string; locale?: Locale
}) {
const { to, name, planName, price, expiresAt, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Το πλάνο ${planName} ενεργοποιήθηκε`,
title: `Καλωσόρισες στο ${planName}!`,
body: `Το πλάνο ${planName} ενεργοποιήθηκε επιτυχώς. Κόστος: ${price}€.`,
note: `Ισχύει έως ${expiresAt}.`, btnText: 'Διαχείριση πλάνου →',
},
en: {
subject: `${planName} plan is now active`,
title: `Welcome to ${planName}!`,
body: `Your ${planName} plan has been activated. Charged: ${price}€.`,
note: `Valid until ${expiresAt}.`, btnText: 'Manage plan →',
},
ru: {
subject: `Тариф ${planName} активирован`,
title: `Добро пожаловать в ${planName}!`,
body: `Тариф ${planName} успешно активирован. Списано: ${price}€.`,
note: `Действует до ${expiresAt}.`, btnText: 'Управление тарифом →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
Γεια ${name},
${copy.body}
${copy.note}
${btn(copy.btnText, `${WEB}/pricing`)}
`
await sendWithTemplate('plan_activated', to,
{ userName: name, planName, price, expiresAt, link: `${WEB}/pricing` },
copy.subject, fallbackBody, locale)
}
export async function emailPlanUpgraded(opts: {
to: string; name: string; oldPlanName: string; newPlanName: string
charged: string; expiresAt: string; locale?: Locale
}) {
const { to, name, oldPlanName, newPlanName, charged, expiresAt, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Αναβάθμιση σε ${newPlanName}`,
title: 'Το πλάνο σας αναβαθμίστηκε! 🎉',
body: `Αναβαθμίσατε από ${oldPlanName} σε ${newPlanName}. Χρεώθηκε: ${charged}€ (με αναλογική έκπτωση).`,
note: `Ισχύει έως ${expiresAt}.`, btnText: 'Δες το πλάνο σου →',
},
en: {
subject: `Upgraded to ${newPlanName}`,
title: 'Your plan has been upgraded! 🎉',
body: `You upgraded from ${oldPlanName} to ${newPlanName}. Charged: ${charged}€ (prorated credit applied).`,
note: `Valid until ${expiresAt}.`, btnText: 'View your plan →',
},
ru: {
subject: `Тариф повышен до ${newPlanName}`,
title: 'Ваш тариф повышен! 🎉',
body: `Вы сменили тариф с ${oldPlanName} на ${newPlanName}. Списано: ${charged}€ (учтён остаток текущего периода).`,
note: `Действует до ${expiresAt}.`, btnText: 'Перейти к тарифу →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
Γεια ${name},
${copy.body}
${copy.note}
${btn(copy.btnText, `${WEB}/pricing`)}
`
await sendWithTemplate('plan_upgraded', to,
{ userName: name, oldPlanName, newPlanName, charged, expiresAt, link: `${WEB}/pricing` },
copy.subject, fallbackBody, locale)
}
export async function emailPlanDowngradeScheduled(opts: {
to: string; name: string; currentPlanName: string; newPlanName: string
scheduledDate: string; locale?: Locale
}) {
const { to, name, currentPlanName, newPlanName, scheduledDate, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: 'Υποβάθμιση πλάνου προγραμματισμένη',
title: 'Η αλλαγή πλάνου προγραμματίστηκε',
body: `Στις ${scheduledDate} το πλάνο σας θα αλλάξει από ${currentPlanName} σε ${newPlanName}. Μέχρι τότε το τρέχον πλάνο παραμένει ενεργό.`,
btnText: 'Διαχείριση πλάνου →',
},
en: {
subject: 'Plan downgrade scheduled',
title: 'Your plan change is scheduled',
body: `On ${scheduledDate} your plan will switch from ${currentPlanName} to ${newPlanName}. Your current plan remains active until then.`,
btnText: 'Manage plan →',
},
ru: {
subject: 'Понижение тарифа запланировано',
title: 'Смена тарифа запланирована',
body: `${scheduledDate} ваш тариф изменится с ${currentPlanName} на ${newPlanName}. До этого момента текущий тариф остаётся активным.`,
btnText: 'Управление тарифом →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
Γεια ${name},
${copy.body}
${btn(copy.btnText, `${WEB}/pricing`)}
`
await sendWithTemplate('plan_downgrade_scheduled', to,
{ userName: name, currentPlanName, newPlanName, scheduledDate, link: `${WEB}/pricing` },
copy.subject, fallbackBody, locale)
}
export async function emailPlanDowngradeApplied(opts: {
to: string; name: string; planName: string; locale?: Locale
}) {
const { to, name, planName, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Το πλάνο σας άλλαξε σε ${planName}`,
title: 'Αλλαγή πλάνου',
body: `Το πλάνο σας άλλαξε αυτόματα σε ${planName} μετά τη λήξη του προηγούμενου.`,
btnText: 'Αναβάθμιση πλάνου →',
},
en: {
subject: `Your plan changed to ${planName}`,
title: 'Plan changed',
body: `Your plan was automatically switched to ${planName} after your previous plan expired.`,
btnText: 'Upgrade plan →',
},
ru: {
subject: `Ваш тариф изменён на ${planName}`,
title: 'Тариф изменён',
body: `Ваш тариф был автоматически переключён на ${planName} после истечения предыдущего.`,
btnText: 'Повысить тариф →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
Γεια ${name},
${copy.body}
${btn(copy.btnText, `${WEB}/pricing`)}
`
await sendWithTemplate('plan_downgrade_applied', to,
{ userName: name, planName, link: `${WEB}/pricing` },
copy.subject, fallbackBody, locale)
}
export async function emailPlanRenewalReminder(opts: {
to: string; name: string; planName: string; amount: string; expiresAt: string; locale?: Locale
}) {
const { to, name, planName, amount, expiresAt, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Το πλάνο ${planName} λήγει σύντομα`,
title: 'Υπενθύμιση ανανέωσης πλάνου',
body: `Το πλάνο ${planName} λήγει στις ${expiresAt}. Για ανανέωση θα χρεωθεί ${amount}€ από το υπόλοιπό σας.`,
btnText: 'Δες το υπόλοιπό σου →',
},
en: {
subject: `Your ${planName} plan expires soon`,
title: 'Plan renewal reminder',
body: `Your ${planName} plan expires on ${expiresAt}. ${amount}€ will be charged from your balance.`,
btnText: 'Check your balance →',
},
ru: {
subject: `Тариф ${planName} скоро истекает`,
title: 'Напоминание о продлении тарифа',
body: `Тариф ${planName} истекает ${expiresAt}. С вашего баланса будет списано ${amount}€.`,
btnText: 'Проверить баланс →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
Γεια ${name},
${copy.body}
${btn(copy.btnText, `${WEB}/profile`)}
`
await sendWithTemplate('plan_renewal_reminder', to,
{ userName: name, planName, amount, expiresAt, link: `${WEB}/profile` },
copy.subject, fallbackBody, locale)
}
export async function emailPlanInsufficientFunds(opts: {
to: string; name: string; currentPlanName: string
requiredAmount: string; currentBalance: string; expiresAt: string; locale?: Locale
}) {
const { to, name, currentPlanName, requiredAmount, currentBalance, expiresAt, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const topUpLink = `${WEB}/payment`
const copy = {
el: {
subject: 'Ανεπαρκές υπόλοιπο — αλλαγή πλάνου',
title: 'Ανεπαρκές υπόλοιπο για ανανέωση',
body: `Το πλάνο ${currentPlanName} λήγει στις ${expiresAt}. Απαιτείται: ${requiredAmount}€, υπόλοιπό σας: ${currentBalance}€. Αν δεν προσθέσετε χρήματα, το πλάνο θα υποβαθμιστεί αυτόματα στο δωρεάν πλάνο.`,
btnText: 'Ανανεώστε το υπόλοιπό σας →',
},
en: {
subject: 'Insufficient balance — plan will downgrade',
title: 'Insufficient balance for renewal',
body: `Your ${currentPlanName} plan expires on ${expiresAt}. Required: ${requiredAmount}€, your balance: ${currentBalance}€. If you don't top up, your plan will automatically downgrade to the free plan.`,
btnText: 'Top up your balance →',
},
ru: {
subject: 'Недостаточно средств — тариф будет понижен',
title: 'Недостаточно средств для продления',
body: `Тариф ${currentPlanName} истекает ${expiresAt}. Необходимо: ${requiredAmount}€, ваш баланс: ${currentBalance}€. Если не пополнить баланс — тариф автоматически сменится на бесплатный.`,
btnText: 'Пополнить баланс →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
Γεια ${name},
${btn(copy.btnText, topUpLink)}
`
await sendWithTemplate('plan_insufficient_funds', to,
{ userName: name, currentPlanName, requiredAmount, currentBalance, expiresAt, topUpLink },
copy.subject, fallbackBody, locale)
}
export async function emailPlanCharged(opts: {
to: string; name: string; planName: string; amount: string; newExpiresAt: string; locale?: Locale
}) {
const { to, name, planName, amount, newExpiresAt, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Το πλάνο ${planName} ανανεώθηκε`,
title: 'Το πλάνο ανανεώθηκε επιτυχώς',
body: `Το πλάνο ${planName} ανανεώθηκε. Χρεώθηκε ${amount}€ από το υπόλοιπό σας. Ισχύει έως ${newExpiresAt}.`,
btnText: 'Δες το λογαριασμό σου →',
},
en: {
subject: `Your ${planName} plan has been renewed`,
title: 'Plan renewed successfully',
body: `Your ${planName} plan has been renewed. ${amount}€ was charged from your balance. Valid until ${newExpiresAt}.`,
btnText: 'View your account →',
},
ru: {
subject: `Тариф ${planName} продлён`,
title: 'Тариф успешно продлён',
body: `Тариф ${planName} продлён. С баланса списано ${amount}€. Действует до ${newExpiresAt}.`,
btnText: 'Перейти в аккаунт →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
Γεια ${name},
${copy.body}
${btn(copy.btnText, `${WEB}/profile`)}
`
await sendWithTemplate('plan_charged', to,
{ userName: name, planName, amount, newExpiresAt, link: `${WEB}/profile` },
copy.subject, fallbackBody, locale)
}
export async function emailReferralReward(opts: {
to: string; name: string; days: number; planName: string; locale?: Locale
}) {
const { to, name, days, planName, locale = 'el' } = opts
const { general } = await loadConfig()
const WEB = general.siteUrl
const copy = {
el: {
subject: `Λάβατε ανταμοιβή παραπομπής — ${general.siteName}`,
title: 'Λάβατε ανταμοιβή παραπομπής! 🎁',
greeting: `Γεια ${name},`,
body: `Ένας φίλος σας εγγράφηκε χρησιμοποιώντας τον σύνδεσμο παραπομπής σας. Ως αντάλλαγμα, ${days} ημέρες πρόσβασης στο πλάνο ${planName} προστέθηκαν στον λογαριασμό σας!`,
btnText: 'Δες τον λογαριασμό σου →',
},
en: {
subject: `You received a referral reward — ${general.siteName}`,
title: 'You received a referral reward! 🎁',
greeting: `Hi ${name},`,
body: `A friend registered using your referral link. As a reward, ${days} days of ${planName} access have been added to your account!`,
btnText: 'View your account →',
},
ru: {
subject: `Вы получили реферальную награду — ${general.siteName}`,
title: 'Вы получили реферальную награду! 🎁',
greeting: `Привет, ${name}!`,
body: `Друг зарегистрировался по вашей реферальной ссылке. В качестве награды на ваш аккаунт добавлено ${days} дней доступа к плану ${planName}!`,
btnText: 'Перейти к аккаунту →',
},
}[locale === 'uk' ? 'en' : locale as 'el' | 'en' | 'ru']
const fallbackBody = `
${copy.title}
${copy.greeting}
${copy.body}
+${days} ${locale === 'el' ? 'ημέρες' : locale === 'ru' ? 'дней' : 'days'} — ${planName}
${btn(copy.btnText, `${WEB}/profile`)}
`
await sendWithTemplate('referral_reward', to,
{ userName: name, days: String(days), planName, link: `${WEB}/profile` },
copy.subject, fallbackBody, locale)
}
export async function emailSiteContact(opts: {
to: string; specialistName: string; senderName: string; senderEmail?: string; message: string
}) {
const { to, specialistName, senderName, senderEmail, message } = opts
const { general } = await loadConfig()
const escapedMessage = message.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')
const subject = `Новое сообщение с вашей страницы — ${general.siteName}`
const fallbackBody = `
Новое сообщение с вашей страницы
Привет, ${specialistName}!
${senderName}${senderEmail ? ` (${senderEmail})` : ''}
${escapedMessage}
${senderEmail ? `Ответить: ${senderEmail}
` : ''}
`
const html = await layout(subject, fallbackBody)
await send(to, subject, html)
}