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,