diff --git a/.agents/docs/packages/libnest.md b/.agents/docs/packages/libnest.md index 82cba18ca..341c6547c 100644 --- a/.agents/docs/packages/libnest.md +++ b/.agents/docs/packages/libnest.md @@ -22,7 +22,15 @@ Generic NestJS decorators, pipes, modules, and utilities used across DNP project | `./testing/plugin` | Vitest plugin (path aliases, env) | `apps/api/vitest.config.ts` | | `./user-config` | User-supplied app config typing | `defineUserConfig` in `apps/api/libnest.config.ts` | -`MailModule`/`MailService` are available but not currently used here. +**`MailModule`/`MailService` are deliberately not used.** `apps/api/src/mail/` builds its own +nodemailer transporter instead, because libnest's `MailService` constructs `this.transporter` in +its constructor from `MAIL_MODULE_OPTIONS_TOKEN` — so the SMTP options resolve once at boot. The +configuration here is admin-editable and stored on `SetupState`, and `POST /v1/mail/test` has to +send with settings that have not been saved yet; neither works with a transporter fixed at +startup, and `forRootAsync` does not help because the limitation is the transporter's lifetime +rather than where the options come from. libnest is expected to gain a lazily-derived transporter +and a per-call transport override, at which point this app should consume it and drop the local +copy. The root subpath is the only fully re-exporting barrel; its `index.ts` is ~38 lines and lists the entire public surface. diff --git a/apps/api/package.json b/apps/api/package.json index 6db82b501..ed90a00fe 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -53,6 +53,7 @@ "mongodb": "^6.15.0", "msgpackr": "catalog:", "neverthrow": "catalog:", + "nodemailer": "catalog:", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.14", @@ -63,6 +64,7 @@ "devDependencies": { "@nestjs/testing": "^11.0.11", "@types/express": "^5.0.0", + "@types/nodemailer": "catalog:", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", "mongodb-memory-server": "^10.3.0", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2c409954e..86ba46969 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -32,6 +32,7 @@ enum AuditLogAction { UPDATE DELETE LOGIN + SEND_EMAIL } enum AuditLogEntity { @@ -102,6 +103,21 @@ type GroupSettings { minimumAge Int? } +// Mirrors $LocalizedString in @opendatacapture/schemas/core; each field is optional so +// content can be authored in a single language without requiring all of them. +type LocalizedString { + en String? + fr String? +} + +// A group-manager-authored remote-assignment email template for a group's participants. +type GroupEmailTemplate { + id String + name String + subject LocalizedString? + body LocalizedString? +} + model Group { createdAt DateTime @default(now()) @db.Date updatedAt DateTime @updatedAt @db.Date @@ -124,6 +140,9 @@ model Group { userIds String[] @db.ObjectId users User[] @relation(fields: [userIds], references: [id]) + emailTemplates GroupEmailTemplate[] + activeAssignmentEmailTemplateId String? + @@map("GroupModel") } @@ -381,6 +400,26 @@ type BrandingConfig { taglineFontSize Int? } +// The admin-configurable SMTP settings used to send outgoing email. The `password` +// is stored here so the server can authenticate to the SMTP server; it is never +// returned to clients (see the mail endpoints, which strip it). +type MailConfig { + enabled Boolean + encryption String + host String + password String + port Int + senderAddress String + senderName String? + username String +} + +// An editable email template whose body may contain {{variable}} placeholders. +type MailTemplate { + body LocalizedString? + subject LocalizedString? +} + model SetupState { createdAt DateTime @default(now()) @db.Date updatedAt DateTime @updatedAt @db.Date @@ -390,6 +429,8 @@ model SetupState { isDemo Boolean isExperimentalFeaturesEnabled Boolean? isSetup Boolean + mailConfig MailConfig? + newUserEmailTemplate MailTemplate? @@map("SetupStateModel") } diff --git a/apps/api/src/assignments/__tests__/assignments.controller.spec.ts b/apps/api/src/assignments/__tests__/assignments.controller.spec.ts new file mode 100644 index 000000000..31618d4ef --- /dev/null +++ b/apps/api/src/assignments/__tests__/assignments.controller.spec.ts @@ -0,0 +1,155 @@ +import type { RequestUser } from '@douglasneuroinformatics/libnest'; +import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; +import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; +import { Test } from '@nestjs/testing'; +import type { Language } from '@opendatacapture/schemas/core'; +import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { AuditLogger } from '@/audit/audit.logger'; +import type { AppAbility } from '@/auth/auth.types'; +import { GroupsService } from '@/groups/groups.service'; +import { MailService } from '@/mail/mail.service'; + +import { AssignmentsController } from '../assignments.controller'; +import { AssignmentsService } from '../assignments.service'; + +const currentUser = { ability: {} as AppAbility, id: 'user-1' } as RequestUser; + +const assignment = { + expiresAt: new Date('2026-08-01T12:00:00.000Z'), + groupId: 'group-1', + id: 'assignment-1', + url: 'https://gateway.example.org/assignments/abc' +}; + +const customTemplate = { + body: { en: 'Custom body {{url}}', fr: 'Corps personnalisé {{url}}' }, + id: 'tpl-1', + name: 'Custom', + subject: { en: 'Custom subject', fr: 'Objet personnalisé' } +}; + +describe('AssignmentsController', () => { + let assignmentsController: AssignmentsController; + let assignmentsService: MockedInstance; + let auditLogger: MockedInstance; + let groupsService: MockedInstance; + let mailService: MockedInstance; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [AssignmentsController], + providers: [ + MockFactory.createForService(AssignmentsService), + MockFactory.createForService(AuditLogger), + MockFactory.createForService(GroupsService), + MockFactory.createForService(MailService) + ] + }).compile(); + assignmentsController = moduleRef.get(AssignmentsController); + assignmentsService = moduleRef.get(AssignmentsService); + auditLogger = moduleRef.get(AuditLogger); + groupsService = moduleRef.get(GroupsService); + mailService = moduleRef.get(MailService); + }); + + it('should be defined', () => { + expect(assignmentsController).toBeDefined(); + }); + + describe('sendEmail', () => { + const sendEmail = (body: { language: Language; recipient: string; templateId?: null | string }) => + assignmentsController.sendEmail('assignment-1', body, currentUser); + + it('uses the built-in default template when the assignment has no group', async () => { + assignmentsService.findById.mockResolvedValueOnce({ ...assignment, groupId: null }); + await sendEmail({ language: 'en', recipient: 'p@x.org' }); + expect(groupsService.findById).not.toHaveBeenCalled(); + expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({ + recipient: 'p@x.org', + template: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE, + url: `${assignment.url}?lang=en` + }); + }); + + // Rendering is the mail service's job, so the raw date has to survive the hand-off — a + // pre-formatted string here would be formatted twice. + it('forwards the raw expiry rather than a formatted date', async () => { + assignmentsService.findById.mockResolvedValueOnce({ ...assignment, groupId: null }); + await sendEmail({ language: 'en', recipient: 'p@x.org' }); + expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({ + expiresAt: assignment.expiresAt + }); + }); + + it("uses the group's active template when no template id is given", async () => { + assignmentsService.findById.mockResolvedValueOnce(assignment); + groupsService.findById.mockResolvedValueOnce({ + activeAssignmentEmailTemplateId: 'tpl-1', + emailTemplates: [customTemplate] + }); + await sendEmail({ language: 'en', recipient: 'p@x.org' }); + expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({ + template: { body: customTemplate.body, subject: customTemplate.subject } + }); + }); + + it('uses the explicitly requested template over the active one', async () => { + assignmentsService.findById.mockResolvedValueOnce(assignment); + groupsService.findById.mockResolvedValueOnce({ + activeAssignmentEmailTemplateId: 'tpl-other', + emailTemplates: [customTemplate] + }); + await sendEmail({ language: 'en', recipient: 'p@x.org', templateId: 'tpl-1' }); + expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({ + template: { body: customTemplate.body, subject: customTemplate.subject } + }); + }); + + it('uses the default template when the template id is null, even if an active template is set', async () => { + assignmentsService.findById.mockResolvedValueOnce(assignment); + groupsService.findById.mockResolvedValueOnce({ + activeAssignmentEmailTemplateId: 'tpl-1', + emailTemplates: [customTemplate] + }); + await sendEmail({ language: 'en', recipient: 'p@x.org', templateId: null }); + expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({ + template: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE + }); + }); + + it('passes the requested language through for the mail service to render', async () => { + assignmentsService.findById.mockResolvedValueOnce(assignment); + groupsService.findById.mockResolvedValueOnce({ + activeAssignmentEmailTemplateId: 'tpl-1', + emailTemplates: [customTemplate] + }); + await sendEmail({ language: 'fr', recipient: 'p@x.org' }); + expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({ + language: 'fr', + url: `${assignment.url}?lang=fr` + }); + }); + + it('records an audit log entry against the assignment group', async () => { + assignmentsService.findById.mockResolvedValueOnce(assignment); + groupsService.findById.mockResolvedValueOnce({ emailTemplates: [] }); + await sendEmail({ language: 'en', recipient: 'p@x.org' }); + expect(auditLogger.log).toHaveBeenCalledWith('SEND_EMAIL', 'ASSIGNMENT', { + groupId: 'group-1', + userId: 'user-1' + }); + }); + + it('returns the delivery result from the mail service', async () => { + assignmentsService.findById.mockResolvedValueOnce({ ...assignment, groupId: null }); + mailService.sendAssignmentEmail.mockResolvedValueOnce({ + message: 'rendered', + recipient: 'p@x.org', + status: 'SENT' + }); + await expect(sendEmail({ language: 'en', recipient: 'p@x.org' })).resolves.toMatchObject({ status: 'SENT' }); + }); + }); +}); diff --git a/apps/api/src/assignments/assignments.controller.ts b/apps/api/src/assignments/assignments.controller.ts index e46bf351c..60ba9e442 100644 --- a/apps/api/src/assignments/assignments.controller.ts +++ b/apps/api/src/assignments/assignments.controller.ts @@ -2,18 +2,31 @@ import { CurrentUser } from '@douglasneuroinformatics/libnest'; import type { RequestUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiOperation } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import type { Assignment } from '@opendatacapture/schemas/assignment'; +import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; +import type { EmailDeliveryResult, MailTemplate } from '@opendatacapture/schemas/mail'; +import { AuditLogger } from '@/audit/audit.logger'; import type { AppAbility } from '@/auth/auth.types'; +import { ASSIGNMENT_EMAIL_THROTTLER_LIMIT, ASSIGNMENT_EMAIL_THROTTLER_TTL } from '@/core/constants'; import { RouteAccess } from '@/core/decorators/route-access.decorator'; +import { GroupsService } from '@/groups/groups.service'; +import { MailService } from '@/mail/mail.service'; import { AssignmentsService } from './assignments.service'; import { CreateAssignmentDto } from './dto/create-assignment.dto'; +import { SendAssignmentEmailDto } from './dto/send-assignment-email.dto'; import { UpdateAssignmentDto } from './dto/update-assignment.dto'; @Controller('assignments') export class AssignmentsController { - constructor(private readonly assignmentsService: AssignmentsService) {} + constructor( + private readonly assignmentsService: AssignmentsService, + private readonly auditLogger: AuditLogger, + private readonly groupsService: GroupsService, + private readonly mailService: MailService + ) {} @ApiOperation({ summary: 'Create Assignment' }) @Post() @@ -29,6 +42,44 @@ export class AssignmentsController { return this.assignmentsService.find({ subjectId }, { ability }); } + @ApiOperation({ summary: 'Email Assignment Link' }) + @Post(':id/email') + @RouteAccess({ action: 'update', subject: 'Assignment' }) + // This route makes the instance's mail identity deliver a live assignment credential to a + // caller-supplied address, so it is bounded independently of the global default. + @Throttle({ long: { limit: ASSIGNMENT_EMAIL_THROTTLER_LIMIT, ttl: ASSIGNMENT_EMAIL_THROTTLER_TTL } }) + async sendEmail( + @Param('id') id: string, + @Body() { language, recipient, templateId }: SendAssignmentEmailDto, + @CurrentUser() currentUser: RequestUser + ): Promise { + const { ability } = currentUser; + const assignment = await this.assignmentsService.findById(id, { ability }); + // Choose the requested template if given, else the group's active one; either way falling back + // to the built-in default when the id resolves to nothing (e.g. null selects the default). + let template: MailTemplate = { ...DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE }; + if (assignment.groupId) { + const group = await this.groupsService.findById(assignment.groupId, { ability }); + const targetId = templateId === undefined ? group.activeAssignmentEmailTemplateId : templateId; + const chosen = group.emailTemplates?.find(({ id }) => id === targetId); + if (chosen?.body && chosen.subject) { + template = { body: chosen.body, subject: chosen.subject }; + } + } + const result = await this.mailService.sendAssignmentEmail({ + expiresAt: assignment.expiresAt, + language, + recipient, + template, + url: `${assignment.url}?lang=${language}` + }); + await this.auditLogger.log('SEND_EMAIL', 'ASSIGNMENT', { + groupId: assignment.groupId ?? null, + userId: currentUser.id + }); + return result; + } + @ApiOperation({ summary: 'Update Assignment' }) @Patch(':id') @RouteAccess({ action: 'update', subject: 'Assignment' }) diff --git a/apps/api/src/assignments/assignments.module.ts b/apps/api/src/assignments/assignments.module.ts index a2f59f5e1..122fdce46 100644 --- a/apps/api/src/assignments/assignments.module.ts +++ b/apps/api/src/assignments/assignments.module.ts @@ -1,6 +1,8 @@ import { forwardRef, Module } from '@nestjs/common'; import { GatewayModule } from '@/gateway/gateway.module'; +import { GroupsModule } from '@/groups/groups.module'; +import { MailModule } from '@/mail/mail.module'; import { AssignmentsController } from './assignments.controller'; import { AssignmentsService } from './assignments.service'; @@ -8,7 +10,7 @@ import { AssignmentsService } from './assignments.service'; @Module({ controllers: [AssignmentsController], exports: [AssignmentsService], - imports: [forwardRef(() => GatewayModule)], + imports: [forwardRef(() => GatewayModule), GroupsModule, MailModule], providers: [AssignmentsService] }) export class AssignmentsModule {} diff --git a/apps/api/src/assignments/dto/send-assignment-email.dto.ts b/apps/api/src/assignments/dto/send-assignment-email.dto.ts new file mode 100644 index 000000000..992d27030 --- /dev/null +++ b/apps/api/src/assignments/dto/send-assignment-email.dto.ts @@ -0,0 +1,20 @@ +import { ValidationSchema } from '@douglasneuroinformatics/libnest'; +import { ApiProperty } from '@nestjs/swagger'; +import type { Language } from '@opendatacapture/schemas/core'; +import { $SendAssignmentEmailData } from '@opendatacapture/schemas/mail'; +import type { SendAssignmentEmailData } from '@opendatacapture/schemas/mail'; + +@ValidationSchema($SendAssignmentEmailData) +export class SendAssignmentEmailDto implements SendAssignmentEmailData { + @ApiProperty({ description: 'The language to send the email in', enum: ['en', 'fr'] }) + language: Language; + + @ApiProperty({ description: "The participant's email address" }) + recipient: string; + + @ApiProperty({ + description: 'Template id to use; null for the built-in default, omitted to use the group active template', + required: false + }) + templateId?: null | string; +} diff --git a/apps/api/src/core/__tests__/secret-cipher.spec.ts b/apps/api/src/core/__tests__/secret-cipher.spec.ts new file mode 100644 index 000000000..98ae35bc9 --- /dev/null +++ b/apps/api/src/core/__tests__/secret-cipher.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { decryptSecret, encryptSecret } from '../secret-cipher'; + +const SECRET_KEY = '2622d72669dd194b98cffd9098b0d04b'; + +describe('secret-cipher', () => { + it('should round-trip a secret', () => { + expect(decryptSecret(encryptSecret('hunter2', SECRET_KEY), SECRET_KEY)).toBe('hunter2'); + }); + + it('should round-trip a non-ASCII secret', () => { + const secret = 'pässwörd–✓'; + expect(decryptSecret(encryptSecret(secret, SECRET_KEY), SECRET_KEY)).toBe(secret); + }); + + // A fresh IV per call, so the same secret never produces the same stored value twice. + it('should produce a different ciphertext each time', () => { + expect(encryptSecret('hunter2', SECRET_KEY)).not.toBe(encryptSecret('hunter2', SECRET_KEY)); + }); + + it('should not contain the plaintext', () => { + expect(encryptSecret('hunter2', SECRET_KEY)).not.toContain('hunter2'); + }); + + it('should throw when the key differs, which is what a rotated SECRET_KEY looks like', () => { + const encrypted = encryptSecret('hunter2', SECRET_KEY); + expect(() => decryptSecret(encrypted, 'a-completely-different-key')).toThrow(); + }); + + it.each(['not-dot-separated', 'only.two', '', 'aaa.bbb.not-base64-ciphertext'])( + 'should throw on the malformed payload %s', + (value) => { + expect(() => decryptSecret(value, SECRET_KEY)).toThrow(); + } + ); + + // GCM authenticates the ciphertext, so a tampered payload must not decrypt to anything. + it('should throw when the ciphertext has been tampered with', () => { + const [iv, authTag, ciphertext] = encryptSecret('hunter2', SECRET_KEY).split('.') as [string, string, string]; + const flipped = Buffer.from(ciphertext, 'base64'); + flipped[0] = (flipped[0] ?? 0) ^ 0xff; + expect(() => decryptSecret([iv, authTag, flipped.toString('base64')].join('.'), SECRET_KEY)).toThrow(); + }); +}); diff --git a/apps/api/src/core/constants.ts b/apps/api/src/core/constants.ts index a377c0370..fa771af4e 100644 --- a/apps/api/src/core/constants.ts +++ b/apps/api/src/core/constants.ts @@ -1,3 +1,8 @@ export const DEFAULT_LOGIN_REQUEST_THROTTLER_LIMIT = 50; export const DEFAULT_LOGIN_REQUEST_THROTTLER_TTL = 60_000; + +/** Assignment emails a single client may send per {@link ASSIGNMENT_EMAIL_THROTTLER_TTL}. */ +export const ASSIGNMENT_EMAIL_THROTTLER_LIMIT = 20; + +export const ASSIGNMENT_EMAIL_THROTTLER_TTL = 60_000; diff --git a/apps/api/src/core/secret-cipher.ts b/apps/api/src/core/secret-cipher.ts new file mode 100644 index 000000000..8a0269a54 --- /dev/null +++ b/apps/api/src/core/secret-cipher.ts @@ -0,0 +1,35 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +/** Derive a 32-byte AES key from the server `SECRET_KEY`. */ +function encryptionKey(secretKey: string): Buffer { + return createHash('sha256').update(secretKey).digest(); +} + +/** + * Encrypt a secret with AES-256-GCM under a key derived from the server `SECRET_KEY`, returning + * `iv.authTag.ciphertext` (all base64) so a database dump yields no usable credential. + */ +export function encryptSecret(plaintext: string, secretKey: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', encryptionKey(secretKey), iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return [iv.toString('base64'), authTag.toString('base64'), ciphertext.toString('base64')].join('.'); +} + +/** + * Reverse {@link encryptSecret}. + * + * Throws when the payload is malformed or fails authentication — which is what rotating + * `SECRET_KEY` looks like. Callers decide whether that is fatal: losing a mail password should + * surface, while a GitHub token that no longer decrypts is recoverable by re-entering it. + */ +export function decryptSecret(value: string, secretKey: string): string { + const [ivB64, authTagB64, ciphertextB64] = value.split('.'); + if (!ivB64 || !authTagB64 || !ciphertextB64) { + throw new Error('Malformed encrypted secret: expected iv.authTag.ciphertext'); + } + const decipher = createDecipheriv('aes-256-gcm', encryptionKey(secretKey), Buffer.from(ivB64, 'base64')); + decipher.setAuthTag(Buffer.from(authTagB64, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(ciphertextB64, 'base64')), decipher.final()]).toString('utf8'); +} diff --git a/apps/api/src/groups/__tests__/groups.service.spec.ts b/apps/api/src/groups/__tests__/groups.service.spec.ts index 58276fab4..de96524d5 100644 --- a/apps/api/src/groups/__tests__/groups.service.spec.ts +++ b/apps/api/src/groups/__tests__/groups.service.spec.ts @@ -2,8 +2,9 @@ import type { Model } from '@douglasneuroinformatics/libnest'; import { getModelToken } from '@douglasneuroinformatics/libnest'; import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; -import { ConflictException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { beforeEach, describe, expect, it } from 'vitest'; import { GroupsService } from '../groups.service'; @@ -89,6 +90,120 @@ describe('GroupsService', () => { expect(groupModel.update).toHaveBeenCalled(); }); + it('should write the email template list as a composite set', async () => { + const templates = [{ body: { en: 'Link {{url}}' }, id: 'tpl-1', name: 'One', subject: { en: 'Subject' } }]; + groupModel.findFirst.mockResolvedValueOnce({ settings: {}, updatedAt: new Date('2026-07-01T00:00:00Z') }); + await groupsService.updateById('123', { + activeAssignmentEmailTemplateId: 'tpl-1', + emailTemplates: templates, + expectedUpdatedAt: new Date('2026-07-01T00:00:00Z') + }); + expect(groupModel.update.mock.lastCall?.[0]).toMatchObject({ data: { emailTemplates: { set: templates } } }); + }); + + // A duplicate id makes `find()` shadow every match after the first. + it('should reject duplicate template ids', async () => { + groupModel.findFirst.mockResolvedValueOnce({ settings: {}, updatedAt: new Date('2026-07-01T00:00:00Z') }); + await expect( + groupsService.updateById('123', { + emailTemplates: [ + { id: 'tpl-1', name: 'One' }, + { id: 'tpl-1', name: 'Two' } + ], + expectedUpdatedAt: new Date('2026-07-01T00:00:00Z') + }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + // A dangling active id makes participants silently receive the built-in wording. + it('should reject an active template id that resolves to nothing', async () => { + groupModel.findFirst.mockResolvedValueOnce({ settings: {}, updatedAt: new Date('2026-07-01T00:00:00Z') }); + await expect( + groupsService.updateById('123', { + activeAssignmentEmailTemplateId: 'tpl-gone', + emailTemplates: [{ id: 'tpl-1', name: 'One' }], + expectedUpdatedAt: new Date('2026-07-01T00:00:00Z') + }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should accept a null active id, which selects the built-in default', async () => { + groupModel.findFirst.mockResolvedValueOnce({ settings: {}, updatedAt: new Date('2026-07-01T00:00:00Z') }); + await groupsService.updateById('123', { + activeAssignmentEmailTemplateId: null, + emailTemplates: [{ id: 'tpl-1', name: 'One' }], + expectedUpdatedAt: new Date('2026-07-01T00:00:00Z') + }); + expect(groupModel.update).toHaveBeenCalled(); + }); + + // The stored active id has to stay resolvable when only the list is replaced. + it('should reject a list that drops the currently active template', async () => { + groupModel.findFirst.mockResolvedValueOnce({ + activeAssignmentEmailTemplateId: 'tpl-1', + settings: {}, + updatedAt: new Date('2026-07-01T00:00:00Z') + }); + await expect( + groupsService.updateById('123', { + emailTemplates: [{ id: 'tpl-2', name: 'Two' }], + expectedUpdatedAt: new Date('2026-07-01T00:00:00Z') + }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + // `emailTemplates` is replaced wholesale from the client's cached copy, so a concurrent edit + // has to be rejected rather than silently overwritten. + it('should reject an update composed against a stale revision', async () => { + groupModel.findFirst.mockResolvedValueOnce({ settings: {}, updatedAt: new Date('2026-07-02T00:00:00Z') }); + await expect( + groupsService.updateById('123', { + emailTemplates: [], + expectedUpdatedAt: new Date('2026-07-01T00:00:00Z') + }) + ).rejects.toBeInstanceOf(ConflictException); + expect(groupModel.update).not.toHaveBeenCalled(); + }); + + // The pre-check is a separate read, so the revision has to constrain the write itself too. + it('should carry the expected revision into the update where clause', async () => { + const expectedUpdatedAt = new Date('2026-07-01T00:00:00Z'); + groupModel.findFirst.mockResolvedValueOnce({ settings: {}, updatedAt: expectedUpdatedAt }); + await groupsService.updateById('123', { emailTemplates: [], expectedUpdatedAt }); + expect(groupModel.update.mock.lastCall?.[0]).toMatchObject({ where: { updatedAt: expectedUpdatedAt } }); + }); + + // The pre-check can pass and the conditional write still lose, when another writer commits + // in between. Prisma reports that as P2025, which has to read as a conflict rather than a 500. + it('should map a lost conditional write to a conflict', async () => { + const expectedUpdatedAt = new Date('2026-07-01T00:00:00Z'); + groupModel.findFirst.mockResolvedValueOnce({ settings: {}, updatedAt: expectedUpdatedAt }); + groupModel.update.mockRejectedValueOnce( + new PrismaClientKnownRequestError('No record was found for an update', { + clientVersion: '6.19.3', + code: 'P2025' + }) + ); + await expect(groupsService.updateById('123', { emailTemplates: [], expectedUpdatedAt })).rejects.toBeInstanceOf( + ConflictException + ); + }); + + it('should not disguise an unrelated database error as a conflict', async () => { + const expectedUpdatedAt = new Date('2026-07-01T00:00:00Z'); + groupModel.findFirst.mockResolvedValueOnce({ settings: {}, updatedAt: expectedUpdatedAt }); + groupModel.update.mockRejectedValueOnce(new Error('connection lost')); + await expect(groupsService.updateById('123', { emailTemplates: [], expectedUpdatedAt })).rejects.toThrow( + 'connection lost' + ); + }); + + it('should leave the where clause unconstrained when no revision is supplied', async () => { + groupModel.findFirst.mockResolvedValueOnce({ settings: {} }); + await groupsService.updateById('123', { name: 'Test Group' }); + expect(groupModel.update.mock.lastCall?.[0].where).not.toHaveProperty('updatedAt'); + }); + it('should throw a ConflictException when renaming to an existing name', async () => { groupModel.findFirst.mockResolvedValueOnce({ name: 'Old Name', settings: {} }); groupModel.exists.mockResolvedValueOnce(true); diff --git a/apps/api/src/groups/dto/update-group.dto.ts b/apps/api/src/groups/dto/update-group.dto.ts index d123fa040..373e3260d 100644 --- a/apps/api/src/groups/dto/update-group.dto.ts +++ b/apps/api/src/groups/dto/update-group.dto.ts @@ -1,10 +1,13 @@ import { ValidationSchema } from '@douglasneuroinformatics/libnest'; import { $UpdateGroupData } from '@opendatacapture/schemas/group'; -import type { GroupSettings, GroupType, UpdateGroupData } from '@opendatacapture/schemas/group'; +import type { GroupEmailTemplate, GroupSettings, GroupType, UpdateGroupData } from '@opendatacapture/schemas/group'; @ValidationSchema($UpdateGroupData) export class UpdateGroupDto implements UpdateGroupData { accessibleInstrumentIds?: string[]; + activeAssignmentEmailTemplateId?: null | string; + emailTemplates?: GroupEmailTemplate[]; + expectedUpdatedAt?: Date; instrumentRepoIds?: string[]; name?: string; settings?: Partial; diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index b6ae1b87b..f02a23de3 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -1,7 +1,9 @@ import { InjectModel } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import type { GroupEmailTemplate } from '@opendatacapture/schemas/group'; import type { Prisma } from '@prisma/client'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { accessibleQuery } from '@/auth/ability.utils'; import type { EntityOperationOptions } from '@/core/types'; @@ -69,7 +71,14 @@ export class GroupsService { async updateById( id: string, - { accessibleInstrumentIds, instrumentRepoIds, settings, ...data }: UpdateGroupDto, + { + accessibleInstrumentIds, + emailTemplates, + expectedUpdatedAt, + instrumentRepoIds, + settings, + ...data + }: UpdateGroupDto, { ability }: EntityOperationOptions = {} ) { const where: Prisma.GroupWhereInput = { AND: [accessibleQuery(ability, 'update', 'Group')], id }; @@ -77,6 +86,13 @@ export class GroupsService { if (!group) { throw new NotFoundException(`Failed to find group with ID: ${id}`); } + if (expectedUpdatedAt && group.updatedAt.getTime() !== expectedUpdatedAt.getTime()) { + throw new ConflictException(`Group with ID '${id}' has been modified since it was loaded`); + } + // The check above is only a fast, friendly rejection — it is a separate read, so two writers + // can both pass it. `expectedUpdatedAt` therefore also goes in the update's own `where`, which + // is what actually makes the write conditional. + const revisionGuard: { updatedAt?: Date } = expectedUpdatedAt ? { updatedAt: expectedUpdatedAt } : {}; // Only guard against a genuine rename collision: check the requested name (not the current one, // which would always match this same group) and skip the check when the name is unchanged. const exists = @@ -84,6 +100,7 @@ export class GroupsService { if (exists) { throw new ConflictException(`Group with name '${data.name}' already exists!`); } + this.validateEmailTemplates(emailTemplates, data.activeAssignmentEmailTemplateId, group); // Guard against stale client state: an instrument may have been deleted since the client loaded the // group (the deleted id can linger in the client's accessible list). Connecting a non-existent @@ -100,25 +117,62 @@ export class GroupsService { validInstrumentIds = existingInstruments.map(({ id }) => id); } - return this.groupModel.update({ - data: { - accessibleInstruments: validInstrumentIds - ? { - set: validInstrumentIds.map((id) => ({ id })) - } - : undefined, - instrumentRepos: instrumentRepoIds - ? { - set: instrumentRepoIds.map((id) => ({ id })) - } - : undefined, - settings: { - ...group.settings, - ...settings + try { + return await this.groupModel.update({ + data: { + accessibleInstruments: validInstrumentIds + ? { + set: validInstrumentIds.map((id) => ({ id })) + } + : undefined, + // Composite list fields must be replaced via `set` in the MongoDB connector. + emailTemplates: emailTemplates ? { set: emailTemplates } : undefined, + instrumentRepos: instrumentRepoIds + ? { + set: instrumentRepoIds.map((id) => ({ id })) + } + : undefined, + settings: { + ...group.settings, + ...settings + }, + ...data }, - ...data - }, - where: { AND: [accessibleQuery(ability, 'update', 'Group')], id } - }); + where: { AND: [accessibleQuery(ability, 'update', 'Group')], id, ...revisionGuard } + }); + } catch (err) { + // P2025 is "no record matched the where clause". The findFirst above already matched on id + // and ability, so the revision guard is the only filter that can have started failing — + // i.e. a concurrent edit. Anything else is a real fault and has to surface. + if (revisionGuard.updatedAt && err instanceof PrismaClientKnownRequestError && err.code === 'P2025') { + throw new ConflictException(`Group with ID '${id}' has been modified since it was loaded`); + } + throw err; + } + } + + /** + * Reject a template list that cannot be resolved later. A duplicate id makes `find()` shadow + * every match after the first, and an active id pointing at nothing makes the assignment + * controller fall back to the built-in wording — so participants silently receive the wrong + * message with nothing logged. + */ + private validateEmailTemplates( + emailTemplates: GroupEmailTemplate[] | undefined, + activeId: null | string | undefined, + group: { activeAssignmentEmailTemplateId?: null | string; emailTemplates?: GroupEmailTemplate[] } + ): void { + const templates = emailTemplates ?? group.emailTemplates ?? []; + if (emailTemplates) { + const ids = emailTemplates.map((template) => template.id); + if (new Set(ids).size !== ids.length) { + throw new BadRequestException('Each email template must have a unique id'); + } + } + // `undefined` leaves the stored value alone; `null` deliberately selects the built-in default. + const nextActiveId = activeId === undefined ? group.activeAssignmentEmailTemplateId : activeId; + if (nextActiveId && !templates.some((template) => template.id === nextActiveId)) { + throw new BadRequestException(`No email template with id '${nextActiveId}' exists in this group`); + } } } diff --git a/apps/api/src/instrument-repos/instrument-repos.service.ts b/apps/api/src/instrument-repos/instrument-repos.service.ts index 3055e038c..e49e51fe6 100644 --- a/apps/api/src/instrument-repos/instrument-repos.service.ts +++ b/apps/api/src/instrument-repos/instrument-repos.service.ts @@ -1,4 +1,3 @@ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -18,6 +17,7 @@ import type { BundlerInput } from '@opendatacapture/instrument-bundler'; import JSZip from 'jszip'; import { accessibleQuery } from '@/auth/ability.utils'; +import { decryptSecret, encryptSecret } from '@/core/secret-cipher'; import type { EntityOperationOptions } from '@/core/types'; import { InstrumentsService } from '@/instruments/instruments.service'; @@ -152,17 +152,13 @@ export class InstrumentReposService implements OnModuleInit { return this.stripSecrets(updated); } - /** Reverse {@link encrypt}. Returns undefined if the value cannot be decrypted. */ + /** + * Reverse {@link encrypt}. Returns undefined if the value cannot be decrypted: a repository whose + * token is unreadable is still usable if it is public, so this stays recoverable rather than fatal. + */ private decrypt(value: string): string | undefined { try { - const [ivB64, authTagB64, ciphertextB64] = value.split('.'); - if (!ivB64 || !authTagB64 || !ciphertextB64) { - return undefined; - } - const decipher = createDecipheriv('aes-256-gcm', this.encryptionKey(), Buffer.from(ivB64, 'base64')); - decipher.setAuthTag(Buffer.from(authTagB64, 'base64')); - const plaintext = Buffer.concat([decipher.update(Buffer.from(ciphertextB64, 'base64')), decipher.final()]); - return plaintext.toString('utf8'); + return decryptSecret(value, this.configService.getOrThrow('SECRET_KEY')); } catch (err) { this.loggingService.error(`Failed to decrypt stored access token: ${String(err)}`); return undefined; @@ -242,19 +238,8 @@ export class InstrumentReposService implements OnModuleInit { throw new BadGatewayException(`Failed to download repository ${owner}/${repoName}. ${hint}`); } - /** Encrypt a secret with AES-256-GCM, returning `iv.authTag.ciphertext` (all base64). */ private encrypt(plaintext: string): string { - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', this.encryptionKey(), iv); - const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - const authTag = cipher.getAuthTag(); - return [iv.toString('base64'), authTag.toString('base64'), ciphertext.toString('base64')].join('.'); - } - - /** Derive a 32-byte AES key from the server SECRET_KEY. */ - private encryptionKey(): Buffer { - const secret = this.configService.getOrThrow('SECRET_KEY'); - return createHash('sha256').update(secret).digest(); + return encryptSecret(plaintext, this.configService.getOrThrow('SECRET_KEY')); } // Unpack a GitHub zipball into `destDir`, returning the single top-level directory the archive wraps diff --git a/apps/api/src/mail/__tests__/mail.controller.spec.ts b/apps/api/src/mail/__tests__/mail.controller.spec.ts new file mode 100644 index 000000000..37139ac05 --- /dev/null +++ b/apps/api/src/mail/__tests__/mail.controller.spec.ts @@ -0,0 +1,57 @@ +import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; +import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; +import { Test } from '@nestjs/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { MailController } from '../mail.controller'; +import { MailService } from '../mail.service'; + +const settings = { config: null, newUserEmailTemplate: { body: {}, subject: {} } }; + +describe('MailController', () => { + let mailController: MailController; + let mailService: MockedInstance; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [MailController], + providers: [MockFactory.createForService(MailService)] + }).compile(); + mailController = moduleRef.get(MailController); + mailService = moduleRef.get(MailService); + }); + + it('should be defined', () => { + expect(mailController).toBeDefined(); + }); + + describe('getSettings', () => { + it('returns the settings from the service', async () => { + mailService.getSettings.mockResolvedValueOnce(settings); + await expect(mailController.getSettings()).resolves.toBe(settings); + }); + }); + + describe('test', () => { + it('forwards the supplied configuration so unsaved settings can be tested', async () => { + mailService.test.mockResolvedValueOnce({ success: true }); + const data = { recipient: 'p@x.org' }; + await mailController.test(data); + expect(mailService.test).toHaveBeenCalledWith(data); + }); + + it('returns the failure code unchanged for the client to localize', async () => { + mailService.test.mockResolvedValueOnce({ error: 'AUTHENTICATION_FAILED', success: false }); + await expect(mailController.test({})).resolves.toMatchObject({ error: 'AUTHENTICATION_FAILED' }); + }); + }); + + describe('updateSettings', () => { + it('forwards the payload to the service', async () => { + mailService.updateSettings.mockResolvedValueOnce(settings); + const data = { newUserEmailTemplate: { body: { en: 'Hi' }, subject: { en: 'Hello' } } }; + await mailController.updateSettings(data); + expect(mailService.updateSettings).toHaveBeenCalledWith(data); + }); + }); +}); diff --git a/apps/api/src/mail/__tests__/mail.service.spec.ts b/apps/api/src/mail/__tests__/mail.service.spec.ts new file mode 100644 index 000000000..15b9e3574 --- /dev/null +++ b/apps/api/src/mail/__tests__/mail.service.spec.ts @@ -0,0 +1,323 @@ +import { ConfigService, getModelToken, LoggingService } from '@douglasneuroinformatics/libnest'; +import type { Model } from '@douglasneuroinformatics/libnest'; +import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; +import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; +import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { UpdateMailConfigData } from '@opendatacapture/schemas/mail'; +import { createTransport } from 'nodemailer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; + +import { decryptSecret, encryptSecret } from '@/core/secret-cipher'; + +import { MailService } from '../mail.service'; + +vi.mock('nodemailer', () => ({ createTransport: vi.fn() })); + +const SECRET_KEY = 'test-secret-key'; + +/** The shape of the Prisma update this service issues, narrowed for assertions. */ +type PersistedUpdate = { data: { mailConfig: { set: { password: string } } } }; + +/** What Mongo holds: everything as configured, but with the password encrypted at rest. */ +const stored = (config: UpdateMailConfigData, password = 'secret') => ({ + ...config, + password: encryptSecret(password, SECRET_KEY) +}); + +const validConfig: UpdateMailConfigData = { + enabled: true, + encryption: 'starttls', + host: 'smtp.example.org', + password: 'secret', + port: 587, + senderAddress: 'noreply@example.org', + senderName: 'ODC', + username: 'user' +}; + +const newUserArgs = { + email: 'u@x.org', + firstName: 'Jane', + group: 'G', + lastName: 'Doe', + url: 'https://x', + username: 'jdoe' +}; + +describe('MailService', () => { + let mailService: MailService; + let setupStateModel: MockedInstance>; + let transporter: { sendMail: Mock; verify: Mock }; + + /** The password as it would come back out of the database. */ + const persistedPassword = () => + decryptSecret( + (setupStateModel.update.mock.lastCall?.[0] as PersistedUpdate).data.mailConfig.set.password, + SECRET_KEY + ); + + beforeEach(async () => { + // `createTransport` is a module-level mock, so its call history outlives the testing module. + vi.clearAllMocks(); + const moduleRef = await Test.createTestingModule({ + providers: [ + MailService, + MockFactory.createForModelToken(getModelToken('SetupState')), + MockFactory.createForService(ConfigService), + MockFactory.createForService(LoggingService) + ] + }).compile(); + moduleRef.get>(ConfigService).getOrThrow.mockReturnValue(SECRET_KEY); + setupStateModel = moduleRef.get(getModelToken('SetupState')); + mailService = moduleRef.get(MailService); + transporter = { sendMail: vi.fn().mockResolvedValue({}), verify: vi.fn().mockResolvedValue(true) }; + (createTransport as Mock).mockReturnValue(transporter); + }); + + // A config that stops validating must not silently mute all mail. + describe('a stored configuration that no longer parses', () => { + it('throws rather than reporting mail as unconfigured', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: { ...stored(validConfig), port: 'not-a-port' } }); + await expect(mailService.getSettings()).rejects.toThrow(); + }); + }); + + // Two empty objects are truthy, so `body && subject` alone would persist an empty template. + describe('an empty stored new-user template', () => { + it('falls back to the built-in default rather than sending an empty message', async () => { + setupStateModel.findFirst.mockResolvedValue({ newUserEmailTemplate: { body: {}, subject: {} } }); + const { newUserEmailTemplate } = await mailService.getSettings(); + expect(newUserEmailTemplate.subject.en).toBeTruthy(); + expect(newUserEmailTemplate.body.en).toContain('{{username}}'); + }); + }); + + describe('getSettings', () => { + it('strips the password and exposes a has-flag', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const settings = await mailService.getSettings(); + expect(settings.config).toMatchObject({ hasPassword: true, host: 'smtp.example.org' }); + expect(settings.config).not.toHaveProperty('password'); + }); + + it('returns null config and a default template when nothing is stored', async () => { + setupStateModel.findFirst.mockResolvedValue(null); + const settings = await mailService.getSettings(); + expect(settings.config).toBeNull(); + expect(settings.newUserEmailTemplate.subject).toBeTruthy(); + }); + }); + + describe('updateSettings', () => { + it('throws before setup is complete', async () => { + setupStateModel.findFirst.mockResolvedValue({ isSetup: false }); + await expect(mailService.updateSettings({ config: { ...validConfig } })).rejects.toBeInstanceOf( + ServiceUnavailableException + ); + }); + + it('keeps the stored password when the update omits it for the same server', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: '1', isSetup: true, mailConfig: stored(validConfig) }); + await mailService.updateSettings({ config: { ...validConfig, password: undefined } }); + expect(persistedPassword()).toBe('secret'); + }); + + // A Mongo dump must not yield a working mail credential. + it('encrypts the password before it reaches the database', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: '1', isSetup: true, mailConfig: stored(validConfig) }); + await mailService.updateSettings({ config: { ...validConfig, password: 'new-secret' } }); + const written = (setupStateModel.update.mock.lastCall?.[0] as PersistedUpdate).data.mailConfig.set.password; + expect(written).not.toBe('new-secret'); + expect(written).not.toContain('new-secret'); + expect(decryptSecret(written, SECRET_KEY)).toBe('new-secret'); + }); + + // Inheriting across servers would let the stored secret be aimed at a host the admin chose; + // blanking it instead would silently break a working configuration on an unrelated edit. + it('refuses to save a different host without a password rather than inheriting or blanking it', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: '1', isSetup: true, mailConfig: stored(validConfig) }); + await expect( + mailService.updateSettings({ + config: { ...validConfig, host: 'smtp.attacker.example', password: undefined } + }) + ).rejects.toBeInstanceOf(BadRequestException); + expect(setupStateModel.update).not.toHaveBeenCalled(); + }); + + it('keeps the stored password when only a non-server field changes', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: '1', isSetup: true, mailConfig: stored(validConfig) }); + await mailService.updateSettings({ + config: { ...validConfig, password: undefined, senderName: 'New Name' } + }); + expect(persistedPassword()).toBe('secret'); + expect(setupStateModel.update.mock.lastCall?.[0]).toMatchObject({ + data: { mailConfig: { set: { senderName: 'New Name' } } } + }); + }); + + // Same host, but downgrading encryption would otherwise reuse the password in the clear. + it('refuses to reuse the stored password when the encryption changes', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: '1', isSetup: true, mailConfig: stored(validConfig) }); + await expect( + mailService.updateSettings({ config: { ...validConfig, encryption: 'none', password: undefined } }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('test', () => { + it('returns success when the connection verifies', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + expect(await mailService.test({})).toEqual({ success: true }); + expect(transporter.verify).toHaveBeenCalled(); + }); + + it('returns a friendly error when verification fails', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + transporter.verify.mockRejectedValueOnce({ code: 'EAUTH' }); + const result = await mailService.test({}); + expect(result.success).toBe(false); + expect(result.error).toBe('AUTHENTICATION_FAILED'); + }); + + it('delivers a message when a recipient is supplied', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.test({ recipient: 'p@x.org' }); + expect(result).toEqual({ success: true }); + expect(transporter.sendMail).toHaveBeenCalled(); + }); + + // Otherwise the stored credential could be read back off an SMTP AUTH to a chosen host. + it('refuses to connect to a different host without being given that host’s password', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.test({ + config: { ...validConfig, host: 'smtp.attacker.example', password: undefined } + }); + expect(result.success).toBe(false); + expect(createTransport).not.toHaveBeenCalled(); + }); + + it('reports when mail has not been configured', async () => { + setupStateModel.findFirst.mockResolvedValue(null); + expect(await mailService.test({})).toMatchObject({ success: false }); + }); + }); + + describe('sendNewUserEmail', () => { + it('is DISABLED (with a copy-pasteable message) when mail is off', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: { ...stored(validConfig), enabled: false } }); + const result = await mailService.sendNewUserEmail(newUserArgs); + expect(result.status).toBe('DISABLED'); + expect(result.message).toContain('Jane'); + }); + + it('is NO_RECIPIENT when enabled but the user has no email', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.sendNewUserEmail({ ...newUserArgs, email: null }); + expect(result.status).toBe('NO_RECIPIENT'); + }); + + it('is SENT when delivery succeeds', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.sendNewUserEmail(newUserArgs); + expect(result.status).toBe('SENT'); + expect(transporter.sendMail).toHaveBeenCalled(); + }); + + it('never includes a password in the rendered message', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.sendNewUserEmail(newUserArgs); + expect(result.message).not.toMatch(/password:/i); + expect(result.message).not.toMatch(/\{\{password\}\}/); + }); + + it('renders the French content when language is fr', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.sendNewUserEmail({ ...newUserArgs, language: 'fr' }); + expect(result.status).toBe('SENT'); + expect(result.message).toContain('Bonjour'); + }); + + it('is FAILED with a friendly error when delivery throws', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + transporter.sendMail.mockRejectedValueOnce({ code: 'ECONNREFUSED' }); + const result = await mailService.sendNewUserEmail(newUserArgs); + expect(result.status).toBe('FAILED'); + expect(result.error).toBe('CONNECTION_REFUSED'); + }); + }); + + describe('sendAssignmentEmail', () => { + const template = (body: string) => ({ body: { en: body }, subject: { en: 'Assignment' } }); + + const assignmentArgs = { + expiresAt: new Date('2026-01-01T12:00:00.000Z'), + language: 'en' as const, + recipient: 'p@x.org', + template: template('Link: {{url}}'), + url: 'https://assign' + }; + + // The rendered message still comes back so the clinician can send it by hand. + it('is DISABLED without attempting delivery when mail is off', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: { ...stored(validConfig), enabled: false } }); + const result = await mailService.sendAssignmentEmail(assignmentArgs); + expect(result.status).toBe('DISABLED'); + expect(result.message).toContain('https://assign'); + expect(transporter.sendMail).not.toHaveBeenCalled(); + }); + + it('is FAILED with a code when delivery throws', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + transporter.sendMail.mockRejectedValueOnce({ code: 'EAUTH' }); + const result = await mailService.sendAssignmentEmail(assignmentArgs); + expect(result.status).toBe('FAILED'); + expect(result.error).toBe('AUTHENTICATION_FAILED'); + }); + + it('substitutes url/expiresAt and sends when enabled', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.sendAssignmentEmail({ + ...assignmentArgs, + template: template('Link: {{url}} (expires {{expiresAt}})') + }); + expect(result.status).toBe('SENT'); + expect(result.message).toContain('Link: https://assign (expires '); + }); + + // Truncating to a bare UTC date shifts the day for anyone west of UTC. + it('renders the expiry with a time rather than a bare date', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.sendAssignmentEmail({ + ...assignmentArgs, + template: template('{{url}} expires {{expiresAt}}') + }); + expect(result.message).not.toContain('2026-01-01'); + expect(result.message).toMatch(/\d{1,2}:\d{2}/); + }); + + it('renders the French body when language is fr', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.sendAssignmentEmail({ + ...assignmentArgs, + language: 'fr', + template: { body: { en: 'Link {{url}}', fr: 'Lien {{url}}' }, subject: { en: 'Assignment', fr: 'Évaluation' } } + }); + expect(result.status).toBe('SENT'); + expect(result.message).toContain('Lien'); + expect(transporter.sendMail.mock.lastCall?.[0]).toMatchObject({ subject: 'Évaluation' }); + }); + + it('appends the link when the template body omits {{url}}', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: stored(validConfig) }); + const result = await mailService.sendAssignmentEmail({ + ...assignmentArgs, + template: template('Please complete your assignment.'), + url: 'https://assign/xyz' + }); + expect(result.status).toBe('SENT'); + expect(result.message).toContain('https://assign/xyz'); + }); + }); +}); diff --git a/apps/api/src/mail/__tests__/mail.utils.spec.ts b/apps/api/src/mail/__tests__/mail.utils.spec.ts new file mode 100644 index 000000000..556183423 --- /dev/null +++ b/apps/api/src/mail/__tests__/mail.utils.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; + +import { + describeMailError, + encryptionToTransportFlags, + formatExpiryDate, + formatSender, + pickLocale, + renderTemplate +} from '../mail.utils'; + +describe('renderTemplate', () => { + it('substitutes known placeholders', () => { + expect( + renderTemplate('Hello {{firstName}}, your username is {{username}}', { firstName: 'Jane', username: 'jdoe' }) + ).toBe('Hello Jane, your username is jdoe'); + }); + + it('tolerates surrounding whitespace in the placeholder', () => { + expect(renderTemplate('{{ url }}', { url: 'https://x' })).toBe('https://x'); + }); + + it('leaves unknown placeholders untouched', () => { + expect(renderTemplate('Hi {{missing}}', { name: 'x' })).toBe('Hi {{missing}}'); + }); +}); + +describe('pickLocale', () => { + it('returns the requested language', () => { + expect(pickLocale({ en: 'Hello', fr: 'Bonjour' }, 'fr')).toBe('Bonjour'); + expect(pickLocale({ en: 'Hello', fr: 'Bonjour' }, 'en')).toBe('Hello'); + }); + + it('falls back to the other language when the requested one is empty', () => { + expect(pickLocale({ en: 'Hello', fr: '' }, 'fr')).toBe('Hello'); + expect(pickLocale({ en: '', fr: 'Bonjour' }, 'en')).toBe('Bonjour'); + }); +}); + +describe('formatExpiryDate', () => { + it('includes the time and zone rather than truncating to a UTC date', () => { + const formatted = formatExpiryDate('2026-08-01T02:00:00Z', 'en'); + expect(formatted).not.toBe('2026-08-01'); + expect(formatted).toMatch(/\d{1,2}:\d{2}/); + }); + + it('renders in the requested language', () => { + const date = '2026-08-01T12:00:00Z'; + expect(formatExpiryDate(date, 'fr')).not.toBe(formatExpiryDate(date, 'en')); + }); +}); + +describe('formatSender', () => { + it('uses just the address when no name is set', () => { + expect(formatSender({ senderAddress: 'a@b.org', senderName: null })).toBe('a@b.org'); + }); + + it('quotes the name when present', () => { + expect(formatSender({ senderAddress: 'a@b.org', senderName: 'ODC' })).toBe('"ODC" '); + }); + + it('escapes double quotes and backslashes in the name', () => { + expect(formatSender({ senderAddress: 'a@b.org', senderName: 'Douglas "DNI" Lab' })).toBe( + '"Douglas \\"DNI\\" Lab" ' + ); + expect(formatSender({ senderAddress: 'a@b.org', senderName: 'back\\slash' })).toBe('"back\\\\slash" '); + }); +}); + +describe('encryptionToTransportFlags', () => { + it('maps ssl to implicit TLS', () => { + expect(encryptionToTransportFlags('ssl')).toEqual({ requireTLS: false, secure: true }); + }); + + it('maps starttls to requireTLS', () => { + expect(encryptionToTransportFlags('starttls')).toEqual({ requireTLS: true, secure: false }); + }); + + it('maps none to a plain connection', () => { + expect(encryptionToTransportFlags('none')).toEqual({ requireTLS: false, secure: false }); + }); +}); + +// A code, not prose: the server cannot know what language the admin reads. +describe('describeMailError', () => { + it('maps auth failures', () => { + expect(describeMailError({ code: 'EAUTH' })).toBe('AUTHENTICATION_FAILED'); + }); + + it('maps unknown host (by code or message)', () => { + expect(describeMailError({ code: 'ENOTFOUND' })).toBe('HOST_NOT_FOUND'); + expect(describeMailError({ message: 'getaddrinfo ENOTFOUND smtp.bad' })).toBe('HOST_NOT_FOUND'); + }); + + it('maps a refused connection', () => { + expect(describeMailError({ code: 'ECONNREFUSED' })).toBe('CONNECTION_REFUSED'); + }); + + it('maps TLS/version mismatch to an encryption hint', () => { + expect(describeMailError({ code: 'ESOCKET' })).toBe('INSECURE_CONNECTION'); + expect(describeMailError({ message: 'SSL routines:tls_validate_record_header:wrong version number' })).toBe( + 'INSECURE_CONNECTION' + ); + }); + + it('maps a rejected sender address', () => { + expect(describeMailError({ code: 'EENVELOPE' })).toBe('SENDER_REJECTED'); + }); + + it('collapses timeouts to UNKNOWN', () => { + expect(describeMailError({ code: 'ETIMEDOUT', message: 'Connection timeout' })).toBe('UNKNOWN'); + }); + + it('never leaks a raw error, returning only a known code', () => { + expect(describeMailError(new Error('0FC2C9C667C0000:error:0A00010B:SSL routines'))).not.toContain('0FC2C9'); + expect(describeMailError('something weird')).toBe('UNKNOWN'); + }); +}); diff --git a/apps/api/src/mail/dto/test-mail.dto.ts b/apps/api/src/mail/dto/test-mail.dto.ts new file mode 100644 index 000000000..89928fdc1 --- /dev/null +++ b/apps/api/src/mail/dto/test-mail.dto.ts @@ -0,0 +1,16 @@ +import { ValidationSchema } from '@douglasneuroinformatics/libnest'; +import { ApiProperty } from '@nestjs/swagger'; +import { $TestMailData } from '@opendatacapture/schemas/mail'; +import type { TestMailData, UpdateMailConfigData } from '@opendatacapture/schemas/mail'; + +@ValidationSchema($TestMailData) +export class TestMailDto implements TestMailData { + @ApiProperty({ + description: 'The (possibly unsaved) configuration to test; omit to test the saved one', + required: false + }) + config?: UpdateMailConfigData; + + @ApiProperty({ description: 'If provided, a real test email is delivered to this address', required: false }) + recipient?: string; +} diff --git a/apps/api/src/mail/dto/update-mail-settings.dto.ts b/apps/api/src/mail/dto/update-mail-settings.dto.ts new file mode 100644 index 000000000..a570b09b2 --- /dev/null +++ b/apps/api/src/mail/dto/update-mail-settings.dto.ts @@ -0,0 +1,13 @@ +import { ValidationSchema } from '@douglasneuroinformatics/libnest'; +import { ApiProperty } from '@nestjs/swagger'; +import { $UpdateMailSettingsData } from '@opendatacapture/schemas/mail'; +import type { MailTemplate, UpdateMailConfigData, UpdateMailSettingsData } from '@opendatacapture/schemas/mail'; + +@ValidationSchema($UpdateMailSettingsData) +export class UpdateMailSettingsDto implements UpdateMailSettingsData { + @ApiProperty({ required: false }) + config?: UpdateMailConfigData; + + @ApiProperty({ required: false }) + newUserEmailTemplate?: MailTemplate; +} diff --git a/apps/api/src/mail/mail.controller.ts b/apps/api/src/mail/mail.controller.ts new file mode 100644 index 000000000..45dc8e749 --- /dev/null +++ b/apps/api/src/mail/mail.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { MailSettings, TestMailResult } from '@opendatacapture/schemas/mail'; + +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + +import { TestMailDto } from './dto/test-mail.dto'; +import { UpdateMailSettingsDto } from './dto/update-mail-settings.dto'; +import { MailService } from './mail.service'; + +@ApiTags('Mail') +@Controller({ path: 'mail' }) +export class MailController { + constructor(private readonly mailService: MailService) {} + + @ApiOperation({ + description: 'Get the mail configuration (without the SMTP password) and templates', + summary: 'Get Mail Settings' + }) + @Get('settings') + @RouteAccess({ action: 'manage', subject: 'all' }) + getSettings(): Promise { + return this.mailService.getSettings(); + } + + @ApiOperation({ description: 'Verify the SMTP connection and optionally send a test email', summary: 'Test Mail' }) + @Post('test') + @RouteAccess({ action: 'manage', subject: 'all' }) + test(@Body() data: TestMailDto): Promise { + return this.mailService.test(data); + } + + @ApiOperation({ description: 'Update the mail configuration and/or templates', summary: 'Update Mail Settings' }) + @Patch('settings') + @RouteAccess({ action: 'manage', subject: 'all' }) + updateSettings(@Body() data: UpdateMailSettingsDto): Promise { + return this.mailService.updateSettings(data); + } +} diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts new file mode 100644 index 000000000..6d58b5934 --- /dev/null +++ b/apps/api/src/mail/mail.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MailController } from './mail.controller'; +import { MailService } from './mail.service'; + +@Module({ + controllers: [MailController], + exports: [MailService], + providers: [MailService] +}) +export class MailModule {} diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts new file mode 100644 index 000000000..4bf5d5295 --- /dev/null +++ b/apps/api/src/mail/mail.service.ts @@ -0,0 +1,310 @@ +import { ConfigService, InjectModel, LoggingService } from '@douglasneuroinformatics/libnest'; +import type { Model } from '@douglasneuroinformatics/libnest'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, + ServiceUnavailableException +} from '@nestjs/common'; +import type { Language } from '@opendatacapture/schemas/core'; +import { + $MailConfig, + DEFAULT_NEW_USER_EMAIL_TEMPLATE, + isMailEnabled, + isSameMailServer +} from '@opendatacapture/schemas/mail'; +import type { + EmailDeliveryResult, + MailConfig, + MailSettings, + MailTemplate, + TestMailData, + TestMailResult, + UpdateMailConfigData, + UpdateMailSettingsData +} from '@opendatacapture/schemas/mail'; +import type { SetupState } from '@prisma/client'; +import { createTransport } from 'nodemailer'; +import type { Transporter } from 'nodemailer'; + +import { decryptSecret, encryptSecret } from '@/core/secret-cipher'; + +import { + describeMailError, + encryptionToTransportFlags, + formatExpiryDate, + formatSender, + pickLocale, + renderTemplate +} from './mail.utils'; + +/** A message whose content is already rendered, ready to hand to a transporter. */ +type MailMessage = { + body: string; + subject: string; + to: string; +}; + +/** Everything the service reads off `SetupState`, validated and decrypted. */ +type MailState = { + config: MailConfig | null; + isEnabled: boolean; + newUserEmailTemplate: MailTemplate; +}; + +/** + * Handles all outgoing email for the application. + * + * Unlike libnest's `MailModule`, which binds a single transporter from static options at boot, + * the SMTP configuration here is owned by the admin and stored in the database (on + * `SetupState`). We therefore build a fresh nodemailer transporter from the current + * configuration whenever we send, so changes take effect without a restart and a "test + * connection" button can validate unsaved settings. We still reuse the same underlying library + * (nodemailer) that libnest depends on. + */ +@Injectable() +export class MailService { + constructor( + @InjectModel('SetupState') private readonly setupStateModel: Model<'SetupState'>, + private readonly configService: ConfigService, + private readonly loggingService: LoggingService + ) {} + + /** Admin-facing settings: the password is replaced by a `hasPassword` flag. */ + async getSettings(): Promise { + return this.toSettings(await this.readState()); + } + + /** + * Email a remote-assignment link to a participant, rendering the chosen template in the + * requested language. + */ + async sendAssignmentEmail({ + expiresAt, + language, + recipient, + template, + url + }: { + expiresAt: Date | number | string; + language: Language; + recipient: string; + template: MailTemplate; + url: string; + }): Promise { + const variables = { expiresAt: formatExpiryDate(expiresAt, language), url }; + const rendered = renderTemplate(pickLocale(template.body, language), variables); + return this.deliver(await this.readState(), { + // The assignment link is the whole point of this email, so if a custom template omits the + // {{url}} placeholder we append the link rather than send a message the recipient can't act on. + message: rendered.includes(url) ? rendered : `${rendered}\n\n${url}`, + recipient, + subject: renderTemplate(pickLocale(template.subject, language), variables) + }); + } + + /** Build and send the welcome email for a newly created user, in the requested language. */ + async sendNewUserEmail({ + email, + firstName, + group, + language = 'en', + lastName, + url, + username + }: { + email?: null | string; + firstName: string; + group: string; + language?: Language; + lastName: string; + url: string; + username: string; + }): Promise { + const state = await this.readState(); + const { body, subject } = state.newUserEmailTemplate; + const variables = { firstName, group, lastName, url, username }; + return this.deliver(state, { + message: renderTemplate(pickLocale(body, language), variables), + recipient: email, + subject: renderTemplate(pickLocale(subject, language), variables) + }); + } + + /** + * Test the SMTP connection, optionally sending a real message to `recipient`. When `config` is + * supplied the (possibly unsaved) values are tested; otherwise the saved configuration is used. + */ + async test({ config, recipient }: TestMailData): Promise { + const { config: saved } = await this.readState(); + if (this.requiresNewPassword(config, saved)) { + // The client already blocks this, so a code the UI maps to "check your credentials" is enough. + return { error: 'AUTHENTICATION_FAILED', success: false }; + } + const resolved = this.resolveConfig(config, saved); + if (!resolved) { + return { error: 'UNKNOWN', success: false }; + } + try { + const transporter = this.createTransporter(resolved); + await transporter.verify(); + if (recipient) { + await this.send(transporter, resolved, { + body: 'This is a test email from Open Data Capture. Your mail server is configured correctly.', + subject: 'Open Data Capture — test email', + to: recipient + }); + } + return { success: true }; + } catch (err) { + return { error: describeMailError(err), success: false }; + } + } + + /** + * Persist the mail configuration and/or new-user template. A blank/omitted `password` + * preserves the stored one so the secret never has to leave the server. Returns the + * admin-facing settings (password stripped). + */ + async updateSettings(data: UpdateMailSettingsData): Promise { + const setupState = await this.setupStateModel.findFirst(); + if (!setupState?.isSetup) { + throw new ServiceUnavailableException('Cannot update mail settings before setup'); + } + const { config: saved } = this.parseState(setupState); + if (this.requiresNewPassword(data.config, saved)) { + throw new BadRequestException('A password is required when changing the mail server'); + } + const resolved = data.config ? this.resolveConfig(data.config, saved) : undefined; + const nextConfig = resolved ? { ...resolved, password: this.encryptPassword(resolved.password) } : undefined; + const updated = await this.setupStateModel.update({ + data: { + ...(nextConfig ? { mailConfig: { set: nextConfig } } : {}), + ...(data.newUserEmailTemplate ? { newUserEmailTemplate: { set: data.newUserEmailTemplate } } : {}) + }, + where: { id: setupState.id } + }); + return this.toSettings(this.parseState(updated)); + } + + private createTransporter(config: MailConfig): Transporter { + return createTransport({ + auth: { pass: config.password, user: config.username }, + // Fail fast (e.g. on a wrong port) so the API returns a clear error rather than + // hanging until the client request times out. + connectionTimeout: 15_000, + greetingTimeout: 15_000, + host: config.host, + port: config.port, + socketTimeout: 20_000, + ...encryptionToTransportFlags(config.encryption) + }); + } + + /** Reverse {@link encryptPassword}. Propagates, so a key rotation surfaces instead of muting mail. */ + private decryptPassword(stored: string): string { + return stored ? decryptSecret(stored, this.configService.getOrThrow('SECRET_KEY')) : ''; + } + + /** + * Hand a rendered message to a transporter, collapsing every outcome into a delivery result. + * The message comes back whatever happens, so the UI can offer it for manual sending. + */ + private async deliver( + { config, isEnabled }: MailState, + { message, recipient, subject }: { message: string; recipient?: null | string; subject: string } + ): Promise { + if (!isEnabled || !config) { + return { message, recipient, status: 'DISABLED' }; + } + if (!recipient) { + return { message, recipient: null, status: 'NO_RECIPIENT' }; + } + try { + await this.send(this.createTransporter(config), config, { body: message, subject, to: recipient }); + return { message, recipient, status: 'SENT' }; + } catch (err) { + this.loggingService.error(`Failed to send "${subject}" to ${recipient}: ${String(err)}`); + return { error: describeMailError(err), message, recipient, status: 'FAILED' }; + } + } + + /** Encrypt the SMTP password so a database dump yields no working mail credential. */ + private encryptPassword(plaintext: string): string { + return plaintext ? encryptSecret(plaintext, this.configService.getOrThrow('SECRET_KEY')) : ''; + } + + /** + * Validate and decrypt the two mail fields off a `SetupState` row. + * + * A stored configuration that no longer validates, or whose password no longer decrypts, is a + * hard failure rather than "not configured": treating it as the latter makes every send return + * `DISABLED` while the admin looks at a configured mail page, with nothing to explain it. + */ + private parseState(setupState: null | Pick): MailState { + const stored = setupState?.mailConfig; + // Validate so scalar columns (e.g. `encryption`) narrow from `string` to their literal unions. + const parsed = stored ? $MailConfig.safeParse(stored) : null; + if (parsed && !parsed.success) { + this.loggingService.error(`Stored mail configuration is invalid: ${parsed.error.message}`); + throw new InternalServerErrorException('The stored mail configuration is invalid'); + } + const { body, subject } = setupState?.newUserEmailTemplate ?? {}; + // Two empty objects are truthy, so check for actual content before preferring the stored one. + const hasStoredTemplate = Boolean(body && subject && pickLocale(body, 'en') && pickLocale(subject, 'en')); + return { + config: parsed?.success ? { ...parsed.data, password: this.decryptPassword(parsed.data.password) } : null, + isEnabled: isMailEnabled(stored), + newUserEmailTemplate: + hasStoredTemplate && body && subject ? { body, subject } : { ...DEFAULT_NEW_USER_EMAIL_TEMPLATE } + }; + } + + private async readState(): Promise { + return this.parseState(await this.setupStateModel.findFirst()); + } + + /** + * Whether the caller has to supply a password rather than inheriting the stored one. Inheritance + * is confined to the server the password belongs to — see {@link isSameMailServer}. + */ + private requiresNewPassword(partial: undefined | UpdateMailConfigData, saved: MailConfig | null): boolean { + if (!partial || partial.password) { + return false; + } + return !saved || !isSameMailServer(saved, partial); + } + + /** + * Resolve a (possibly partial) update payload into a complete config. Callers reject the + * payload with {@link requiresNewPassword} first, which is what makes inheriting a blank + * password below safe. + */ + private resolveConfig(partial: undefined | UpdateMailConfigData, saved: MailConfig | null): MailConfig | null { + if (!partial) { + return saved; + } + return { + enabled: partial.enabled, + encryption: partial.encryption, + host: partial.host, + password: partial.password ?? saved?.password ?? '', + port: partial.port, + senderAddress: partial.senderAddress, + senderName: partial.senderName ?? null, + username: partial.username + }; + } + + private async send(transporter: Transporter, config: MailConfig, { body, subject, to }: MailMessage): Promise { + await transporter.sendMail({ from: formatSender(config), subject, text: body, to }); + } + + private toSettings({ config, newUserEmailTemplate }: MailState): MailSettings { + if (!config) { + return { config: null, newUserEmailTemplate }; + } + const { password, ...rest } = config; + return { config: { ...rest, hasPassword: Boolean(password) }, newUserEmailTemplate }; + } +} diff --git a/apps/api/src/mail/mail.utils.ts b/apps/api/src/mail/mail.utils.ts new file mode 100644 index 000000000..9a6864765 --- /dev/null +++ b/apps/api/src/mail/mail.utils.ts @@ -0,0 +1,90 @@ +import type { Language, LocalizedString } from '@opendatacapture/schemas/core'; +import type { MailConfig, MailEncryption, MailErrorCode } from '@opendatacapture/schemas/mail'; + +/** + * Substitute `{{key}}` placeholders in a template with the provided values. + * Unknown placeholders are left untouched so a malformed template degrades + * gracefully rather than throwing. + */ +export function renderTemplate(template: string, variables: { [key: string]: string }): string { + return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key: string) => { + return Object.prototype.hasOwnProperty.call(variables, key) ? variables[key]! : match; + }); +} + +/** Format the RFC 5322 "from" header from the configured sender name/address. */ +export function formatSender({ senderAddress, senderName }: Pick): string { + if (!senderName) { + return senderAddress; + } + const escaped = senderName.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}" <${senderAddress}>`; +} + +/** + * Format an assignment's expiry for a recipient. Truncating to a bare UTC date shifts the day for + * anyone west of UTC — an assignment expiring at 02:00Z reads as the next day in Montréal, where + * it has in fact already expired — so the time and zone are rendered rather than dropped. + */ +export function formatExpiryDate(expiresAt: Date | number | string, language: Language): string { + // Explicit components rather than dateStyle/timeStyle, which cannot be combined with timeZoneName. + return new Intl.DateTimeFormat(language, { + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + month: 'long', + timeZoneName: 'short', + year: 'numeric' + }).format(new Date(expiresAt)); +} + +/** Pick a language from a localized string, falling back to any available language when the requested one is empty. */ +export function pickLocale(localized: LocalizedString, language: Language): string { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty strings mean "no translation"; fall back intentionally + return localized[language] || Object.values(localized).find((v) => v) || ''; +} + +/** + * Classify a nodemailer/SMTP error — matching on the error code and, as a fallback, the message + * text (SMTP libraries are inconsistent about codes). + * + * Returns a {@link MailErrorCode} rather than prose: the server does not know what language the + * admin reads, so the client renders the message. The raw technical error is never surfaced, and + * anything unrecognized (including timeouts) collapses to `UNKNOWN`. + */ +export function describeMailError(err: unknown): MailErrorCode { + const e = (err ?? {}) as { code?: unknown; message?: unknown }; + const code = typeof e.code === 'string' ? e.code : ''; + const message = typeof e.message === 'string' ? e.message.toLowerCase() : ''; + const matches = (codes: string[], pattern: RegExp) => codes.includes(code) || pattern.test(message); + + if (matches(['EAUTH'], /invalid login|authentication failed|535|credentials|username and password/)) { + return 'AUTHENTICATION_FAILED'; + } + if (matches(['EDNS', 'ENOTFOUND'], /getaddrinfo|enotfound|not be found|unknown host/)) { + return 'HOST_NOT_FOUND'; + } + if (matches(['ECONNREFUSED'], /econnrefused|refused/)) { + return 'CONNECTION_REFUSED'; + } + if (matches(['ESOCKET'], /wrong version number|ssl routines|tls|certificate|self[- ]signed|esocket/)) { + return 'INSECURE_CONNECTION'; + } + if (matches(['EENVELOPE'], /envelope|sender address|from address|mail from/)) { + return 'SENDER_REJECTED'; + } + return 'UNKNOWN'; +} + +/** + * Map our user-facing `encryption` choice onto the nodemailer transport flags. + * - `ssl` → implicit TLS (`secure: true`, typically port 465) + * - `starttls` → upgrade a plain connection (`requireTLS: true`, typically port 587) + * - `none` → plain connection (typically port 25) + */ +export function encryptionToTransportFlags(encryption: MailEncryption): { requireTLS: boolean; secure: boolean } { + return { + requireTLS: encryption === 'starttls', + secure: encryption === 'ssl' + }; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f7bb48b04..c2745450e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -10,6 +10,7 @@ import { GroupsModule } from './groups/groups.module'; import { InstrumentRecordsModule } from './instrument-records/instrument-records.module'; import { InstrumentReposModule } from './instrument-repos/instrument-repos.module'; import { InstrumentsModule } from './instruments/instruments.module'; +import { MailModule } from './mail/mail.module'; import { SessionsModule } from './sessions/sessions.module'; import { SetupModule } from './setup/setup.module'; import { StorageModule } from './storage/storage.module'; @@ -35,7 +36,7 @@ export default AppFactory.create({ url: 'https://www.apache.org/licenses/LICENSE-2.0' }, path: '/', - tags: ['Authentication', 'Groups', 'Instruments', 'Instrument Records', 'Subjects', 'Users'], + tags: ['Authentication', 'Groups', 'Instruments', 'Instrument Records', 'Mail', 'Subjects', 'Users'], title: 'Open Data Capture' }, envSchema: $Env, @@ -46,6 +47,7 @@ export default AppFactory.create({ InstrumentRecordsModule, InstrumentReposModule, InstrumentsModule, + MailModule, PrismaModule.forRootAsync({ useClass: PrismaModuleOptionsFactory }), diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index a719710a5..a07298e0d 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -7,6 +7,7 @@ import { InternalServerErrorException, ServiceUnavailableException } from '@nestjs/common'; +import { isMailEnabled } from '@opendatacapture/schemas/mail'; import { $BrandingConfig } from '@opendatacapture/schemas/setup'; import type { CreateAdminData, InitAppOptions, SetupState, UpdateSetupStateData } from '@opendatacapture/schemas/setup'; @@ -56,6 +57,9 @@ export class SetupService { isDemo: Boolean(savedOptions?.isDemo), isExperimentalFeaturesEnabled: Boolean(savedOptions?.isExperimentalFeaturesEnabled), isGatewayEnabled: this.configService.get('GATEWAY_ENABLED'), + // Non-secret flag so the client can hide email UI when mail is off. The SMTP + // configuration itself is never exposed here (this is a public route). + isMailEnabled: isMailEnabled(savedOptions?.mailConfig), isSetup: Boolean(savedOptions?.isSetup), release: __RELEASE__, uptime: Math.round(process.uptime()) diff --git a/apps/api/src/users/__tests__/users.controller.spec.ts b/apps/api/src/users/__tests__/users.controller.spec.ts new file mode 100644 index 000000000..41f49d322 --- /dev/null +++ b/apps/api/src/users/__tests__/users.controller.spec.ts @@ -0,0 +1,134 @@ +import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; +import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; +import { NotFoundException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { AppAbility } from '@/auth/auth.types'; +import { GroupsService } from '@/groups/groups.service'; +import { MailService } from '@/mail/mail.service'; + +import { UsersController } from '../users.controller'; +import { UsersService } from '../users.service'; + +const ability = {} as AppAbility; + +const createUserData = { + basePermissionLevel: 'STANDARD' as const, + email: 'jane@example.org', + firstName: 'Jane', + groupIds: ['group-1', 'group-2'], + lastName: 'Doe', + password: 'Password123', + username: 'jane.doe' +}; + +describe('UsersController', () => { + let usersController: UsersController; + let usersService: MockedInstance; + let groupsService: MockedInstance; + let mailService: MockedInstance; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + MockFactory.createForService(UsersService), + MockFactory.createForService(GroupsService), + MockFactory.createForService(MailService) + ] + }).compile(); + usersController = moduleRef.get(UsersController); + usersService = moduleRef.get(UsersService); + groupsService = moduleRef.get(GroupsService); + mailService = moduleRef.get(MailService); + }); + + it('should be defined', () => { + expect(usersController).toBeDefined(); + }); + + describe('create', () => { + beforeEach(() => { + usersService.create.mockResolvedValue({ id: 'user-1', username: createUserData.username }); + groupsService.findById.mockImplementation((id: string, _options?: unknown) => + Promise.resolve({ id, name: `Name of ${id}` }) + ); + mailService.sendNewUserEmail.mockResolvedValue({ + message: 'rendered', + recipient: createUserData.email, + status: 'SENT' + }); + }); + + it('returns the created user together with the welcome email result', async () => { + await expect(usersController.create(createUserData, ability)).resolves.toMatchObject({ + id: 'user-1', + welcomeEmail: { status: 'SENT' } + }); + }); + + it('sends the welcome email with the login url derived from the request origin', async () => { + await usersController.create(createUserData, ability, 'https://odc.example.org'); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).toMatchObject({ + email: createUserData.email, + url: 'https://odc.example.org/auth/login', + username: createUserData.username + }); + }); + + it('never hands the password to the welcome email', async () => { + await usersController.create(createUserData, ability, 'https://odc.example.org'); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).not.toHaveProperty('password'); + }); + + it('sends an empty url when the origin header is absent', async () => { + await usersController.create(createUserData, ability); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).toMatchObject({ url: '' }); + }); + + // The controller forwards whatever was asked for and leaves the default to the mail service. + it('passes the language query param through to sendNewUserEmail', async () => { + await usersController.create(createUserData, ability); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).toMatchObject({ language: undefined }); + await usersController.create(createUserData, ability, undefined, 'fr'); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).toMatchObject({ language: 'fr' }); + }); + + it('joins the names of the resolved groups', async () => { + await usersController.create(createUserData, ability); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).toMatchObject({ + group: 'Name of group-1, Name of group-2' + }); + }); + + // Without this, dropping `{ ability }` from the service calls would leave every other test + // green while the lookups silently read across every group. + it('scopes both the create and the group lookups to the caller ability', async () => { + await usersController.create(createUserData, ability); + expect(usersService.create).toHaveBeenCalledWith(createUserData, { ability }); + expect(groupsService.findById).toHaveBeenCalledWith('group-1', { ability }); + expect(groupsService.findById).toHaveBeenCalledWith('group-2', { ability }); + }); + + it('omits groups the creator cannot read', async () => { + groupsService.findById.mockImplementation((id: string) => + id === 'group-1' ? Promise.reject(new NotFoundException()) : Promise.resolve({ id, name: `Name of ${id}` }) + ); + await usersController.create(createUserData, ability); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).toMatchObject({ group: 'Name of group-2' }); + }); + + it('propagates a group lookup failure that is not a permission denial', async () => { + groupsService.findById.mockRejectedValue(new Error('connection lost')); + await expect(usersController.create(createUserData, ability)).rejects.toThrow('connection lost'); + expect(mailService.sendNewUserEmail).not.toHaveBeenCalled(); + }); + + it('passes an empty group name when the user has no groups', async () => { + await usersController.create({ ...createUserData, groupIds: [] }, ability); + expect(groupsService.findById).not.toHaveBeenCalled(); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).toMatchObject({ group: '' }); + }); + }); +}); diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 730980550..5ca5bab65 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -1,11 +1,15 @@ -import { CurrentUser } from '@douglasneuroinformatics/libnest'; +import { CurrentUser, ParseSchemaPipe } from '@douglasneuroinformatics/libnest'; import type { RequestUser } from '@douglasneuroinformatics/libnest'; -import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Headers, NotFoundException, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { $Language } from '@opendatacapture/schemas/core'; +import type { Language } from '@opendatacapture/schemas/core'; import { $SelfUpdateUserData } from '@opendatacapture/schemas/user'; import type { AppAbility } from '@/auth/auth.types'; import { RouteAccess } from '@/core/decorators/route-access.decorator'; +import { GroupsService } from '@/groups/groups.service'; +import { MailService } from '@/mail/mail.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -14,7 +18,11 @@ import { UsersService } from './users.service'; @ApiTags('Users') @Controller({ path: 'users' }) export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly groupsService: GroupsService, + private readonly mailService: MailService + ) {} @ApiOperation({ summary: 'Get User by Username' }) @Get('/check-username/:username') @@ -26,8 +34,26 @@ export class UsersController { @ApiOperation({ summary: 'Create User' }) @Post() @RouteAccess({ action: 'create', subject: 'User' }) - create(@Body() user: CreateUserDto, @CurrentUser('ability') ability: AppAbility) { - return this.usersService.create(user, { ability }); + async create( + @Body() user: CreateUserDto, + @CurrentUser('ability') ability: AppAbility, + @Headers('origin') origin?: string, + @Query('language', new ParseSchemaPipe({ schema: $Language.optional() })) language?: Language + ) { + const created = await this.usersService.create(user, { ability }); + // Attempt to send the welcome email. The result is always returned (even when + // mail is disabled or the user has no email) so the client can fall back to a + // copy-pasteable message; the client ignores it entirely when mail is off. + const welcomeEmail = await this.mailService.sendNewUserEmail({ + email: user.email, + firstName: user.firstName, + group: await this.resolveGroupNames(user.groupIds, ability), + language, + lastName: user.lastName, + url: origin ? `${origin}/auth/login` : '', + username: user.username + }); + return { ...created, welcomeEmail }; } @ApiOperation({ summary: 'Delete User' }) @@ -69,4 +95,29 @@ export class UsersController { ) { return this.usersService.updateSelfById(id, update, currentUser); } + + /** + * Resolve accessible group names for the welcome-email `{{group}}` variable. A group the + * creator cannot read is omitted from the list; every other failure propagates, so a database + * fault surfaces instead of silently rendering an email with no groups in it. + */ + private async resolveGroupNames(groupIds: string[], ability: AppAbility): Promise { + if (!groupIds.length) { + return ''; + } + const groups = await Promise.all( + groupIds.map((id) => + this.groupsService.findById(id, { ability }).catch((err) => { + if (err instanceof NotFoundException) { + return null; + } + throw err; + }) + ) + ); + return groups + .filter((group): group is NonNullable => group !== null) + .map((group) => group.name) + .join(', '); + } } diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 9c4f894be..ca53f69da 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { GroupsModule } from '@/groups/groups.module'; +import { MailModule } from '@/mail/mail.module'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @@ -8,7 +9,7 @@ import { UsersService } from './users.service'; @Module({ controllers: [UsersController], exports: [UsersService], - imports: [GroupsModule], + imports: [GroupsModule, MailModule], providers: [UsersService] }) export class UsersModule {} diff --git a/apps/web/src/__tests__/assignment-email-form.test.tsx b/apps/web/src/__tests__/assignment-email-form.test.tsx new file mode 100644 index 000000000..f46933911 --- /dev/null +++ b/apps/web/src/__tests__/assignment-email-form.test.tsx @@ -0,0 +1,106 @@ +import type { Assignment } from '@opendatacapture/schemas/assignment'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AssignmentEmailForm } from '@/components/AssignmentEmailForm'; + +import '@/services/i18n'; + +const mocks = vi.hoisted(() => ({ + isMailEnabled: true, + sendEmail: vi.fn(), + useGroupQuery: vi.fn() +})); + +vi.mock('@/hooks/useGroupQuery', () => ({ + GROUP_QUERY_KEY: 'group', + useGroupQuery: mocks.useGroupQuery +})); + +vi.mock('@/hooks/useSetupStateQuery', () => ({ + useSetupStateQuery: () => ({ data: { isMailEnabled: mocks.isMailEnabled } }) +})); + +vi.mock('@/hooks/useSendAssignmentEmailMutation', () => ({ + useSendAssignmentEmailMutation: () => ({ isPending: false, mutate: mocks.sendEmail }) +})); + +const assignment = { + groupId: 'group-of-the-assignment', + id: 'assignment-1', + url: 'https://gateway.example.org/a/1' +} as Assignment; + +const frenchOnlyTemplate = { + body: { fr: 'Lien {{url}}' }, + id: 'tpl-fr', + name: 'Rappel', + subject: { fr: 'Objet' } +}; + +const submit = (recipient: string) => { + fireEvent.change(screen.getByTestId('assignment-email'), { target: { value: recipient } }); + fireEvent.click(screen.getByTestId('assignment-email-submit')); +}; + +beforeEach(() => { + // There are no vitest setup files in this repo, so RTL never auto-unmounts between tests. + cleanup(); + vi.clearAllMocks(); + mocks.isMailEnabled = true; + mocks.useGroupQuery.mockReturnValue({ data: { activeAssignmentEmailTemplateId: null, emailTemplates: [] } }); +}); + +describe('AssignmentEmailForm', () => { + // The server resolves the template from the assignment's own group; querying the currently + // selected group instead would offer templates the server will never consider. + it('queries the group the assignment belongs to', () => { + render(); + expect(mocks.useGroupQuery).toHaveBeenCalledWith('group-of-the-assignment'); + }); + + it('renders nothing when mail is disabled', () => { + mocks.isMailEnabled = false; + render(); + expect(screen.queryByTestId('assignment-email-form')).toBeNull(); + }); + + it('does not query a group when mail is disabled', () => { + mocks.isMailEnabled = false; + render(); + expect(mocks.useGroupQuery).toHaveBeenCalledWith(null); + }); + + it('sends the recipient and the built-in default template', () => { + render(); + submit('p@x.org'); + expect(mocks.sendEmail.mock.lastCall?.[0]).toMatchObject({ + assignmentId: 'assignment-1', + recipient: 'p@x.org', + templateId: null + }); + }); + + // The dropdown is rebuilt from the selected template's languages on every render, so an + // initial 'en' must not survive into the request when only French is on offer. + it('posts a language the selected template is actually authored in', () => { + mocks.useGroupQuery.mockReturnValue({ + data: { activeAssignmentEmailTemplateId: 'tpl-fr', emailTemplates: [frenchOnlyTemplate] } + }); + render(); + submit('p@x.org'); + expect(mocks.sendEmail.mock.lastCall?.[0]).toMatchObject({ language: 'fr', templateId: 'tpl-fr' }); + }); + + it('restricts the language to those the instrument supports', () => { + render(); + submit('p@x.org'); + expect(mocks.sendEmail.mock.lastCall?.[0]).toMatchObject({ language: 'fr' }); + }); + + it('does not send without a recipient', () => { + render(); + fireEvent.click(screen.getByTestId('assignment-email-submit')); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx new file mode 100644 index 000000000..053afb65e --- /dev/null +++ b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; + +import { Button, Input, Label, Select, Tooltip } from '@douglasneuroinformatics/libui/components'; +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { Assignment } from '@opendatacapture/schemas/assignment'; +import type { Language } from '@opendatacapture/schemas/core'; +import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; +import { CircleHelpIcon } from 'lucide-react'; + +import { LanguageSelect } from '@/components/LanguageSelect'; +import { useGroupQuery } from '@/hooks/useGroupQuery'; +import { useMailErrorMessage } from '@/hooks/useMailErrorMessage'; +import { useSendAssignmentEmailMutation } from '@/hooks/useSendAssignmentEmailMutation'; +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; +import { authoredLanguages, LANGUAGES } from '@/utils/language'; + +const DEFAULT_TEMPLATE_OPTION = '__default__'; + +export type AssignmentEmailFormProps = { + assignment: Assignment | null; + instrumentLanguages?: string[]; +}; + +export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: AssignmentEmailFormProps) => { + const { resolvedLanguage, t } = useTranslation(); + const setupStateQuery = useSetupStateQuery(); + const sendEmailMutation = useSendAssignmentEmailMutation(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const mailErrorMessage = useMailErrorMessage(); + const [recipient, setRecipient] = useState(''); + const [languageChoice, setLanguageChoice] = useState(resolvedLanguage); + const [templateChoice, setTemplateChoice] = useState(null); + const [feedback, setFeedback] = useState(null); + + // The server resolves the template from the assignment's own group, so this must query that + // group rather than whichever one is currently selected in the app store — on the datahub they + // can differ, and the two would then disagree about which templates exist. + const groupQuery = useGroupQuery(setupStateQuery.data.isMailEnabled ? assignment?.groupId : null); + + const templates = groupQuery.data?.emailTemplates ?? []; + const activeValue = groupQuery.data?.activeAssignmentEmailTemplateId ?? DEFAULT_TEMPLATE_OPTION; + const selectedTemplate = templateChoice ?? activeValue; + const templateOptions = [ + { label: t({ en: 'Built-in default', fr: 'Modèle par défaut' }), value: DEFAULT_TEMPLATE_OPTION }, + ...templates.map((template) => ({ label: template.name, value: template.id })) + ].sort((a, b) => (a.value === activeValue ? -1 : b.value === activeValue ? 1 : 0)); + + const selectedBody = + selectedTemplate === DEFAULT_TEMPLATE_OPTION + ? DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body + : templates.find((template) => template.id === selectedTemplate)?.body; + + const authored = authoredLanguages(selectedBody); + const templateLanguages = authored.length > 0 ? authored : LANGUAGES; + const languageOptions = instrumentLanguages + ? templateLanguages.filter((code) => instrumentLanguages.includes(code)) + : templateLanguages; + // Clamp to what is actually offered: switching to a template authored in one language must not + // leave the trigger showing a value with no matching item while a different one is posted. + const language = languageOptions.includes(languageChoice) ? languageChoice : (languageOptions[0] ?? 'en'); + + const sendEmail = () => { + if (!assignment || !recipient) { + return; + } + setFeedback(null); + const fail = (message: string) => { + setFeedback({ message, tone: 'error' }); + addNotification({ message, title: t({ en: 'Email failed', fr: 'Échec du courriel' }), type: 'error' }); + }; + sendEmailMutation.mutate( + { + assignmentId: assignment.id, + language, + recipient, + templateId: selectedTemplate === DEFAULT_TEMPLATE_OPTION ? null : selectedTemplate + }, + { + onError: () => fail(t({ en: 'The email could not be sent', fr: "Le courriel n'a pas pu être envoyé" })), + onSuccess: (result) => { + if (result.status !== 'SENT') { + fail(mailErrorMessage(result.error)); + return; + } + const message = t( + { en: 'Assignment link sent to {}', fr: "Lien d'évaluation envoyé à {}" }, + { args: [recipient] } + ); + setFeedback({ message, tone: 'success' }); + addNotification({ message, title: t({ en: 'Email sent', fr: 'Courriel envoyé' }), type: 'success' }); + setRecipient(''); + } + } + ); + }; + + if (!setupStateQuery.data.isMailEnabled) { + return null; + } + + return ( +
+ {templates.length > 0 && ( +
+ + +
+ )} +
+
+ + + + + + +

+ {/* Only the email is localized: the gateway does not read the link's `lang` param. */} + {t({ + en: 'The email is sent in the selected language when the template has been written in it. Participants choose their own language when they open the assignment.', + fr: "Le courriel est envoyé dans la langue sélectionnée lorsque le modèle a été rédigé dans celle-ci. Les participants choisissent leur propre langue à l'ouverture de l'évaluation." + })} +

+
+
+
+ +
+ +
+ setRecipient(event.target.value)} + /> + +
+ {feedback && ( +

+ {feedback.message} +

+ )} +
+ ); +}; diff --git a/apps/web/src/components/AssignmentEmailForm/index.ts b/apps/web/src/components/AssignmentEmailForm/index.ts new file mode 100644 index 000000000..ce648e834 --- /dev/null +++ b/apps/web/src/components/AssignmentEmailForm/index.ts @@ -0,0 +1 @@ +export * from './AssignmentEmailForm'; diff --git a/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx b/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx new file mode 100644 index 000000000..58cbeb74c --- /dev/null +++ b/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import { Button, Input, Label, TextArea } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { Language } from '@opendatacapture/schemas/core'; +import type { MailTemplate } from '@opendatacapture/schemas/mail'; + +import { LanguageSelect } from '@/components/LanguageSelect'; + +export type EmailTemplateEditorProps = { + error?: string; + /** Namespaces the control ids and test ids, so two editors can coexist on one page. */ + idPrefix: string; + onChange: (template: MailTemplate) => void; + readOnly?: boolean; + template: MailTemplate; + /** Placeholder names offered as insert buttons above the body. */ + variables?: readonly string[]; +}; + +/** Authors the per-language subject and body of an email template. */ +export const EmailTemplateEditor = ({ + error, + idPrefix, + onChange, + readOnly = false, + template, + variables = [] +}: EmailTemplateEditorProps) => { + const { resolvedLanguage, t } = useTranslation(); + const [language, setLanguage] = React.useState(resolvedLanguage); + // Where the caret was when the body last lost focus, so an inserted placeholder lands there + // rather than always at the end. + const bodyCursorRef = React.useRef(null); + + const body = template.body[language] ?? ''; + const subject = template.subject[language] ?? ''; + + const insertVariable = (variable: string) => { + const tag = `{{${variable}}}`; + const cursor = bodyCursorRef.current; + if (cursor === null) { + onChange({ ...template, body: { ...template.body, [language]: body ? `${body} ${tag}` : tag } }); + return; + } + bodyCursorRef.current = { end: cursor.start + tag.length, start: cursor.start + tag.length }; + onChange({ + ...template, + body: { ...template.body, [language]: body.slice(0, cursor.start) + tag + body.slice(cursor.end) } + }); + }; + + return ( +
+
+ + +
+ +
+ + + onChange({ ...template, subject: { ...template.subject, [language]: event.target.value } }) + } + /> +
+ +
+
+ + {!readOnly && variables.length > 0 && ( +
+ {t({ en: 'Insert:', fr: 'Insérer :' })} + {variables.map((variable) => { + // Placeholder syntax, not copy — it must render verbatim in every language. + const tag = `{{${variable}}}`; + return ( + + ); + })} +
+ )} +
+