From 67e28d6ad43a33a838a06e53fa8e47767071ee91 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 14 Oct 2026 13:05:14 +0300 Subject: [PATCH] fix(email): send email via SMTP on self-hosted instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted instances suppressed every outgoing letter (plan 15 rev 5), so EMAIL_CONFIG_STRING was silently ignored and invites never arrived — with only a debug-level log line. Self-hosted now composes letters locally (EMAIL_TEXT + nunjucks templates) and sends them through the configured SMTP server. Without SMTP config the send is skipped with a warning; SMTP failures and timeouts are logged at error level. The reminder stays SaaS-only. The invite use case always calls the email service and logs the invitation/confirmation link only when the send fails. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../invite-user-in-company.use.case.ts | 44 ++-- .../email-config/email-config.service.ts | 5 + .../src/entities/email/email/email.service.ts | 231 +++++++++++++++--- .../unit-tests/email-dispatch-outcome.test.ts | 4 + 5 files changed, 224 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 218628d93..5a4eb7919 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ The provided Docker command example includes several environment variables. Here 4. **TEMPORARY_JWT_SECRET**: Similar to `JWT_SECRET`, this is also used for JWT token operations. It may be used as a secondary key for temporary tokens or during token rotation processes. 5. **APP_DOMAIN_ADDRESS** (optional): This sets the domain address of your application. The format is a URL, such as `https://rocketadmin.yourcompany.internal`. This address is used for internal links or email messages. -6. **EMAIL_CONFIG_STRING**: Settings for connecting to an SMTP server for email sending in an application. It follows the format `smtps://username:password@smtp.example.com/?pool=true`, where smtps:// indicates a secure SMTP connection using SSL/TLS encryption. username and password are the credentials for the SMTP server, smtp.example.com is the server address, and the pool=true query parameter enables connection pooling for efficient management of multiple email transmissions. More information about the parameters you can find in the nodemailer npm module documentation -7. **EMAIL_FROM** (optional): Emails will be sent from this email +6. **EMAIL_CONFIG_STRING**: Settings for connecting to an SMTP server for email sending in an application. It follows the format `smtps://username:password@smtp.example.com/?pool=true`, where smtps:// indicates a secure SMTP connection using SSL/TLS encryption. username and password are the credentials for the SMTP server, smtp.example.com is the server address, and the pool=true query parameter enables connection pooling for efficient management of multiple email transmissions. More information about the parameters you can find in the nodemailer npm module documentation. Use `smtp://` with port 587 (STARTTLS), e.g. `smtp://user:pass@email-smtp.us-east-1.amazonaws.com:587`. If no SMTP server is configured, no emails are sent: invitation and confirmation links are printed to the backend logs instead (search for `Invitation link:`), and so are send failures. +7. **EMAIL_FROM** (optional, but required by most SMTP providers): The sender address. It must be an address your SMTP server is allowed to send from (for Amazon SES — a verified identity); the default Rocketadmin support address will be rejected. 8. **OPENAI_API_KEY** (optional): This API key is used to authenticate requests to the OpenAI service. It allows the application to access OpenAI's language models and other AI functionalities. You can obtain this key by signing up for an API key on the OpenAI website. If this key is not provided, any features relying on OpenAI services will be disabled. diff --git a/backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts b/backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts index 7030f9bdf..1573524e1 100644 --- a/backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts +++ b/backend/src/entities/company-info/use-cases/invite-user-in-company.use.case.ts @@ -97,17 +97,16 @@ export class InviteUserInCompanyAndConnectionGroupUseCase const { rawToken } = await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(foundInvitedUser); - if (isSaaS()) { - // Custom domains retired (plan 46): links are built on the default domain. - await this.emailService.sendEmailConfirmation( - foundInvitedUser.email, - rawToken, - null, - ValidationHelper.resolveEmailVerificationLinkBase(inputData.emailVerificationLinkBase), - ); - } else { - // Plan 15 Phase 6 (rev 5): self-hosted sends no email — the admin takes the full - // confirmation link from the server logs and hands it to the user. + // Custom domains retired (plan 46): links are built on the default domain. + const confirmationResult = await this.emailService.sendEmailConfirmation( + foundInvitedUser.email, + rawToken, + null, + ValidationHelper.resolveEmailVerificationLinkBase(inputData.emailVerificationLinkBase), + ); + if (!confirmationResult && !isSaaS()) { + // Self-hosted without a working SMTP server: the admin takes the full confirmation + // link from the server logs and hands it to the user. this.logger.printTechString( `Email confirmation link: ${Constants.APP_DOMAIN_ADDRESS}/external/user/email/verify/${rawToken}`, ); @@ -150,18 +149,17 @@ export class InviteUserInCompanyAndConnectionGroupUseCase return invitationRO; } - if (isSaaS()) { - await this.emailService.sendInvitationToCompany( - invitedUserEmail, - rawToken, - companyId, - foundCompany.name, - null, - ValidationHelper.resolveEmailVerificationLinkBase(inputData.inviteLinkBase), - ); - } else { - // Plan 15 Phase 6 (rev 5): self-hosted sends no email — the admin takes the full - // invitation link from the server logs and hands it to the invited user. + const invitationResult = await this.emailService.sendInvitationToCompany( + invitedUserEmail, + rawToken, + companyId, + foundCompany.name, + null, + ValidationHelper.resolveEmailVerificationLinkBase(inputData.inviteLinkBase), + ); + if (!invitationResult && !isSaaS()) { + // Self-hosted without a working SMTP server: the admin takes the full invitation link + // from the server logs and hands it to the invited user. this.logger.printTechString( `Invitation link: ${Constants.APP_DOMAIN_ADDRESS}/company/${companyId}/verify/${rawToken}/`, ); diff --git a/backend/src/entities/email/email-config/email-config.service.ts b/backend/src/entities/email/email-config/email-config.service.ts index 5bfbf2131..02d02d7a7 100644 --- a/backend/src/entities/email/email-config/email-config.service.ts +++ b/backend/src/entities/email/email-config/email-config.service.ts @@ -4,6 +4,11 @@ import { IEmailConfig, IEmailConfigService } from './email-config.interface.js'; @Injectable() export class EmailConfigService implements IEmailConfigService { + public isConfigured(): boolean { + const { configString, host } = appConfig.email; + return Boolean(configString || host); + } + public getEmailServiceConfig(): IEmailConfig | string { const { configString, host, port, username, password, nonSecure } = appConfig.email; if (configString) { diff --git a/backend/src/entities/email/email/email.service.ts b/backend/src/entities/email/email/email.service.ts index 16c8e9cb8..cd01f8529 100644 --- a/backend/src/entities/email/email/email.service.ts +++ b/backend/src/entities/email/email/email.service.ts @@ -1,21 +1,29 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import * as Sentry from '@sentry/node'; import Mail from 'nodemailer/lib/mailer/index.js'; import SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'; +import * as nunjucks from 'nunjucks'; import PQueue from 'p-queue'; +import { BaseType } from '../../../common/data-injection.tokens.js'; import { TableActionEventEnum } from '../../../enums/table-action-event-enum.js'; import { isSaaS } from '../../../helpers/app/is-saas.js'; import { isTest } from '../../../helpers/app/is-test.js'; import { Constants } from '../../../helpers/constants/constants.js'; import { getErrorMessage } from '../../../helpers/get-error-message.js'; import { SaasEmailGatewayService } from '../../../microservices/gateways/saas-gateway.ts/saas-email-gateway.service.js'; +import { appConfig } from '../../../shared/config/app-config.js'; import { WinstonLogger } from '../../logging/winston-logger.js'; import { UserInfoMessageData } from '../../table-actions/table-actions-module/table-action-activation.service.js'; +import { EmailConfigService } from '../email-config/email-config.service.js'; import { EmailLetter } from '../email-messages/email-message.js'; +import { EMAIL_TEXT } from '../email-text/email-text.js'; import { EmailTransporterService } from '../transporter/email-transporter-service.js'; +import { escapeHtml } from '../utils/escape-html.util.js'; import { EmailGenerator } from './email.generator.js'; import { IMessage } from './email.interface.js'; +const SMTP_SEND_TIMEOUT_MS = 4000; + export interface ICronMessagingResults { email: string; messageId?: string; @@ -51,32 +59,23 @@ export function mapReminderOutcomeToCronResult(email: string, outcome: EmailDisp return base; } -// Plan 15 Phase 3: the core composes no letters and transports no email in any mode. // Every public send* method builds the letter parameters (including the legacy link // computation with linkBase/customCompanyDomain handling) and hands them to the -// `dispatchEmail` seam, which fires the saas-side composer webhook in SaaS mode and -// suppresses the send entirely self-hosted (rev-5 decision: self-hosted sends NOTHING). -// The transporter/nunjucks path below the seam (`sendEmailToUser`/`sendMail`) is dead -// code kept only until the Phase 7 deletion. +// `dispatchEmail` seam. In SaaS mode the seam fires the saas-side composer webhook; +// self-hosted it composes the letter locally (EMAIL_TEXT + nunjucks templates) and sends +// it through the instance's own SMTP server (EMAIL_CONFIG_STRING / EMAIL_SERVICE_*), or +// suppresses it when no SMTP server is configured. @Injectable() export class EmailService { constructor( private readonly emailTransporterService: EmailTransporterService, private readonly saasEmailGatewayService: SaasEmailGatewayService, private readonly logger: WinstonLogger, + private readonly emailConfigService: EmailConfigService, + @Inject(BaseType.NUNJUCKS) + private readonly nunjucksEnv: nunjucks.Environment, ) {} - // Dead code since plan 15 Phase 3 (kept for Phase 7 deletion): nothing routes letters - // through the local transporter anymore. - public async sendEmailToUser(letterContent: IMessage): Promise { - if (isTest()) return null; - const mailResult = await this.sendEmailWithTimeout(letterContent); - if (mailResult) { - return mailResult; - } - return null; - } - public async sendEmailActionToUser( userEmail: string, userInfo: UserInfoMessageData, @@ -205,8 +204,6 @@ export class EmailService { return await this.dispatchEmail('password_reset_request', email, { link }); } - // Dead code since plan 15 Phase 3 (kept for Phase 7 deletion), together with the - // transporter/template-engine machinery it drives. public async sendMail(letterContent: IMessage): Promise { const testEmail = new EmailLetter({ from: letterContent.from, @@ -220,11 +217,13 @@ export class EmailService { return await this.emailTransporterService.transportEmail(emailMessage); } - // Plan 15 Phase 3 seam — the single gate every outgoing letter passes through: - // - test mode -> no-op (unchanged semantics); - // - SaaS mode -> saas-side composer webhook, mapped onto a SentMessageInfo-compatible - // object (a failure never fails the parent operation); - // - self-hosted -> suppressed entirely (plan 15 rev 5: self-hosted sends NOTHING, ever). + // The single gate every outgoing letter passes through: + // - test mode -> no-op (unchanged semantics); + // - SaaS mode -> saas-side composer webhook, mapped onto a SentMessageInfo-compatible + // object (a failure never fails the parent operation); + // - self-hosted -> composed locally and sent through the configured SMTP server; suppressed + // when none is configured. The reminder is SaaS onboarding copy and never + // leaves a self-hosted instance. // Transactional callers use this null-contract wrapper; the reminder cron uses // dispatchEmailWithOutcome so the failure reason survives into its Slack report. private async dispatchEmail( @@ -245,8 +244,7 @@ export class EmailService { return { ok: false, reason: 'suppressed: test env' }; } if (!isSaaS()) { - this.logger.debug(`email suppressed (self-hosted): ${type}`); - return { ok: false, reason: 'suppressed: not SaaS' }; + return await this.dispatchSelfHostedEmail(type, to, params); } const webhookOutcome = await this.saasEmailGatewayService.sendEmail(type, to, params); if (webhookOutcome.ok === false) { @@ -264,20 +262,175 @@ export class EmailService { }; } - // Dead code since plan 15 Phase 3 (kept for Phase 7 deletion). - private async sendEmailWithTimeout(letterContent: IMessage): Promise { - return new Promise(async (resolve) => { - setTimeout(() => { - resolve(null); - }, 4000); - try { - const mailResult = await this.sendMail(letterContent); - resolve(mailResult); - } catch (e) { - Sentry.captureException(e); - console.error(e); - resolve(null); + private async dispatchSelfHostedEmail( + type: string, + to: string, + params: Record, + ): Promise { + if (type === 'reminder') { + return { ok: false, reason: 'suppressed: reminder is SaaS-only' }; + } + if (!this.emailConfigService.isConfigured()) { + this.logger.warn( + `Email "${type}" to ${to} not sent: no SMTP server configured (set EMAIL_CONFIG_STRING or EMAIL_SERVICE_HOST)`, + ); + return { ok: false, reason: 'suppressed: SMTP not configured' }; + } + let letter: IMessage; + try { + letter = this.composeLocalLetter(type, to, params); + } catch (error) { + this.logger.error(`Failed to compose email "${type}" to ${to}: ${getErrorMessage(error)}`); + Sentry.captureException(error); + return { ok: false, reason: `compose failed: ${getErrorMessage(error)}` }; + } + const outcome = await this.sendEmailWithTimeout(letter); + if (outcome.ok === false) { + this.logger.error(`Failed to send email "${type}" to ${to} via SMTP: ${outcome.reason}`); + } + return outcome; + } + + // Self-hosted counterpart of the saas-side composer: same letter types and params as the + // webhook payload (see the public send* methods above). + private composeLocalLetter(type: string, to: string, params: Record): IMessage { + const from = appConfig.email.from; + const currentYear = new Date().getFullYear(); + switch (type) { + case 'table_action': { + const userInfo = params.userInfo as UserInfoMessageData; + const tableName = params.tableName as string; + const primaryKeyValuesArray = params.primaryKeyValuesArray as Array>; + const action = this.describeTableAction(params.triggerOperation as TableActionEventEnum); + const textContent = EMAIL_TEXT.ACTION_EMAIL.EMAIL_TEXT(userInfo, action, tableName, primaryKeyValuesArray); + return { + from, + to, + subject: EMAIL_TEXT.ACTION_EMAIL.EMAIL_SUBJECT, + text: textContent, + html: this.nunjucksEnv.render('action-email-activation.njk', { + userInfo, + triggerOperation: params.triggerOperation, + tableName, + action, + primaryKeysValuesStr: JSON.stringify(primaryKeyValuesArray), + currentYear, + textContent, + primaryKeyValuesArray, + }), + }; + } + case 'company_2fa_enabled': { + const companyName = params.companyName as string; + return { + from, + to, + subject: EMAIL_TEXT.COMPANY_2FA_ENABLED.EMAIL_SUBJECT, + text: EMAIL_TEXT.COMPANY_2FA_ENABLED.EMAIL_TEXT(companyName), + html: this.nunjucksEnv.render('company-2fa-enabled-notification.njk', { companyName }), + }; + } + case 'group_invite': { + const groupTitle = params.groupTitle as string; + return { + from, + to, + subject: EMAIL_TEXT.INVITE_IN_GROUP.EMAIL_SUBJECT, + text: EMAIL_TEXT.INVITE_IN_GROUP.EMAIL_TEXT(groupTitle), + html: this.nunjucksEnv.render('invite-in-group-notification.njk', { groupTitle, currentYear }), + }; + } + case 'company_invite': { + const link = params.link as string; + const rawCompanyName = params.companyName as string | null; + const companyName = rawCompanyName ? ` "${rawCompanyName}" ` : ' '; + return { + from, + to, + subject: EMAIL_TEXT.INVITE_IN_COMPANY.EMAIL_SUBJECT, + text: EMAIL_TEXT.INVITE_IN_COMPANY.EMAIL_TEXT(link, escapeHtml(companyName)), + html: this.nunjucksEnv.render('invite-in-company-notification.njk', { + linkToAccept: link, + companyName, + currentYear, + }), + }; + } + case 'email_confirmation': { + const link = params.link as string; + return { + from, + to, + subject: EMAIL_TEXT.CONFIRM_EMAIL.EMAIL_SUBJECT, + text: EMAIL_TEXT.CONFIRM_EMAIL.EMAIL_TEXT(link), + html: this.nunjucksEnv.render('confirm-email-notification.njk', { linkToConfirm: link, currentYear }), + }; } + case 'email_changed': + return { + from, + to, + subject: EMAIL_TEXT.CHANGED_EMAIL.EMAIL_SUBJECT, + text: EMAIL_TEXT.CHANGED_EMAIL.EMAIL_TEXT, + html: this.nunjucksEnv.render('changed-email-notification.njk', { currentYear }), + }; + case 'email_change_request': { + const linkToConfirm = params.link as string; + return { + from, + to, + subject: EMAIL_TEXT.CHANGE_EMAIL_REQUEST.EMAIL_SUBJECT, + text: EMAIL_TEXT.CHANGE_EMAIL_REQUEST.EMAIL_TEXT(linkToConfirm), + html: this.nunjucksEnv.render('change-email-request-notification.njk', { linkToConfirm, currentYear }), + }; + } + case 'password_reset_request': { + const linkToConfirm = params.link as string; + return { + from, + to, + subject: EMAIL_TEXT.RESET_PASSWORD_REQUEST.EMAIL_SUBJECT, + text: EMAIL_TEXT.RESET_PASSWORD_REQUEST.EMAIL_TEXT(linkToConfirm), + html: this.nunjucksEnv.render('reset-password-request-notification.njk', { linkToConfirm, currentYear }), + }; + } + default: + throw new Error(`unknown email type "${type}"`); + } + } + + private describeTableAction(triggerOperation: TableActionEventEnum): string { + switch (triggerOperation) { + case TableActionEventEnum.ADD_ROW: + return 'added a row'; + case TableActionEventEnum.UPDATE_ROW: + return 'updated a row'; + case TableActionEventEnum.DELETE_ROW: + return 'deleted a row'; + default: + return 'performed an action'; + } + } + + private async sendEmailWithTimeout(letterContent: IMessage): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout( + () => resolve({ ok: false, reason: `timed out after ${SMTP_SEND_TIMEOUT_MS}ms` }), + SMTP_SEND_TIMEOUT_MS, + ); }); + const send = this.sendMail(letterContent).then( + (info): EmailDispatchOutcome => ({ ok: true, info }), + (error: unknown): EmailDispatchOutcome => { + Sentry.captureException(error); + return { ok: false, reason: getErrorMessage(error) }; + }, + ); + try { + return await Promise.race([send, timeout]); + } finally { + clearTimeout(timer); + } } } diff --git a/backend/test/ava-tests/unit-tests/email-dispatch-outcome.test.ts b/backend/test/ava-tests/unit-tests/email-dispatch-outcome.test.ts index ca0c43473..ee7e49095 100644 --- a/backend/test/ava-tests/unit-tests/email-dispatch-outcome.test.ts +++ b/backend/test/ava-tests/unit-tests/email-dispatch-outcome.test.ts @@ -1,10 +1,12 @@ import test from 'ava'; import SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'; +import type * as nunjucks from 'nunjucks'; import { EmailDispatchOutcome, EmailService, mapReminderOutcomeToCronResult, } from '../../../src/entities/email/email/email.service.js'; +import { EmailConfigService } from '../../../src/entities/email/email-config/email-config.service.js'; import { EmailTransporterService } from '../../../src/entities/email/transporter/email-transporter-service.js'; import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; import { SaasEmailGatewayService } from '../../../src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.js'; @@ -50,6 +52,8 @@ test('sendRemindersToUsers in test env -> every result names the suppression', a {} as unknown as EmailTransporterService, {} as unknown as SaasEmailGatewayService, { error: () => {}, debug: () => {} } as unknown as WinstonLogger, + {} as unknown as EmailConfigService, + {} as unknown as nunjucks.Environment, ); const results = await emailService.sendRemindersToUsers(['a@x.com', 'b@y.com']); t.deepEqual(results, [