feat: Add ability to configure and test SMTP via UI

This commit is contained in:
Maksim Eltyshev
2025-09-22 20:35:13 +02:00
parent 3a12bb7457
commit c6f4dcdb70
114 changed files with 2161 additions and 301 deletions
+15 -19
View File
@@ -154,9 +154,9 @@ module.exports = {
await sails.helpers.notifications.createOne.with({
values: {
action,
userId: action.data.user.id,
type: action.type,
data: action.data,
userId: action.data.user.id,
creatorUser: values.user,
card: values.card,
},
@@ -179,24 +179,20 @@ module.exports = {
const notifiableUserIds = _.union(cardSubscriptionUserIds, boardSubscriptionUserIds);
await Promise.all(
notifiableUserIds.map((userId) =>
sails.helpers.notifications.createOne.with({
values: {
userId,
action,
type: action.type,
data: action.data,
creatorUser: values.user,
card: values.card,
},
project: inputs.project,
board: inputs.board,
list: inputs.list,
webhooks: inputs.webhooks,
}),
),
);
await sails.helpers.notifications.createMany.with({
arrayOfValues: notifiableUserIds.map((userId) => ({
userId,
action,
type: action.type,
data: action.data,
creatorUser: values.user,
card: values.card,
})),
project: inputs.project,
board: inputs.board,
list: inputs.list,
webhooks: inputs.webhooks,
});
}
}
@@ -0,0 +1,29 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
sync: true,
inputs: {
oidc: {
type: 'ref',
},
user: {
type: 'ref',
},
},
fn(inputs) {
const data = {
oidc: inputs.oidc,
version: sails.config.custom.version,
};
if (inputs.user && inputs.user.role === User.Roles.ADMIN) {
data.activeUsersLimit = sails.config.custom.activeUsersLimit;
}
return data;
},
};
+19 -23
View File
@@ -124,29 +124,25 @@ module.exports = {
boardSubscriptionUserIds,
);
await Promise.all(
notifiableUserIds.map((userId) =>
sails.helpers.notifications.createOne.with({
webhooks,
values: {
userId,
comment,
type: mentionUserIdsSet.has(userId)
? Notification.Types.MENTION_IN_COMMENT
: Notification.Types.COMMENT_CARD,
data: {
card: _.pick(values.card, ['name']),
text: comment.text,
},
creatorUser: values.user,
card: values.card,
},
project: inputs.project,
board: inputs.board,
list: inputs.list,
}),
),
);
await sails.helpers.notifications.createMany.with({
webhooks,
arrayOfValues: notifiableUserIds.map((userId) => ({
userId,
comment,
type: mentionUserIdsSet.has(userId)
? Notification.Types.MENTION_IN_COMMENT
: Notification.Types.COMMENT_CARD,
data: {
card: _.pick(values.card, ['name']),
text: comment.text,
},
creatorUser: values.user,
card: values.card,
})),
project: inputs.project,
board: inputs.board,
list: inputs.list,
});
if (values.user.subscribeToCardWhenCommenting) {
let cardSubscription;
+7 -10
View File
@@ -11,20 +11,17 @@ module.exports = {
type: 'ref',
required: true,
},
user: {
type: 'ref',
},
},
fn(inputs) {
const data = {
...inputs.record,
version: sails.config.custom.version,
};
if (inputs.user && inputs.user.role === User.Roles.ADMIN) {
data.activeUsersLimit = sails.config.custom.activeUsersLimit;
if (sails.config.custom.smtpHost) {
return _.omit(inputs.record, Config.SMTP_FIELD_NAMES);
}
return data;
if (inputs.record.smtpPassword) {
return _.omit(inputs.record, 'smtpPassword');
}
return inputs.record;
},
};
+53
View File
@@ -0,0 +1,53 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
values: {
type: 'json',
required: true,
},
actorUser: {
type: 'ref',
required: true,
},
request: {
type: 'ref',
},
},
async fn(inputs) {
const { values } = inputs;
const config = await Config.qm.updateOneMain(values);
const configRelatedUserIds = await sails.helpers.users.getAllIds(User.Roles.ADMIN);
configRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'configUpdate',
{
item: sails.helpers.config.presentOne(config),
},
inputs.request,
);
});
const webhooks = await Webhook.qm.getAll();
// TODO: with prevData?
sails.helpers.utils.sendWebhooks.with({
webhooks,
event: Webhook.Events.CONFIG_UPDATE,
buildData: () => ({
item: sails.helpers.config.presentOne(config),
}),
user: inputs.actorUser,
});
return config;
},
};
@@ -1,33 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/* eslint-disable no-underscore-dangle */
module.exports = {
inputs: {
record: {
type: 'ref',
required: true,
},
i18n: {
type: 'ref',
required: true,
},
},
async fn(inputs) {
const { i18n } = inputs;
await sails.helpers.utils.sendNotifications.with({
services: [_.pick(inputs.record, ['url', 'format'])],
title: i18n.__('Test Title'),
bodyByFormat: {
text: i18n.__('This is a test text message!'),
markdown: i18n.__('This is a *test* **markdown** `message`!'),
html: i18n.__('This is a <i>test</i> <b>html</b> <code>message</code>'),
},
});
},
};
@@ -0,0 +1,358 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const escapeMarkdown = require('escape-markdown');
const escapeHtml = require('escape-html');
const { mentionMarkupToText } = require('../../../utils/mentions');
const buildTitle = (notification, t) => {
switch (notification.type) {
case Notification.Types.MOVE_CARD:
return t('Card Moved');
case Notification.Types.COMMENT_CARD:
return t('New Comment');
case Notification.Types.ADD_MEMBER_TO_CARD:
return t('You Were Added to Card');
case Notification.Types.MENTION_IN_COMMENT:
return t('You Were Mentioned in Comment');
default:
return null;
}
};
const buildBodyByFormat = (board, card, notification, actorUser, t) => {
const markdownCardLink = `[${escapeMarkdown(card.name)}](${sails.config.custom.baseUrl}/cards/${card.id})`;
const htmlCardLink = `<a href="${sails.config.custom.baseUrl}/cards/${card.id}">${escapeHtml(card.name)}</a>`;
switch (notification.type) {
case Notification.Types.MOVE_CARD: {
const fromListName = sails.helpers.lists.makeName(notification.data.fromList);
const toListName = sails.helpers.lists.makeName(notification.data.toList);
return {
text: t(
'%s moved %s from %s to %s on %s',
actorUser.name,
card.name,
fromListName,
toListName,
board.name,
),
markdown: t(
'%s moved %s from %s to %s on %s',
escapeMarkdown(actorUser.name),
markdownCardLink,
`**${escapeMarkdown(fromListName)}**`,
`**${escapeMarkdown(toListName)}**`,
escapeMarkdown(board.name),
),
html: t(
'%s moved %s from %s to %s on %s',
escapeHtml(actorUser.name),
htmlCardLink,
`<b>${escapeHtml(fromListName)}</b>`,
`<b>${escapeHtml(toListName)}</b>`,
escapeHtml(board.name),
),
};
}
case Notification.Types.COMMENT_CARD: {
const commentText = _.truncate(mentionMarkupToText(notification.data.text));
return {
text: `${t(
'%s left a new comment to %s on %s',
actorUser.name,
card.name,
board.name,
)}:\n${commentText}`,
markdown: `${t(
'%s left a new comment to %s on %s',
escapeMarkdown(actorUser.name),
markdownCardLink,
escapeMarkdown(board.name),
)}:\n\n*${escapeMarkdown(commentText)}*`,
html: `${t(
'%s left a new comment to %s on %s',
escapeHtml(actorUser.name),
htmlCardLink,
escapeHtml(board.name),
)}:\n\n<i>${escapeHtml(commentText)}</i>`,
};
}
case Notification.Types.ADD_MEMBER_TO_CARD:
return {
text: t('%s added you to %s on %s', actorUser.name, card.name, board.name),
markdown: t(
'%s added you to %s on %s',
escapeMarkdown(actorUser.name),
markdownCardLink,
escapeMarkdown(board.name),
),
html: t(
'%s added you to %s on %s',
escapeHtml(actorUser.name),
htmlCardLink,
escapeHtml(board.name),
),
};
case Notification.Types.MENTION_IN_COMMENT: {
const commentText = _.truncate(mentionMarkupToText(notification.data.text));
return {
text: `${t(
'%s mentioned you in %s on %s',
actorUser.name,
card.name,
board.name,
)}:\n${commentText}`,
markdown: `${t(
'%s mentioned you in %s on %s',
escapeMarkdown(actorUser.name),
markdownCardLink,
escapeMarkdown(board.name),
)}:\n\n*${escapeMarkdown(commentText)}*`,
html: `${t(
'%s mentioned you in %s on %s',
escapeHtml(actorUser.name),
htmlCardLink,
escapeHtml(board.name),
)}:\n\n<i>${escapeHtml(commentText)}</i>`,
};
}
default:
return null;
}
};
const buildAndSendNotifications = async (services, board, card, notification, actorUser, t) => {
await sails.helpers.utils.sendNotifications(
services,
buildTitle(notification, t),
buildBodyByFormat(board, card, notification, actorUser, t),
);
};
// TODO: use templates (views) to build html
const buildEmail = (board, card, notification, actorUser, notifiableUser, t) => {
const cardLink = `<a href="${sails.config.custom.baseUrl}/cards/${card.id}">${escapeHtml(card.name)}</a>`;
const boardLink = `<a href="${sails.config.custom.baseUrl}/boards/${board.id}">${escapeHtml(board.name)}</a>`;
let html;
switch (notification.type) {
case Notification.Types.MOVE_CARD: {
const fromListName = sails.helpers.lists.makeName(notification.data.fromList);
const toListName = sails.helpers.lists.makeName(notification.data.toList);
html = `<p>${t(
'%s moved %s from %s to %s on %s',
escapeHtml(actorUser.name),
cardLink,
escapeHtml(fromListName),
escapeHtml(toListName),
boardLink,
)}</p>`;
break;
}
case Notification.Types.COMMENT_CARD:
html = `<p>${t(
'%s left a new comment to %s on %s',
escapeHtml(actorUser.name),
cardLink,
boardLink,
)}</p><p>${escapeHtml(mentionMarkupToText(notification.data.text))}</p>`;
break;
case Notification.Types.ADD_MEMBER_TO_CARD:
html = `<p>${t(
'%s added you to %s on %s',
escapeHtml(actorUser.name),
cardLink,
boardLink,
)}</p>`;
break;
case Notification.Types.MENTION_IN_COMMENT:
html = `<p>${t(
'%s mentioned you in %s on %s',
escapeHtml(actorUser.name),
cardLink,
boardLink,
)}</p><p>${escapeHtml(mentionMarkupToText(notification.data.text))}</p>`;
break;
default:
return null; // TODO: throw error?
}
return {
html,
to: notifiableUser.email,
subject: buildTitle(notification, t),
};
};
const sendEmails = async (transporter, emails) => {
await Promise.all(
emails.map((email) =>
sails.helpers.utils.sendEmail.with({
...email,
transporter,
}),
),
);
transporter.close();
};
module.exports = {
inputs: {
arrayOfValues: {
type: 'ref',
required: true,
},
project: {
type: 'ref',
required: true,
},
board: {
type: 'ref',
required: true,
},
list: {
type: 'ref',
required: true,
},
webhooks: {
type: 'ref',
required: true,
},
},
async fn(inputs) {
const { arrayOfValues } = inputs;
const ids = await sails.helpers.utils.generateIds(arrayOfValues.length);
const valuesById = {};
const notifications = await Notification.qm.create(
arrayOfValues.map((values) => {
const id = ids.shift();
const isCommentRelated =
values.type === Notification.Types.COMMENT_CARD ||
values.type === Notification.Types.MENTION_IN_COMMENT;
const nextValues = {
...values,
id,
creatorUserId: values.creatorUser.id,
boardId: values.card.boardId,
cardId: values.card.id,
};
if (isCommentRelated) {
nextValues.commentId = values.comment.id;
} else {
nextValues.actionId = values.action.id;
}
valuesById[id] = { ...nextValues }; // FIXME: hack
return nextValues;
}),
);
notifications.forEach((notification) => {
const values = valuesById[notification.id];
sails.sockets.broadcast(`user:${notification.userId}`, 'notificationCreate', {
item: notification,
included: {
users: [sails.helpers.users.presentOne(values.creatorUser, {})], // FIXME: hack
},
});
sails.helpers.utils.sendWebhooks.with({
webhooks: inputs.webhooks,
event: Webhook.Events.NOTIFICATION_CREATE,
buildData: () => ({
item: notification,
included: {
projects: [inputs.project],
boards: [inputs.board],
lists: [inputs.list],
cards: [values.card],
...(notification.commentId
? {
comments: [values.comment],
}
: {
actions: [values.action],
}),
},
}),
user: values.creatorUser,
});
});
const notificationsByUserId = _.groupBy(notifications, 'userId');
const userIds = Object.keys(notificationsByUserId);
const notificationServices = await NotificationService.qm.getByUserIds(userIds);
const { transporter } = await sails.helpers.utils.makeSmtpTransporter();
if (notificationServices.length > 0 || transporter) {
const users = await User.qm.getByIds(userIds);
const userById = _.keyBy(users, 'id');
const notificationServicesByUserId = _.groupBy(notificationServices, 'userId');
Object.keys(notificationsByUserId).forEach(async (userId) => {
const notifiableUser = userById[userId];
const t = sails.helpers.utils.makeTranslator(notifiableUser.language);
const emails = notificationsByUserId[userId].flatMap((notification) => {
const values = valuesById[notification.id];
if (notificationServicesByUserId[userId]) {
const services = notificationServicesByUserId[userId].map((notificationService) =>
_.pick(notificationService, ['url', 'format']),
);
buildAndSendNotifications(
services,
inputs.board,
values.card,
notification,
values.creatorUser,
t,
);
}
if (transporter) {
return buildEmail(
inputs.board,
values.card,
notification,
values.creatorUser,
notifiableUser,
t,
);
}
return [];
});
if (emails.length > 0) {
sendEmails(transporter, emails);
}
});
}
return notifications;
},
};
+19 -10
View File
@@ -137,7 +137,15 @@ const buildAndSendNotifications = async (services, board, card, notification, ac
};
// TODO: use templates (views) to build html
const buildAndSendEmail = async (board, card, notification, actorUser, notifiableUser, t) => {
const buildAndSendEmail = async (
transporter,
board,
card,
notification,
actorUser,
notifiableUser,
t,
) => {
const cardLink = `<a href="${sails.config.custom.baseUrl}/cards/${card.id}">${escapeHtml(card.name)}</a>`;
const boardLink = `<a href="${sails.config.custom.baseUrl}/boards/${board.id}">${escapeHtml(board.name)}</a>`;
@@ -164,7 +172,7 @@ const buildAndSendEmail = async (board, card, notification, actorUser, notifiabl
escapeHtml(actorUser.name),
cardLink,
boardLink,
)}</p><p>${escapeHtml(notification.data.text)}</p>`;
)}</p><p>${escapeHtml(mentionMarkupToText(notification.data.text))}</p>`;
break;
case Notification.Types.ADD_MEMBER_TO_CARD:
@@ -182,7 +190,7 @@ const buildAndSendEmail = async (board, card, notification, actorUser, notifiabl
escapeHtml(actorUser.name),
cardLink,
boardLink,
)}</p><p>${escapeHtml(notification.data.text)}</p>`;
)}</p><p>${escapeHtml(mentionMarkupToText(notification.data.text))}</p>`;
break;
default:
@@ -190,10 +198,13 @@ const buildAndSendEmail = async (board, card, notification, actorUser, notifiabl
}
await sails.helpers.utils.sendEmail.with({
transporter,
html,
to: notifiableUser.email,
subject: buildTitle(notification, t),
});
transporter.close();
};
module.exports = {
@@ -223,10 +234,6 @@ module.exports = {
async fn(inputs) {
const { values } = inputs;
if (values.user) {
values.userId = values.user.id;
}
const isCommentRelated =
values.type === Notification.Types.COMMENT_CARD ||
values.type === Notification.Types.MENTION_IN_COMMENT;
@@ -274,9 +281,10 @@ module.exports = {
});
const notificationServices = await NotificationService.qm.getByUserId(notification.userId);
const { transporter } = await sails.helpers.utils.makeSmtpTransporter();
if (notificationServices.length > 0 || sails.hooks.smtp.isEnabled()) {
const notifiableUser = values.user || (await User.qm.getOneById(notification.userId));
if (notificationServices.length > 0 || transporter) {
const notifiableUser = await User.qm.getOneById(notification.userId);
const t = sails.helpers.utils.makeTranslator(notifiableUser.language);
if (notificationServices.length > 0) {
@@ -294,8 +302,9 @@ module.exports = {
);
}
if (sails.hooks.smtp.isEnabled()) {
if (transporter) {
buildAndSendEmail(
transporter,
inputs.board,
values.card,
notification,
@@ -0,0 +1,61 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const nodemailer = require('nodemailer');
module.exports = {
inputs: {
defaultOptions: {
type: 'json',
},
},
async fn(inputs) {
let config;
let sourceConfig;
if (sails.config.custom.smtpHost) {
sourceConfig = sails.config.custom;
} else {
config = await Config.qm.getOneMain();
if (config.smtpHost) {
sourceConfig = config;
}
}
if (!sourceConfig) {
return {
config,
transporter: null,
};
}
const transporter = nodemailer.createTransport(
{
...inputs.defaultOptions,
host: sourceConfig.smtpHost,
port: sourceConfig.smtpPort,
name: sourceConfig.smtpName,
secure: sourceConfig.smtpSecure,
auth: sourceConfig.smtpUser && {
user: sourceConfig.smtpUser,
pass: sourceConfig.smtpPassword,
},
tls: {
rejectUnauthorized: sourceConfig.smtpTlsRejectUnauthorized,
},
},
{
from: sourceConfig.smtpFrom,
},
);
return {
transporter,
config,
};
},
};
+5 -6
View File
@@ -5,6 +5,10 @@
module.exports = {
inputs: {
transporter: {
type: 'ref',
required: true,
},
to: {
type: 'string',
required: true,
@@ -20,13 +24,8 @@ module.exports = {
},
async fn(inputs) {
const transporter = sails.hooks.smtp.getTransporter(); // TODO: check if enabled?
try {
const info = await transporter.sendMail({
...inputs,
from: sails.config.custom.smtpFrom,
});
const info = await inputs.transporter.sendMail(inputs);
sails.log.info(`Email sent: ${info.messageId}`);
} catch (error) {