From aac95c2b2ebcd3e538eac632effcdc5339473b82 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Wed, 26 Mar 2025 05:48:47 -0700 Subject: [PATCH] Add `SMTP_SERVICE` environment variable for well-known services (#8781) * Add SMTP_SERVICE environment variable for well-known services * Fix PR #8777: Restore code in teams.ts and users.ts * The rest of the work * fix validation --------- Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com> Co-authored-by: Tom Moor --- .env.sample | 6 +----- app.json | 4 ++++ plugins/email/server/index.ts | 2 +- server/emails/mailer.tsx | 13 ++++++++++++- server/env.ts | 16 +++++++++++++--- server/models/Team.ts | 2 +- server/routes/api/teams/teams.ts | 5 ++--- server/routes/api/users/users.ts | 9 ++++----- server/utils/validators.ts | 28 ++++++++++++++++++++++++++++ 9 files changed, 66 insertions(+), 19 deletions(-) diff --git a/.env.sample b/.env.sample index 8f2e4910e8..6478fe6fe6 100644 --- a/.env.sample +++ b/.env.sample @@ -205,14 +205,10 @@ SENTRY_TUNNEL= # To support sending outgoing transactional emails such as "document updated" or # "you've been invited" you'll need to provide authentication for an SMTP server -SMTP_HOST= -SMTP_PORT= +SMTP_SERVICE= SMTP_USERNAME= SMTP_PASSWORD= SMTP_FROM_EMAIL= -SMTP_REPLY_EMAIL= -SMTP_TLS_CIPHERS= -SMTP_SECURE=true # The default interface language. See translate.getoutline.com for a list of # available language codes and their rough percentage translated. diff --git a/app.json b/app.json index 32783bae09..447a183d29 100644 --- a/app.json +++ b/app.json @@ -171,6 +171,10 @@ "description": "smtp.example.com (optional)", "required": false }, + "SMTP_SERVICE": { + "description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')", + "required": false + }, "SMTP_PORT": { "description": "1234 (optional)", "required": false diff --git a/plugins/email/server/index.ts b/plugins/email/server/index.ts index 87a353f2cd..6e01e3bfc0 100644 --- a/plugins/email/server/index.ts +++ b/plugins/email/server/index.ts @@ -3,7 +3,7 @@ import { Hook, PluginManager } from "@server/utils/PluginManager"; import config from "../plugin.json"; import router from "./auth/email"; -const enabled = !!env.SMTP_HOST || env.isDevelopment; +const enabled = !!(env.SMTP_HOST || env.SMTP_SERVICE) || env.isDevelopment; if (enabled) { PluginManager.add({ diff --git a/server/emails/mailer.tsx b/server/emails/mailer.tsx index 8ea55f9596..80764e6473 100644 --- a/server/emails/mailer.tsx +++ b/server/emails/mailer.tsx @@ -33,7 +33,7 @@ export class Mailer { transporter: Transporter | undefined; constructor() { - if (env.SMTP_HOST) { + if (env.SMTP_HOST || env.SMTP_SERVICE) { this.transporter = nodemailer.createTransport(this.getOptions()); } if (useTestEmailService) { @@ -198,6 +198,17 @@ export class Mailer { }; private getOptions(): SMTPTransport.Options { + // nodemailer will use the service config to determine host/port + if (env.SMTP_SERVICE) { + return { + service: env.SMTP_SERVICE, + auth: { + user: env.SMTP_USERNAME, + pass: env.SMTP_PASSWORD, + }, + }; + } + return { name: env.SMTP_NAME, host: env.SMTP_HOST, diff --git a/server/env.ts b/server/env.ts index 5b420f2e11..34f926029f 100644 --- a/server/env.ts +++ b/server/env.ts @@ -15,7 +15,7 @@ import { } from "class-validator"; import uniq from "lodash/uniq"; import { languages } from "@shared/i18n"; -import { CannotUseWithout } from "@server/utils/validators"; +import { CannotUseWith, CannotUseWithout } from "@server/utils/validators"; import Deprecated from "./models/decorators/Deprecated"; import { getArg } from "./utils/args"; import { Public, PublicEnvironmentRegister } from "./utils/decorators/Public"; @@ -291,10 +291,19 @@ export class Environment { /** * The host of your SMTP server for enabling emails. */ - public SMTP_HOST = environment.SMTP_HOST; + @CannotUseWith("SMTP_SERVICE") + public SMTP_HOST = this.toOptionalString(environment.SMTP_HOST); + + /** + * The service name of a well-known SMTP service for nodemailer. + * See https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/ + */ + @CannotUseWith("SMTP_HOST") + public SMTP_SERVICE = this.toOptionalString(environment.SMTP_SERVICE); @Public - public EMAIL_ENABLED = !!this.SMTP_HOST || this.isDevelopment; + public EMAIL_ENABLED = + !!(this.SMTP_HOST || this.SMTP_SERVICE) || this.isDevelopment; /** * Optional hostname of the client, used for identifying to the server @@ -307,6 +316,7 @@ export class Environment { */ @IsNumber() @IsOptional() + @CannotUseWith("SMTP_SERVICE") public SMTP_PORT = this.toOptionalNumber(environment.SMTP_PORT); /** diff --git a/server/models/Team.ts b/server/models/Team.ts index 2bf5d4c738..39fc72f381 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -190,7 +190,7 @@ class Team extends ParanoidModel< * @return {boolean} Whether to show email login options */ get emailSigninEnabled(): boolean { - return this.guestSignin && (!!env.SMTP_HOST || env.isDevelopment); + return this.guestSignin && env.EMAIL_ENABLED; } get url() { diff --git a/server/routes/api/teams/teams.ts b/server/routes/api/teams/teams.ts index 563c3d053f..dd75a91585 100644 --- a/server/routes/api/teams/teams.ts +++ b/server/routes/api/teams/teams.ts @@ -19,7 +19,6 @@ import { safeEqual } from "@server/utils/crypto"; import * as T from "./schema"; const router = new Router(); -const emailEnabled = !!(env.SMTP_HOST || env.isDevelopment); const handleTeamUpdate = async (ctx: APIContext) => { const { transaction } = ctx.state; @@ -68,7 +67,7 @@ router.post( rateLimiter(RateLimiterStrategy.FivePerHour), auth(), async (ctx: APIContext) => { - if (!emailEnabled) { + if (!env.EMAIL_ENABLED) { throw ValidationError("Email support is not setup for this instance"); } @@ -101,7 +100,7 @@ router.post( authorize(user, "delete", team); - if (emailEnabled) { + if (env.EMAIL_ENABLED) { const deleteConfirmationCode = team.getDeleteConfirmationCode(user); if (!safeEqual(code, deleteConfirmationCode)) { diff --git a/server/routes/api/users/users.ts b/server/routes/api/users/users.ts index c6c42d8537..e6ca3ff4ee 100644 --- a/server/routes/api/users/users.ts +++ b/server/routes/api/users/users.ts @@ -30,7 +30,6 @@ import pagination from "../middlewares/pagination"; import * as T from "./schema"; const router = new Router(); -const emailEnabled = !!(env.SMTP_HOST || env.isDevelopment); router.post( "users.list", @@ -210,7 +209,7 @@ router.post( auth(), validate(T.UsersUpdateEmailSchema), async (ctx: APIContext) => { - if (!emailEnabled) { + if (!env.EMAIL_ENABLED) { throw ValidationError("Email support is not setup for this instance"); } @@ -252,7 +251,7 @@ router.get( transaction(), validate(T.UsersUpdateEmailConfirmSchema), async (ctx: APIContext) => { - if (!emailEnabled) { + if (!env.EMAIL_ENABLED) { throw ValidationError("Email support is not setup for this instance"); } @@ -626,7 +625,7 @@ router.post( rateLimiter(RateLimiterStrategy.FivePerHour), auth(), async (ctx: APIContext) => { - if (!emailEnabled) { + if (!env.EMAIL_ENABLED) { throw ValidationError("Email support is not setup for this instance"); } @@ -671,7 +670,7 @@ router.post( // If we're attempting to delete our own account then a confirmation code // is required. This acts as CSRF protection. - if ((!id || id === actor.id) && emailEnabled) { + if ((!id || id === actor.id) && env.EMAIL_ENABLED) { const deleteConfirmationCode = user.deleteConfirmationCode; if (!safeEqual(code, deleteConfirmationCode)) { diff --git a/server/utils/validators.ts b/server/utils/validators.ts index 7b1712c4e8..382a8caebb 100644 --- a/server/utils/validators.ts +++ b/server/utils/validators.ts @@ -29,3 +29,31 @@ export function CannotUseWithout( }); }; } + +export function CannotUseWith( + property: string, + validationOptions?: ValidationOptions +) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: "cannotUseWith", + target: object.constructor, + propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: T, args: ValidationArguments) { + if (value === undefined) { + return true; + } + const obj = args.object as unknown as T; + const forbidden = args.constraints[0] as keyof T; + return obj[forbidden] === undefined; + }, + defaultMessage(args: ValidationArguments) { + return `${propertyName} cannot be used with ${args.constraints[0]}.`; + }, + }, + }); + }; +}