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

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