diff --git a/packages/@n8n/config/.eslintrc.js b/packages/@n8n/config/.eslintrc.js new file mode 100644 index 0000000000..032e99b09e --- /dev/null +++ b/packages/@n8n/config/.eslintrc.js @@ -0,0 +1,10 @@ +const sharedOptions = require('@n8n_io/eslint-config/shared'); + +/** + * @type {import('@types/eslint').ESLint.ConfigData} + */ +module.exports = { + extends: ['@n8n_io/eslint-config/node'], + + ...sharedOptions(__dirname), +}; diff --git a/packages/@n8n/config/jest.config.js b/packages/@n8n/config/jest.config.js new file mode 100644 index 0000000000..d6c48554a7 --- /dev/null +++ b/packages/@n8n/config/jest.config.js @@ -0,0 +1,2 @@ +/** @type {import('jest').Config} */ +module.exports = require('../../../jest.config'); diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json new file mode 100644 index 0000000000..947f862939 --- /dev/null +++ b/packages/@n8n/config/package.json @@ -0,0 +1,26 @@ +{ + "name": "@n8n/config", + "version": "1.0.0", + "scripts": { + "clean": "rimraf dist .turbo", + "dev": "pnpm watch", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json", + "format": "prettier --write . --ignore-path ../../../.prettierignore", + "lint": "eslint .", + "lintfix": "eslint . --fix", + "watch": "tsc -p tsconfig.build.json --watch", + "test": "jest", + "test:dev": "jest --watch" + }, + "main": "dist/index.js", + "module": "src/index.ts", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "dependencies": { + "reflect-metadata": "0.2.2", + "typedi": "0.10.0" + } +} diff --git a/packages/@n8n/config/src/configs/credentials.ts b/packages/@n8n/config/src/configs/credentials.ts new file mode 100644 index 0000000000..9659061c05 --- /dev/null +++ b/packages/@n8n/config/src/configs/credentials.ts @@ -0,0 +1,25 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +class CredentialsOverwrite { + /** + * Prefilled data ("overwrite") in credential types. End users cannot view or change this data. + * Format: { CREDENTIAL_NAME: { PARAMETER: VALUE }} + */ + @Env('CREDENTIALS_OVERWRITE_DATA') + readonly data: string = '{}'; + + /** Internal API endpoint to fetch overwritten credential types from. */ + @Env('CREDENTIALS_OVERWRITE_ENDPOINT') + readonly endpoint: string = ''; +} + +@Config +export class CredentialsConfig { + /** Default name for credentials */ + @Env('CREDENTIALS_DEFAULT_NAME') + readonly defaultName: string = 'My credentials'; + + @Nested + readonly overwrite: CredentialsOverwrite; +} diff --git a/packages/@n8n/config/src/configs/database.ts b/packages/@n8n/config/src/configs/database.ts new file mode 100644 index 0000000000..384ecb1fb0 --- /dev/null +++ b/packages/@n8n/config/src/configs/database.ts @@ -0,0 +1,151 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +class LoggingConfig { + /** Whether database logging is enabled. */ + @Env('DB_LOGGING_ENABLED') + readonly enabled: boolean = false; + + /** + * Database logging level. Requires `DB_LOGGING_MAX_EXECUTION_TIME` to be higher than `0`. + */ + @Env('DB_LOGGING_OPTIONS') + readonly options: 'query' | 'error' | 'schema' | 'warn' | 'info' | 'log' | 'all' = 'error'; + + /** + * Only queries that exceed this time (ms) will be logged. Set `0` to disable. + */ + @Env('DB_LOGGING_MAX_EXECUTION_TIME') + readonly maxQueryExecutionTime: number = 0; +} + +@Config +class PostgresSSLConfig { + /** + * Whether to enable SSL. + * If `DB_POSTGRESDB_SSL_CA`, `DB_POSTGRESDB_SSL_CERT`, or `DB_POSTGRESDB_SSL_KEY` are defined, `DB_POSTGRESDB_SSL_ENABLED` defaults to `true`. + */ + @Env('DB_POSTGRESDB_SSL_ENABLED') + readonly enabled: boolean = false; + + /** SSL certificate authority */ + @Env('DB_POSTGRESDB_SSL_CA') + readonly ca: string = ''; + + /** SSL certificate */ + @Env('DB_POSTGRESDB_SSL_CERT') + readonly cert: string = ''; + + /** SSL key */ + @Env('DB_POSTGRESDB_SSL_KEY') + readonly key: string = ''; + + /** If unauthorized SSL connections should be rejected */ + @Env('DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED') + readonly rejectUnauthorized: boolean = true; +} + +@Config +class PostgresConfig { + /** Postgres database name */ + @Env('DB_POSTGRESDB_DATABASE') + database: string = 'n8n'; + + /** Postgres database host */ + @Env('DB_POSTGRESDB_HOST') + readonly host: string = 'localhost'; + + /** Postgres database password */ + @Env('DB_POSTGRESDB_PASSWORD') + readonly password: string = ''; + + /** Postgres database port */ + @Env('DB_POSTGRESDB_PORT') + readonly port: number = 5432; + + /** Postgres database user */ + @Env('DB_POSTGRESDB_USER') + readonly user: string = 'postgres'; + + /** Postgres database schema */ + @Env('DB_POSTGRESDB_SCHEMA') + readonly schema: string = 'public'; + + /** Postgres database pool size */ + @Env('DB_POSTGRESDB_POOL_SIZE') + readonly poolSize = 2; + + @Nested + readonly ssl: PostgresSSLConfig; +} + +@Config +class MysqlConfig { + /** @deprecated MySQL database name */ + @Env('DB_MYSQLDB_DATABASE') + database: string = 'n8n'; + + /** MySQL database host */ + @Env('DB_MYSQLDB_HOST') + readonly host: string = 'localhost'; + + /** MySQL database password */ + @Env('DB_MYSQLDB_PASSWORD') + readonly password: string = ''; + + /** MySQL database port */ + @Env('DB_MYSQLDB_PORT') + readonly port: number = 3306; + + /** MySQL database user */ + @Env('DB_MYSQLDB_USER') + readonly user: string = 'root'; +} + +@Config +class SqliteConfig { + /** SQLite database file name */ + @Env('DB_SQLITE_DATABASE') + readonly database: string = 'database.sqlite'; + + /** SQLite database pool size. Set to `0` to disable pooling. */ + @Env('DB_SQLITE_POOL_SIZE') + readonly poolSize: number = 0; + + /** + * Enable SQLite WAL mode. + */ + @Env('DB_SQLITE_ENABLE_WAL') + readonly enableWAL: boolean = this.poolSize > 1; + + /** + * Run `VACUUM` on startup to rebuild the database, reducing file size and optimizing indexes. + * + * @warning Long-running blocking operation that will increase startup time. + */ + @Env('DB_SQLITE_VACUUM_ON_STARTUP') + readonly executeVacuumOnStartup: boolean = false; +} + +@Config +export class DatabaseConfig { + /** Type of database to use */ + @Env('DB_TYPE') + type: 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb' = 'sqlite'; + + /** Prefix for table names */ + @Env('DB_TABLE_PREFIX') + readonly tablePrefix: string = ''; + + @Nested + readonly logging: LoggingConfig; + + @Nested + readonly postgresdb: PostgresConfig; + + @Nested + readonly mysqldb: MysqlConfig; + + @Nested + readonly sqlite: SqliteConfig; +} diff --git a/packages/@n8n/config/src/configs/email.ts b/packages/@n8n/config/src/configs/email.ts new file mode 100644 index 0000000000..318c352380 --- /dev/null +++ b/packages/@n8n/config/src/configs/email.ts @@ -0,0 +1,78 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +export class SmtpAuth { + /** SMTP login username */ + @Env('N8N_SMTP_USER') + readonly user: string = ''; + + /** SMTP login password */ + @Env('N8N_SMTP_PASS') + readonly pass: string = ''; + + /** SMTP OAuth Service Client */ + @Env('N8N_SMTP_OAUTH_SERVICE_CLIENT') + readonly serviceClient: string = ''; + + /** SMTP OAuth Private Key */ + @Env('N8N_SMTP_OAUTH_PRIVATE_KEY') + readonly privateKey: string = ''; +} + +@Config +export class SmtpConfig { + /** SMTP server host */ + @Env('N8N_SMTP_HOST') + readonly host: string = ''; + + /** SMTP server port */ + @Env('N8N_SMTP_PORT') + readonly port: number = 465; + + /** Whether to use SSL for SMTP */ + @Env('N8N_SMTP_SSL') + readonly secure: boolean = true; + + /** Whether to use STARTTLS for SMTP when SSL is disabled */ + @Env('N8N_SMTP_STARTTLS') + readonly startTLS: boolean = true; + + /** How to display sender name */ + @Env('N8N_SMTP_SENDER') + readonly sender: string = ''; + + @Nested + readonly auth: SmtpAuth; +} + +@Config +export class TemplateConfig { + /** Overrides default HTML template for inviting new people (use full path) */ + @Env('N8N_UM_EMAIL_TEMPLATES_INVITE') + readonly invite: string = ''; + + /** Overrides default HTML template for resetting password (use full path) */ + @Env('N8N_UM_EMAIL_TEMPLATES_PWRESET') + readonly passwordReset: string = ''; + + /** Overrides default HTML template for notifying that a workflow was shared (use full path) */ + @Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED') + readonly workflowShared: string = ''; + + /** Overrides default HTML template for notifying that credentials were shared (use full path) */ + @Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED') + readonly credentialsShared: string = ''; +} + +@Config +export class EmailConfig { + /** How to send emails */ + @Env('N8N_EMAIL_MODE') + readonly mode: '' | 'smtp' = 'smtp'; + + @Nested + readonly smtp: SmtpConfig; + + @Nested + readonly template: TemplateConfig; +} diff --git a/packages/@n8n/config/src/decorators.ts b/packages/@n8n/config/src/decorators.ts new file mode 100644 index 0000000000..a0f0540e1b --- /dev/null +++ b/packages/@n8n/config/src/decorators.ts @@ -0,0 +1,79 @@ +import 'reflect-metadata'; +import { readFileSync } from 'fs'; +import { Container, Service } from 'typedi'; + +// eslint-disable-next-line @typescript-eslint/ban-types +type Class = Function; +type PropertyKey = string | symbol; +interface PropertyMetadata { + type: unknown; + envName?: string; +} + +const globalMetadata = new Map>(); + +export const Config: ClassDecorator = (ConfigClass: Class) => { + const factory = function () { + const config = new (ConfigClass as new () => Record)(); + const classMetadata = globalMetadata.get(ConfigClass); + if (!classMetadata) { + // eslint-disable-next-line n8n-local-rules/no-plain-errors + throw new Error('Invalid config class: ' + ConfigClass.name); + } + + for (const [key, { type, envName }] of classMetadata) { + if (typeof type === 'function' && globalMetadata.has(type)) { + config[key] = Container.get(type); + } else if (envName) { + let value: unknown = process.env[envName]; + + // Read the value from a file, if "_FILE" environment variable is defined + const filePath = process.env[`${envName}_FILE`]; + if (filePath) { + value = readFileSync(filePath, 'utf8'); + } + + if (type === Number) { + value = Number(value); + if (isNaN(value as number)) { + // TODO: add a warning + value = undefined; + } + } else if (type === Boolean) { + if (value !== 'true' && value !== 'false') { + // TODO: add a warning + value = undefined; + } else { + value = value === 'true'; + } + } + + if (value !== undefined) { + config[key] = value; + } + } + } + return config; + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Service({ factory })(ConfigClass); +}; + +export const Nested: PropertyDecorator = (target: object, key: PropertyKey) => { + const ConfigClass = target.constructor; + const classMetadata = globalMetadata.get(ConfigClass) ?? new Map(); + const type = Reflect.getMetadata('design:type', target, key) as unknown; + classMetadata.set(key, { type }); + globalMetadata.set(ConfigClass, classMetadata); +}; + +export const Env = + (envName: string): PropertyDecorator => + (target: object, key: PropertyKey) => { + const ConfigClass = target.constructor; + const classMetadata = + globalMetadata.get(ConfigClass) ?? new Map(); + const type = Reflect.getMetadata('design:type', target, key) as unknown; + classMetadata.set(key, { type, envName }); + globalMetadata.set(ConfigClass, classMetadata); + }; diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts new file mode 100644 index 0000000000..4696a0c3ca --- /dev/null +++ b/packages/@n8n/config/src/index.ts @@ -0,0 +1,22 @@ +import { Config, Nested } from './decorators'; +import { CredentialsConfig } from './configs/credentials'; +import { DatabaseConfig } from './configs/database'; +import { EmailConfig } from './configs/email'; + +@Config +class UserManagementConfig { + @Nested + emails: EmailConfig; +} + +@Config +export class GlobalConfig { + @Nested + database: DatabaseConfig; + + @Nested + credentials: CredentialsConfig; + + @Nested + userManagement: UserManagementConfig; +} diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts new file mode 100644 index 0000000000..e0c41af64e --- /dev/null +++ b/packages/@n8n/config/test/config.test.ts @@ -0,0 +1,145 @@ +import fs from 'fs'; +import { Container } from 'typedi'; +import { mock } from 'jest-mock-extended'; +import { GlobalConfig } from '../src/index'; + +jest.mock('fs'); +const mockFs = mock(); +fs.readFileSync = mockFs.readFileSync; + +describe('GlobalConfig', () => { + beforeEach(() => { + Container.reset(); + }); + + const originalEnv = process.env; + afterEach(() => { + process.env = originalEnv; + }); + + const defaultConfig = { + database: { + logging: { + enabled: false, + maxQueryExecutionTime: 0, + options: 'error', + }, + mysqldb: { + database: 'n8n', + host: 'localhost', + password: '', + port: 3306, + user: 'root', + }, + postgresdb: { + database: 'n8n', + host: 'localhost', + password: '', + poolSize: 2, + port: 5432, + schema: 'public', + ssl: { + ca: '', + cert: '', + enabled: false, + key: '', + rejectUnauthorized: true, + }, + user: 'postgres', + }, + sqlite: { + database: 'database.sqlite', + enableWAL: false, + executeVacuumOnStartup: false, + poolSize: 0, + }, + tablePrefix: '', + type: 'sqlite', + }, + + credentials: { + defaultName: 'My credentials', + overwrite: { + data: '{}', + endpoint: '', + }, + }, + userManagement: { + emails: { + mode: 'smtp', + smtp: { + host: '', + port: 465, + secure: true, + sender: '', + startTLS: true, + auth: { + pass: '', + user: '', + privateKey: '', + serviceClient: '', + }, + }, + template: { + credentialsShared: '', + invite: '', + passwordReset: '', + workflowShared: '', + }, + }, + }, + }; + + it('should use all default values when no env variables are defined', () => { + process.env = {}; + const config = Container.get(GlobalConfig); + expect(config).toEqual(defaultConfig); + expect(mockFs.readFileSync).not.toHaveBeenCalled(); + }); + + it('should use values from env variables when defined', () => { + process.env = { + DB_POSTGRESDB_HOST: 'some-host', + DB_POSTGRESDB_USER: 'n8n', + DB_TABLE_PREFIX: 'test_', + }; + const config = Container.get(GlobalConfig); + expect(config).toEqual({ + ...defaultConfig, + database: { + logging: defaultConfig.database.logging, + mysqldb: defaultConfig.database.mysqldb, + postgresdb: { + ...defaultConfig.database.postgresdb, + host: 'some-host', + user: 'n8n', + }, + sqlite: defaultConfig.database.sqlite, + tablePrefix: 'test_', + type: 'sqlite', + }, + }); + expect(mockFs.readFileSync).not.toHaveBeenCalled(); + }); + + it('should use values from env variables when defined and convert them to the correct type', () => { + const passwordFile = '/path/to/postgres/password'; + process.env = { + DB_POSTGRESDB_PASSWORD_FILE: passwordFile, + }; + mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file'); + + const config = Container.get(GlobalConfig); + expect(config).toEqual({ + ...defaultConfig, + database: { + ...defaultConfig.database, + postgresdb: { + ...defaultConfig.database.postgresdb, + password: 'password-from-file', + }, + }, + }); + expect(mockFs.readFileSync).toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/config/tsconfig.build.json b/packages/@n8n/config/tsconfig.build.json new file mode 100644 index 0000000000..82375d5e15 --- /dev/null +++ b/packages/@n8n/config/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": ["./tsconfig.json", "../../../tsconfig.build.json"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/build.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**"] +} diff --git a/packages/@n8n/config/tsconfig.json b/packages/@n8n/config/tsconfig.json new file mode 100644 index 0000000000..15e134d6b4 --- /dev/null +++ b/packages/@n8n/config/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "types": ["node", "jest"], + "baseUrl": "src", + "tsBuildInfoFile": "dist/typecheck.tsbuildinfo" + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index f7a85bed09..c18cbc41fa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -68,6 +68,7 @@ "@types/convict": "^6.1.1", "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.21", + "@types/flat": "^5.0.5", "@types/formidable": "^3.4.5", "@types/json-diff": "^1.0.0", "@types/jsonwebtoken": "^9.0.6", @@ -91,6 +92,7 @@ }, "dependencies": { "@n8n/client-oauth2": "workspace:*", + "@n8n/config": "workspace:*", "@n8n/localtunnel": "2.1.0", "@n8n/n8n-nodes-langchain": "workspace:*", "@n8n/permissions": "workspace:*", @@ -123,6 +125,7 @@ "express-prom-bundle": "6.6.0", "express-rate-limit": "7.2.0", "fast-glob": "3.2.12", + "flat": "5.0.2", "flatted": "3.2.7", "formidable": "3.5.1", "google-timezones-json": "1.1.0", diff --git a/packages/cli/src/CredentialsOverwrites.ts b/packages/cli/src/CredentialsOverwrites.ts index f65dc6c688..b80a31d9ce 100644 --- a/packages/cli/src/CredentialsOverwrites.ts +++ b/packages/cli/src/CredentialsOverwrites.ts @@ -1,7 +1,7 @@ import { Service } from 'typedi'; +import { GlobalConfig } from '@n8n/config'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import { deepCopy, jsonParse } from 'n8n-workflow'; -import config from '@/config'; import type { ICredentialsOverwrite } from '@/Interfaces'; import { CredentialTypes } from '@/CredentialTypes'; import { Logger } from '@/Logger'; @@ -13,10 +13,11 @@ export class CredentialsOverwrites { private resolvedTypes: string[] = []; constructor( + globalConfig: GlobalConfig, private readonly credentialTypes: CredentialTypes, private readonly logger: Logger, ) { - const data = config.getEnv('credentials.overwrite.data'); + const data = globalConfig.credentials.overwrite.data; const overwriteData = jsonParse(data, { errorMessage: 'The credentials-overwrite is not valid JSON.', }); diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index f8c56be0d4..50067bc828 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -2,6 +2,7 @@ import { Service } from 'typedi'; import { snakeCase } from 'change-case'; import os from 'node:os'; import { get as pslGet } from 'psl'; +import { GlobalConfig } from '@n8n/config'; import type { ExecutionStatus, INodesGraphResult, @@ -37,6 +38,7 @@ import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus'; @Service() export class InternalHooks { constructor( + private readonly globalConfig: GlobalConfig, private readonly telemetry: Telemetry, private readonly nodeTypes: NodeTypes, private readonly sharedWorkflowRepository: SharedWorkflowRepository, @@ -73,7 +75,7 @@ export class InternalHooks { const info = { version_cli: N8N_VERSION, - db_type: config.getEnv('database.type'), + db_type: this.globalConfig.database.type, n8n_version_notifications_enabled: config.getEnv('versionNotifications.enabled'), n8n_disable_production_main_process: config.getEnv( 'endpoints.disableProductionWebhooksOnMainProcess', @@ -105,7 +107,7 @@ export class InternalHooks { }, n8n_deployment_type: config.getEnv('deployment.type'), n8n_binary_data_mode: binaryDataConfig.mode, - smtp_set_up: config.getEnv('userManagement.emails.mode') === 'smtp', + smtp_set_up: this.globalConfig.userManagement.emails.mode === 'smtp', ldap_allowed: authenticationMethod === 'ldap', saml_enabled: authenticationMethod === 'saml', license_plan_name: this.license.getPlanName(), diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 597c96ec0f..b6fce88b9c 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -5,6 +5,7 @@ import { promisify } from 'util'; import cookieParser from 'cookie-parser'; import express from 'express'; import helmet from 'helmet'; +import { GlobalConfig } from '@n8n/config'; import { InstanceSettings } from 'n8n-core'; import type { IN8nUISettings } from 'n8n-workflow'; @@ -95,7 +96,9 @@ export class Server extends AbstractServer { } this.presetCredentialsLoaded = false; - this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint'); + + const globalConfig = Container.get(GlobalConfig); + this.endpointPresetCredentials = globalConfig.credentials.overwrite.endpoint; await super.start(); this.logger.debug(`Server ID: ${this.uniqueInstanceId}`); diff --git a/packages/cli/src/UserManagement/email/NodeMailer.ts b/packages/cli/src/UserManagement/email/NodeMailer.ts index f6eedd80ac..8d27e2181b 100644 --- a/packages/cli/src/UserManagement/email/NodeMailer.ts +++ b/packages/cli/src/UserManagement/email/NodeMailer.ts @@ -1,87 +1,52 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Service } from 'typedi'; +import { pick } from 'lodash'; import type { Transporter } from 'nodemailer'; import { createTransport } from 'nodemailer'; import type SMTPConnection from 'nodemailer/lib/smtp-connection'; -import { Service } from 'typedi'; +import { GlobalConfig } from '@n8n/config'; import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; -import config from '@/config'; -import type { MailData, SendEmailResult } from './Interfaces'; + import { Logger } from '@/Logger'; +import type { MailData, SendEmailResult } from './Interfaces'; @Service() export class NodeMailer { - private transport?: Transporter; + readonly sender: string; - constructor(private readonly logger: Logger) {} + private transport: Transporter; - async init(): Promise { - const transportConfig: SMTPConnection.Options = { - host: config.getEnv('userManagement.emails.smtp.host'), - port: config.getEnv('userManagement.emails.smtp.port'), - secure: config.getEnv('userManagement.emails.smtp.secure'), - ignoreTLS: !config.getEnv('userManagement.emails.smtp.startTLS'), - }; + constructor( + globalConfig: GlobalConfig, + private readonly logger: Logger, + ) { + const smtpConfig = globalConfig.userManagement.emails.smtp; + const transportConfig: SMTPConnection.Options = pick(smtpConfig, ['host', 'port', 'secure']); + transportConfig.ignoreTLS = !smtpConfig.startTLS; - if ( - config.getEnv('userManagement.emails.smtp.auth.user') && - config.getEnv('userManagement.emails.smtp.auth.pass') - ) { - transportConfig.auth = { - user: config.getEnv('userManagement.emails.smtp.auth.user'), - pass: config.getEnv('userManagement.emails.smtp.auth.pass'), - }; + const { auth } = smtpConfig; + if (auth.user && auth.pass) { + transportConfig.auth = pick(auth, ['user', 'pass']); } - - if ( - config.getEnv('userManagement.emails.smtp.auth.serviceClient') && - config.getEnv('userManagement.emails.smtp.auth.privateKey') - ) { + if (auth.serviceClient && auth.privateKey) { transportConfig.auth = { type: 'OAuth2', - user: config.getEnv('userManagement.emails.smtp.auth.user'), - serviceClient: config.getEnv('userManagement.emails.smtp.auth.serviceClient'), - privateKey: config - .getEnv('userManagement.emails.smtp.auth.privateKey') - .replace(/\\n/g, '\n'), + user: auth.user, + serviceClient: auth.serviceClient, + privateKey: auth.privateKey.replace(/\\n/g, '\n'), }; } - this.transport = createTransport(transportConfig); - } - async verifyConnection(): Promise { - if (!this.transport) { - await this.init(); - } - const host = config.getEnv('userManagement.emails.smtp.host'); - const user = config.getEnv('userManagement.emails.smtp.auth.user'); - const pass = config.getEnv('userManagement.emails.smtp.auth.pass'); - - try { - await this.transport?.verify(); - } catch (error) { - const message: string[] = []; - if (!host) message.push('SMTP host not defined (N8N_SMTP_HOST).'); - if (!user) message.push('SMTP user not defined (N8N_SMTP_USER).'); - if (!pass) message.push('SMTP pass not defined (N8N_SMTP_PASS).'); - throw message.length ? new Error(message.join(' '), { cause: error }) : error; + this.sender = smtpConfig.sender; + if (!this.sender && auth.user.includes('@')) { + this.sender = auth.user; } } async sendMail(mailData: MailData): Promise { - if (!this.transport) { - await this.init(); - } - let sender = config.getEnv('userManagement.emails.smtp.sender'); - const user = config.getEnv('userManagement.emails.smtp.auth.user'); - - if (!sender && user.includes('@')) { - sender = user; - } - try { await this.transport?.sendMail({ - from: sender, + from: this.sender, to: mailData.emailRecipients, subject: mailData.subject, text: mailData.textOnly, @@ -92,7 +57,10 @@ export class NodeMailer { ); } catch (error) { ErrorReporter.error(error); - this.logger.error('Failed to send email', { recipients: mailData.emailRecipients, error }); + this.logger.error('Failed to send email', { + recipients: mailData.emailRecipients, + error: error as Error, + }); throw error; } diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts index c6adec6e4d..4563b8a691 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -3,9 +3,8 @@ import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import Handlebars from 'handlebars'; import { join as pathJoin } from 'path'; -import { ApplicationError } from 'n8n-workflow'; +import { GlobalConfig } from '@n8n/config'; -import config from '@/config'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { UserRepository } from '@db/repositories/user.repository'; @@ -22,42 +21,25 @@ import { EventRelay } from '@/eventbus/event-relay.service'; type Template = HandlebarsTemplateDelegate; type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared'; -const templates: Partial> = {}; - -async function getTemplate( - templateName: TemplateName, - defaultFilename = `${templateName}.html`, -): Promise