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

View File

@@ -67,14 +67,15 @@ SECRET_KEY=notsecretkey
# OIDC_ENFORCED=true
# Email Notifications (https://nodemailer.com/smtp/)
# These values override and disable configuration in the UI if set.
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_NAME=
# SMTP_SECURE=true
# SMTP_TLS_REJECT_UNAUTHORIZED=false
# SMTP_USER=
# SMTP_PASSWORD=
# SMTP_FROM="Demo Demo" <demo@demo.demo>
# SMTP_TLS_REJECT_UNAUTHORIZED=false
# Using Gravatar directly exposes user IPs and hashed emails to a third party (GDPR risk).
# Use a proxy you control for privacy, or leave commented out or empty to disable.

View File

@@ -0,0 +1,72 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /bootstrap:
* get:
* summary: Get application bootstrap
* description: Retrieves the application bootstrap.
* tags:
* - Bootstrap
* operationId: getBootstrap
* responses:
* 200:
* description: Bootstrap retrieved successfully
* content:
* application/json:
* schema:
* type: object
* required:
* - oidc
* - version
* properties:
* oidc:
* type: object
* required:
* - authorizationUrl
* - endSessionUrl
* - isEnforced
* nullable: true
* description: OpenID Connect configuration (null if not configured)
* properties:
* authorizationUrl:
* type: string
* format: uri
* description: OIDC authorization URL for initiating authentication
* example: https://oidc.example.com/auth
* endSessionUrl:
* type: string
* format: uri
* nullable: true
* description: OIDC end session URL for logout (null if not supported by provider)
* example: https://oidc.example.com/logout
* isEnforced:
* type: boolean
* description: Whether OIDC authentication is enforced (users must use OIDC to login)
* example: false
* activeUsersLimit:
* type: number
* nullable: true
* description: Maximum number of active users allowed (conditionally added for admins if configured)
* example: 100
* version:
* type: string
* description: Current version of the PLANKA application
* example: 2.0.0
* security: []
*/
module.exports = {
async fn() {
const { currentUser } = this.req;
const oidc = await sails.hooks.oidc.getBootstrap();
return {
item: sails.helpers.bootstrap.presentOne(oidc, currentUser),
};
},
};

View File

@@ -8,7 +8,7 @@
* /config:
* get:
* summary: Get application configuration
* description: Retrieves the application configuration.
* description: Retrieves the application configuration. Requires admin privileges.
* tags:
* - Config
* operationId: getConfig
@@ -24,39 +24,14 @@
* properties:
* item:
* $ref: '#/components/schemas/Config'
* security: []
*/
module.exports = {
async fn() {
const { currentUser } = this.req;
const oidcClient = await sails.hooks.oidc.getClient();
let oidc = null;
if (oidcClient) {
const authorizationUrlParams = {
scope: sails.config.custom.oidcScopes,
};
if (!sails.config.custom.oidcUseDefaultResponseMode) {
authorizationUrlParams.response_mode = sails.config.custom.oidcResponseMode;
}
oidc = {
authorizationUrl: oidcClient.authorizationUrl(authorizationUrlParams),
endSessionUrl: oidcClient.issuer.end_session_endpoint ? oidcClient.endSessionUrl({}) : null,
isEnforced: sails.config.custom.oidcEnforced,
};
}
const config = await Config.qm.getOneMain();
return {
item: sails.helpers.config.presentOne(
{
oidc,
},
currentUser,
),
item: sails.helpers.config.presentOne(config),
};
},
};

View File

@@ -0,0 +1,117 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /config/test-smtp:
* post:
* summary: Test SMTP configuration
* description: Sends a test email to verify the SMTP is configured correctly. Only available when SMTP is configured via the UI.
* tags:
* - Config
* operationId: testSmtpConfig
* responses:
* 200:
* description: Test email sent successfully
* content:
* application/json:
* schema:
* type: object
* required:
* - item
* properties:
* item:
* $ref: '#/components/schemas/Config'
* 401:
* $ref: '#/components/responses/Unauthorized'
* 403:
* $ref: '#/components/responses/Forbidden'
*/
const Errors = {
NOT_AVAILABLE: {
notAvailable: 'Not available',
},
};
module.exports = {
exits: {
notAvailable: {
responseType: 'forbidden',
},
},
async fn() {
const { currentUser } = this.req;
if (sails.config.custom.smtpHost) {
return Errors.NOT_AVAILABLE;
}
const { transporter, config } = await sails.helpers.utils.makeSmtpTransporter({
connectionTimeout: 5000,
greetingTimeout: 5000,
socketTimeout: 10000,
dnsTimeout: 3000,
});
if (!transporter) {
return Errors.NOT_AVAILABLE;
}
const logs = [];
try {
logs.push('📧 Sending test email...');
/* eslint-disable no-underscore-dangle */
const info = await transporter.sendMail({
to: currentUser.email,
subject: this.req.i18n.__('Test Title'),
text: this.req.i18n.__('This is a test text message!'),
html: this.req.i18n.__('This is a <i>test</i> <b>html</b> <code>message</code>!'),
});
/* eslint-enable no-underscore-dangle */
logs.push('✅ Email sent successfully!', '');
logs.push(`📬 Message ID: ${info.messageId}`);
if (info.response) {
logs.push(`📤 Server response: ${info.response.trim()}`);
}
logs.push('', '🎉 Your configuration is working correctly!');
} catch (error) {
logs.push('❌ Failed to send email!', '');
if (error.code) {
logs.push(`⚠️ Error code: ${error.code}`);
}
logs.push(`💬 Reason: ${error.message.trim()}`);
if (error.code === 'EDNS') {
logs.push('', '💡 Hint: Check your host setting.');
} else if (error.code === 'ETIMEDOUT') {
logs.push('', '💡 Hint: Check your host and port settings.');
} else if (error.code === 'EAUTH') {
logs.push('', '💡 Hint: Check your username and password.');
} else if (error.code === 'ESOCKET') {
if (error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) {
logs.push('', '💡 Hint: Check your host and port settings.');
} else if (error.message.includes('wrong version number')) {
logs.push('', '💡 Hint: Try toggling "Use secure connection".');
} else if (error.message.includes('certificate')) {
logs.push('', '💡 Hint: Try toggling "Reject unauthorized TLS certificates".');
}
}
} finally {
transporter.close();
}
return {
item: sails.helpers.config.presentOne(config),
included: {
logs,
},
};
},
};

View File

@@ -0,0 +1,151 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /config:
* patch:
* summary: Update application configuration
* description: Updates the application configuration. Requires admin privileges.
* tags:
* - Config
* operationId: updateConfig
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* smtpHost:
* type: string
* maxLength: 256
* nullable: true
* description: Hostname or IP address of the SMTP server
* example: smtp.example.com
* smtpHost:
* type: number
* minimum: 0
* maximum: 65535
* nullable: true
* description: Port number of the SMTP server
* example: 587
* smtpName:
* type: string
* maxLength: 256
* nullable: true
* description: Client hostname used in the EHLO command for SMTP
* example: localhost
* smtpSecure:
* type: boolean
* description: Whether to use a secure connection for SMTP
* example: false
* smtpTlsRejectUnauthorized:
* type: boolean
* description: Whether to reject unauthorized or self-signed TLS certificates for SMTP connections
* example: true
* smtpUser:
* type: string
* maxLength: 256
* nullable: true
* description: Username for authenticating with the SMTP server
* example: no-reply@example.com
* smtpPassword:
* type: string
* maxLength: 256
* nullable: true
* description: Password for authenticating with the SMTP server
* example: SecurePassword123!
* smtpFrom:
* type: string
* maxLength: 256
* nullable: true
* description: Default "from" used for outgoing SMTP emails
* example: no-reply@example.com
* responses:
* 200:
* description: Configuration updated successfully
* content:
* application/json:
* schema:
* type: object
* required:
* - item
* properties:
* item:
* $ref: '#/components/schemas/Config'
*/
module.exports = {
inputs: {
smtpHost: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
allowNull: true,
},
smtpPort: {
type: 'number',
min: 0,
max: 65535,
allowNull: true,
},
smtpName: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
allowNull: true,
},
smtpSecure: {
type: 'boolean',
},
smtpTlsRejectUnauthorized: {
type: 'boolean',
},
smtpUser: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
allowNull: true,
},
smtpPassword: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
allowNull: true,
},
smtpFrom: {
type: 'string',
isNotEmptyString: true,
maxLength: 256,
allowNull: true,
},
},
async fn(inputs) {
const { currentUser } = this.req;
const values = _.pick(inputs, [
'smtpHost',
'smtpPort',
'smtpName',
'smtpSecure',
'smtpTlsRejectUnauthorized',
'smtpUser',
'smtpPassword',
'smtpFrom',
]);
const config = await sails.helpers.config.updateMain.with({
values,
actorUser: currentUser,
request: this.req,
});
return {
item: sails.helpers.config.presentOne(config),
};
},
};

View File

@@ -84,10 +84,17 @@ module.exports = {
}
}
await sails.helpers.notificationServices.testOne.with({
record: notificationService,
i18n: this.req.i18n,
/* eslint-disable no-underscore-dangle */
await sails.helpers.utils.sendNotifications.with({
services: [_.pick(notificationService, ['url', 'format'])],
title: this.req.i18n.__('Test Title'),
bodyByFormat: {
text: this.req.i18n.__('This is a test text message!'),
markdown: this.req.i18n.__('This is a *test* **markdown** `message`!'),
html: this.req.i18n.__('This is a <i>test</i> <b>html</b> <code>message</code>!'),
},
});
/* eslint-enable no-underscore-dangle */
return {
item: notificationService,

View File

@@ -54,14 +54,12 @@
* included:
* type: object
* required:
* - accessTokens
* - accessToken
* properties:
* accessTokens:
* type: array
* accessToken:
* type: string
* description: New acces tokens (when updating own password)
* items:
* type: string
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ4...
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ4...
* 400:
* $ref: '#/components/responses/ValidationError'
* 401:
@@ -180,7 +178,7 @@ module.exports = {
return {
item: sails.helpers.users.presentOne(user, currentUser),
included: {
accessTokens: [accessToken],
accessToken,
},
};
}

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,
});
}
}

View File

@@ -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;
},
};

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;

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;
},
};

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;
},
};

View File

@@ -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>'),
},
});
},
};

View File

@@ -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;
},
};

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,

View File

@@ -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,
};
},
};

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) {

View File

@@ -59,6 +59,28 @@ module.exports = function defineOidcHook(sails) {
return client;
},
async getBootstrap() {
const instance = await this.getClient();
if (!instance) {
return null;
}
const authorizationUrlParams = {
scope: sails.config.custom.oidcScopes,
};
if (!sails.config.custom.oidcUseDefaultResponseMode) {
authorizationUrlParams.response_mode = sails.config.custom.oidcResponseMode;
}
return {
authorizationUrl: instance.authorizationUrl(authorizationUrlParams),
endSessionUrl: instance.issuer.end_session_endpoint ? instance.endSessionUrl({}) : null,
isEnforced: sails.config.custom.oidcEnforced,
};
},
isEnabled() {
return !!sails.config.custom.oidcIssuer;
},

View File

@@ -9,6 +9,37 @@ const defaultFind = (criteria) => Notification.find(criteria).sort('id DESC');
/* Query methods */
const create = (arrayOfValues) =>
sails.getDatastore().transaction(async (db) => {
const notifications = await Notification.createEach(arrayOfValues).fetch().usingConnection(db);
const userIds = sails.helpers.utils.mapRecords(notifications, 'userId', true, true);
if (userIds.length > 0) {
const queryValues = [];
const inValues = userIds.map((userId) => {
queryValues.push(userId);
return `$${queryValues.length}`;
});
queryValues.push(LIMIT);
const query = `
WITH exceeded_notification AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY id DESC) AS rank
FROM notification
WHERE user_id IN (${inValues.join(', ')}) AND is_read = FALSE
)
UPDATE notification
SET is_read = TRUE
WHERE id IN (SELECT id FROM exceeded_notification WHERE rank > $${queryValues.length})
`;
await sails.sendNativeQuery(query, queryValues).usingConnection(db);
}
return notifications;
});
const createOne = (values) => {
if (values.userId) {
return sails.getDatastore().transaction(async (db) => {
@@ -26,7 +57,7 @@ const createOne = (values) => {
)
UPDATE notification
SET is_read = TRUE
WHERE id in (SELECT id FROM exceeded_notification)
WHERE id IN (SELECT id FROM exceeded_notification)
`;
await sails.sendNativeQuery(query, [values.userId, LIMIT]).usingConnection(db);
@@ -66,6 +97,7 @@ const updateOne = (criteria, values) => Notification.updateOne(criteria).set({ .
const delete_ = (criteria) => Notification.destroy(criteria).fetch();
module.exports = {
create,
createOne,
getByIds,
getUnreadByUserId,

View File

@@ -14,6 +14,11 @@ const getByUserId = (userId) =>
userId,
});
const getByUserIds = (userIds) =>
defaultFind({
userId: userIds,
});
const getByBoardId = (boardId) =>
defaultFind({
boardId,
@@ -36,6 +41,7 @@ const deleteOne = (criteria) => NotificationService.destroyOne(criteria);
module.exports = {
createOne,
getByUserId,
getByUserIds,
getByBoardId,
getByBoardIds,
getOneById,

View File

@@ -1,55 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* smtp hook
*
* @description :: A hook definition. Extends Sails by adding shadow routes, implicit actions,
* and/or initialization logic.
* @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
*/
const nodemailer = require('nodemailer');
module.exports = function defineSmtpHook(sails) {
let transporter = null;
return {
/**
* Runs when this Sails app loads/lifts.
*/
async initialize() {
if (!this.isEnabled()) {
return;
}
sails.log.info('Initializing custom hook (`smtp`)');
transporter = nodemailer.createTransport({
pool: true,
host: sails.config.custom.smtpHost,
port: sails.config.custom.smtpPort,
name: sails.config.custom.smtpName,
secure: sails.config.custom.smtpSecure,
auth: sails.config.custom.smtpUser && {
user: sails.config.custom.smtpUser,
pass: sails.config.custom.smtpPassword,
},
tls: {
rejectUnauthorized: sails.config.custom.smtpTlsRejectUnauthorized,
},
});
},
getTransporter() {
return transporter;
},
isEnabled() {
return !!sails.config.custom.smtpHost;
},
};
};

View File

@@ -17,54 +17,131 @@
* Config:
* type: object
* required:
* - version
* - oidc
* - id
* - isInitialized
* properties:
* version:
* id:
* type: string
* description: Current version of the PLANKA application
* example: 2.0.0
* activeUsersLimit:
* description: Unique identifier for the config (always set to '1')
* example: 1
* smtpHost:
* type: string
* nullable: true
* description: Hostname or IP address of the SMTP server
* example: smtp.example.com
* smtpPort:
* type: number
* nullable: true
* description: Maximum number of active users allowed (conditionally added for admins if configured)
* example: 100
* oidc:
* type: object
* required:
* - authorizationUrl
* - endSessionUrl
* - isEnforced
* description: Port number of the SMTP server
* example: 587
* smtpName:
* type: string
* nullable: true
* description: OpenID Connect configuration (null if not configured)
* properties:
* authorizationUrl:
* type: string
* format: uri
* description: OIDC authorization URL for initiating authentication
* example: https://oidc.example.com/auth
* endSessionUrl:
* type: string
* format: uri
* nullable: true
* description: OIDC end session URL for logout (null if not supported by provider)
* example: https://oidc.example.com/logout
* isEnforced:
* type: boolean
* description: Whether OIDC authentication is enforced (users must use OIDC to login)
* example: false
* description: Client hostname used in the EHLO command for SMTP
* example: localhost
* smtpSecure:
* type: boolean
* description: Whether to use a secure connection for SMTP
* example: false
* smtpTlsRejectUnauthorized:
* type: boolean
* description: Whether to reject unauthorized or self-signed TLS certificates for SMTP connections
* example: true
* smtpUser:
* type: string
* nullable: true
* description: Username for authenticating with the SMTP server
* example: no-reply@example.com
* smtpPassword:
* type: string
* nullable: true
* description: Password for authenticating with the SMTP server
* example: SecurePassword123!
* smtpFrom:
* type: string
* nullable: true
* description: Default "from" used for outgoing SMTP emails
* example: no-reply@example.com
* isInitialized:
* type: boolean
* description: Whether the PLANKA instance has been initialized
* example: true
* createdAt:
* type: string
* format: date-time
* nullable: true
* description: When the config was created
* example: 2024-01-01T00:00:00.000Z
* updatedAt:
* type: string
* format: date-time
* nullable: true
* description: When the config was last updated
* example: 2024-01-01T00:00:00.000Z
*/
const MAIN_ID = '1';
const SMTP_FIELD_NAMES = [
'smtpHost',
'smtpPort',
'smtpName',
'smtpSecure',
'smtpTlsRejectUnauthorized',
'smtpUser',
'smtpPassword',
'smtpFrom',
];
module.exports = {
MAIN_ID,
SMTP_FIELD_NAMES,
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
smtpHost: {
type: 'string',
allowNull: true,
columnName: 'smtp_host',
},
smtpPort: {
type: 'number',
allowNull: true,
columnName: 'smtp_port',
},
smtpName: {
type: 'string',
allowNull: true,
columnName: 'smtp_name',
},
smtpSecure: {
type: 'boolean',
required: true,
columnName: 'smtp_secure',
},
smtpTlsRejectUnauthorized: {
type: 'boolean',
required: true,
columnName: 'smtp_tls_reject_unauthorized',
},
smtpUser: {
type: 'string',
allowNull: true,
columnName: 'smtp_user',
},
smtpPassword: {
type: 'string',
allowNull: true,
columnName: 'smtp_password',
},
smtpFrom: {
type: 'string',
allowNull: true,
columnName: 'smtp_from',
},
isInitialized: {
type: 'boolean',
required: true,

View File

@@ -107,6 +107,8 @@ const Events = {
COMMENT_UPDATE: 'commentUpdate',
COMMENT_DELETE: 'commentDelete',
CONFIG_UPDATE: 'configUpdate',
CUSTOM_FIELD_CREATE: 'customFieldCreate',
CUSTOM_FIELD_UPDATE: 'customFieldUpdate',
CUSTOM_FIELD_DELETE: 'customFieldDelete',

View File

@@ -102,10 +102,10 @@ module.exports.custom = {
smtpPort: process.env.SMTP_PORT || 587,
smtpName: process.env.SMTP_NAME,
smtpSecure: process.env.SMTP_SECURE === 'true',
smtpTlsRejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
smtpUser: process.env.SMTP_USER,
smtpPassword: process.env.SMTP_PASSWORD,
smtpFrom: process.env.SMTP_FROM,
smtpTlsRejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
gravatarBaseUrl: process.env.GRAVATAR_BASE_URL,
};

View File

@@ -5,7 +5,7 @@
"Test Title": "Testtitel",
"This is a test text message!": "Dies ist eine Test-Textnachricht!",
"This is a *test* **markdown** `message`!": "Dies ist eine *Test*-**Markdown**-`Nachricht`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Dies ist eine <i>Test</i>-<b>HTML</b>-<code>Nachricht</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Dies ist eine <i>Test</i>-<b>HTML</b>-<code>Nachricht</code>!",
"You Were Added to Card": "Sie wurden zur Karte hinzugefügt",
"You Were Mentioned in Comment": "Sie wurden in einem Kommentar erwähnt",
"%s added you to %s on %s": "%s hat Sie zu %s am %s hinzugefügt",

View File

@@ -5,7 +5,7 @@
"Test Title": "Τίτλος δοκιμής",
"This is a test text message!": "Αυτό είναι ένα δοκιμαστικό μήνυμα!",
"This is a *test* **markdown** `message`!": "Αυτό είναι ένα *δοκιμαστικό* **markdown** `μήνυμα`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Αυτό είναι ένα <i>δοκιμαστικό</i> <b>html</b> <code>μήνυμα</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Αυτό είναι ένα <i>δοκιμαστικό</i> <b>html</b> <code>μήνυμα</code>!",
"You Were Added to Card": "Προστέθηκες στην κάρτα",
"You Were Mentioned in Comment": "Αναφέρθηκες σε σχόλιο",
"%s added you to %s on %s": "%s σε πρόσθεσε στο %s στο %s",

View File

@@ -5,7 +5,7 @@
"Test Title": "Test Title",
"This is a test text message!": "This is a test text message!",
"This is a *test* **markdown** `message`!": "This is a *test* **markdown** `message`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "This is a <i>test</i> <b>html</b> <code>message</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "This is a <i>test</i> <b>html</b> <code>message</code>!",
"You Were Added to Card": "You Were Added to Card",
"You Were Mentioned in Comment": "You Were Mentioned in Comment",
"%s added you to %s on %s": "%s added you to %s on %s",

View File

@@ -5,7 +5,7 @@
"Test Title": "Test Title",
"This is a test text message!": "This is a test text message!",
"This is a *test* **markdown** `message`!": "This is a *test* **markdown** `message`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "This is a <i>test</i> <b>html</b> <code>message</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "This is a <i>test</i> <b>html</b> <code>message</code>!",
"You Were Added to Card": "You Were Added to Card",
"You Were Mentioned in Comment": "You Were Mentioned in Comment",
"%s added you to %s on %s": "%s added you to %s on %s",

View File

@@ -5,7 +5,7 @@
"Test Title": "Título de prueba",
"This is a test text message!": "¡Este es un mensaje de texto de prueba!",
"This is a *test* **markdown** `message`!": "¡Este es un *mensaje* **markdown** `de prueba`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Este es un <i>mensaje</i> <b>html</b> <code>de prueba</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Este es un <i>mensaje</i> <b>html</b> <code>de prueba</code>!",
"You Were Added to Card": "Fuiste añadido a la tarjeta",
"You Were Mentioned in Comment": "Fuiste mencionado en un comentario",
"%s added you to %s on %s": "%s te añadió a %s en %s",

View File

@@ -5,7 +5,7 @@
"Test Title": "Testin otsikko",
"This is a test text message!": "Tämä on testiviesti!",
"This is a *test* **markdown** `message`!": "Tämä on *testi* **markdown** `viesti`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Tämä on <i>testi</i> <b>html</b> <code>viesti</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Tämä on <i>testi</i> <b>html</b> <code>viesti</code>!",
"You Were Added to Card": "Sinut lisättiin korttiin",
"You Were Mentioned in Comment": "Sinut mainittiin kommentissa",
"%s added you to %s on %s": "%s lisäsi sinut kohteeseen %s kohteessa %s",

View File

@@ -5,7 +5,7 @@
"Test Title": "Titre de test",
"This is a test text message!": "Ceci est un message texte de test !",
"This is a *test* **markdown** `message`!": "Ceci est un *message* **markdown** `de test` !",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Ceci est un <i>test</i> <b>html</b> <code>message</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Ceci est un <i>test</i> <b>html</b> <code>message</code>!",
"You Were Added to Card": "Vous avez été ajouté à la carte",
"You Were Mentioned in Comment": "Vous avez été mentionné dans un commentaire",
"%s added you to %s on %s": "%s vous a ajouté à %s le %s",

View File

@@ -5,7 +5,7 @@
"Test Title": "Titolo di test",
"This is a test text message!": "Questo è un messaggio di testo di test!",
"This is a *test* **markdown** `message`!": "Questo è un *test* **markdown** `messaggio`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Questo è un <i>test</i> <b>html</b> <code>messaggio</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Questo è un <i>test</i> <b>html</b> <code>messaggio</code>!",
"You Were Added to Card": "Sei stato aggiunto alla task",
"You Were Mentioned in Comment": "Sei stato menzionato nel commento",
"%s created %s in %s on %s": "%s ha creato %s in %s in %s",

View File

@@ -5,7 +5,7 @@
"Test Title": "Тестовый заголовок",
"This is a test text message!": "Это тестовое сообщение!",
"This is a *test* **markdown** `message`!": "Это *тестовое* **markdown** `сообщение`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Это <i>тестовое</i> <b>html</b> <code>сообщение</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Это <i>тестовое</i> <b>html</b> <code>сообщение</code>!",
"You Were Added to Card": "Вы были добавлены к карточке",
"You Were Mentioned in Comment": "Вы были упомянуты в комментарии",
"%s added you to %s on %s": "%s добавил(а) вас к %s на %s",

View File

@@ -5,7 +5,7 @@
"Test Title": "Test başlığı",
"This is a test text message!": "Bu bir test metin mesajıdır!",
"This is a *test* **markdown** `message`!": "Bu bir *test* **markdown** `mesajı`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Bu bir <i>test</i> <b>html</b> <code>mesajı</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Bu bir <i>test</i> <b>html</b> <code>mesajı</code>!",
"You Were Added to Card": "Karta eklendiniz",
"You Were Mentioned in Comment": "Bir yorumda bahsedildiniz",
"%s added you to %s on %s": "%s sizi %s'ye %s tarihinde ekledi",

View File

@@ -5,7 +5,7 @@
"Test Title": "Тестовий заголовок",
"This is a test text message!": "Це нове повідомлення!",
"This is a *test* **markdown** `message`!": "Це *тестове* **markdown** `повідомлення`!",
"This is a <i>test</i> <b>html</b> <code>message</code>": "Це <i>тестове</i> <b>html</b> <code>повідомлення</code>",
"This is a <i>test</i> <b>html</b> <code>message</code>!": "Це <i>тестове</i> <b>html</b> <code>повідомлення</code>!",
"You Were Added to Card": "Вас було додано до картки",
"You Were Mentioned in Comment": "Вас було згадано у коментарі",
"%s added you to %s on %s": "%s додав(ла) вас до %s на %s",

View File

@@ -18,6 +18,10 @@ module.exports.policies = {
'*': ['is-authenticated', 'is-external'],
'config/show': ['is-authenticated', 'is-admin'],
'config/update': ['is-authenticated', 'is-admin'],
'config/test-smtp': ['is-authenticated', 'is-admin'],
'webhooks/index': ['is-authenticated', 'is-external', 'is-admin'],
'webhooks/create': ['is-authenticated', 'is-external', 'is-admin'],
'webhooks/update': ['is-authenticated', 'is-external', 'is-admin'],
@@ -35,7 +39,7 @@ module.exports.policies = {
'projects/create': ['is-authenticated', 'is-external', 'is-admin-or-project-owner'],
'config/show': true,
'bootstrap/show': true,
'terms/show': true,
'access-tokens/create': true,
'access-tokens/exchange-with-oidc': true,

View File

@@ -62,10 +62,14 @@ function staticDirServer(prefix, dirFn) {
}
module.exports.routes = {
'GET /api/config': 'config/show',
'GET /api/bootstrap': 'bootstrap/show',
'GET /api/terms/:type': 'terms/show',
'GET /api/config': 'config/show',
'PATCH /api/config': 'config/update',
'POST /api/config/test-smtp': 'config/test-smtp',
'GET /api/webhooks': 'webhooks/index',
'POST /api/webhooks': 'webhooks/create',
'PATCH /api/webhooks/:id': 'webhooks/update',

View File

@@ -0,0 +1,36 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('config', (table) => {
/* Columns */
table.text('smtp_host');
table.integer('smtp_port');
table.text('smtp_name');
table.boolean('smtp_secure').notNullable().defaultTo(false);
table.boolean('smtp_tls_reject_unauthorized').notNullable().defaultTo(true);
table.text('smtp_user');
table.text('smtp_password');
table.text('smtp_from');
});
return knex.schema.alterTable('config', (table) => {
table.boolean('smtp_secure').notNullable().alter();
table.boolean('smtp_tls_reject_unauthorized').notNullable().alter();
});
};
exports.down = (knex) =>
knex.schema.alterTable('config', (table) => {
table.dropColumn('smtp_host');
table.dropColumn('smtp_port');
table.dropColumn('smtp_name');
table.dropColumn('smtp_secure');
table.dropColumn('smtp_tls_reject_unauthorized');
table.dropColumn('smtp_user');
table.dropColumn('smtp_password');
table.dropColumn('smtp_from');
});