The emailAccounts table doesn't have a userId column - it uses a many-to-many relationship through userEmailAccounts table. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
538 lines
18 KiB
JavaScript
538 lines
18 KiB
JavaScript
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { db } from '../../config/database.js';
|
|
import { prilohy, registracie, ucastnici, kurzy, emailAccounts, userEmailAccounts } 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 (through userEmailAccounts join table)
|
|
const [emailAccountResult] = await db
|
|
.select({
|
|
id: emailAccounts.id,
|
|
email: emailAccounts.email,
|
|
emailPassword: emailAccounts.emailPassword,
|
|
jmapAccountId: emailAccounts.jmapAccountId,
|
|
isActive: emailAccounts.isActive,
|
|
})
|
|
.from(userEmailAccounts)
|
|
.innerJoin(emailAccounts, eq(userEmailAccounts.emailAccountId, emailAccounts.id))
|
|
.where(and(eq(userEmailAccounts.userId, userId), eq(emailAccounts.isActive, true)))
|
|
.limit(1);
|
|
|
|
if (!emailAccountResult) {
|
|
throw new BadRequestError('Nemáte nastavený aktívny emailový účet pre odosielanie');
|
|
}
|
|
|
|
const emailAccount = emailAccountResult;
|
|
|
|
// 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,
|
|
};
|
|
};
|