From 6eced7263a6f24b275b6d1e1a8c6af0c0f06d2e3 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Fri, 30 Jan 2026 08:22:22 +0100 Subject: [PATCH] feat: Add certificate email sending feature - Create certificate-email.service.js with HTML email template - Add beautiful gradient email template with course details - Support PDF attachment via JMAP - Add POST /prilohy/:prilohaId/send-email endpoint - Add sendCertificateEmail controller function Co-Authored-By: Claude Opus 4.5 --- src/controllers/ai-kurzy.controller.js | 19 + src/routes/ai-kurzy.routes.js | 6 + .../ai-kurzy/certificate-email.service.js | 528 ++++++++++++++++++ 3 files changed, 553 insertions(+) create mode 100644 src/services/ai-kurzy/certificate-email.service.js diff --git a/src/controllers/ai-kurzy.controller.js b/src/controllers/ai-kurzy.controller.js index 4c6b590..71a3727 100644 --- a/src/controllers/ai-kurzy.controller.js +++ b/src/controllers/ai-kurzy.controller.js @@ -282,3 +282,22 @@ export const generateCertificate = async (req, res, next) => { next(error); } }; + +// ==================== CERTIFICATE EMAIL ==================== + +export const sendCertificateEmail = async (req, res, next) => { + try { + const { prilohaId } = req.params; + const userId = req.user.id; + const { sendCertificateEmail: sendCertEmail } = await import('../services/ai-kurzy/certificate-email.service.js'); + + const result = await sendCertEmail(parseInt(prilohaId), userId); + + res.json({ + data: result, + message: result.message, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/routes/ai-kurzy.routes.js b/src/routes/ai-kurzy.routes.js index 60c2f7a..e49b23b 100644 --- a/src/routes/ai-kurzy.routes.js +++ b/src/routes/ai-kurzy.routes.js @@ -174,4 +174,10 @@ router.post( aiKurzyController.generateCertificate ); +router.post( + '/prilohy/:prilohaId/send-email', + validateParams(prilohaIdSchema), + aiKurzyController.sendCertificateEmail +); + export default router; diff --git a/src/services/ai-kurzy/certificate-email.service.js b/src/services/ai-kurzy/certificate-email.service.js new file mode 100644 index 0000000..76e76ba --- /dev/null +++ b/src/services/ai-kurzy/certificate-email.service.js @@ -0,0 +1,528 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { db } from '../../config/database.js'; +import { prilohy, registracie, ucastnici, kurzy, emailAccounts } from '../../db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import { NotFoundError, BadRequestError } from '../../utils/errors.js'; +import { logger } from '../../utils/logger.js'; +import { jmapRequest, getMailboxes, getIdentities } from '../jmap/client.js'; +import { getJmapConfig } from '../jmap/config.js'; + +/** + * Format date to Slovak locale + */ +const formatDate = (date) => { + if (!date) return ''; + return new Intl.DateTimeFormat('sk-SK', { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(date)); +}; + +/** + * Generate HTML email template for certificate notification + */ +export const generateCertificateEmailHtml = ({ participantName, courseName, courseDate, courseType }) => { + return ` + + + + + + Certifikát o absolvovaní kurzu + + + + + + +
+ + + + + + + + + + + + + + + + + +
+
+ 🎓 +
+

+ Gratulujeme k úspešnému absolvovaniu! +

+
+

+ Vážený/á ${participantName}, +

+ +

+ Srdečne Vám ďakujeme za účasť na našom kurze. Úspešne ste ho absolvovali a s potešením Vám zasielame Váš certifikát. +

+ + + + + + +
+ + + + + + + +
+ + ${courseType || 'Kurz'} + +
+

+ ${courseName} +

+

+ 📅 + ${courseDate} +

+
+
+ + +
+

+ 📎 Príloha: Váš certifikát nájdete v prílohe tohto emailu vo formáte PDF. +

+
+ +

+ Prajeme Vám veľa úspechov pri uplatňovaní nových vedomostí v praxi! +

+ +

+ S pozdravom,
+ Tím AI Kurzov +

+
+

+ Táto správa bola automaticky vygenerovaná.
+ V prípade otázok nás neváhajte kontaktovať. +

+
+
+ + +`.trim(); +}; + +/** + * Generate plain text version of the certificate email + */ +export const generateCertificateEmailText = ({ participantName, courseName, courseDate, courseType }) => { + return ` +Vážený/á ${participantName}, + +Srdečne Vám ďakujeme za účasť na našom kurze. Úspešne ste ho absolvovali a s potešením Vám zasielame Váš certifikát. + +KURZ: ${courseName} +TYP: ${courseType || 'Kurz'} +DÁTUM: ${courseDate} + +Váš certifikát nájdete v prílohe tohto emailu vo formáte PDF. + +Prajeme Vám veľa úspechov pri uplatňovaní nových vedomostí v praxi! + +S pozdravom, +Tím AI Kurzov + +--- +Táto správa bola automaticky vygenerovaná. +V prípade otázok nás neváhajte kontaktovať. +`.trim(); +}; + +/** + * Send email with HTML content and PDF attachment via JMAP + */ +const sendEmailWithAttachment = async (jmapConfig, to, subject, htmlBody, textBody, attachment) => { + logger.info(`Odosielam certifikát emailom na: ${to}`); + + // Get mailboxes + const mailboxes = await getMailboxes(jmapConfig); + const sentMailbox = mailboxes.find((m) => m.role === 'sent' || m.name === 'Sent'); + + if (!sentMailbox) { + throw new Error('Priečinok Odoslané nebol nájdený'); + } + + // Read attachment file and encode to base64 + const fileBuffer = await fs.readFile(attachment.path); + const base64Content = fileBuffer.toString('base64'); + + // Create email with attachment + const createResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/set', + { + accountId: jmapConfig.accountId, + create: { + draft: { + mailboxIds: { + [sentMailbox.id]: true, + }, + keywords: { + $draft: true, + }, + from: [{ email: jmapConfig.username }], + to: [{ email: to }], + subject: subject, + htmlBody: [{ partId: 'html', type: 'text/html' }], + textBody: [{ partId: 'text', type: 'text/plain' }], + attachments: [ + { + blobId: null, // Will be set via bodyValues + type: attachment.mimeType || 'application/pdf', + name: attachment.filename, + disposition: 'attachment', + }, + ], + bodyValues: { + html: { + value: htmlBody, + }, + text: { + value: textBody, + }, + }, + }, + }, + }, + 'set1', + ], + ]); + + // Check if we need to upload blob first (for some JMAP servers) + const createResult = createResponse.methodResponses[0][1]; + + if (createResult.notCreated?.draft) { + // Try alternative approach - upload blob first, then create email + logger.info('Skúšam alternatívny prístup s nahraním prílohy'); + + // Upload blob + const uploadResponse = await jmapRequest(jmapConfig, [ + [ + 'Blob/upload', + { + accountId: jmapConfig.accountId, + create: { + attachment1: { + data: [{ 'data:asBase64': base64Content }], + type: attachment.mimeType || 'application/pdf', + }, + }, + }, + 'blob1', + ], + ]); + + const blobId = uploadResponse.methodResponses[0][1]?.created?.attachment1?.blobId; + + if (!blobId) { + // Try yet another approach - inline base64 + logger.info('Skúšam inline base64 prílohu'); + + const inlineResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/set', + { + accountId: jmapConfig.accountId, + create: { + draft: { + mailboxIds: { + [sentMailbox.id]: true, + }, + keywords: { + $draft: true, + }, + from: [{ email: jmapConfig.username }], + to: [{ email: to }], + subject: subject, + bodyStructure: { + type: 'multipart/mixed', + subParts: [ + { + type: 'multipart/alternative', + subParts: [ + { + type: 'text/plain', + partId: 'text', + }, + { + type: 'text/html', + partId: 'html', + }, + ], + }, + { + type: attachment.mimeType || 'application/pdf', + name: attachment.filename, + disposition: 'attachment', + partId: 'attachment', + }, + ], + }, + bodyValues: { + html: { value: htmlBody }, + text: { value: textBody }, + attachment: { value: base64Content, isEncodingProblem: false }, + }, + }, + }, + }, + 'set2', + ], + ]); + + const inlineResult = inlineResponse.methodResponses[0][1]; + const createdEmailId = inlineResult.created?.draft?.id; + + if (!createdEmailId) { + logger.error('Nepodarilo sa vytvoriť email s prílohou', inlineResult.notCreated?.draft); + throw new Error('Nepodarilo sa vytvoriť email s prílohou'); + } + + // Submit the email + return await submitEmail(jmapConfig, createdEmailId); + } + + // Create email with uploaded blob + const emailWithBlobResponse = await jmapRequest(jmapConfig, [ + [ + 'Email/set', + { + accountId: jmapConfig.accountId, + create: { + draft: { + mailboxIds: { + [sentMailbox.id]: true, + }, + from: [{ email: jmapConfig.username }], + to: [{ email: to }], + subject: subject, + htmlBody: [{ partId: 'html', type: 'text/html' }], + textBody: [{ partId: 'text', type: 'text/plain' }], + attachments: [ + { + blobId: blobId, + type: attachment.mimeType || 'application/pdf', + name: attachment.filename, + disposition: 'attachment', + }, + ], + bodyValues: { + html: { value: htmlBody }, + text: { value: textBody }, + }, + }, + }, + }, + 'set3', + ], + ]); + + const createdEmailId = emailWithBlobResponse.methodResponses[0][1].created?.draft?.id; + + if (!createdEmailId) { + throw new Error('Nepodarilo sa vytvoriť email s prílohou'); + } + + return await submitEmail(jmapConfig, createdEmailId); + } + + const createdEmailId = createResult.created?.draft?.id; + + if (!createdEmailId) { + throw new Error('Nepodarilo sa vytvoriť email'); + } + + return await submitEmail(jmapConfig, createdEmailId); +}; + +/** + * Submit email for sending + */ +const submitEmail = async (jmapConfig, emailId) => { + // Get user identity + const identities = await getIdentities(jmapConfig); + const identity = identities.find((i) => i.email === jmapConfig.username) || identities[0]; + + if (!identity) { + throw new Error('Nenašla sa identita pre odosielanie emailov'); + } + + // Submit the email + const submitResponse = await jmapRequest(jmapConfig, [ + [ + 'EmailSubmission/set', + { + accountId: jmapConfig.accountId, + create: { + submission: { + emailId: emailId, + identityId: identity.id, + }, + }, + }, + 'submit1', + ], + ]); + + const submissionId = submitResponse.methodResponses[0][1].created?.submission?.id; + + if (!submissionId) { + const error = submitResponse.methodResponses[0][1].notCreated?.submission; + logger.error('Nepodarilo sa odoslať email', error); + throw new Error('Nepodarilo sa odoslať email'); + } + + logger.success(`Email s certifikátom úspešne odoslaný`); + return { success: true, submissionId }; +}; + +/** + * Send certificate email to participant + * @param {number} prilohaId - ID of the certificate attachment + * @param {number} userId - ID of the user sending the email (for getting email account) + */ +export const sendCertificateEmail = async (prilohaId, userId) => { + // Get priloha with registration and participant info + const [priloha] = await db + .select({ + id: prilohy.id, + nazovSuboru: prilohy.nazovSuboru, + typPrilohy: prilohy.typPrilohy, + cestaKSuboru: prilohy.cestaKSuboru, + mimeType: prilohy.mimeType, + registraciaId: prilohy.registraciaId, + }) + .from(prilohy) + .where(eq(prilohy.id, prilohaId)) + .limit(1); + + if (!priloha) { + throw new NotFoundError('Príloha nenájdená'); + } + + if (priloha.typPrilohy !== 'certifikat') { + throw new BadRequestError('Táto príloha nie je certifikát'); + } + + // Check if file exists + try { + await fs.access(priloha.cestaKSuboru); + } catch { + throw new NotFoundError('Súbor certifikátu nebol nájdený na serveri'); + } + + // Get registration with participant and course info + const [registration] = await db + .select({ + id: registracie.id, + ucastnikMeno: ucastnici.meno, + ucastnikPriezvisko: ucastnici.priezvisko, + ucastnikTitul: ucastnici.titul, + ucastnikEmail: ucastnici.email, + kurzNazov: kurzy.nazov, + kurzTyp: kurzy.typKurzu, + kurzDatumOd: kurzy.datumOd, + kurzDatumDo: kurzy.datumDo, + }) + .from(registracie) + .leftJoin(ucastnici, eq(registracie.ucastnikId, ucastnici.id)) + .leftJoin(kurzy, eq(registracie.kurzId, kurzy.id)) + .where(eq(registracie.id, priloha.registraciaId)) + .limit(1); + + if (!registration) { + throw new NotFoundError('Registrácia nenájdená'); + } + + if (!registration.ucastnikEmail) { + throw new BadRequestError('Účastník nemá zadaný email'); + } + + // Get email account for sending + const [emailAccount] = await db + .select() + .from(emailAccounts) + .where(and(eq(emailAccounts.userId, userId), eq(emailAccounts.isActive, true))) + .limit(1); + + if (!emailAccount) { + throw new BadRequestError('Nemáte nastavený aktívny emailový účet pre odosielanie'); + } + + // Build participant name + const participantName = [ + registration.ucastnikTitul, + registration.ucastnikMeno, + registration.ucastnikPriezvisko, + ] + .filter(Boolean) + .join(' '); + + // Build course date string + const courseDate = registration.kurzDatumOd + ? registration.kurzDatumDo && registration.kurzDatumDo !== registration.kurzDatumOd + ? `${formatDate(registration.kurzDatumOd)} - ${formatDate(registration.kurzDatumDo)}` + : formatDate(registration.kurzDatumOd) + : ''; + + // Generate email content + const emailData = { + participantName, + courseName: registration.kurzNazov || 'Kurz', + courseDate, + courseType: registration.kurzTyp, + }; + + const htmlBody = generateCertificateEmailHtml(emailData); + const textBody = generateCertificateEmailText(emailData); + const subject = `Certifikát o absolvovaní kurzu: ${registration.kurzNazov}`; + + // Get JMAP config + const jmapConfig = getJmapConfig(emailAccount, true); + + // Send email with attachment + await sendEmailWithAttachment( + jmapConfig, + registration.ucastnikEmail, + subject, + htmlBody, + textBody, + { + path: priloha.cestaKSuboru, + filename: priloha.nazovSuboru, + mimeType: priloha.mimeType || 'application/pdf', + } + ); + + logger.success(`Certifikát odoslaný na ${registration.ucastnikEmail}`); + + return { + success: true, + message: `Certifikát bol odoslaný na ${registration.ucastnikEmail}`, + recipientEmail: registration.ucastnikEmail, + }; +};