Enhance email management features with new schemas and routes
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
This commit introduces new schemas and routes for managing email accounts, email messages, and email templates. It adds functionality for CRUD operations on these entities, including listing, searching, and retrieving properties. The email message handling is enhanced with a worker for asynchronous email delivery, and the audit log schema is updated to accommodate system actions. Additionally, utility functions are improved for better data handling and validation. These changes significantly enhance the application's email management capabilities.
This commit is contained in:
parent
cb63d8dabb
commit
d86dbe0261
16
src/__tests__/auditOwner.test.js
Normal file
16
src/__tests__/auditOwner.test.js
Normal file
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,16 +1,22 @@
|
|||||||
const AUDIT_OWNER_TYPES = ['user', 'printer', 'host', 'marketplace'];
|
const AUDIT_OWNER_TYPES = ['user', 'printer', 'host', 'marketplace'];
|
||||||
|
|
||||||
export function resolveAuditOwner(actor) {
|
export function resolveAuditOwner(actor) {
|
||||||
const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType)
|
if (actor === 'system') {
|
||||||
? actor._objectType
|
|
||||||
: 'user';
|
|
||||||
return {
|
return {
|
||||||
|
system: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerType = AUDIT_OWNER_TYPES.includes(actor?._objectType) ? actor._objectType : 'user';
|
||||||
|
return {
|
||||||
|
system: false,
|
||||||
owner: actor?._id,
|
owner: actor?._id,
|
||||||
ownerType,
|
ownerType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function actorDisplayName(actor) {
|
export function actorDisplayName(actor) {
|
||||||
|
if (actor === 'system') return 'system';
|
||||||
if (actor?._objectType === 'marketplace' || actor?._objectType === 'host') {
|
if (actor?._objectType === 'marketplace' || actor?._objectType === 'host') {
|
||||||
return actor.name || actor._objectType;
|
return actor.name || actor._objectType;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,9 +72,9 @@ jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Now import the database utilities
|
// Now import the database utilities
|
||||||
const { listObjects, getObject, newObject, editObject, deleteObject } = await import(
|
const { listObjects, getObject, newObject, editObject, deleteObject } =
|
||||||
'../database.js'
|
await import('../database.js');
|
||||||
);
|
const { editAuditLog, editNotification } = await import('../../utils.js');
|
||||||
|
|
||||||
describe('Database Utilities (CRUD)', () => {
|
describe('Database Utilities (CRUD)', () => {
|
||||||
let mockModel;
|
let mockModel;
|
||||||
@ -178,6 +178,26 @@ describe('Database Utilities (CRUD)', () => {
|
|||||||
|
|
||||||
expect(mockModel.findByIdAndUpdate).toHaveBeenCalledWith(id, { file: fileId });
|
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', () => {
|
describe('deleteObject', () => {
|
||||||
|
|||||||
@ -1240,6 +1240,7 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc
|
|||||||
await editAuditLog(previousExpandedObject, updatedObject, id, parentType, user);
|
await editAuditLog(previousExpandedObject, updatedObject, id, parentType, user);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
user !== 'system' &&
|
||||||
parentType !== 'notification' &&
|
parentType !== 'notification' &&
|
||||||
parentType !== 'auditLog' &&
|
parentType !== 'auditLog' &&
|
||||||
parentType !== 'userNotifier' &&
|
parentType !== 'userNotifier' &&
|
||||||
@ -1318,7 +1319,11 @@ export const newObject = async (
|
|||||||
if (!result || result.length === 0) {
|
if (!result || result.length === 0) {
|
||||||
return { error: 'No object created.', code: 500 };
|
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);
|
await newAuditLog(newData, created._id, parentType, user);
|
||||||
|
|
||||||
@ -1593,12 +1598,15 @@ export const deleteObject = async (
|
|||||||
return { error: `${parentType} not found.`, code: 404 };
|
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
|
// Audit log the deletion
|
||||||
await deleteAuditLog(deleted, id.toString(), parentType, user);
|
await deleteAuditLog(deleted, id.toString(), parentType, user);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
user !== 'system' &&
|
||||||
parentType !== 'notification' &&
|
parentType !== 'notification' &&
|
||||||
parentType !== 'auditLog' &&
|
parentType !== 'auditLog' &&
|
||||||
parentType !== 'userNotifier' &&
|
parentType !== 'userNotifier' &&
|
||||||
|
|||||||
@ -25,13 +25,18 @@ const auditLogSchema = new Schema(
|
|||||||
owner: {
|
owner: {
|
||||||
type: Schema.Types.ObjectId,
|
type: Schema.Types.ObjectId,
|
||||||
refPath: 'ownerType',
|
refPath: 'ownerType',
|
||||||
required: true,
|
required: false,
|
||||||
},
|
},
|
||||||
ownerType: {
|
ownerType: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: false,
|
||||||
enum: ['user', 'printer', 'host', 'marketplace'],
|
enum: ['user', 'printer', 'host', 'marketplace'],
|
||||||
},
|
},
|
||||||
|
system: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
33
src/database/schemas/management/emailaccount.schema.js
Normal file
33
src/database/schemas/management/emailaccount.schema.js
Normal file
@ -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);
|
||||||
57
src/database/schemas/management/emailmessage.schema.js
Normal file
57
src/database/schemas/management/emailmessage.schema.js
Normal file
@ -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);
|
||||||
78
src/database/schemas/management/emailtemplate.schema.js
Normal file
78
src/database/schemas/management/emailtemplate.schema.js
Normal file
@ -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: '<Container></Container>' },
|
||||||
|
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);
|
||||||
@ -35,6 +35,9 @@ import { userNotifierModel } from './misc/usernotifier.schema.js';
|
|||||||
import { objectViewModel } from './misc/objectview.schema.js';
|
import { objectViewModel } from './misc/objectview.schema.js';
|
||||||
import { documentSizeModel } from './management/documentsize.schema.js';
|
import { documentSizeModel } from './management/documentsize.schema.js';
|
||||||
import { documentTemplateModel } from './management/documenttemplate.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 { hostModel } from './management/host.schema.js';
|
||||||
import { documentPrinterModel } from './management/documentprinter.schema.js';
|
import { documentPrinterModel } from './management/documentprinter.schema.js';
|
||||||
import { documentJobModel } from './management/documentjob.schema.js';
|
import { documentJobModel } from './management/documentjob.schema.js';
|
||||||
@ -107,6 +110,9 @@ export const models = {
|
|||||||
OVW: modelEntry(() => objectViewModel, 'objectView', 'Object View'),
|
OVW: modelEntry(() => objectViewModel, 'objectView', 'Object View'),
|
||||||
DSZ: modelEntry(() => documentSizeModel, 'documentSize', 'Document Size'),
|
DSZ: modelEntry(() => documentSizeModel, 'documentSize', 'Document Size'),
|
||||||
DTP: modelEntry(() => documentTemplateModel, 'documentTemplate', 'Document Template'),
|
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'),
|
DPR: modelEntry(() => documentPrinterModel, 'documentPrinter', 'Document Printer'),
|
||||||
DJB: modelEntry(() => documentJobModel, 'documentJob', 'Document Job'),
|
DJB: modelEntry(() => documentJobModel, 'documentJob', 'Document Job'),
|
||||||
HST: modelEntry(() => hostModel, 'host', 'Host'),
|
HST: modelEntry(() => hostModel, 'host', 'Host'),
|
||||||
|
|||||||
@ -44,6 +44,9 @@ import {
|
|||||||
hostRoutes,
|
hostRoutes,
|
||||||
documentSizesRoutes,
|
documentSizesRoutes,
|
||||||
documentTemplatesRoutes,
|
documentTemplatesRoutes,
|
||||||
|
emailAccountsRoutes,
|
||||||
|
emailTemplatesRoutes,
|
||||||
|
emailMessagesRoutes,
|
||||||
documentPrintersRoutes,
|
documentPrintersRoutes,
|
||||||
documentJobsRoutes,
|
documentJobsRoutes,
|
||||||
courierRoutes,
|
courierRoutes,
|
||||||
@ -213,6 +216,9 @@ app.use('/auditlogs', auditLogRoutes);
|
|||||||
app.use('/notetypes', noteTypeRoutes);
|
app.use('/notetypes', noteTypeRoutes);
|
||||||
app.use('/documentsizes', documentSizesRoutes);
|
app.use('/documentsizes', documentSizesRoutes);
|
||||||
app.use('/documenttemplates', documentTemplatesRoutes);
|
app.use('/documenttemplates', documentTemplatesRoutes);
|
||||||
|
app.use('/emailaccounts', emailAccountsRoutes);
|
||||||
|
app.use('/emailtemplates', emailTemplatesRoutes);
|
||||||
|
app.use('/emailmessages', emailMessagesRoutes);
|
||||||
app.use('/documentprinters', documentPrintersRoutes);
|
app.use('/documentprinters', documentPrintersRoutes);
|
||||||
app.use('/documentjobs', documentJobsRoutes);
|
app.use('/documentjobs', documentJobsRoutes);
|
||||||
app.use('/couriers', courierRoutes);
|
app.use('/couriers', courierRoutes);
|
||||||
|
|||||||
@ -42,6 +42,35 @@ const logger = log4js.getLogger('MailWorker');
|
|||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
async function sendEmail(payload) {
|
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 {
|
const {
|
||||||
email,
|
email,
|
||||||
title,
|
title,
|
||||||
@ -143,5 +172,13 @@ async function sendEmail(payload) {
|
|||||||
parentPort.on('message', (payload) => {
|
parentPort.on('message', (payload) => {
|
||||||
sendEmail(payload).catch((err) => {
|
sendEmail(payload).catch((err) => {
|
||||||
logger.error('MailWorker: send failed', err.message);
|
logger.error('MailWorker: send failed', err.message);
|
||||||
|
if (payload?.kind === 'emailMessage') {
|
||||||
|
parentPort.postMessage({
|
||||||
|
kind: 'emailMessageResult',
|
||||||
|
requestId: payload.requestId,
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,6 +36,9 @@ import auditLogRoutes from './management/auditlogs.js';
|
|||||||
import noteTypeRoutes from './management/notetypes.js';
|
import noteTypeRoutes from './management/notetypes.js';
|
||||||
import documentSizesRoutes from './management/documentsizes.js';
|
import documentSizesRoutes from './management/documentsizes.js';
|
||||||
import documentTemplatesRoutes from './management/documenttemplates.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 documentPrintersRoutes from './management/documentprinters.js';
|
||||||
import documentJobsRoutes from './management/documentjobs.js';
|
import documentJobsRoutes from './management/documentjobs.js';
|
||||||
import courierRoutes from './management/courier.js';
|
import courierRoutes from './management/courier.js';
|
||||||
@ -105,6 +108,9 @@ export {
|
|||||||
noteRoutes,
|
noteRoutes,
|
||||||
documentSizesRoutes,
|
documentSizesRoutes,
|
||||||
documentTemplatesRoutes,
|
documentTemplatesRoutes,
|
||||||
|
emailAccountsRoutes,
|
||||||
|
emailTemplatesRoutes,
|
||||||
|
emailMessagesRoutes,
|
||||||
documentPrintersRoutes,
|
documentPrintersRoutes,
|
||||||
documentJobsRoutes,
|
documentJobsRoutes,
|
||||||
courierRoutes,
|
courierRoutes,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const listAllowedFilters = [
|
|||||||
'owner',
|
'owner',
|
||||||
'parent',
|
'parent',
|
||||||
'operation',
|
'operation',
|
||||||
|
'system',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'_reference',
|
'_reference',
|
||||||
|
|||||||
82
src/routes/management/emailaccounts.js
Normal file
82
src/routes/management/emailaccounts.js
Normal file
@ -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;
|
||||||
132
src/routes/management/emailmessages.js
Normal file
132
src/routes/management/emailmessages.js
Normal file
@ -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;
|
||||||
99
src/routes/management/emailtemplates.js
Normal file
99
src/routes/management/emailtemplates.js
Normal file
@ -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;
|
||||||
115
src/services/management/__tests__/emailaccounts.test.js
Normal file
115
src/services/management/__tests__/emailaccounts.test.js
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
373
src/services/management/__tests__/emailmessages.test.js
Normal file
373
src/services/management/__tests__/emailmessages.test.js
Normal file
@ -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: '<abc@smtp>',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
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: '<p>Hi</p>',
|
||||||
|
});
|
||||||
|
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: '<abc@smtp>',
|
||||||
|
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: '<p>Hi</p>',
|
||||||
|
htmlWithPixel: '<p>Hi</p><img>',
|
||||||
|
});
|
||||||
|
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: '<p>Hi</p>',
|
||||||
|
htmlWithPixel: '<p>Hi</p><img>',
|
||||||
|
});
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
src/services/management/__tests__/emailtemplates.test.js
Normal file
170
src/services/management/__tests__/emailtemplates.test.js
Normal file
@ -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]),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
257
src/services/management/emailaccounts.js
Normal file
257
src/services/management/emailaccounts.js
Normal file
@ -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);
|
||||||
|
};
|
||||||
565
src/services/management/emailmessages.js
Normal file
565
src/services/management/emailmessages.js
Normal file
@ -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);
|
||||||
|
};
|
||||||
322
src/services/management/emailtemplates.js
Normal file
322
src/services/management/emailtemplates.js
Normal file
@ -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);
|
||||||
|
};
|
||||||
@ -9,8 +9,7 @@ jest.unstable_mockModule('fs', () => ({
|
|||||||
return '<div class="preview"><%- content %></div>';
|
return '<div class="preview"><%- content %></div>';
|
||||||
if (filePath.endsWith('rendertemplate.ejs'))
|
if (filePath.endsWith('rendertemplate.ejs'))
|
||||||
return '<div class="render"><%- content %></div>';
|
return '<div class="render"><%- content %></div>';
|
||||||
if (filePath.endsWith('contentplaceholder.ejs'))
|
if (filePath.endsWith('contentplaceholder.ejs')) return '<div class="placeholder"></div>';
|
||||||
return '<div class="placeholder"></div>';
|
|
||||||
if (filePath.endsWith('previewpagination.js')) return 'console.log("pagination");';
|
if (filePath.endsWith('previewpagination.js')) return 'console.log("pagination");';
|
||||||
return '';
|
return '';
|
||||||
}),
|
}),
|
||||||
@ -43,6 +42,10 @@ jest.unstable_mockModule('../../database/schemas/management/documenttemplate.sch
|
|||||||
documentTemplateModel: { modelName: 'DocumentTemplate' },
|
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('../../database/schemas/management/documentsize.schema.js', () => ({}));
|
||||||
|
|
||||||
jest.unstable_mockModule('../../services/misc/model.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 { getObject, listObjects } = await import('../../database/database.js');
|
||||||
const { getFilter } = await import('../../utils.js');
|
const { getFilter } = await import('../../utils.js');
|
||||||
const ejs = (await import('ejs')).default;
|
const ejs = (await import('ejs')).default;
|
||||||
@ -179,6 +183,32 @@ describe('TemplateManager', () => {
|
|||||||
templateManager = new 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: '<Text>Body</Text>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: <Text>Body</Text>');
|
||||||
|
expect(result).not.toHaveProperty('height');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('renderTemplate', () => {
|
describe('renderTemplate', () => {
|
||||||
it('should render a template successfully', async () => {
|
it('should render a template successfully', async () => {
|
||||||
const mockTemplate = {
|
const mockTemplate = {
|
||||||
@ -259,9 +289,7 @@ describe('TemplateManager', () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getObject).toHaveBeenCalledWith(
|
expect(getObject).toHaveBeenCalledWith(expect.objectContaining({ id: 'preview-printer-id' }));
|
||||||
expect.objectContaining({ id: 'preview-printer-id' })
|
|
||||||
);
|
|
||||||
expect(ejs.render).toHaveBeenCalledWith(
|
expect(ejs.render).toHaveBeenCalledWith(
|
||||||
'some content',
|
'some content',
|
||||||
expect.objectContaining({ name: 'Preview Printer' }),
|
expect.objectContaining({ name: 'Preview Printer' }),
|
||||||
@ -466,10 +494,9 @@ describe('TemplateManager', () => {
|
|||||||
await templateManager.renderTemplate('temp-id', 'some content', { name: 'Parent' });
|
await templateManager.renderTemplate('temp-id', 'some content', { name: 'Parent' });
|
||||||
ejs.render.mockClear();
|
ejs.render.mockClear();
|
||||||
|
|
||||||
const nestedHtml = await capturedFc.renderDocumentTemplate(
|
const nestedHtml = await capturedFc.renderDocumentTemplate('DTP:PKEFDZV5X1SU', {
|
||||||
'DTP:PKEFDZV5X1SU',
|
totalAmount: 10,
|
||||||
{ totalAmount: 10 }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(ejs.render).toHaveBeenCalledWith(
|
expect(ejs.render).toHaveBeenCalledWith(
|
||||||
'<Text>invoice</Text>',
|
'<Text>invoice</Text>',
|
||||||
@ -508,12 +535,7 @@ describe('TemplateManager', () => {
|
|||||||
|
|
||||||
const result = await templateManager.listObjects('printer', { name: '=Test' });
|
const result = await templateManager.listObjects('printer', { name: '=Test' });
|
||||||
|
|
||||||
expect(getFilter).toHaveBeenCalledWith(
|
expect(getFilter).toHaveBeenCalledWith({ name: '=Test' }, ['name'], true, expect.anything());
|
||||||
{ name: '=Test' },
|
|
||||||
['name'],
|
|
||||||
true,
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
expect(listObjects).toHaveBeenCalledWith(
|
expect(listObjects).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
filter: parsedFilter,
|
filter: parsedFilter,
|
||||||
@ -661,13 +683,9 @@ describe('TemplateManager', () => {
|
|||||||
objectType: 'printer',
|
objectType: 'printer',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await templateManager.renderDownload(
|
const result = await templateManager.renderDownload('temp-id', 'content', {}, 'pdf', {
|
||||||
'temp-id',
|
renderRequestId: 'render-123',
|
||||||
'content',
|
});
|
||||||
{},
|
|
||||||
'pdf',
|
|
||||||
{ renderRequestId: 'render-123' }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.type).toBe('pdf');
|
expect(result.type).toBe('pdf');
|
||||||
expect(redisServer.setKey).toHaveBeenCalledWith(
|
expect(redisServer.setKey).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { dirname, join } from 'path';
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import { documentTemplateModel } from '../database/schemas/management/documenttemplate.schema.js';
|
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 '../database/schemas/management/documentsize.schema.js';
|
||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
import { getObject, listObjects } from '../database/database.js';
|
import { getObject, listObjects } from '../database/database.js';
|
||||||
@ -645,6 +646,158 @@ export class TemplateManager {
|
|||||||
return nestedContent;
|
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
|
||||||
|
? `<img src="${config.app.urlApi}/emailmessages/${pixelId}/pixel" width="1" height="1" style="display:none" />`
|
||||||
|
: '';
|
||||||
|
const emailContent =
|
||||||
|
preview === true
|
||||||
|
? `<div style="max-width:${width}px;margin:0 auto">${transformed}</div>`
|
||||||
|
: transformed;
|
||||||
|
|
||||||
|
const outerHtml = (css, content, pixel = undefined) => {
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${css}</style></head><body>${pixel != undefined ? pixel : ''}${content}</body></html>`;
|
||||||
|
};
|
||||||
|
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) {
|
async hydratePreviewTestObject(documentTemplate, data = {}, preview = true) {
|
||||||
if (documentTemplate?.global == true) {
|
if (documentTemplate?.global == true) {
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@ -1505,7 +1505,7 @@ function getChangedValues(oldObj, newObj, old = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier', 'objectView', 'marketplaceEvent'];
|
const AUDIT_EXCLUDED_MODELS = ['notification', 'userNotifier', 'objectView', 'marketplaceEvent'];
|
||||||
const SENSITIVE_KEYS = ['secret'];
|
const SENSITIVE_KEYS = ['secret', 'password'];
|
||||||
|
|
||||||
const DISTRIBUTE_KEYS = {
|
const DISTRIBUTE_KEYS = {
|
||||||
_id: true,
|
_id: true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user