/opt/canhelp/apps/api/src/lib
NameSizeModeActions
activity.ts13240644editdlrm
apple-client-secret.ts40730644editdlrm
balance.ts35060644editdlrm
daily-limits.ts42820644editdlrm
email.ts527080644editdlrm
fomo.ts113660644editdlrm
google-oauth.ts105260644editdlrm
helper-capability.ts42070644editdlrm
messaging-rules.ts31230644editdlrm
nameUtils.ts7090644editdlrm
notif.ts189290644editdlrm
plan-history.ts10200644editdlrm
push.ts63860644editdlrm
referral.ts61100644editdlrm
telegram.ts62070644editdlrm
turnstile.ts7410644editdlrm
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}
${general.siteName} 🇬🇷
${body}

© ${new Date().getFullYear()} ${general.siteName} · ${general.siteUrl.replace(/^https?:\/\//, '')}

` } 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}

${taskTitle}

${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}

${taskTitle}

${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}

${taskTitle}

${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}

"${preview}"

${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}

${taskTitle}

${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}

${taskTitle}

${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}

${taskTitle}

${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}

${taskTitle}

${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}

${taskTitle}

${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},

${copy.body}

${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) }