diff --git a/src/__tests__/auditOwner.test.js b/src/__tests__/auditOwner.test.js new file mode 100644 index 0000000..1eada5e --- /dev/null +++ b/src/__tests__/auditOwner.test.js @@ -0,0 +1,16 @@ +import { actorDisplayName, resolveAuditOwner } from '../auditOwner.js'; + +describe('audit owner resolution', () => { + it('represents system actions without an owner', () => { + expect(resolveAuditOwner('system')).toEqual({ system: true }); + expect(actorDisplayName('system')).toBe('system'); + }); + + it('preserves object actors as owners', () => { + expect(resolveAuditOwner({ _id: 'user-1' })).toEqual({ + system: false, + owner: 'user-1', + ownerType: 'user', + }); + }); +}); diff --git a/src/auditOwner.js b/src/auditOwner.js index 3447aa1..362d89f 100644 --- a/src/auditOwner.js +++ b/src/auditOwner.js @@ -1,16 +1,22 @@ const AUDIT_OWNER_TYPES = ['user', 'printer', 'host', 'marketplace']; export function resolveAuditOwner(actor) { - const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType) - ? actor._objectType - : 'user'; + if (actor === 'system') { + return { + system: true, + }; + } + + const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType) ? actor._objectType : 'user'; return { + system: false, owner: actor?._id, ownerType, }; } export function actorDisplayName(actor) { + if (actor === 'system') return 'system'; if (actor?._objectType === 'marketplace' || actor?._objectType === 'host') { return actor.name || actor._objectType; } diff --git a/src/database/__tests__/database.test.js b/src/database/__tests__/database.test.js index c9d0954..5265bf4 100644 --- a/src/database/__tests__/database.test.js +++ b/src/database/__tests__/database.test.js @@ -72,9 +72,9 @@ jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({ })); // Now import the database utilities -const { listObjects, getObject, newObject, editObject, deleteObject } = await import( - '../database.js' -); +const { listObjects, getObject, newObject, editObject, deleteObject } = + await import('../database.js'); +const { editAuditLog, editNotification } = await import('../../utils.js'); describe('Database Utilities (CRUD)', () => { let mockModel; @@ -178,6 +178,26 @@ describe('Database Utilities (CRUD)', () => { expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, { file: fileId }); }); + + it('creates an audit log but no user notification for system edits', async () => { + mockModel.lean.mockResolvedValue({ _id: '123', name: 'Old' }); + + await editObject({ + model: mockModel, + id: '123', + updateData: { name: 'Updated' }, + user: 'system', + }); + + expect(editAuditLog).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + '123', + 'TestModel', + 'system' + ); + expect(editNotification).not.toHaveBeenCalled(); + }); }); describe('deleteObject', () => { diff --git a/src/database/database.js b/src/database/database.js index 31c606b..5d22250 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -1240,6 +1240,7 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc await editAuditLog(previousExpandedObject, updatedObject, id, parentType, user); if ( + user !== 'system' && parentType !== 'notification' && parentType !== 'auditLog' && parentType !== 'userNotifier' && @@ -1318,7 +1319,11 @@ export const newObject = async ( if (!result || result.length === 0) { return { error: 'No object created.', code: 500 }; } - const created = expandObjectIds(result.toObject()); + // Prefer the public document serializer; fall back to Mongoose's + // internal method if a schema field shadows `toObject`. + const serialized = + typeof result.toObject === 'function' ? result.toObject() : result.$toObject(); + const created = expandObjectIds(serialized); await newAuditLog(newData, created._id, parentType, user); @@ -1593,12 +1598,15 @@ export const deleteObject = async ( return { error: `${parentType} not found.`, code: 404 }; } - const deleted = expandObjectIds(result.toObject()); + const serialized = + typeof result.toObject === 'function' ? result.toObject() : result.$toObject(); + const deleted = expandObjectIds(serialized); // Audit log the deletion await deleteAuditLog(deleted, id.toString(), parentType, user); if ( + user !== 'system' && parentType !== 'notification' && parentType !== 'auditLog' && parentType !== 'userNotifier' && diff --git a/src/database/schemas/management/auditlog.schema.js b/src/database/schemas/management/auditlog.schema.js index 1ac2bcf..464872f 100644 --- a/src/database/schemas/management/auditlog.schema.js +++ b/src/database/schemas/management/auditlog.schema.js @@ -25,13 +25,18 @@ const auditLogSchema = new Schema( owner: { type: Schema.Types.ObjectId, refPath: 'ownerType', - required: true, + required: false, }, ownerType: { type: String, - required: true, + required: false, enum: ['user', 'printer', 'host', 'marketplace'], }, + system: { + type: Boolean, + required: true, + default: false, + }, }, { timestamps: true } ); diff --git a/src/database/schemas/management/emailaccount.schema.js b/src/database/schemas/management/emailaccount.schema.js new file mode 100644 index 0000000..5ff8335 --- /dev/null +++ b/src/database/schemas/management/emailaccount.schema.js @@ -0,0 +1,33 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; + +const { Schema } = mongoose; + +const emailAccountSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true, unique: true }, + active: { type: Boolean, required: true, default: true }, + host: { type: String, required: true }, + port: { type: Number, required: true, default: 587, min: 1, max: 65535 }, + secure: { type: Boolean, required: true, default: false }, + username: { type: String, required: false }, + password: { type: String, required: false, select: false }, + fromName: { type: String, required: true }, + fromEmail: { type: String, required: true }, + }, + { timestamps: true } +); + +emailAccountSchema.index({ name: 'text', host: 'text', fromEmail: 'text' }); +emailAccountSchema.virtual('id').get(function () { + return this._id; +}); +function omitPassword(_document, value) { + delete value.password; + return value; +} +emailAccountSchema.set('toJSON', { virtuals: true, transform: omitPassword }); +emailAccountSchema.set('toObject', { virtuals: true, transform: omitPassword }); + +export const emailAccountModel = mongoose.model('emailAccount', emailAccountSchema); diff --git a/src/database/schemas/management/emailmessage.schema.js b/src/database/schemas/management/emailmessage.schema.js new file mode 100644 index 0000000..ee0f7a3 --- /dev/null +++ b/src/database/schemas/management/emailmessage.schema.js @@ -0,0 +1,57 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; + +const { Schema } = mongoose; + +const emailMessageSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true }, + emailTemplate: { + type: Schema.Types.ObjectId, + ref: 'emailTemplate', + required: true, + }, + objectType: { type: String, required: true }, + object: { type: Schema.Types.ObjectId, refPath: 'objectType', required: true }, + emailAccount: { + type: Schema.Types.ObjectId, + ref: 'emailAccount', + required: true, + }, + recipientEmail: { type: String, required: true }, + recipientType: { type: String, enum: ['client', 'vendor'], required: false }, + recipient: { + type: Schema.Types.ObjectId, + refPath: 'recipientType', + required: false, + }, + fromEmail: { type: String, required: true, immutable: true }, + messageId: { type: String, required: false }, + subject: { type: String, required: false, default: '' }, + content: { type: String, required: false, default: '' }, + attachments: [{ type: Schema.Types.ObjectId, ref: 'file', required: false }], + state: { + type: { type: String, required: true, default: 'queued' }, + progress: { type: Number, required: false, default: 0 }, + message: { type: String, required: false }, + }, + read: { type: Boolean, required: true, default: false }, + sentAt: { type: Date, required: false }, + readAt: { type: Date, required: false }, + }, + { timestamps: true, suppressReservedKeysWarning: true } +); + +emailMessageSchema.index({ + name: 'text', + recipientEmail: 'text', + fromEmail: 'text', + objectType: 'text', +}); +emailMessageSchema.virtual('id').get(function () { + return this._id; +}); +emailMessageSchema.set('toJSON', { virtuals: true }); + +export const emailMessageModel = mongoose.model('emailMessage', emailMessageSchema); diff --git a/src/database/schemas/management/emailtemplate.schema.js b/src/database/schemas/management/emailtemplate.schema.js new file mode 100644 index 0000000..612fe8f --- /dev/null +++ b/src/database/schemas/management/emailtemplate.schema.js @@ -0,0 +1,78 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; + +const { Schema } = mongoose; +const RENDER_EMAIL_TEMPLATE_CALL = /fc\.renderEmailTemplate\s*\(\s*(['"])([^'"]+)\1/g; + +function extractReferences(content) { + const references = []; + const seen = new Set(); + for (const match of String(content || '').matchAll(RENDER_EMAIL_TEMPLATE_CALL)) { + const reference = match[2]?.trim(); + if (reference && !seen.has(reference)) { + seen.add(reference); + references.push(reference); + } + } + return references; +} + +const emailTemplateAttachmentSchema = new Schema( + { + type: { type: String, enum: ['file', 'documentTemplate'], required: true }, + file: { type: Schema.Types.ObjectId, refPath: 'attachments.type', required: true }, + fileName: { type: String, required: true }, + fileType: { type: String, enum: ['pdf', 'jpeg', 'png', 'svg'], required: false }, + }, + { _id: true } +); + +const emailTemplateSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + name: { type: String, required: true, unique: true }, + objectType: { type: String, required: false }, + tags: [{ type: String }], + active: { type: Boolean, required: true, default: true }, + global: { type: Boolean, required: true, default: false }, + parent: { type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false }, + referencedTemplates: [ + { type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false }, + ], + subject: { type: String, required: false, default: '' }, + content: { type: String, required: false, default: '' }, + testObject: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false }, + attachments: { type: [emailTemplateAttachmentSchema], default: [] }, + }, + { timestamps: true } +); + +emailTemplateSchema.index({ name: 'text', tags: 'text', objectType: 'text' }); +emailTemplateSchema.virtual('id').get(function () { + return this._id; +}); +emailTemplateSchema.set('toJSON', { virtuals: true }); + +emailTemplateSchema.statics.recalculate = async function (emailTemplate, user) { + const id = emailTemplate?._id || emailTemplate; + if (!id || !(await this.exists({ _id: id }))) return; + + const referencedTemplates = []; + for (const reference of extractReferences(emailTemplate?.content)) { + const normalizedReference = reference.replace(/^ETP:/i, '').trim(); + const found = await this.findOne({ _reference: normalizedReference }).select('_id').lean(); + if (found?._id) referencedTemplates.push(found._id); + } + + const { editObject } = await import('../../database.js'); + await editObject({ + model: this, + id, + updateData: { referencedTemplates }, + user, + populate: [{ path: 'referencedTemplates', strictPopulate: false }], + recalculate: false, + }); +}; + +export const emailTemplateModel = mongoose.model('emailTemplate', emailTemplateSchema); diff --git a/src/database/schemas/models.js b/src/database/schemas/models.js index 9c67b04..fa4395b 100644 --- a/src/database/schemas/models.js +++ b/src/database/schemas/models.js @@ -35,6 +35,9 @@ import { userNotifierModel } from './misc/usernotifier.schema.js'; import { objectViewModel } from './misc/objectview.schema.js'; import { documentSizeModel } from './management/documentsize.schema.js'; import { documentTemplateModel } from './management/documenttemplate.schema.js'; +import { emailAccountModel } from './management/emailaccount.schema.js'; +import { emailTemplateModel } from './management/emailtemplate.schema.js'; +import { emailMessageModel } from './management/emailmessage.schema.js'; import { hostModel } from './management/host.schema.js'; import { documentPrinterModel } from './management/documentprinter.schema.js'; import { documentJobModel } from './management/documentjob.schema.js'; @@ -107,6 +110,9 @@ export const models = { OVW: modelEntry(() => objectViewModel, 'objectView', 'Object View'), DSZ: modelEntry(() => documentSizeModel, 'documentSize', 'Document Size'), DTP: modelEntry(() => documentTemplateModel, 'documentTemplate', 'Document Template'), + EAC: modelEntry(() => emailAccountModel, 'emailAccount', 'Email Account'), + ETP: modelEntry(() => emailTemplateModel, 'emailTemplate', 'Email Template'), + EMS: modelEntry(() => emailMessageModel, 'emailMessage', 'Email Message'), DPR: modelEntry(() => documentPrinterModel, 'documentPrinter', 'Document Printer'), DJB: modelEntry(() => documentJobModel, 'documentJob', 'Document Job'), HST: modelEntry(() => hostModel, 'host', 'Host'), diff --git a/src/index.js b/src/index.js index 1a0a62e..aebbea4 100644 --- a/src/index.js +++ b/src/index.js @@ -44,6 +44,9 @@ import { hostRoutes, documentSizesRoutes, documentTemplatesRoutes, + emailAccountsRoutes, + emailTemplatesRoutes, + emailMessagesRoutes, documentPrintersRoutes, documentJobsRoutes, courierRoutes, @@ -213,6 +216,9 @@ app.use('/auditlogs', auditLogRoutes); app.use('/notetypes', noteTypeRoutes); app.use('/documentsizes', documentSizesRoutes); app.use('/documenttemplates', documentTemplatesRoutes); +app.use('/emailaccounts', emailAccountsRoutes); +app.use('/emailtemplates', emailTemplatesRoutes); +app.use('/emailmessages', emailMessagesRoutes); app.use('/documentprinters', documentPrintersRoutes); app.use('/documentjobs', documentJobsRoutes); app.use('/couriers', courierRoutes); diff --git a/src/mailworker.js b/src/mailworker.js index b50534e..43e5fec 100644 --- a/src/mailworker.js +++ b/src/mailworker.js @@ -42,6 +42,35 @@ const logger = log4js.getLogger('MailWorker'); logger.level = config.server.logLevel; async function sendEmail(payload) { + if (payload.kind === 'emailMessage') { + const { requestId, recipientEmail, from, subject, html, smtpConfig, attachments } = payload; + if (!recipientEmail || !smtpConfig?.host) { + throw new Error('Missing recipient or SMTP configuration.'); + } + const transporter = nodemailer.createTransport({ + host: smtpConfig.host, + port: smtpConfig.port || 587, + secure: smtpConfig.secure === true, + auth: smtpConfig.username + ? { user: smtpConfig.username, pass: smtpConfig.password || '' } + : undefined, + }); + const info = await transporter.sendMail({ + from, + to: recipientEmail, + subject: subject || '', + html: html || '', + attachments: attachments || [], + }); + parentPort.postMessage({ + kind: 'emailMessageResult', + requestId, + success: true, + messageId: info?.messageId, + }); + return; + } + const { email, title, @@ -143,5 +172,13 @@ async function sendEmail(payload) { parentPort.on('message', (payload) => { sendEmail(payload).catch((err) => { logger.error('MailWorker: send failed', err.message); + if (payload?.kind === 'emailMessage') { + parentPort.postMessage({ + kind: 'emailMessageResult', + requestId: payload.requestId, + success: false, + error: err.message, + }); + } }); }); diff --git a/src/routes/index.js b/src/routes/index.js index 8637c31..9468efc 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -36,6 +36,9 @@ import auditLogRoutes from './management/auditlogs.js'; import noteTypeRoutes from './management/notetypes.js'; import documentSizesRoutes from './management/documentsizes.js'; import documentTemplatesRoutes from './management/documenttemplates.js'; +import emailAccountsRoutes from './management/emailaccounts.js'; +import emailTemplatesRoutes from './management/emailtemplates.js'; +import emailMessagesRoutes from './management/emailmessages.js'; import documentPrintersRoutes from './management/documentprinters.js'; import documentJobsRoutes from './management/documentjobs.js'; import courierRoutes from './management/courier.js'; @@ -105,6 +108,9 @@ export { noteRoutes, documentSizesRoutes, documentTemplatesRoutes, + emailAccountsRoutes, + emailTemplatesRoutes, + emailMessagesRoutes, documentPrintersRoutes, documentJobsRoutes, courierRoutes, diff --git a/src/routes/management/auditlogs.js b/src/routes/management/auditlogs.js index add538f..91ad348 100644 --- a/src/routes/management/auditlogs.js +++ b/src/routes/management/auditlogs.js @@ -19,6 +19,7 @@ const listAllowedFilters = [ 'owner', 'parent', 'operation', + 'system', 'createdAt', 'updatedAt', '_reference', diff --git a/src/routes/management/emailaccounts.js b/src/routes/management/emailaccounts.js new file mode 100644 index 0000000..abd82aa --- /dev/null +++ b/src/routes/management/emailaccounts.js @@ -0,0 +1,82 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { checkPermissions } from '../../database/permissions.js'; +import { convertPropertiesString, getFilter, getSort } from '../../utils.js'; +import { + deleteEmailAccountRouteHandler, + editEmailAccountRouteHandler, + getEmailAccountRouteHandler, + getEmailAccountHistoryRouteHandler, + getEmailAccountNeighborsRouteHandler, + getEmailAccountPropertyValuesRouteHandler, + getEmailAccountStatsRouteHandler, + listEmailAccountsByPropertiesRouteHandler, + listEmailAccountsRouteHandler, + newEmailAccountRouteHandler, + searchEmailAccountsRouteHandler, +} from '../../services/management/emailaccounts.js'; + +const router = express.Router(); +const filters = ['name', 'active', 'host', 'fromEmail', 'createdAt', 'updatedAt', '_reference']; +const sorters = ['name', 'active', 'host', 'fromEmail', 'createdAt', 'updatedAt']; +const propertyFilters = ['active', 'secure', 'host', 'fromEmail']; + +router.get('/', isAuthenticated, checkPermissions('emailAccount', 'list'), async (req, res) => { + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, filters); + return listEmailAccountsRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, sorters), + sortOrder + ); +}); +router.get('/properties', isAuthenticated, checkPermissions('emailAccount', 'list'), async (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertyFilters, false); + const masterFilter = req.query.masterFilter ? JSON.parse(req.query.masterFilter) : {}; + return listEmailAccountsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); +router.get('/values', isAuthenticated, checkPermissions('emailAccount', 'list'), async (req, res) => { + const filter = await getFilter(req.query, filters, true); + const masterFilter = req.query.masterFilter + ? await getFilter(JSON.parse(req.query.masterFilter), filters, true) + : {}; + return getEmailAccountPropertyValuesRouteHandler( + req, + res, + req.query.property, + filter, + masterFilter + ); +}); +router.get('/search', isAuthenticated, checkPermissions('emailAccount', 'list'), (req, res) => + searchEmailAccountsRouteHandler(req, res, req.query.search) +); +router.get('/stats', isAuthenticated, getEmailAccountStatsRouteHandler); +router.get('/history', isAuthenticated, getEmailAccountHistoryRouteHandler); +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sortProperty, sortOrder, id } = req.query; + const filter = await getFilter(req.query, filters); + return getEmailAccountNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, sorters), + sortOrder, + id + ); +}); +router.post('/', isAuthenticated, checkPermissions('emailAccount', 'new'), newEmailAccountRouteHandler); +router.get('/:id', isAuthenticated, getEmailAccountRouteHandler); +router.put('/:id', isAuthenticated, checkPermissions('emailAccount', 'edit'), editEmailAccountRouteHandler); +router.delete('/:id', isAuthenticated, checkPermissions('emailAccount', 'delete'), deleteEmailAccountRouteHandler); + +export default router; diff --git a/src/routes/management/emailmessages.js b/src/routes/management/emailmessages.js new file mode 100644 index 0000000..f9c2b1a --- /dev/null +++ b/src/routes/management/emailmessages.js @@ -0,0 +1,132 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { checkPermissions } from '../../database/permissions.js'; +import { convertPropertiesString, getFilter, getSort } from '../../utils.js'; +import { + getEmailMessageRouteHandler, + getEmailMessageHistoryRouteHandler, + getEmailMessageNeighborsRouteHandler, + getEmailMessagePropertyValuesRouteHandler, + getEmailMessageStatsRouteHandler, + listEmailMessagesByPropertiesRouteHandler, + listEmailMessagesRouteHandler, + newEmailMessageRouteHandler, + searchEmailMessagesRouteHandler, + markEmailMessageAsReadRouteHandler, +} from '../../services/management/emailmessages.js'; + +const router = express.Router(); +const filters = [ + 'name', + 'emailTemplate', + 'emailAccount', + 'objectType', + 'object', + 'recipientEmail', + 'recipientType', + 'recipient', + 'fromEmail', + 'messageId', + 'state', + 'read', + 'sentAt', + 'readAt', + 'createdAt', + 'updatedAt', + '_reference', +]; +const sorters = [ + 'name', + 'recipientEmail', + 'fromEmail', + 'messageId', + 'state', + 'read', + 'sentAt', + 'readAt', + 'createdAt', + 'updatedAt', +]; +const propertyFilters = [ + 'emailTemplate', + 'emailAccount', + 'objectType', + 'recipientType', + 'state', + 'read', +]; + +router.get('/', isAuthenticated, checkPermissions('emailMessage', 'list'), async (req, res) => { + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, filters); + return listEmailMessagesRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, sorters), + sortOrder + ); +}); +router.get( + '/properties', + isAuthenticated, + checkPermissions('emailMessage', 'list'), + async (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertyFilters, false); + const masterFilter = req.query.masterFilter ? JSON.parse(req.query.masterFilter) : {}; + return listEmailMessagesByPropertiesRouteHandler(req, res, properties, filter, masterFilter); + } +); +router.get( + '/values', + isAuthenticated, + checkPermissions('emailMessage', 'list'), + async (req, res) => { + const filter = await getFilter(req.query, filters, true); + const masterFilter = req.query.masterFilter + ? await getFilter(JSON.parse(req.query.masterFilter), filters, true) + : {}; + return getEmailMessagePropertyValuesRouteHandler( + req, + res, + req.query.property, + filter, + masterFilter + ); + } +); +router.get('/search', isAuthenticated, checkPermissions('emailMessage', 'list'), (req, res) => + searchEmailMessagesRouteHandler(req, res, req.query.search) +); +router.get('/stats', isAuthenticated, getEmailMessageStatsRouteHandler); +router.get('/history', isAuthenticated, getEmailMessageHistoryRouteHandler); +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sortProperty, sortOrder, id } = req.query; + const filter = await getFilter(req.query, filters); + return getEmailMessageNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, sorters), + sortOrder, + id + ); +}); +router.post( + '/', + isAuthenticated, + checkPermissions('emailMessage', 'new'), + newEmailMessageRouteHandler +); +router.get('/:id', isAuthenticated, getEmailMessageRouteHandler); + +router.get('/:id/pixel', markEmailMessageAsReadRouteHandler); + +export default router; diff --git a/src/routes/management/emailtemplates.js b/src/routes/management/emailtemplates.js new file mode 100644 index 0000000..a02c7ff --- /dev/null +++ b/src/routes/management/emailtemplates.js @@ -0,0 +1,99 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { checkPermissions } from '../../database/permissions.js'; +import { convertPropertiesString, getFilter, getSort } from '../../utils.js'; +import { + deleteEmailTemplateRouteHandler, + editEmailTemplateRouteHandler, + formatEmailTemplateRouteHandler, + getEmailTemplateRouteHandler, + getEmailTemplateHistoryRouteHandler, + getEmailTemplateNeighborsRouteHandler, + getEmailTemplatePropertyValuesRouteHandler, + getEmailTemplateStatsRouteHandler, + listEmailTemplatesByPropertiesRouteHandler, + listEmailTemplatesRouteHandler, + newEmailTemplateRouteHandler, + previewEmailTemplateRouteHandler, + searchEmailTemplatesRouteHandler, +} from '../../services/management/emailtemplates.js'; + +const router = express.Router(); +const filters = [ + 'name', + 'tags', + 'active', + 'global', + 'parent', + 'parent._id', + 'objectType', + 'referencedTemplates', + 'referencedTemplates._id', + 'createdAt', + 'updatedAt', + '_reference', +]; +const sorters = ['name', 'active', 'global', 'objectType', 'createdAt', 'updatedAt']; +const propertyFilters = ['tags', 'active', 'global', 'objectType', 'parent']; + +router.get('/', isAuthenticated, checkPermissions('emailTemplate', 'list'), async (req, res) => { + const { page, limit, property, search, sortProperty, sortOrder } = req.query; + const filter = await getFilter(req.query, filters); + return listEmailTemplatesRouteHandler( + req, + res, + page, + limit, + property, + filter, + search, + getSort(sortProperty, sorters), + sortOrder + ); +}); +router.get('/properties', isAuthenticated, checkPermissions('emailTemplate', 'list'), async (req, res) => { + const properties = convertPropertiesString(req.query.properties); + const filter = await getFilter(req.query, propertyFilters, false); + const masterFilter = req.query.masterFilter ? JSON.parse(req.query.masterFilter) : {}; + return listEmailTemplatesByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); +router.get('/values', isAuthenticated, checkPermissions('emailTemplate', 'list'), async (req, res) => { + const filter = await getFilter(req.query, filters, true); + const masterFilter = req.query.masterFilter + ? await getFilter(JSON.parse(req.query.masterFilter), filters, true) + : {}; + return getEmailTemplatePropertyValuesRouteHandler( + req, + res, + req.query.property, + filter, + masterFilter + ); +}); +router.get('/search', isAuthenticated, checkPermissions('emailTemplate', 'list'), (req, res) => + searchEmailTemplatesRouteHandler(req, res, req.query.search) +); +router.get('/stats', isAuthenticated, getEmailTemplateStatsRouteHandler); +router.get('/history', isAuthenticated, getEmailTemplateHistoryRouteHandler); +router.get('/neighbors', isAuthenticated, async (req, res) => { + const { property, search, sortProperty, sortOrder, id } = req.query; + const filter = await getFilter(req.query, filters); + return getEmailTemplateNeighborsRouteHandler( + req, + res, + property, + filter, + search, + getSort(sortProperty, sorters), + sortOrder, + id + ); +}); +router.post('/', isAuthenticated, checkPermissions('emailTemplate', 'new'), newEmailTemplateRouteHandler); +router.post('/format', isAuthenticated, checkPermissions('emailTemplate', 'design'), formatEmailTemplateRouteHandler); +router.post('/:id/preview', isAuthenticated, checkPermissions('emailTemplate', 'design'), previewEmailTemplateRouteHandler); +router.get('/:id', isAuthenticated, getEmailTemplateRouteHandler); +router.put('/:id', isAuthenticated, checkPermissions('emailTemplate', 'edit'), editEmailTemplateRouteHandler); +router.delete('/:id', isAuthenticated, checkPermissions('emailTemplate', 'delete'), deleteEmailTemplateRouteHandler); + +export default router; diff --git a/src/services/management/__tests__/emailaccounts.test.js b/src/services/management/__tests__/emailaccounts.test.js new file mode 100644 index 0000000..dcdaf27 --- /dev/null +++ b/src/services/management/__tests__/emailaccounts.test.js @@ -0,0 +1,115 @@ +import { jest } from '@jest/globals'; + +const updateOne = jest.fn(); +jest.unstable_mockModule('../../../database/schemas/management/emailaccount.schema.js', () => ({ + emailAccountModel: { modelName: 'emailAccount', updateOne }, +})); +jest.unstable_mockModule('../../../database/database.js', () => ({ + deleteObject: jest.fn(), + editObject: jest.fn(), + getObject: jest.fn(), + getModelHistory: jest.fn(), + getModelStats: jest.fn(), + getObjectNeighbors: jest.fn(), + getPropertyValues: jest.fn(), + listObjects: jest.fn(), + listObjectsByProperties: jest.fn(), + newObject: jest.fn(), + searchObjects: jest.fn(), +})); +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }), + }, +})); + +const { + editEmailAccountRouteHandler, + getEmailAccountNeighborsRouteHandler, + getEmailAccountPropertyValuesRouteHandler, + listEmailAccountsByPropertiesRouteHandler, + newEmailAccountRouteHandler, +} = await import('../emailaccounts.js'); +const { editObject, getObjectNeighbors, getPropertyValues, listObjectsByProperties, newObject } = + await import('../../../database/database.js'); + +describe('email account secret handling', () => { + let req; + let res; + + beforeEach(() => { + jest.clearAllMocks(); + req = { + params: { id: '507f1f77bcf86cd799439011' }, + body: { + name: 'Primary', + active: true, + host: 'smtp.example.com', + port: 587, + secure: false, + username: 'mailer', + password: 'secret', + fromName: 'FarmControl', + fromEmail: 'farm@example.com', + }, + user: { _id: 'user-1' }, + }; + res = { send: jest.fn(), status: jest.fn().mockReturnThis() }; + }); + + it('accepts a password when creating an account', async () => { + newObject.mockResolvedValue({ _id: 'account-1', name: 'Primary' }); + await newEmailAccountRouteHandler(req, res); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + newData: expect.objectContaining({ password: 'secret' }), + }) + ); + expect(res.send).toHaveBeenCalledWith( + expect.not.objectContaining({ password: expect.anything() }) + ); + }); + + it('updates a password outside generic audit and event data', async () => { + editObject.mockResolvedValue({ _id: req.params.id, name: 'Primary' }); + await editEmailAccountRouteHandler(req, res); + expect(updateOne).toHaveBeenCalledWith( + { _id: expect.anything() }, + { $set: { password: 'secret' } } + ); + expect(editObject.mock.calls[0][0].updateData).not.toHaveProperty('password'); + }); + + it('supports property lists and values used by selectors', async () => { + listObjectsByProperties.mockResolvedValue([{ active: true, name: 'Primary' }]); + getPropertyValues.mockResolvedValue(['Primary']); + + await listEmailAccountsByPropertiesRouteHandler( + req, + res, + ['name', 'active'], + { active: true }, + {} + ); + await getEmailAccountPropertyValuesRouteHandler(req, res, 'name', { active: true }, {}); + + expect(listObjectsByProperties).toHaveBeenCalledWith( + expect.objectContaining({ properties: ['name', 'active'], filter: { active: true } }) + ); + expect(getPropertyValues).toHaveBeenCalledWith( + expect.objectContaining({ property: 'name', filter: { active: true } }) + ); + }); + + it('validates neighbor requests', async () => { + await getEmailAccountNeighborsRouteHandler(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(getObjectNeighbors).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/management/__tests__/emailmessages.test.js b/src/services/management/__tests__/emailmessages.test.js new file mode 100644 index 0000000..33080b1 --- /dev/null +++ b/src/services/management/__tests__/emailmessages.test.js @@ -0,0 +1,373 @@ +import { jest } from '@jest/globals'; + +const accountLean = jest.fn(); +const accountSelect = jest.fn(() => ({ lean: accountLean })); +const accountFindById = jest.fn(() => ({ select: accountSelect })); +const notifierCreate = jest.fn(); +const templateLean = jest.fn(); +const templatePopulate = jest.fn(() => ({ lean: templateLean })); +const templateFindById = jest.fn(() => ({ populate: templatePopulate })); +const fileFindById = jest.fn(); +const workerMessages = []; + +jest.unstable_mockModule('../../../database/schemas/management/emailmessage.schema.js', () => ({ + emailMessageModel: { + modelName: 'emailMessage', + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + }, +})); +jest.unstable_mockModule('../../../database/schemas/management/emailaccount.schema.js', () => ({ + emailAccountModel: { findById: accountFindById }, +})); +jest.unstable_mockModule('../../../database/schemas/management/emailtemplate.schema.js', () => ({ + emailTemplateModel: { modelName: 'emailTemplate', findById: templateFindById }, +})); +jest.unstable_mockModule('../../../database/schemas/management/file.schema.js', () => ({ + fileModel: { modelName: 'file', findById: fileFindById }, +})); +jest.unstable_mockModule('../../../database/schemas/misc/usernotifier.schema.js', () => ({ + userNotifierModel: { create: notifierCreate }, +})); +jest.unstable_mockModule('../../../database/database.js', () => ({ + createFileThumbnails: jest.fn(), + editObject: jest.fn(), + getObject: jest.fn(), + getModelHistory: jest.fn(), + getModelStats: jest.fn(), + getObjectNeighbors: jest.fn(), + getPropertyValues: jest.fn(), + listObjects: jest.fn(), + listObjectsByProperties: jest.fn(), + newObject: jest.fn(), + searchObjects: jest.fn(), +})); +jest.unstable_mockModule('../../../database/ceph.js', () => ({ + BUCKETS: { FILES: 'farmcontrol' }, + downloadFile: jest.fn(), + uploadFile: jest.fn(), +})); +jest.unstable_mockModule('../../../utils.js', () => ({ + distributeNew: jest.fn(), + distributeUpdate: jest.fn(), + getFileMeta: jest.fn().mockResolvedValue({}), + notfiyObjectUserNotifiers: jest.fn(), +})); +jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({ + templateManager: { renderEmailTemplate: jest.fn(), renderDownload: jest.fn() }, +})); +jest.unstable_mockModule('worker_threads', () => ({ + Worker: class { + constructor() { + this.handlers = {}; + } + on(event, handler) { + this.handlers[event] = handler; + } + postMessage(payload) { + workerMessages.push(payload); + queueMicrotask(() => { + this.handlers.message?.({ + kind: 'emailMessageResult', + requestId: payload.requestId, + success: true, + messageId: '', + }); + }); + } + }, +})); +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }), + }, +})); + +const { + listEmailMessagesByPropertiesRouteHandler, + markEmailMessageAsReadRouteHandler, + newEmailMessageRouteHandler, + processEmailMessage, +} = await import('../emailmessages.js'); +const { createFileThumbnails, editObject, listObjectsByProperties, newObject } = + await import('../../../database/database.js'); +const { downloadFile, uploadFile } = await import('../../../database/ceph.js'); +const { distributeNew } = await import('../../../utils.js'); +const { emailMessageModel } = + await import('../../../database/schemas/management/emailmessage.schema.js'); +const { fileModel } = await import('../../../database/schemas/management/file.schema.js'); +const { templateManager } = await import('../../../templates/templatemanager.js'); + +describe('email message creation', () => { + let req; + let res; + let immediate; + + beforeEach(() => { + jest.clearAllMocks(); + workerMessages.length = 0; + templateLean.mockResolvedValue({ attachments: [] }); + immediate = jest.spyOn(global, 'setImmediate').mockImplementation(() => 1); + req = { + body: { + name: 'Invoice email', + emailTemplate: 'template-1', + emailAccount: '507f1f77bcf86cd799439011', + objectType: 'invoice', + object: 'object-1', + recipientEmail: 'client@example.com', + }, + user: { _id: 'user-1' }, + }; + res = { send: jest.fn(), status: jest.fn().mockReturnThis() }; + }); + + afterEach(() => immediate.mockRestore()); + + it('rejects an inactive account before persisting', async () => { + accountLean.mockResolvedValue({ _id: '507f1f77bcf86cd799439011', active: false }); + await newEmailMessageRouteHandler(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(newObject).not.toHaveBeenCalled(); + }); + + it('locks fromEmail to the account and schedules processing after responding', async () => { + accountLean.mockResolvedValue({ + _id: '507f1f77bcf86cd799439011', + active: true, + fromEmail: 'farm@example.com', + }); + const created = { _id: 'message-1', fromEmail: 'farm@example.com' }; + newObject.mockResolvedValue(created); + notifierCreate.mockResolvedValue({ _id: 'notifier-1' }); + + await newEmailMessageRouteHandler(req, res); + + expect(newObject.mock.calls[0][0].newData.fromEmail).toBe('farm@example.com'); + expect(notifierCreate).toHaveBeenCalledWith( + expect.objectContaining({ object: 'message-1', objectType: 'emailMessage' }) + ); + expect(distributeNew).toHaveBeenCalled(); + expect(res.send).toHaveBeenCalledWith(created); + expect(setImmediate).toHaveBeenCalled(); + }); + + it('populates message references in property responses', async () => { + listObjectsByProperties.mockResolvedValue([{ state: { type: 'sent' } }]); + await listEmailMessagesByPropertiesRouteHandler( + req, + res, + ['state', 'emailAccount'], + { read: false }, + {} + ); + + expect(listObjectsByProperties).toHaveBeenCalledWith( + expect.objectContaining({ + properties: ['state', 'emailAccount'], + filter: { read: false }, + populate: ['emailTemplate', 'emailAccount', 'object', 'recipient', 'attachments'], + }) + ); + }); + + it('updates send state through editObject and stores messageId', async () => { + const user = { _id: 'user-1' }; + emailMessageModel.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: 'message-1', + emailTemplate: 'template-1', + object: 'object-1', + recipientEmail: 'client@example.com', + }), + }); + templateManager.renderEmailTemplate.mockResolvedValue({ + subject: 'Invoice', + html: '

Hi

', + }); + editObject.mockResolvedValue({ _id: 'message-1' }); + + await processEmailMessage( + 'message-1', + { fromEmail: 'farm@example.com', host: 'smtp.example.com' }, + user + ); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'message-1', + user, + updateData: expect.objectContaining({ + state: { type: 'sent', progress: 1 }, + messageId: '', + sentAt: expect.any(Date), + }), + }) + ); + }); + + it('renders document template attachments into files before sending', async () => { + const user = { _id: 'user-1' }; + const pdfBytes = Buffer.from('pdf-bytes'); + emailMessageModel.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: 'message-1', + emailTemplate: 'template-1', + object: 'object-1', + recipientEmail: 'client@example.com', + }), + }); + templateLean.mockResolvedValue({ + attachments: [ + { + type: 'documentTemplate', + file: { _id: 'doc-1', name: 'Invoice' }, + fileName: 'Invoice', + fileType: 'pdf', + }, + ], + }); + templateManager.renderEmailTemplate.mockResolvedValue({ + subject: 'Invoice', + html: '

Hi

', + htmlWithPixel: '

Hi

', + }); + templateManager.renderDownload.mockResolvedValue({ + type: 'pdf', + mime: 'application/pdf', + extension: 'pdf', + buffers: [pdfBytes], + }); + newObject.mockResolvedValue({ _id: 'file-new' }); + editObject.mockResolvedValue({ _id: 'message-1' }); + + await processEmailMessage( + 'message-1', + { fromEmail: 'farm@example.com', host: 'smtp.example.com' }, + user + ); + + expect(templateManager.renderDownload).toHaveBeenCalledWith( + 'doc-1', + undefined, + 'object-1', + 'pdf' + ); + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + model: fileModel, + newData: expect.objectContaining({ + name: 'Invoice', + type: 'application/pdf', + extension: '.pdf', + }), + }) + ); + expect(uploadFile).toHaveBeenCalledWith( + 'farmcontrol', + 'files/file-new.pdf', + pdfBytes, + 'application/pdf', + expect.objectContaining({ originalName: 'Invoice.pdf' }) + ); + expect(createFileThumbnails).toHaveBeenCalledWith(pdfBytes, { _id: 'file-new' }); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + state: { type: 'sending', progress: 0.7, message: 'Sending email...' }, + attachments: ['file-new'], + }), + }) + ); + expect(workerMessages[0].attachments).toEqual([ + { + filename: 'Invoice.pdf', + content: pdfBytes, + contentType: 'application/pdf', + }, + ]); + }); + + it('retrieves existing file attachments without creating a new file', async () => { + const user = { _id: 'user-1' }; + const existingBytes = Buffer.from('existing-pdf'); + emailMessageModel.findById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: 'message-1', + emailTemplate: 'template-1', + object: 'object-1', + recipientEmail: 'client@example.com', + }), + }); + templateLean.mockResolvedValue({ + attachments: [ + { + type: 'file', + file: { + _id: 'file-1', + name: 'Terms', + extension: '.pdf', + type: 'application/pdf', + }, + fileName: 'Terms', + }, + ], + }); + templateManager.renderEmailTemplate.mockResolvedValue({ + subject: 'Invoice', + html: '

Hi

', + htmlWithPixel: '

Hi

', + }); + downloadFile.mockResolvedValue(existingBytes); + editObject.mockResolvedValue({ _id: 'message-1' }); + + await processEmailMessage( + 'message-1', + { fromEmail: 'farm@example.com', host: 'smtp.example.com' }, + user + ); + + expect(templateManager.renderDownload).not.toHaveBeenCalled(); + expect(newObject).not.toHaveBeenCalled(); + expect(downloadFile).toHaveBeenCalledWith('farmcontrol', 'files/file-1.pdf'); + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + attachments: ['file-1'], + }), + }) + ); + expect(workerMessages[0].attachments).toEqual([ + { + filename: 'Terms.pdf', + content: existingBytes, + contentType: 'application/pdf', + }, + ]); + }); + + it('uses a system audit actor for automatic read updates', async () => { + req.params = { id: '507f1f77bcf86cd799439011' }; + editObject.mockResolvedValue({ _id: '507f1f77bcf86cd799439011', read: true }); + + await markEmailMessageAsReadRouteHandler(req, res); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + updateData: expect.objectContaining({ + read: true, + readAt: expect.any(Date), + state: { type: 'read' }, + }), + user: 'system', + }) + ); + }); +}); diff --git a/src/services/management/__tests__/emailtemplates.test.js b/src/services/management/__tests__/emailtemplates.test.js new file mode 100644 index 0000000..5e24c64 --- /dev/null +++ b/src/services/management/__tests__/emailtemplates.test.js @@ -0,0 +1,170 @@ +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('../../../database/schemas/management/emailtemplate.schema.js', () => ({ + emailTemplateModel: { modelName: 'emailTemplate' }, +})); +jest.unstable_mockModule('../../../database/database.js', () => ({ + deleteObject: jest.fn(), + editObject: jest.fn(), + getObject: jest.fn(), + getModelHistory: jest.fn(), + getModelStats: jest.fn(), + getObjectNeighbors: jest.fn(), + getPropertyValues: jest.fn(), + listObjects: jest.fn(), + listObjectsByProperties: jest.fn(), + newObject: jest.fn(), + searchObjects: jest.fn(), +})); +jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({ + templateManager: { renderEmailTemplate: jest.fn() }, +})); +jest.unstable_mockModule('../../../templates/templateformatter.js', () => ({ + formatTemplateContent: jest.fn((content) => ({ content })), +})); +jest.unstable_mockModule('log4js', () => ({ + default: { + getLogger: () => ({ + level: 'info', + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }), + }, +})); + +const { + editEmailTemplateRouteHandler, + getEmailTemplateNeighborsRouteHandler, + getEmailTemplateStatsRouteHandler, + listEmailTemplatesByPropertiesRouteHandler, + newEmailTemplateRouteHandler, +} = await import('../emailtemplates.js'); +const { editObject, getModelStats, getObjectNeighbors, listObjectsByProperties, newObject } = + await import('../../../database/database.js'); + +const ATTACHMENT_POPULATE = { path: 'attachments.file', strictPopulate: false }; + +describe('email template standard handlers', () => { + let req; + let res; + + beforeEach(() => { + jest.clearAllMocks(); + req = { query: {}, params: {}, body: {}, user: { _id: 'user-1' } }; + res = { send: jest.fn(), status: jest.fn().mockReturnThis() }; + }); + + it('supports active property lists and parent population', async () => { + listObjectsByProperties.mockResolvedValue([{ name: 'Invoice', active: true }]); + await listEmailTemplatesByPropertiesRouteHandler( + req, + res, + ['name', 'active'], + { active: true }, + {} + ); + + expect(listObjectsByProperties).toHaveBeenCalledWith( + expect.objectContaining({ + properties: ['name', 'active'], + filter: { active: true }, + populate: [ + { path: 'parent', strictPopulate: false }, + ATTACHMENT_POPULATE, + ], + }) + ); + }); + + it('returns stats and neighbors through generic database helpers', async () => { + getModelStats.mockResolvedValue({ count: 2 }); + getObjectNeighbors.mockResolvedValue({ previous: null, next: 'template-2' }); + + await getEmailTemplateStatsRouteHandler(req, res); + await getEmailTemplateNeighborsRouteHandler( + req, + res, + '', + { active: true }, + '', + 'name', + 'ascend', + 'template-1' + ); + + expect(getModelStats).toHaveBeenCalled(); + expect(getObjectNeighbors).toHaveBeenCalledWith( + expect.objectContaining({ id: 'template-1', filter: { active: true }, sort: 'name' }) + ); + }); + + it('persists normalized attachments on create and edit', async () => { + const attachments = [ + { + type: 'documentTemplate', + file: { _id: 'doc-1' }, + fileName: 'Invoice', + fileType: 'pdf', + }, + { + type: 'file', + file: 'file-1', + fileName: 'Terms', + }, + ]; + req.body = { name: 'Invoice', attachments }; + newObject.mockResolvedValue({ _id: 'template-1' }); + editObject.mockResolvedValue({ _id: 'template-1' }); + + await newEmailTemplateRouteHandler(req, res); + + expect(newObject).toHaveBeenCalledWith( + expect.objectContaining({ + newData: expect.objectContaining({ + attachments: [ + { + _id: undefined, + type: 'documentTemplate', + file: 'doc-1', + fileName: 'Invoice', + fileType: 'pdf', + }, + { + _id: undefined, + type: 'file', + file: 'file-1', + fileName: 'Terms', + fileType: undefined, + }, + ], + }), + }) + ); + + req.params = { id: '507f1f77bcf86cd799439011' }; + await editEmailTemplateRouteHandler(req, res); + + expect(editObject).toHaveBeenCalledWith( + expect.objectContaining({ + updateData: expect.objectContaining({ + attachments: [ + expect.objectContaining({ + type: 'documentTemplate', + file: 'doc-1', + fileName: 'Invoice', + fileType: 'pdf', + }), + expect.objectContaining({ + type: 'file', + file: 'file-1', + fileName: 'Terms', + }), + ], + }), + populate: expect.arrayContaining([ATTACHMENT_POPULATE]), + }) + ); + }); +}); diff --git a/src/services/management/emailaccounts.js b/src/services/management/emailaccounts.js new file mode 100644 index 0000000..667c639 --- /dev/null +++ b/src/services/management/emailaccounts.js @@ -0,0 +1,257 @@ +import config from '../../config.js'; +import { emailAccountModel } from '../../database/schemas/management/emailaccount.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + getObject, + listObjects, + listObjectsByProperties, + editObject, + newObject, + deleteObject, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, +} from '../../database/database.js'; + +const logger = log4js.getLogger('Email Accounts'); +logger.level = config.server.logLevel; + +export const listEmailAccountsRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: emailAccountModel, + page, + limit, + property, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error listing email accounts.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of email accounts (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listEmailAccountsByPropertiesRouteHandler = async ( + req, + res, + properties = [], + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: emailAccountModel, + properties, + filter, + masterFilter, + populate: [], + }); + + if (result?.error) { + logger.error('Error listing email accounts.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of email accounts. Count: ${result.length}`); + res.send(result); +}; + +export const getEmailAccountPropertyValuesRouteHandler = async ( + req, + res, + property, + filter, + masterFilter +) => { + const result = await getPropertyValues({ + model: emailAccountModel, + property, + filter: { ...filter, ...masterFilter }, + }); + res.send(result); +}; + +export const searchEmailAccountsRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: emailAccountModel, + search, + }); + res.send(result); +}; + +export const getEmailAccountRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: emailAccountModel, + id, + }); + if (result?.error) { + logger.warn(`Email account not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retreived email account with ID: ${id}`); + res.send(result); +}; + +export const editEmailAccountRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Email account with ID: ${id}`); + + // Keep the secret out of generic auditing, caching, and event distribution. + if (typeof req.body.password === 'string' && req.body.password.length > 0) { + await emailAccountModel.updateOne({ _id: id }, { $set: { password: req.body.password } }); + } + + const updateData = { + updatedAt: new Date(), + name: req.body.name, + active: req.body.active, + host: req.body.host, + port: req.body.port, + secure: req.body.secure, + username: req.body.username, + fromName: req.body.fromName, + fromEmail: req.body.fromEmail, + }; + const result = await editObject({ + model: emailAccountModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing email account:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited email account with ID: ${id}`); + + res.send(result); +}; + +export const newEmailAccountRouteHandler = async (req, res) => { + const newData = { + createdAt: new Date(), + updatedAt: new Date(), + name: req.body.name, + active: req.body.active, + host: req.body.host, + port: req.body.port, + secure: req.body.secure, + username: req.body.username, + password: req.body.password, + fromName: req.body.fromName, + fromEmail: req.body.fromEmail, + }; + + const result = await newObject({ + model: emailAccountModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No email account created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New email account with ID: ${result._id}`); + + res.send(result); +}; + +export const deleteEmailAccountRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Email account with ID: ${id}`); + + const result = await deleteObject({ + model: emailAccountModel, + id, + user: req.user, + checkUnused: true, + }); + if (result.error) { + logger.error('No email account deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted email account with ID: ${id}`); + res.send(result); +}; + +export const getEmailAccountStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: emailAccountModel }); + if (result?.error) { + logger.error('Error fetching email account stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Email account stats:', result); + res.send(result); +}; + +export const getEmailAccountHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: emailAccountModel, from, to }); + if (result?.error) { + logger.error('Error fetching email account history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Email account history:', result); + res.send(result); +}; + +export const getEmailAccountNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: emailAccountModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching email account neighbors.'); + return res.status(result.code).send(result); + } + + logger.debug(`Retrieved email account neighbors for ID: ${id}`); + res.send(result); +}; diff --git a/src/services/management/emailmessages.js b/src/services/management/emailmessages.js new file mode 100644 index 0000000..5c38cfb --- /dev/null +++ b/src/services/management/emailmessages.js @@ -0,0 +1,565 @@ +import config from '../../config.js'; +import { emailAccountModel } from '../../database/schemas/management/emailaccount.schema.js'; +import { emailMessageModel } from '../../database/schemas/management/emailmessage.schema.js'; +import { emailTemplateModel } from '../../database/schemas/management/emailtemplate.schema.js'; +import { fileModel } from '../../database/schemas/management/file.schema.js'; +import { userNotifierModel } from '../../database/schemas/misc/usernotifier.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { Worker } from 'worker_threads'; +import { randomUUID } from 'crypto'; +import { fileURLToPath } from 'url'; +import { getModelByName } from '../misc/model.js'; +import path from 'path'; +import { + getObject, + listObjects, + listObjectsByProperties, + editObject, + newObject, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, + createFileThumbnails, +} from '../../database/database.js'; +import { BUCKETS, downloadFile, uploadFile } from '../../database/ceph.js'; +import { distributeNew, getFileMeta, notfiyObjectUserNotifiers } from '../../utils.js'; +import { templateManager } from '../../templates/templatemanager.js'; + +const logger = log4js.getLogger('Email Messages'); +logger.level = config.server.logLevel; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const populate = ['emailTemplate', 'emailAccount', 'object', 'recipient', 'attachments']; +let worker; +const pending = new Map(); + +function rejectPending(error) { + for (const { reject } of pending.values()) reject(error); + pending.clear(); +} + +function getWorker() { + if (worker) return worker; + worker = new Worker(path.resolve(__dirname, '../../mailworker.js')); + worker.on('message', (result) => { + if (result?.kind !== 'emailMessageResult') return; + const callback = pending.get(result.requestId); + if (!callback) return; + pending.delete(result.requestId); + if (result.success) callback.resolve(result); + else callback.reject(new Error(result.error || 'Email delivery failed.')); + }); + worker.on('error', (error) => { + logger.error('Email message worker error:', error); + rejectPending(error); + }); + worker.on('exit', (code) => { + if (code !== 0) rejectPending(new Error(`Mail worker exited with code ${code}.`)); + worker = null; + }); + return worker; +} + +function deliver(payload) { + return new Promise((resolve, reject) => { + const requestId = randomUUID(); + pending.set(requestId, { resolve, reject }); + try { + getWorker().postMessage({ ...payload, requestId }); + } catch (error) { + pending.delete(requestId); + reject(error); + } + }); +} + +async function streamToBuffer(body) { + if (body == null) return Buffer.alloc(0); + if (Buffer.isBuffer(body)) return body; + if (body instanceof Uint8Array) return Buffer.from(body); + if (typeof body.transformToByteArray === 'function') { + return Buffer.from(await body.transformToByteArray()); + } + const chunks = []; + for await (const chunk of body) { + chunks.push(chunk); + } + return Buffer.concat( + chunks.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + ); +} + +function splitAttachmentName(fileName, extension, index, total) { + const rawExt = String(extension || '').replace(/^\./, ''); + const ext = rawExt ? `.${rawExt}` : ''; + const raw = String(fileName || 'attachment'); + const parsed = path.parse(raw); + const baseName = parsed.ext ? parsed.name : raw; + const suffix = total > 1 ? `-${index + 1}` : ''; + return { + name: `${baseName}${suffix}`, + extension: ext, + filename: `${baseName}${suffix}${ext}`, + }; +} + +async function createStoredFile({ buffer, name, extension, mime, user, metaData }) { + const originalName = `${name}${extension}`; + const meta = await getFileMeta({ + originalname: originalName, + mimetype: mime, + buffer, + }); + const created = await newObject({ + model: fileModel, + newData: { + name, + type: mime, + extension, + size: buffer.length, + updatedAt: new Date(), + metaData: { + originalName, + ...meta, + ...metaData, + }, + }, + user, + }); + if (created?.error) { + throw new Error(created.error); + } + await uploadFile(BUCKETS.FILES, `files/${created._id}${extension}`, buffer, mime, { + originalName, + uploadedBy: user?.username || 'email', + }); + createFileThumbnails(buffer, created); + return created; +} + +async function loadFileRecord(file) { + if (file && typeof file === 'object' && file.extension != null && file.name != null) { + return file; + } + const id = file?._id ?? file; + const record = await fileModel.findById(id).lean(); + if (!record) throw new Error('Attachment file not found.'); + return record; +} + +async function resolveEmailAttachments(emailMessage, user) { + const template = await emailTemplateModel + .findById(emailMessage.emailTemplate) + .populate({ path: 'attachments.file', strictPopulate: false }) + .lean(); + if (!template) throw new Error('Email template not found.'); + + const mailAttachments = []; + const fileIds = []; + + for (const attachment of template.attachments || []) { + if (attachment.type === 'documentTemplate') { + const templateId = attachment.file?._id ?? attachment.file; + console.log(templateId); + if (!templateId) throw new Error('Document template attachment is missing a file.'); + const model = getModelByName(emailMessage.objectType); + const object = await getObject({ + model: getModelByName(emailMessage.objectType).model, + id: emailMessage.object?._id || emailMessage.object, + }); + console.log(object); + const rendered = await templateManager.renderDownload( + templateId, + undefined, + object, + attachment.fileType || 'pdf' + ); + console.log(rendered); + if (rendered?.error) throw new Error(rendered.error); + const buffers = rendered.buffers || []; + if (!buffers.length) throw new Error('Document template attachment produced no files.'); + console.log(buffers); + for (let index = 0; index < buffers.length; index += 1) { + const buffer = Buffer.isBuffer(buffers[index]) + ? buffers[index] + : Buffer.from(buffers[index]); + const names = splitAttachmentName( + attachment.fileName || attachment.file?.name, + rendered.extension, + index, + buffers.length + ); + const created = await createStoredFile({ + buffer, + name: names.name, + extension: names.extension, + mime: rendered.mime, + user, + metaData: { + source: 'emailTemplateAttachment', + emailMessage: String(emailMessage._id), + documentTemplate: String(templateId), + }, + }); + fileIds.push(created._id); + mailAttachments.push({ + filename: names.filename, + content: buffer, + contentType: rendered.mime, + }); + } + continue; + } + + if (attachment.type === 'file') { + const fileRecord = await loadFileRecord(attachment.file); + const extension = fileRecord.extension || ''; + const body = await downloadFile(BUCKETS.FILES, `files/${fileRecord._id}${extension}`); + const buffer = await streamToBuffer(body); + const names = splitAttachmentName(attachment.fileName || fileRecord.name, extension, 0, 1); + fileIds.push(fileRecord._id); + mailAttachments.push({ + filename: names.filename, + content: buffer, + contentType: fileRecord.type, + }); + } + } + + return { fileIds, mailAttachments }; +} + +const updateEmailMessage = async (id, updateData, user) => { + return editObject({ + model: emailMessageModel, + id, + updateData, + user, + populate, + }); +}; + +const report = async (id, success, message) => { + await notfiyObjectUserNotifiers( + id, + 'emailMessage', + success ? 'Email sent' : 'Email delivery failed', + message, + success ? 'success' : 'error', + { objectType: 'emailMessage', object: { _id: String(id) } } + ); +}; + +export const processEmailMessage = async (id, account, user) => { + try { + await updateEmailMessage( + id, + { state: { type: 'rendering', progress: 0.2, message: 'Rendering email...' } }, + user + ); + const emailMessage = await emailMessageModel.findById(id).lean(); + if (!emailMessage) throw new Error('Email message not found.'); + + const rendered = await templateManager.renderEmailTemplate( + emailMessage.emailTemplate, + undefined, + undefined, + emailMessage.object, + {}, + false, + String(id) + ); + if (rendered?.error) throw new Error(rendered.error); + + const { fileIds, mailAttachments } = await resolveEmailAttachments(emailMessage, user); + + console.log(fileIds); + console.log(mailAttachments); + + await updateEmailMessage( + id, + { + state: { type: 'sending', progress: 0.7, message: 'Sending email...' }, + subject: rendered.subject, + content: rendered.html, + attachments: fileIds, + }, + user + ); + const result = await deliver({ + kind: 'emailMessage', + recipientEmail: emailMessage.recipientEmail, + from: account.fromName ? `${account.fromName} <${account.fromEmail}>` : account.fromEmail, + subject: rendered.subject, + html: rendered.htmlWithPixel, + attachments: mailAttachments, + smtpConfig: { + host: account.host, + port: account.port, + secure: account.secure, + username: account.username, + password: account.password, + }, + }); + await updateEmailMessage( + id, + { + state: { type: 'sent', progress: 1 }, + messageId: result.messageId || undefined, + sentAt: new Date(), + }, + user + ); + await report(id, true, 'The email was sent successfully.'); + } catch (error) { + logger.error(`Email message ${id} failed:`, error); + await updateEmailMessage( + id, + { state: { type: 'error', progress: 1, message: error.message } }, + user + ); + await report(id, false, error.message); + } +}; + +export const listEmailMessagesRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'descend' +) => { + const result = await listObjects({ + model: emailMessageModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate, + }); + + if (result?.error) { + logger.error('Error listing email messages.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of email messages (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listEmailMessagesByPropertiesRouteHandler = async ( + req, + res, + properties = [], + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: emailMessageModel, + properties, + filter, + masterFilter, + populate, + }); + + if (result?.error) { + logger.error('Error listing email messages.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of email messages. Count: ${result.length}`); + res.send(result); +}; + +export const getEmailMessagePropertyValuesRouteHandler = async ( + req, + res, + property, + filter, + masterFilter +) => { + const result = await getPropertyValues({ + model: emailMessageModel, + property, + filter: { ...filter, ...masterFilter }, + }); + res.send(result); +}; + +export const searchEmailMessagesRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: emailMessageModel, + search, + }); + res.send(result); +}; + +export const getEmailMessageRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: emailMessageModel, + id, + populate, + }); + if (result?.error) { + logger.warn(`Email message not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retreived email message with ID: ${id}`); + res.send(result); +}; + +export const newEmailMessageRouteHandler = async (req, res) => { + const accountId = req.body.emailAccount?._id || req.body.emailAccount; + if (!mongoose.Types.ObjectId.isValid(accountId)) { + return res.status(400).send({ error: 'Invalid email account.', code: 400 }); + } + const account = await emailAccountModel.findById(accountId).select('+password').lean(); + if (!account) { + return res.status(400).send({ error: 'Email account not found.', code: 400 }); + } + if (!account.active) { + return res.status(400).send({ error: 'Email account is inactive.', code: 400 }); + } + if (req.body.fromEmail && req.body.fromEmail !== account.fromEmail) { + return res.status(400).send({ + error: 'fromEmail must match the selected email account.', + code: 400, + }); + } + + const newData = { + createdAt: new Date(), + updatedAt: new Date(), + name: req.body.name, + emailTemplate: req.body.emailTemplate?._id ?? req.body.emailTemplate, + objectType: req.body.objectType, + object: req.body.object?._id ?? req.body.object, + emailAccount: account._id, + recipientEmail: req.body.recipientEmail, + recipientType: req.body.recipientType, + recipient: req.body.recipient?._id ?? req.body.recipient, + fromEmail: account.fromEmail, + state: { type: 'queued', progress: 0, message: 'Queued' }, + read: false, + }; + + const result = await newObject({ + model: emailMessageModel, + newData, + user: req.user, + populate, + }); + if (result.error) { + logger.error('No email message created:', result.error); + return res.status(result.code).send(result); + } + + const notifier = await userNotifierModel.create({ + user: req.user._id, + object: result._id, + objectType: 'emailMessage', + email: false, + }); + await distributeNew(notifier, 'userNotifier'); + + logger.debug(`New email message with ID: ${result._id}`); + + res.send(result); + setImmediate(() => processEmailMessage(result._id, account, req.user)); +}; + +export const getEmailMessageStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: emailMessageModel }); + if (result?.error) { + logger.error('Error fetching email message stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Email message stats:', result); + res.send(result); +}; + +export const getEmailMessageHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: emailMessageModel, from, to }); + if (result?.error) { + logger.error('Error fetching email message history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Email message history:', result); + res.send(result); +}; + +export const getEmailMessageNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: emailMessageModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching email message neighbors.'); + return res.status(result.code).send(result); + } + + logger.debug(`Retrieved email message neighbors for ID: ${id}`); + res.send(result); +}; + +export const markEmailMessageAsReadRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Email message with ID: ${id}`); + + const updateData = { + updatedAt: new Date(), + read: true, + readAt: new Date(), + state: { type: 'read' }, + }; + const result = await editObject({ + model: emailMessageModel, + id, + updateData, + user: 'system', + populate, + }); + + if (result.error) { + logger.error('Error marking email message as read:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Marked email message as read with ID: ${id}`); + + res.send(result); +}; diff --git a/src/services/management/emailtemplates.js b/src/services/management/emailtemplates.js new file mode 100644 index 0000000..bbc5cd3 --- /dev/null +++ b/src/services/management/emailtemplates.js @@ -0,0 +1,322 @@ +import config from '../../config.js'; +import { emailTemplateModel } from '../../database/schemas/management/emailtemplate.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + getObject, + listObjects, + listObjectsByProperties, + editObject, + newObject, + deleteObject, + getModelStats, + getModelHistory, + searchObjects, + getPropertyValues, + getObjectNeighbors, +} from '../../database/database.js'; +import { templateManager } from '../../templates/templatemanager.js'; +import { formatTemplateContent } from '../../templates/templateformatter.js'; + +const logger = log4js.getLogger('Email Templates'); +logger.level = config.server.logLevel; +const populate = [ + { path: 'parent', strictPopulate: false }, + { path: 'referencedTemplates', strictPopulate: false }, + { path: 'attachments.file', strictPopulate: false }, +]; + +const normalizeAttachments = (attachments) => + (attachments || []).map((attachment) => ({ + _id: attachment._id, + type: attachment.type, + file: attachment.file?._id ?? attachment.file, + fileName: attachment.fileName, + fileType: attachment.fileType, + })); + +export const listEmailTemplatesRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const result = await listObjects({ + model: emailTemplateModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate, + }); + + if (result?.error) { + logger.error('Error listing email templates.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of email templates (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listEmailTemplatesByPropertiesRouteHandler = async ( + req, + res, + properties = [], + filter = {}, + masterFilter = {} +) => { + const result = await listObjectsByProperties({ + model: emailTemplateModel, + properties, + filter, + masterFilter, + populate: [ + { path: 'parent', strictPopulate: false }, + { path: 'attachments.file', strictPopulate: false }, + ], + }); + + if (result?.error) { + logger.error('Error listing email templates.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of email templates. Count: ${result.length}`); + res.send(result); +}; + +export const getEmailTemplatePropertyValuesRouteHandler = async ( + req, + res, + property, + filter, + masterFilter +) => { + const result = await getPropertyValues({ + model: emailTemplateModel, + property, + filter: { ...filter, ...masterFilter }, + }); + res.send(result); +}; + +export const searchEmailTemplatesRouteHandler = async (req, res, search) => { + const result = await searchObjects({ + model: emailTemplateModel, + search, + }); + res.send(result); +}; + +export const getEmailTemplateRouteHandler = async (req, res) => { + const id = req.params.id; + const result = await getObject({ + model: emailTemplateModel, + id, + populate: [...populate, { path: 'testObject', strictPopulate: false }], + }); + if (result?.error) { + logger.warn(`Email template not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retreived email template with ID: ${id}`); + res.send(result); +}; + +export const editEmailTemplateRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Email template with ID: ${id}`); + + let content = req.body.content; + if (content != null) { + const formatted = formatTemplateContent(content); + if (formatted.error) { + logger.error('Error formatting email template:', formatted.error); + return res.status(formatted.code).send(formatted); + } + content = formatted.content; + } + + const updateData = { + updatedAt: new Date(), + name: req.body.name, + tags: req.body.tags, + active: req.body.active, + global: req.body.global, + parent: req.body.parent?._id ?? req.body.parent, + objectType: req.body.objectType, + subject: req.body.subject, + content, + testObject: req.body.testObject?._id ?? req.body.testObject, + attachments: normalizeAttachments(req.body.attachments), + }; + const result = await editObject({ + model: emailTemplateModel, + id, + updateData, + user: req.user, + populate, + }); + + if (result.error) { + logger.error('Error editing email template:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Edited email template with ID: ${id}`); + + res.send(result); +}; + +export const newEmailTemplateRouteHandler = async (req, res) => { + let content = req.body.content; + if (content != null) { + const formatted = formatTemplateContent(content); + if (formatted.error) { + logger.error('Error formatting email template:', formatted.error); + return res.status(formatted.code).send(formatted); + } + content = formatted.content; + } + + const newData = { + createdAt: new Date(), + updatedAt: new Date(), + name: req.body.name, + tags: req.body.tags, + active: req.body.active, + global: req.body.global, + parent: req.body.parent?._id ?? req.body.parent, + objectType: req.body.objectType, + subject: req.body.subject, + content, + testObject: req.body.testObject?._id ?? req.body.testObject, + attachments: normalizeAttachments(req.body.attachments), + }; + + const result = await newObject({ + model: emailTemplateModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No email template created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New email template with ID: ${result._id}`); + + res.send(result); +}; + +export const deleteEmailTemplateRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Email template with ID: ${id}`); + + const result = await deleteObject({ + model: emailTemplateModel, + id, + user: req.user, + checkUnused: true, + }); + if (result.error) { + logger.error('No email template deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted email template with ID: ${id}`); + res.send(result); +}; + +export const getEmailTemplateStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: emailTemplateModel }); + if (result?.error) { + logger.error('Error fetching email template stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Email template stats:', result); + res.send(result); +}; + +export const getEmailTemplateHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: emailTemplateModel, from, to }); + if (result?.error) { + logger.error('Error fetching email template history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Email template history:', result); + res.send(result); +}; + +export const getEmailTemplateNeighborsRouteHandler = async ( + req, + res, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend', + id +) => { + if (!id) { + return res.status(400).send({ error: 'Missing id parameter', code: 400 }); + } + + const result = await getObjectNeighbors({ + model: emailTemplateModel, + id, + filter, + search, + sort, + order, + }); + + if (result?.error) { + logger.error('Error fetching email template neighbors.'); + return res.status(result.code).send(result); + } + + logger.debug(`Retrieved email template neighbors for ID: ${id}`); + res.send(result); +}; + +export const formatEmailTemplateRouteHandler = (req, res) => { + const result = formatTemplateContent(req.body?.content); + if (result.error) { + logger.error('Error formatting email template:', result.error); + return res.status(result.code).send(result); + } + res.send(result); +}; + +export const previewEmailTemplateRouteHandler = async (req, res) => { + const result = await templateManager.renderEmailTemplate( + req.params.id, + req.body?.subject, + req.body?.content, + req.body?.testObject || req.body?.object || {}, + { width: Number(req.body?.width) || 600 }, + true + ); + if (result?.error) { + logger.error('Error previewing email template:', result.error); + return res.status(result.code).send(result); + } + res.send(result); +}; diff --git a/src/templates/__tests__/templatemanager.test.js b/src/templates/__tests__/templatemanager.test.js index 4cb83a4..a228551 100644 --- a/src/templates/__tests__/templatemanager.test.js +++ b/src/templates/__tests__/templatemanager.test.js @@ -9,8 +9,7 @@ jest.unstable_mockModule('fs', () => ({ return '
<%- content %>
'; if (filePath.endsWith('rendertemplate.ejs')) return '
<%- content %>
'; - if (filePath.endsWith('contentplaceholder.ejs')) - return '
'; + if (filePath.endsWith('contentplaceholder.ejs')) return '
'; if (filePath.endsWith('previewpagination.js')) return 'console.log("pagination");'; return ''; }), @@ -43,6 +42,10 @@ jest.unstable_mockModule('../../database/schemas/management/documenttemplate.sch documentTemplateModel: { modelName: 'DocumentTemplate' }, })); +jest.unstable_mockModule('../../database/schemas/management/emailtemplate.schema.js', () => ({ + emailTemplateModel: { modelName: 'EmailTemplate' }, +})); + jest.unstable_mockModule('../../database/schemas/management/documentsize.schema.js', () => ({})); jest.unstable_mockModule('../../services/misc/model.js', () => ({ @@ -109,7 +112,8 @@ jest.unstable_mockModule('../../config.js', () => ({ }, })); -const { TemplateManager, resolveDocumentPadding, stripDocumentTemplateReference } = await import('../templatemanager.js'); +const { TemplateManager, resolveDocumentPadding, stripDocumentTemplateReference } = + await import('../templatemanager.js'); const { getObject, listObjects } = await import('../../database/database.js'); const { getFilter } = await import('../../utils.js'); const ejs = (await import('ejs')).default; @@ -179,6 +183,32 @@ describe('TemplateManager', () => { templateManager = new TemplateManager(); }); + describe('renderEmailTemplate', () => { + it('renders subject and HTML without document dimensions', async () => { + getObject.mockResolvedValue({ + _reference: 'EMAIL1', + active: true, + global: true, + subject: 'Hello', + content: 'Body', + }); + + const result = await templateManager.renderEmailTemplate( + 'email-template-id', + undefined, + undefined, + { name: 'Test' }, + { width: 640 }, + true + ); + + expect(result.subject).toBe('rendered: Hello'); + expect(result.html).toContain('max-width:640px'); + expect(result.html).toContain('transformed: rendered: Body'); + expect(result).not.toHaveProperty('height'); + }); + }); + describe('renderTemplate', () => { it('should render a template successfully', async () => { const mockTemplate = { @@ -259,9 +289,7 @@ describe('TemplateManager', () => { true ); - expect(getObject).toHaveBeenCalledWith( - expect.objectContaining({ id: 'preview-printer-id' }) - ); + expect(getObject).toHaveBeenCalledWith(expect.objectContaining({ id: 'preview-printer-id' })); expect(ejs.render).toHaveBeenCalledWith( 'some content', expect.objectContaining({ name: 'Preview Printer' }), @@ -466,10 +494,9 @@ describe('TemplateManager', () => { await templateManager.renderTemplate('temp-id', 'some content', { name: 'Parent' }); ejs.render.mockClear(); - const nestedHtml = await capturedFc.renderDocumentTemplate( - 'DTP:PKEFDZV5X1SU', - { totalAmount: 10 } - ); + const nestedHtml = await capturedFc.renderDocumentTemplate('DTP:PKEFDZV5X1SU', { + totalAmount: 10, + }); expect(ejs.render).toHaveBeenCalledWith( 'invoice', @@ -508,12 +535,7 @@ describe('TemplateManager', () => { const result = await templateManager.listObjects('printer', { name: '=Test' }); - expect(getFilter).toHaveBeenCalledWith( - { name: '=Test' }, - ['name'], - true, - expect.anything() - ); + expect(getFilter).toHaveBeenCalledWith({ name: '=Test' }, ['name'], true, expect.anything()); expect(listObjects).toHaveBeenCalledWith( expect.objectContaining({ filter: parsedFilter, @@ -661,13 +683,9 @@ describe('TemplateManager', () => { objectType: 'printer', }); - const result = await templateManager.renderDownload( - 'temp-id', - 'content', - {}, - 'pdf', - { renderRequestId: 'render-123' } - ); + const result = await templateManager.renderDownload('temp-id', 'content', {}, 'pdf', { + renderRequestId: 'render-123', + }); expect(result.type).toBe('pdf'); expect(redisServer.setKey).toHaveBeenCalledWith( diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js index 49a146f..b4fa3bf 100644 --- a/src/templates/templatemanager.js +++ b/src/templates/templatemanager.js @@ -10,6 +10,7 @@ import { dirname, join } from 'path'; import sharp from 'sharp'; import { documentTemplateModel } from '../database/schemas/management/documenttemplate.schema.js'; +import { emailTemplateModel } from '../database/schemas/management/emailtemplate.schema.js'; import '../database/schemas/management/documentsize.schema.js'; import config from '../config.js'; import { getObject, listObjects } from '../database/database.js'; @@ -645,6 +646,158 @@ export class TemplateManager { return nestedContent; } + createEmailTemplateFc(getDefaultData, options, visited) { + const fc = { + listObjects: this.listObjects.bind(this), + getObject: this.getObject.bind(this), + formatDate: this.formatDate.bind(this), + }; + fc.renderEmailTemplate = async (reference, object) => { + const dataToUse = object !== undefined ? object : getDefaultData(); + return this.renderNestedEmailTemplate(reference, dataToUse, options, visited); + }; + return fc; + } + + async getEmailTemplateByReference(reference) { + const strippedReference = String(reference ?? '') + .replace(/^ETP:/i, '') + .trim(); + if (!strippedReference) { + return { error: 'Email template reference is required.', code: 400 }; + } + const results = await listObjects({ + model: emailTemplateModel, + filter: { _reference: strippedReference }, + populate: [{ path: 'parent', strictPopulate: false }], + pagination: false, + }); + if (!Array.isArray(results) || results.length === 0) { + return { error: results?.error || 'Email template not found.', code: results?.code || 404 }; + } + return results[0]; + } + + async renderNestedEmailTemplate(reference, object, options = {}, visited = new Set()) { + const strippedReference = String(reference ?? '') + .replace(/^ETP:/i, '') + .trim(); + if (!strippedReference) throw new Error('Email template reference is required.'); + if (visited.has(strippedReference)) { + throw new Error(`Circular email template reference: ${strippedReference}`); + } + + const template = await this.getEmailTemplateByReference(strippedReference); + if (template?.error) throw new Error(template.error); + const nextVisited = new Set(visited); + nextVisited.add(strippedReference); + const renderOptions = { async: true, ...options }; + let data; + const fc = this.createEmailTemplateFc(() => data, renderOptions, nextVisited); + data = buildTemplateData(template, object, fc); + let rendered = await ejs.render(template.content || '', data, renderOptions); + + let parent = template.parent; + if (parent) { + if (typeof parent === 'string' || parent.content == null) { + parent = await getObject({ + model: emailTemplateModel, + id: parent._id || parent.id || parent, + populate: [{ path: 'parent', strictPopulate: false }], + }); + } + if (parent?.error || typeof parent.content !== 'string') { + throw new Error('Parent email template content is required and must be a string.'); + } + const parentData = { content: rendered }; + parentData.fc = this.createEmailTemplateFc(() => parentData, renderOptions, nextVisited); + rendered = await ejs.render(parent.content, parentData, renderOptions); + } + return rendered; + } + + async renderEmailTemplate( + id, + subject, + content, + data = {}, + options = {}, + preview = true, + pixelId = null + ) { + try { + const template = await getObject({ + model: emailTemplateModel, + id, + populate: [{ path: 'parent', strictPopulate: false }], + }); + if (template?.error) return template; + if (template?.active === false && preview !== true) { + return { error: 'Email template is inactive.', code: 400 }; + } + + const resolvedData = await this.hydratePreviewTestObject(template, data, preview); + if (resolvedData?.error) return resolvedData; + const renderOptions = { async: true, ...options }; + const visited = new Set(template._reference ? [String(template._reference)] : []); + let templateData; + const fc = this.createEmailTemplateFc(() => templateData, renderOptions, visited); + templateData = buildTemplateData(template, resolvedData, fc); + const renderedSubject = await ejs.render( + typeof subject === 'string' ? subject : template.subject || '', + templateData, + renderOptions + ); + let renderedContent = await ejs.render( + typeof content === 'string' ? content : template.content || '', + templateData, + renderOptions + ); + + let parent = template.parent; + if (parent) { + if (typeof parent === 'string' || parent.content == null) { + parent = await getObject({ + model: emailTemplateModel, + id: parent._id || parent.id || parent, + }); + } + if (parent?.error || typeof parent.content !== 'string') { + return { + error: 'Parent email template content is required and must be a string.', + code: 400, + }; + } + const parentData = { content: renderedContent }; + parentData.fc = this.createEmailTemplateFc(() => parentData, renderOptions, visited); + renderedContent = await ejs.render(parent.content, parentData, renderOptions); + } + + const transformed = await transformCustomElements(renderedContent); + const width = Number(options.width) > 0 ? Number(options.width) : 600; + const renderedPixel = + pixelId != null + ? `` + : ''; + const emailContent = + preview === true + ? `
${transformed}
` + : transformed; + + const outerHtml = (css, content, pixel = undefined) => { + return `${pixel != undefined ? pixel : ''}${content}`; + }; + return { + subject: renderedSubject, + html: outerHtml(baseCSS, emailContent), + htmlWithPixel: outerHtml(baseCSS, emailContent, renderedPixel), + }; + } catch (error) { + logger.warn('Error whilst rendering email template:', error.message); + return { error: error.message, code: 500 }; + } + } + async hydratePreviewTestObject(documentTemplate, data = {}, preview = true) { if (documentTemplate?.global == true) { return data; diff --git a/src/utils.js b/src/utils.js index f55b4e1..5f4dfbc 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1505,7 +1505,7 @@ function getChangedValues(oldObj, newObj, old = false) { } const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier', 'objectView', 'marketplaceEvent']; -const SENSITIVE_KEYS = ['secret']; +const SENSITIVE_KEYS = ['secret', 'password']; const DISTRIBUTE_KEYS = { _id: true,