diff --git a/apps/backend/src/aws/s3/README.md b/apps/backend/src/aws/s3/README.md index ad89861ff..e3ab1d252 100644 --- a/apps/backend/src/aws/s3/README.md +++ b/apps/backend/src/aws/s3/README.md @@ -7,46 +7,59 @@ A global NestJS module providing S3 file upload and retrieval via `AWSS3Service` Add these variables to `.env` and `example.env`: ``` +S3_ENABLED=true AWS_REGION=us-east-2 -AWS_ACCESS_KEY=your-access-key-id -AWS_SECRET_KEY=your-secret-access-key +AWS_ACCESS_KEY_ID=your-access-key-id +AWS_SECRET_ACCESS_KEY=your-secret-access-key # one entry per bucket — see "Adding a New Bucket" below AWS_MY_BUCKET_NAME=my-bucket-name ``` +`AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` are shared across every AWS module in this app (S3, SES, …) and use the AWS SDK's standard names — define them once and don't rename them per-service. Only `S3_ENABLED` and the bucket vars are S3-specific. + Import `AWSS3Module` once in your root `AppModule`. Because the module is `@Global()`, `AWSS3Service` is injectable in all feature modules without additional imports. -The service throws at startup if any bucket env var is unset, so misconfiguration is caught immediately rather than at runtime. +Every bucket env var **must** follow the `AWS__BUCKET_NAME` format, where `` is the `S3Buckets` enum member verbatim. `AWSS3Service` builds its bucket lookup from that convention, so a name that doesn't match will never be found. + +## `S3_ENABLED` + +`S3_ENABLED` gates the startup config check.SES_ENABLED When it is anything other than `'true'` (case-insensitive), `AWSS3Module.onModuleInit` skips validation entirely and logs `S3 disabled: …`, so a project that doesn't use S3 boots without AWS config and without a warning on every startup. + +When it is `'true'`, `onModuleInit` logs a warning listing any env var in its `REQUIRED_ENV_VARS_WHEN_ENABLED` list that is unset or blank. + +The flag only controls that check — it does not disable `AWSS3Service`. Calls to `upload()` / `getImageData()` still reach AWS and fail there if credentials are absent. ## Adding a New Bucket -**1. Add an env var** in `.env` and `example.env`: +**1. Add an env var** in `.env` and `example.env`, named `AWS__BUCKET_NAME`: ``` AWS_MY_BUCKET_NAME=my-bucket-name ``` -**2. Add an entry to the `s3Buckets` enum** (`types/s3Buckets.ts`): +**2. Add an entry to the `S3Buckets` enum** (`types/s3Buckets.ts`), matching the middle of that env var name: ```typescript -export enum s3Buckets { +export enum S3Buckets { MY_BUCKET = 'MY_BUCKET', } ``` -**3. Add a mapping to `mapBucket`** (`aws-s3.service.ts`): +**3. Add the env var name to `REQUIRED_ENV_VARS_WHEN_ENABLED`** (`aws-s3.module.ts`), so a missing value is reported at startup when `S3_ENABLED=true`: ```typescript -const bucketNames: Record = { - [s3Buckets.MY_BUCKET]: process.env.AWS_MY_BUCKET_NAME, -}; +const REQUIRED_ENV_VARS_WHEN_ENABLED = [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_MY_BUCKET_NAME', +] as const; ``` -Because `mapBucket` uses a `Record`, TypeScript will produce a compile error if you add an enum entry without adding the corresponding mapping — catching missed steps at build time. +No change to `aws-s3.service.ts` is needed: its constructor resolves `process.env['AWS_' + bucket + '_BUCKET_NAME']` for every member of `S3Buckets`. A bucket whose env var is missing resolves to `''`, and any `upload()` to it throws `Missing required environment variable for S3 bucket: MY_BUCKET`. ## Required IAM Permissions -The credentials supplied via `AWS_ACCESS_KEY` / `AWS_SECRET_KEY` must have: +The credentials supplied via `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` must have: | Permission | Required by | |---|---| diff --git a/apps/backend/src/aws/s3/aws-s3.module.spec.ts b/apps/backend/src/aws/s3/aws-s3.module.spec.ts new file mode 100644 index 000000000..6e09adc79 --- /dev/null +++ b/apps/backend/src/aws/s3/aws-s3.module.spec.ts @@ -0,0 +1,120 @@ +import { AWSS3Module } from './aws-s3.module'; + +describe('AWSS3Module', () => { + const ENV_VARS = [ + 'S3_ENABLED', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + ] as const; + + const REQUIRED_WHEN_ENABLED = [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + ] as const; + + const originalEnv: Record = {}; + let module: AWSS3Module; + let warnSpy: jest.SpyInstance; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + for (const name of ENV_VARS) { + originalEnv[name] = process.env[name]; + } + + // Default to a fully-configured, enabled setup; individual tests override. + process.env.S3_ENABLED = 'true'; + process.env.AWS_ACCESS_KEY_ID = 'test-access-key'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key'; + + module = new AWSS3Module(); + + warnSpy = jest + .spyOn(module['logger'], 'warn') + .mockImplementation(() => undefined); + logSpy = jest + .spyOn(module['logger'], 'log') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + + for (const name of ENV_VARS) { + if (originalEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalEnv[name]; + } + } + }); + + describe('onModuleInit', () => { + it('logs and does not warn when all required env vars are set and enabled', () => { + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('S3 enabled'); + }); + + it('does not warn when disabled, even if required vars are missing', () => { + process.env.S3_ENABLED = 'false'; + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('S3 disabled'), + ); + }); + + it('does not warn when S3_ENABLED is unset', () => { + delete process.env.S3_ENABLED; + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('S3 disabled'), + ); + }); + + it.each(REQUIRED_WHEN_ENABLED)( + 'warns when enabled and %s is missing', + (name) => { + delete process.env[name]; + + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(name)); + }, + ); + + it('warns when enabled and a required var is empty/whitespace-only', () => { + process.env.AWS_ACCESS_KEY_ID = ' '; + + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_ACCESS_KEY_ID'), + ); + }); + + it('lists every missing env var in a single warning', () => { + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + + module.onModuleInit(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY'), + ); + }); + }); +}); diff --git a/apps/backend/src/aws/s3/aws-s3.module.ts b/apps/backend/src/aws/s3/aws-s3.module.ts index a3a6a2482..907ef2ea8 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.ts @@ -1,9 +1,43 @@ -import { Global, Module } from '@nestjs/common'; +import { Global, Logger, Module, OnModuleInit } from '@nestjs/common'; import { AWSS3Service } from './aws-s3.service'; +import { getMissingEnvVars } from '../../utils/env'; + +// Env vars required only when S3 is enabled (S3_ENABLED === 'true'). +// The credentials are the shared AWS ones (also used by the SES module). +// Add one entry per bucket here: +const REQUIRED_ENV_VARS_WHEN_ENABLED = [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', +] as const; @Global() @Module({ providers: [AWSS3Service], exports: [AWSS3Service], }) -export class AWSS3Module {} +export class AWSS3Module implements OnModuleInit { + private readonly logger = new Logger(AWSS3Module.name); + + onModuleInit(): void { + // S3 is disabled: skip validation so teams not using S3 can boot without + // any AWS config (and without a warning on every startup). + if (process.env.S3_ENABLED?.toLowerCase() !== 'true') { + this.logger.log( + 'S3 disabled: S3_ENABLED is not "true". Uploads and downloads will fail.', + ); + return; + } + + const missing = getMissingEnvVars(REQUIRED_ENV_VARS_WHEN_ENABLED); + + if (missing.length > 0) { + this.logger.warn( + `S3 enabled but not fully configured: missing env vars (${missing.join( + ', ', + )}). S3 uploads and downloads will fail.`, + ); + } else { + this.logger.log('S3 enabled'); + } + } +} diff --git a/apps/backend/src/aws/s3/aws-s3.service.spec.ts b/apps/backend/src/aws/s3/aws-s3.service.spec.ts index 0e7659d08..332be191c 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.spec.ts @@ -22,33 +22,19 @@ describe('AWSS3Service', () => { let service: AWSS3Service; beforeEach(() => { - process.env.AWS_ACCESS_KEY = 'test-access-key'; - process.env.AWS_SECRET_KEY = 'test-secret-key'; + process.env.AWS_ACCESS_KEY_ID = 'test-access-key'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key'; process.env.AWS_REGION = region; process.env.AWS_TEST_BUCKET_NAME = testBucket; s3Mock.reset(); service = new AWSS3Service(); + // The constructor resolves AWS__BUCKET_NAME for every member of + // S3Buckets, but the scaffold enum is empty — inject the sentinel by hand. service['bucketNames'][testBucketEnum] = testBucket; }); - describe('constructor', () => { - it('should throw if AWS_ACCESS_KEY is missing', () => { - delete process.env.AWS_ACCESS_KEY; - expect(() => new AWSS3Service()).toThrow( - 'Missing required environment variable: AWS_ACCESS_KEY', - ); - }); - - it('should throw if AWS_SECRET_KEY is missing', () => { - delete process.env.AWS_SECRET_KEY; - expect(() => new AWSS3Service()).toThrow( - 'Missing required environment variable: AWS_SECRET_KEY', - ); - }); - }); - describe('upload', () => { const validInput: S3UploadInput = { fileBuffer: Buffer.from('test'), @@ -174,15 +160,19 @@ describe('AWSS3Service', () => { } as unknown as GetObjectCommandOutput['Body'], }); - const result = await service.getImageData('photo.jpg', testBucket); + const result = await service.getImageData('photo.jpg', testBucketEnum); + const commandCall = s3Mock.call(0); + expect((commandCall.args[0] as GetObjectCommand).input.Bucket).toBe( + testBucket, + ); expect(result).toBe(imageBytes); }); it('should return null when response body is missing', async () => { s3Mock.on(GetObjectCommand).resolves({ Body: undefined }); - const result = await service.getImageData('photo.jpg', testBucket); + const result = await service.getImageData('photo.jpg', testBucketEnum); expect(result).toBeNull(); }); @@ -192,7 +182,7 @@ describe('AWSS3Service', () => { .on(GetObjectCommand) .rejects(new NoSuchKey({ message: 'Not found', $metadata: {} })); - const result = await service.getImageData('missing.jpg', testBucket); + const result = await service.getImageData('missing.jpg', testBucketEnum); expect(result).toBeNull(); }); @@ -213,11 +203,11 @@ describe('AWSS3Service', () => { .spyOn(service['logger'], 'error') .mockImplementation(() => undefined); - const result = await service.getImageData('photo.jpg', testBucket); + const result = await service.getImageData('photo.jpg', testBucketEnum); expect(result).toBeNull(); expect(loggerErrorSpy).toHaveBeenCalledWith( - `S3 error retrieving object: key=photo.jpg, bucket=${testBucket}, error=Access denied`, + `S3 error retrieving object: key=photo.jpg, bucket=${testBucketEnum}, error=Access denied`, ); }); @@ -225,7 +215,7 @@ describe('AWSS3Service', () => { s3Mock.on(GetObjectCommand).rejects(new Error('network error')); await expect( - service.getImageData('photo.jpg', testBucket), + service.getImageData('photo.jpg', testBucketEnum), ).rejects.toThrow('network error'); }); }); diff --git a/apps/backend/src/aws/s3/aws-s3.service.ts b/apps/backend/src/aws/s3/aws-s3.service.ts index f4da46362..74795bef9 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.ts @@ -41,31 +41,23 @@ export class AWSS3Service { constructor() { this.region = process.env.AWS_REGION ?? 'us-east-2'; - // Add one entry per bucket in s3Buckets enum. - // Example: [s3Buckets.DOCUMENTS]: process.env.AWS_DOCUMENTS_BUCKET_NAME, + // Every bucket in the S3Buckets enum is read from an env var named AWS__BUCKET_NAME: + // - e.g. S3Buckets.DOCUMENTS reads AWS_DOCUMENTS_BUCKET_NAME. Add each of those names to REQUIRED_ENV_VARS_WHEN_ENABLED this.bucketNames = {} as Record; for (const bucket of Object.values(S3Buckets) as unknown as S3Buckets[]) { - if (!this.bucketNames[bucket]) { - throw new Error( - `Missing required environment variable for S3 bucket: ${bucket}`, - ); - } - } - - const accessKeyId = process.env.AWS_ACCESS_KEY; - const secretAccessKey = process.env.AWS_SECRET_KEY; - - if (!accessKeyId) { - throw new Error('Missing required environment variable: AWS_ACCESS_KEY'); - } - if (!secretAccessKey) { - throw new Error('Missing required environment variable: AWS_SECRET_KEY'); + this.bucketNames[bucket] = process.env[`AWS_${bucket}_BUCKET_NAME`] ?? ''; } + // AWS credentials are checked at module initialization (see AWSS3Module), + // which warns rather than throws. The ?? '' keeps the client constructible + // when they are absent; requests against it then fail at the AWS call. this.client = new S3Client({ region: this.region, - credentials: { accessKeyId, secretAccessKey }, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '', + }, }); } @@ -126,10 +118,13 @@ export class AWSS3Service { async getImageData( objectKey: string, - bucket: string, + bucket: S3Buckets, ): Promise { try { - const command = new GetObjectCommand({ Bucket: bucket, Key: objectKey }); + const command = new GetObjectCommand({ + Bucket: this.bucketNames[bucket], + Key: objectKey, + }); const response = await this.client.send(command); if (!response.Body) { return null; diff --git a/apps/backend/src/aws/ses/README.md b/apps/backend/src/aws/ses/README.md index 3841717d9..cd512b728 100644 --- a/apps/backend/src/aws/ses/README.md +++ b/apps/backend/src/aws/ses/README.md @@ -4,16 +4,16 @@ Thin wrapper around Amazon SES v2 for sending transactional emails (with optiona ## Injecting `EmailsService` -`EmailsModule` exports `EmailsService`, so any consuming module just needs to import `EmailsModule` and then inject `EmailsService` through the constructor. +`AWSSESModule` exports `EmailsService`, so any consuming module just needs to import `AWSSESModule` and then inject `EmailsService` through the constructor. -1. **Import `EmailsModule`** in the consuming module: +1. **Import `AWSSESModule`** in the consuming module: ```ts // users.module.ts - import { EmailsModule } from '../aws/ses/email.module'; + import { AWSSESModule } from '../aws/ses/email.module'; @Module({ - imports: [TypeOrmModule.forFeature([User]), EmailsModule], + imports: [TypeOrmModule.forFeature([User]), AWSSESModule], controllers: [UsersController], providers: [UsersService], }) @@ -58,9 +58,9 @@ To verify a sender: If you swap `AWS_SES_SENDER_EMAIL` later, the new address must be verified separately — verification is per-identity, not per-account. -## `SEND_AUTOMATED_EMAILS` flag +## `SES_ENABLED` flag A boolean env var (`'true'` to enable, anything else — including unset — to disable) that gates real SES dispatch. -- When `SEND_AUTOMATED_EMAILS === 'true'`: `sendEmail` runs DTO validation, then schedules the send through the rate limiter, then calls SES. Returns the `SendEmailCommandOutput` from SES (MessageId + metadata). `AWS_SES_SENDER_EMAIL` must be set at this point, or the send throws. -- When `SEND_AUTOMATED_EMAILS` is unset or any other value: `sendEmail` still runs DTO validation (so a bad payload still throws), then logs a warning (`SEND_AUTOMATED_EMAILS is not "true". Email not sent.`) and returns `void` without contacting SES. Neither `AWS_SES_SENDER_EMAIL` nor the AWS credentials need to be defined — teams not using SES can omit them entirely and the app still boots. +- When `SES_ENABLED === 'true'`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SES_SENDER_EMAIL` are checked at module initialization (`AWSSESModule.onModuleInit`), which logs a warning listing any that are missing and boots anyway — sends then fail at the SES call. In production a missing var almost certainly means missing secrets: raise that `logger.warn` to `logger.error`, or throw, so the app fails at startup instead. `sendEmail` runs DTO validation, schedules the send through the rate limiter, then calls SES. Returns the `SendEmailCommandOutput` from SES (MessageId + metadata). +- When `SES_ENABLED` is unset or any other value: `sendEmail` still runs DTO validation (so a bad payload still throws), then logs a warning (`SES_ENABLED is not "true". Email not sent.`) and returns `void` without contacting SES. Neither `AWS_SES_SENDER_EMAIL` nor the AWS credentials need to be defined — teams not using SES can omit them entirely and the app still boots. diff --git a/apps/backend/src/aws/ses/awsSes.wrapper.ts b/apps/backend/src/aws/ses/awsSes.wrapper.ts index c3893bbd3..bdee97d7a 100644 --- a/apps/backend/src/aws/ses/awsSes.wrapper.ts +++ b/apps/backend/src/aws/ses/awsSes.wrapper.ts @@ -30,8 +30,9 @@ export class AmazonSESWrapper { * or if SES rejects the send (bad recipient, throttling, unverified sender, quota exceeded). */ async sendEmail(dto: SendEmailDTO): Promise { - const senderEmail = process.env.AWS_SES_SENDER_EMAIL; - if (!senderEmail) throw new Error('AWS_SES_SENDER_EMAIL is not defined'); + // Checked at module initialization (see AWSSESModule) when SES is enabled; + // sendEmail is only ever reached when SES_ENABLED is 'true', so senderEmail is expected to be present here. + const senderEmail = process.env.AWS_SES_SENDER_EMAIL ?? ''; const mailOptions: Mail.Options = { from: senderEmail, diff --git a/apps/backend/src/aws/ses/awsSesClient.factory.ts b/apps/backend/src/aws/ses/awsSesClient.factory.ts index ea288070f..183a43451 100644 --- a/apps/backend/src/aws/ses/awsSesClient.factory.ts +++ b/apps/backend/src/aws/ses/awsSesClient.factory.ts @@ -11,22 +11,19 @@ export const AMAZON_SES_CLIENT = 'AMAZON_SES_CLIENT'; export const AmazonSESClientFactory: Provider = { provide: AMAZON_SES_CLIENT, useFactory: () => { - // Create dummy client that is never used when email sending is set to false - if (process.env.SEND_AUTOMATED_EMAILS !== 'true') { + // Create dummy client that is NOT used when email sending is unset or set to false. + if (process.env.SES_ENABLED?.toLowerCase() !== 'true') { return new SESv2Client({}); } - const region = process.env.AWS_REGION; - const accessKeyId = process.env.AWS_ACCESS_KEY_ID; - const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - - if (!region) throw new Error('AWS_REGION is not defined'); - if (!accessKeyId) throw new Error('AWS_ACCESS_KEY_ID is not defined'); - if (!secretAccessKey) - throw new Error('AWS_SECRET_ACCESS_KEY is not defined'); + // If email sending is enabled, AWSSESModule.onModuleInit() warns when these env vars are missing. + // The empty-string fallbacks keep the client constructible; sends against it fail at the SES call. return new SESv2Client({ - region, - credentials: { accessKeyId, secretAccessKey }, + region: process.env.AWS_REGION ?? '', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '', + }, }); }, }; diff --git a/apps/backend/src/aws/ses/email.module.spec.ts b/apps/backend/src/aws/ses/email.module.spec.ts new file mode 100644 index 000000000..570a76916 --- /dev/null +++ b/apps/backend/src/aws/ses/email.module.spec.ts @@ -0,0 +1,125 @@ +import { AWSSESModule } from './email.module'; + +describe('AWSSESModule', () => { + const ENV_VARS = [ + 'SES_ENABLED', + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SES_SENDER_EMAIL', + ] as const; + + const REQUIRED_WHEN_ENABLED = [ + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SES_SENDER_EMAIL', + ] as const; + + const originalEnv: Record = {}; + let module: AWSSESModule; + let warnSpy: jest.SpyInstance; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + for (const name of ENV_VARS) { + originalEnv[name] = process.env[name]; + } + + // Default to a fully-configured, enabled setup; individual tests override. + process.env.SES_ENABLED = 'true'; + process.env.AWS_REGION = 'us-east-2'; + process.env.AWS_ACCESS_KEY_ID = 'test-access-key-id'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-access-key'; + process.env.AWS_SES_SENDER_EMAIL = 'sender@example.com'; + + module = new AWSSESModule(); + + warnSpy = jest + .spyOn(module['logger'], 'warn') + .mockImplementation(() => undefined); + logSpy = jest + .spyOn(module['logger'], 'log') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + + for (const name of ENV_VARS) { + if (originalEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalEnv[name]; + } + } + }); + + describe('onModuleInit', () => { + it('logs and does not warn when all required env vars are set and enabled', () => { + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('SES enabled'); + }); + + it('does not warn when disabled, even if required vars are missing', () => { + process.env.SES_ENABLED = 'false'; + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('SES disabled'), + ); + }); + + it('does not warn when SES_ENABLED is unset', () => { + delete process.env.SES_ENABLED; + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('SES disabled'), + ); + }); + + it.each(REQUIRED_WHEN_ENABLED)( + 'warns when enabled and %s is missing', + (name) => { + delete process.env[name]; + + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(name)); + }, + ); + + it('warns when enabled and a required var is empty/whitespace-only', () => { + process.env.AWS_SES_SENDER_EMAIL = ' '; + + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_SES_SENDER_EMAIL'), + ); + }); + + it('lists every missing env var in a single warning', () => { + delete process.env.AWS_REGION; + delete process.env.AWS_SES_SENDER_EMAIL; + + module.onModuleInit(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_REGION, AWS_SES_SENDER_EMAIL'), + ); + }); + }); +}); diff --git a/apps/backend/src/aws/ses/email.module.ts b/apps/backend/src/aws/ses/email.module.ts index a6cd1bd12..48a7e9451 100644 --- a/apps/backend/src/aws/ses/email.module.ts +++ b/apps/backend/src/aws/ses/email.module.ts @@ -1,10 +1,46 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { EmailsService } from './email.service'; import { AmazonSESWrapper } from './awsSes.wrapper'; import { AmazonSESClientFactory } from './awsSesClient.factory'; +import { getMissingEnvVars } from '../../utils/env'; + +// Env vars required only when SES dispatch is enabled (SES_ENABLED === 'true'). +// AWS_REGION and the credentials are the shared AWS ones (also used by the S3 module); +// only AWS_SES_SENDER_EMAIL is specific to SES. +const REQUIRED_ENV_VARS_WHEN_ENABLED = [ + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SES_SENDER_EMAIL', +] as const; @Module({ providers: [AmazonSESWrapper, AmazonSESClientFactory, EmailsService], exports: [EmailsService], }) -export class EmailsModule {} +export class AWSSESModule implements OnModuleInit { + private readonly logger = new Logger(AWSSESModule.name); + + onModuleInit(): void { + // Email sending is disabled: skip validation so teams not using SES can + // boot without any AWS config. + if (process.env.SES_ENABLED?.toLowerCase() !== 'true') { + this.logger.log( + 'SES disabled: SES_ENABLED is not "true". No emails will be sent.', + ); + return; + } + + const missing = getMissingEnvVars(REQUIRED_ENV_VARS_WHEN_ENABLED); + + if (missing.length > 0) { + this.logger.warn( + `SES enabled but not fully configured: missing env vars (${missing.join( + ', ', + )}). Email sends will fail.`, + ); + } else { + this.logger.log('SES enabled'); + } + } +} diff --git a/apps/backend/src/aws/ses/email.service.spec.ts b/apps/backend/src/aws/ses/email.service.spec.ts index a094500d4..a7ebc0674 100644 --- a/apps/backend/src/aws/ses/email.service.spec.ts +++ b/apps/backend/src/aws/ses/email.service.spec.ts @@ -8,7 +8,7 @@ describe('EmailsService', () => { let service: EmailsService; let mockWrapper: { sendEmail: jest.Mock }; - const originalSendFlag = process.env.SEND_AUTOMATED_EMAILS; + const originalSendFlag = process.env.SES_ENABLED; const validDto: SendEmailDTO = { toEmail: 'recipient@example.com', @@ -36,15 +36,15 @@ describe('EmailsService', () => { afterEach(() => { if (originalSendFlag === undefined) { - delete process.env.SEND_AUTOMATED_EMAILS; + delete process.env.SES_ENABLED; } else { - process.env.SEND_AUTOMATED_EMAILS = originalSendFlag; + process.env.SES_ENABLED = originalSendFlag; } }); describe('sendEmail', () => { - it('does not call the wrapper when SEND_AUTOMATED_EMAILS is not "true"', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'false'; + it('does not call the wrapper when SES_ENABLED is not "true"', async () => { + process.env.SES_ENABLED = 'false'; const result = await service.sendEmail(validDto); @@ -53,7 +53,7 @@ describe('EmailsService', () => { }); it('rate-limits sends to roughly 14 per second', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; mockWrapper.sendEmail.mockResolvedValue(successOutput); const calls = 10; @@ -74,7 +74,7 @@ describe('EmailsService', () => { }); it('rejects without calling the wrapper when the DTO is invalid', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; const invalidDto: SendEmailDTO = { toEmail: 'not-a-real-email', @@ -87,7 +87,7 @@ describe('EmailsService', () => { }); it('returns the wrapper output when sending succeeds', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; mockWrapper.sendEmail.mockResolvedValue(successOutput); const result = await service.sendEmail(validDto); @@ -97,7 +97,7 @@ describe('EmailsService', () => { }); it('propagates errors thrown by the wrapper', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; mockWrapper.sendEmail.mockRejectedValue( new Error('SES rejected: throttled'), ); @@ -109,7 +109,7 @@ describe('EmailsService', () => { }); it('passes ccEmails and bccEmails through to the wrapper', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; mockWrapper.sendEmail.mockResolvedValue(successOutput); const dto: SendEmailDTO = { @@ -132,7 +132,7 @@ describe('EmailsService', () => { }); it('rejects when ccEmails contains an invalid address', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; const dto: SendEmailDTO = { toEmail: 'recipient@example.com', @@ -146,7 +146,7 @@ describe('EmailsService', () => { }); it('rejects when bccEmails contains an invalid address', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; const dto: SendEmailDTO = { toEmail: 'recipient@example.com', diff --git a/apps/backend/src/aws/ses/email.service.ts b/apps/backend/src/aws/ses/email.service.ts index 6301e1744..cb42cc4eb 100644 --- a/apps/backend/src/aws/ses/email.service.ts +++ b/apps/backend/src/aws/ses/email.service.ts @@ -31,7 +31,7 @@ export class EmailsService { * etc.) causes the method to reject with a ValidationError[] before any * SES request is made. * - * Sending is skipped (with a warning) when SEND_AUTOMATED_EMAILS is not + * Sending is skipped (with a warning) when SES_ENABLED is not * set to 'true'. * * @param dto the email payload - validated against SendEmailDTO's decorators @@ -49,8 +49,10 @@ export class EmailsService { const validated = plainToInstance(SendEmailDTO, dto); await validateOrReject(validated); - if (process.env.SEND_AUTOMATED_EMAILS !== 'true') { - this.logger.warn('SEND_AUTOMATED_EMAILS is not "true". Email not sent.'); + if (process.env.SES_ENABLED?.toLowerCase() !== 'true') { + this.logger.log( + 'SES disabled: SES_ENABLED is not "true". No emails will be sent.', + ); return; } diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index 2638f52fc..e59be58db 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -6,10 +6,10 @@ import { User } from './user.entity'; import { JwtStrategy } from '../auth/jwt.strategy'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthService } from '../auth/auth.service'; -import { EmailsModule } from '../aws/ses/email.module'; +import { AWSSESModule } from '../aws/ses/email.module'; @Module({ - imports: [TypeOrmModule.forFeature([User]), EmailsModule], + imports: [TypeOrmModule.forFeature([User]), AWSSESModule], controllers: [UsersController], providers: [UsersService, AuthService, JwtStrategy, CurrentUserInterceptor], }) diff --git a/apps/backend/src/utils/env.ts b/apps/backend/src/utils/env.ts new file mode 100644 index 000000000..a2211fc07 --- /dev/null +++ b/apps/backend/src/utils/env.ts @@ -0,0 +1,25 @@ +/** + * Checks whether an environment variable value is a defined, non-empty string. + * + * @param value The environment variable value to check. + * @returns `true` if the value is a defined, non-empty string; `false` otherwise. + * + * Also tells TypeScript that provided `value` is a `string` if the function returns true. + */ +export function isNonEmptyEnv(value: string | undefined): value is string { + if (value === undefined) { + return false; + } + return value.trim() !== ''; +} + +/** + * Finds which of the given environment variables are not usably set. + * Unset and empty/whitespace-only values both count as missing. + * + * @param names The environment variable names to check. + * @returns The names that are missing, in the order they were given. + */ +export function getMissingEnvVars(names: readonly string[]): string[] { + return names.filter((name) => !isNonEmptyEnv(process.env[name])); +} diff --git a/example.env b/example.env index c67f2ec65..ed758c708 100644 --- a/example.env +++ b/example.env @@ -5,17 +5,31 @@ NX_DB_PASSWORD= NX_DB_DATABASE=jumpstart NX_DB_PORT=5432 -# AWS S3 +# Swagger UI: set to true only in local and staging — not in production. +SWAGGER_ENABLED=false + +# --- AWS (shared by the S3 and SES modules) --- +# One IAM user's credentials, read by every AWS module. These are the AWS SDK's +# standard variable names, so don't rename them per-service. +# AWS_ACCESS_KEY_ID='ABCDEFGHIJK12345678' +# AWS_SECRET_ACCESS_KEY='bhduerv797887veerfwev78899y87tre' +AWS_REGION='us-east-2' + +# --- AWS S3 --- +# Set S3_ENABLED=true to enable S3; the credentials above plus the bucket vars +# are only required when it is true (see apps/backend/src/aws/s3/README.md). +S3_ENABLED=false # Add one env var per S3 bucket (see apps/backend/src/aws/s3/README.md for the full setup steps) # Example: # AWS_MY_BUCKET_NAME_1=my-bucket-name-1 # AWS_MY_BUCKET_NAME_2=my-bucket-name2 +# --- AWS SES --- +# Set SES_ENABLED=true to enable sends; the vars above plus the sender +# are only required when it is true (see apps/backend/src/aws/ses/README.md). +SES_ENABLED=false +AWS_SES_SENDER_EMAIL='example@example.com' + # Swagger UI: set to true only in local and staging — not in production. SWAGGER_ENABLED=false -# AWS_ACCESS_KEY_ID = 'ABCDEFGHIJK12345678' -# AWS_SECRET_ACCESS_KEY = 'bhduerv797887veerfwev78899y87tre' -AWS_REGION = 'us-east-2' -AWS_SES_SENDER_EMAIL = 'example@example.com' -SEND_AUTOMATED_EMAILS=false