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 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-30 08:22:22 +01:00
parent 09f4c72acb
commit 6eced7263a
3 changed files with 553 additions and 0 deletions

View File

@@ -282,3 +282,22 @@ export const generateCertificate = async (req, res, next) => {
next(error); 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);
}
};

View File

@@ -174,4 +174,10 @@ router.post(
aiKurzyController.generateCertificate aiKurzyController.generateCertificate
); );
router.post(
'/prilohy/:prilohaId/send-email',
validateParams(prilohaIdSchema),
aiKurzyController.sendCertificateEmail
);
export default router; export default router;

View File

@@ -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 `
<!DOCTYPE html>
<html lang="sk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Certifikát o absolvovaní kurzu</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f8fafc;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f8fafc;">
<tr>
<td style="padding: 40px 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);">
<!-- Header with gradient -->
<tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 16px 16px 0 0;">
<div style="margin-bottom: 16px;">
<span style="font-size: 48px;">🎓</span>
</div>
<h1 style="margin: 0; font-size: 26px; font-weight: 700; color: #ffffff; letter-spacing: -0.5px;">
Gratulujeme k úspešnému absolvovaniu!
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 24px 0; font-size: 17px; color: #334155; line-height: 1.7;">
Vážený/á <strong style="color: #1e293b;">${participantName}</strong>,
</p>
<p style="margin: 0 0 28px 0; font-size: 16px; color: #475569; line-height: 1.7;">
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.
</p>
<!-- Course Details Card -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 12px; border: 1px solid #bae6fd; margin-bottom: 28px;">
<tr>
<td style="padding: 24px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding-bottom: 16px;">
<span style="display: inline-block; padding: 6px 14px; background-color: #0ea5e9; color: #ffffff; font-size: 11px; font-weight: 600; border-radius: 20px; text-transform: uppercase; letter-spacing: 0.5px;">
${courseType || 'Kurz'}
</span>
</td>
</tr>
<tr>
<td>
<h2 style="margin: 0 0 12px 0; font-size: 20px; font-weight: 700; color: #0c4a6e;">
${courseName}
</h2>
<p style="margin: 0; font-size: 14px; color: #0369a1;">
<span style="display: inline-block; margin-right: 8px;">📅</span>
${courseDate}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Certificate note -->
<div style="background-color: #fefce8; border-radius: 10px; padding: 16px 20px; margin-bottom: 28px; border-left: 4px solid #eab308;">
<p style="margin: 0; font-size: 14px; color: #713f12; line-height: 1.6;">
<strong>📎 Príloha:</strong> Váš certifikát nájdete v prílohe tohto emailu vo formáte PDF.
</p>
</div>
<p style="margin: 0 0 8px 0; font-size: 16px; color: #475569; line-height: 1.7;">
Prajeme Vám veľa úspechov pri uplatňovaní nových vedomostí v praxi!
</p>
<p style="margin: 24px 0 0 0; font-size: 16px; color: #334155;">
S pozdravom,<br>
<strong style="color: #1e293b;">Tím AI Kurzov</strong>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 40px; background-color: #f1f5f9; border-top: 1px solid #e2e8f0; border-radius: 0 0 16px 16px;">
<p style="margin: 0; font-size: 12px; color: #64748b; text-align: center; line-height: 1.6;">
Táto správa bola automaticky vygenerovaná.<br>
V prípade otázok nás neváhajte kontaktovať.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.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,
};
};