feat: add optional reply address to communication threads and enhance email transport status handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s

This commit is contained in:
Torsten Schulz (local)
2026-07-22 15:28:39 +02:00
parent fd0b3020bf
commit d190c2cb70
8 changed files with 140 additions and 4 deletions

View File

@@ -0,0 +1,3 @@
-- Optional reply address for a communication thread.
ALTER TABLE club_communication_threads
ADD COLUMN reply_to VARCHAR(320) NULL AFTER subject;

View File

@@ -23,6 +23,11 @@ const ClubCommunicationThread = sequelize.define('ClubCommunicationThread', {
type: DataTypes.STRING(255),
allowNull: false,
},
replyTo: {
type: DataTypes.STRING(320),
allowNull: true,
field: 'reply_to',
},
status: {
type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'archived'),
allowNull: false,

View File

@@ -1,4 +1,5 @@
import sequelize from '../database.js';
import fs from 'fs';
import {
ClubCommunicationThread,
ClubCommunicationMessage,
@@ -11,8 +12,11 @@ import {
Member,
MemberContact,
User,
ClubDocument,
ClubDocumentLink,
ClubDocumentVersion,
} from '../models/index.js';
import { sendClubCommunicationEmail } from './emailService.js';
import { getEmailTransportStatus, sendClubCommunicationEmail } from './emailService.js';
const THREAD_TYPES = new Set(['direct', 'group', 'broadcast']);
const THREAD_STATUSES = new Set(['draft', 'scheduled', 'sent', 'archived']);
@@ -37,6 +41,12 @@ function normalizeId(value) {
}
function normalizeThreadPayload(payload = {}) {
const replyTo = trimText(payload.replyTo, 320);
if (replyTo && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(replyTo)) {
const error = new Error('Reply-To muss eine gültige E-Mail-Adresse sein.');
error.status = 400;
throw error;
}
return {
threadType: THREAD_TYPES.has(payload.threadType) ? payload.threadType : 'direct',
subject: trimText(payload.subject, 255),
@@ -45,9 +55,54 @@ function normalizeThreadPayload(payload = {}) {
distributionGroupId: normalizeId(payload.distributionGroupId),
scheduledAt: normalizeDate(payload.scheduledAt),
recipientFilters: normalizeRecipientFilters(payload.recipientFilters),
replyTo,
attachmentDocumentIds: Array.isArray(payload.attachmentDocumentIds)
? [...new Set(payload.attachmentDocumentIds.map(normalizeId).filter(Boolean))]
: [],
};
}
async function replaceThreadAttachments(clubId, threadId, documentIds, transaction) {
const ids = Array.isArray(documentIds) ? documentIds : [];
if (ids.length) {
const count = await ClubDocument.count({
where: { id: ids, clubId, status: 'active' },
transaction,
});
if (count !== ids.length) {
const error = new Error('Mindestens ein ausgewähltes Dokument ist nicht mehr verfügbar.');
error.status = 400;
throw error;
}
}
await ClubDocumentLink.destroy({ where: { linkedEntityType: 'club_communication_thread', linkedEntityId: threadId }, transaction });
if (ids.length) {
await ClubDocumentLink.bulkCreate(ids.map((documentId) => ({
documentId,
linkedEntityType: 'club_communication_thread',
linkedEntityId: threadId,
})), { transaction });
}
}
async function loadThreadAttachments(clubId, threadIds) {
if (!threadIds.length) return new Map();
const links = await ClubDocumentLink.findAll({
where: { linkedEntityType: 'club_communication_thread', linkedEntityId: threadIds },
include: [{ model: ClubDocument, as: 'document', required: true, where: { clubId }, include: [{ model: ClubDocumentVersion, as: 'versions', required: false }] }],
});
const result = new Map();
for (const link of links) {
const document = link.document;
const version = [...(document?.versions || [])].sort((a, b) => Number(b.versionNo) - Number(a.versionNo))[0];
if (!document || !version) continue;
const entries = result.get(Number(link.linkedEntityId)) || [];
entries.push({ id: Number(document.id), title: document.title, fileName: version.fileName, mimeType: version.mimeType, storagePath: version.storagePath });
result.set(Number(link.linkedEntityId), entries);
}
return result;
}
function normalizeGroupPayload(payload = {}) {
const memberIds = Array.isArray(payload.memberIds)
? [...new Set(payload.memberIds.map(normalizeId).filter(Boolean))]
@@ -364,7 +419,7 @@ async function replaceRecipientsForThread(thread, threadPayload, transaction) {
class ClubCommunicationService {
async listClubCommunication(clubId) {
const [threads, groups, members, templates] = await Promise.all([
const [threads, groups, members, templates, availableDocuments] = await Promise.all([
ClubCommunicationThread.findAll({
where: { clubId },
include: [
@@ -410,9 +465,17 @@ class ClubCommunicationService {
where: { clubId },
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
}),
ClubDocument.findAll({ where: { clubId, status: 'active' }, include: [{ model: ClubDocumentVersion, as: 'versions', required: false }], order: [['title', 'ASC']] }),
]);
return { threads, groups, members, templates };
const attachmentsByThreadId = await loadThreadAttachments(clubId, threads.map((thread) => Number(thread.id)));
threads.forEach((thread) => thread.setDataValue('attachments', attachmentsByThreadId.get(Number(thread.id)) || []));
return {
threads, groups, members, templates,
availableDocuments: availableDocuments.map((document) => ({ id: Number(document.id), title: document.title })),
transport: getEmailTransportStatus(),
};
}
async createThread(clubId, userId, payload) {
@@ -428,6 +491,7 @@ class ClubCommunicationService {
...normalized,
}, { transaction });
await replaceRecipientsForThread(thread, normalized, transaction);
await replaceThreadAttachments(clubId, thread.id, normalized.attachmentDocumentIds, transaction);
return thread;
});
}
@@ -451,6 +515,7 @@ class ClubCommunicationService {
sentAt: normalized.status === 'sent' ? (thread.sentAt || new Date()) : (normalized.status === 'archived' ? thread.sentAt : null),
}, { transaction });
await replaceRecipientsForThread(thread, normalized, transaction);
await replaceThreadAttachments(clubId, thread.id, normalized.attachmentDocumentIds, transaction);
});
return thread;
}
@@ -566,11 +631,17 @@ class ClubCommunicationService {
const now = new Date();
try {
const attachmentsByThreadId = await loadThreadAttachments(clubId, [Number(thread.id)]);
const attachments = (attachmentsByThreadId.get(Number(thread.id)) || [])
.filter((attachment) => fs.existsSync(attachment.storagePath))
.map((attachment) => ({ filename: attachment.fileName, path: attachment.storagePath, contentType: attachment.mimeType || undefined }));
const result = await sendClubCommunicationEmail({
to: recipient.emailSnapshot,
subject: thread.subject,
text: renderMessageText(latestMessage.body),
html: renderMessageHtml(latestMessage.body),
replyTo: thread.replyTo,
attachments,
});
await sequelize.transaction(async (transaction) => {

View File

@@ -26,6 +26,18 @@ function getDefaultFrom() {
return process.env.EMAIL_FROM || process.env.EMAIL_USER;
}
function getEmailTransportStatus() {
const configured = Boolean(process.env.EMAIL_USER && process.env.EMAIL_PASS);
return {
configured,
from: configured ? getDefaultFrom() : null,
provider: configured ? 'Gmail' : null,
message: configured
? 'E-Mail-Versand ist konfiguriert.'
: 'E-Mail-Versand ist nicht konfiguriert. EMAIL_USER und EMAIL_PASS fehlen.',
};
}
async function sendMail(mailOptions) {
return getTransporter().sendMail({
from: getDefaultFrom(),
@@ -155,6 +167,7 @@ const sendClubCommunicationEmail = async ({
text,
html,
replyTo,
attachments,
}) => {
return sendMail({
to,
@@ -162,7 +175,8 @@ const sendClubCommunicationEmail = async ({
text,
html,
replyTo: replyTo || undefined,
attachments: Array.isArray(attachments) && attachments.length ? attachments : undefined,
});
};
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail, sendClubCommunicationEmail };
export { getEmailTransportStatus, sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail, sendClubCommunicationEmail };