From a53b04b50ed89b175f585b42d46cc7faeb5a09a7 Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Thu, 23 Jul 2026 11:55:18 -0400 Subject: [PATCH 1/3] feat(mail): add outgoing email, group email templates, and assignment emails Adds a mail module to the API backed by nodemailer, with admin-configurable SMTP settings stored on the setup state and never returned to clients. The public setup route exposes only a derived `isMailEnabled` flag so the client can hide all email UI when mail is off. Group managers can author named, categorized email templates (remote assignment / information) per group, with one active template per category. Remote assignment links can be emailed to a participant, and creating a user sends a welcome email whose rendered text is offered for manual copying when delivery fails. Adds an admin mail settings page, a group email templates page, and the supporting queries and mutations. Co-Authored-By: Claude Opus 4.8 --- apps/api/package.json | 2 + apps/api/prisma/schema.prisma | 58 + .../__tests__/assignments.controller.spec.ts | 149 +++ .../src/assignments/assignments.controller.ts | 43 +- .../api/src/assignments/assignments.module.ts | 4 +- .../dto/send-assignment-email.dto.ts | 19 + apps/api/src/groups/dto/update-group.dto.ts | 5 +- apps/api/src/groups/groups.service.ts | 4 +- .../src/mail/__tests__/mail.service.spec.ts | 310 +++++ .../api/src/mail/__tests__/mail.utils.spec.ts | 262 +++++ apps/api/src/mail/dto/test-mail.dto.ts | 16 + .../src/mail/dto/update-mail-settings.dto.ts | 13 + apps/api/src/mail/mail.controller.ts | 39 + apps/api/src/mail/mail.module.ts | 11 + apps/api/src/mail/mail.service.ts | 361 ++++++ apps/api/src/mail/mail.utils.ts | 344 ++++++ apps/api/src/main.ts | 4 +- apps/api/src/setup/setup.service.ts | 4 + .../users/__tests__/users.controller.spec.ts | 111 ++ apps/api/src/users/users.controller.ts | 48 +- apps/api/src/users/users.module.ts | 3 +- .../AssignmentEmailForm.tsx | 227 ++++ .../components/AssignmentEmailForm/index.ts | 1 + .../GroupEmailTemplates.tsx | 693 +++++++++++ .../components/GroupEmailTemplates/index.ts | 1 + apps/web/src/components/SaveStatus.tsx | 26 + .../SegmentedControl/SegmentedControl.tsx | 54 + .../src/components/SegmentedControl/index.ts | 1 + apps/web/src/hooks/useCreateUserMutation.ts | 47 +- apps/web/src/hooks/useMailSettingsQuery.ts | 20 + apps/web/src/hooks/useNavItems.ts | 16 +- .../hooks/useSendAssignmentEmailMutation.ts | 25 + apps/web/src/hooks/useTestMailMutation.ts | 18 + apps/web/src/hooks/useUpdateGroupMutation.ts | 5 - .../hooks/useUpdateMailSettingsMutation.ts | 25 + apps/web/src/route-tree.ts | 42 + apps/web/src/routes/_app/admin/mail.tsx | 1031 +++++++++++++++++ .../src/routes/_app/admin/users/create.tsx | 166 ++- .../_app/datahub/$subjectId/assignments.tsx | 9 + .../src/routes/_app/group/email-templates.tsx | 27 + apps/web/src/routes/_app/group/manage.tsx | 4 +- .../routes/_app/session/remote-assignment.tsx | 98 +- apps/web/src/utils/languages.ts | 1 + packages/schemas/package.json | 1 + packages/schemas/src/group/group.ts | 26 + packages/schemas/src/mail/mail.ts | 475 ++++++++ packages/schemas/src/setup/setup.ts | 7 + pnpm-lock.yaml | 6 + 48 files changed, 4768 insertions(+), 94 deletions(-) create mode 100644 apps/api/src/assignments/__tests__/assignments.controller.spec.ts create mode 100644 apps/api/src/assignments/dto/send-assignment-email.dto.ts create mode 100644 apps/api/src/mail/__tests__/mail.service.spec.ts create mode 100644 apps/api/src/mail/__tests__/mail.utils.spec.ts create mode 100644 apps/api/src/mail/dto/test-mail.dto.ts create mode 100644 apps/api/src/mail/dto/update-mail-settings.dto.ts create mode 100644 apps/api/src/mail/mail.controller.ts create mode 100644 apps/api/src/mail/mail.module.ts create mode 100644 apps/api/src/mail/mail.service.ts create mode 100644 apps/api/src/mail/mail.utils.ts create mode 100644 apps/api/src/users/__tests__/users.controller.spec.ts create mode 100644 apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx create mode 100644 apps/web/src/components/AssignmentEmailForm/index.ts create mode 100644 apps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsx create mode 100644 apps/web/src/components/GroupEmailTemplates/index.ts create mode 100644 apps/web/src/components/SaveStatus.tsx create mode 100644 apps/web/src/components/SegmentedControl/SegmentedControl.tsx create mode 100644 apps/web/src/components/SegmentedControl/index.ts create mode 100644 apps/web/src/hooks/useMailSettingsQuery.ts create mode 100644 apps/web/src/hooks/useSendAssignmentEmailMutation.ts create mode 100644 apps/web/src/hooks/useTestMailMutation.ts create mode 100644 apps/web/src/hooks/useUpdateMailSettingsMutation.ts create mode 100644 apps/web/src/routes/_app/admin/mail.tsx create mode 100644 apps/web/src/routes/_app/group/email-templates.tsx create mode 100644 apps/web/src/utils/languages.ts create mode 100644 packages/schemas/src/mail/mail.ts diff --git a/apps/api/package.json b/apps/api/package.json index 6db82b501..88a4c6abc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -53,6 +53,7 @@ "mongodb": "^6.15.0", "msgpackr": "catalog:", "neverthrow": "catalog:", + "nodemailer": "^7.0.12", "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": "^7.0.5", "@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 05a717c14..45e3548b2 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -102,6 +102,30 @@ type GroupSettings { minimumAge Int? } +// Mirrors $GroupEmailTemplateCategory in @opendatacapture/schemas so Prisma returns the +// narrowed enum rather than a bare string (which the Zod-typed Group records reject). +enum GroupEmailTemplateCategory { + REMOTE_ASSIGNMENT + INFORMATION +} + +// A string authored in the instance's active languages; each field is optional so a +// template can be authored in a single language without requiring all of them. +type LocalizedString { + en String? + es String? + fr String? +} + +// A group-manager-authored, categorized email template for a group's participants. +type GroupEmailTemplate { + id String + name String + category GroupEmailTemplateCategory + subject LocalizedString? + body LocalizedString? +} + model Group { createdAt DateTime @default(now()) @db.Date updatedAt DateTime @updatedAt @db.Date @@ -124,6 +148,10 @@ model Group { userIds String[] @db.ObjectId users User[] @relation(fields: [userIds], references: [id]) + emailTemplates GroupEmailTemplate[] + activeAssignmentEmailTemplateId String? + activeInformationTemplateId String? + @@map("GroupModel") } @@ -381,14 +409,44 @@ 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 { + apiKey String + awsAccessKeyId String? + awsRegion String? + domain String? + enabled Boolean + encryption String + host String + password String + port Int + provider String? + region String? + senderAddress String + senderName String? + transport 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 id String @id @default(auto()) @map("_id") @db.ObjectId + activeLanguages String[] branding BrandingConfig? 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..26ab7b6af --- /dev/null +++ b/apps/api/src/assignments/__tests__/assignments.controller.spec.ts @@ -0,0 +1,149 @@ +import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; +import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; +import { Test } from '@nestjs/testing'; +import type { MailLanguage } from '@opendatacapture/schemas/mail'; +import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; +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 { AssignmentsController } from '../assignments.controller'; +import { AssignmentsService } from '../assignments.service'; + +const ability = {} as AppAbility; + +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}}' }, + category: 'REMOTE_ASSIGNMENT', + id: 'tpl-1', + name: 'Custom', + subject: { en: 'Custom subject', fr: 'Objet personnalisé' } +}; + +describe('AssignmentsController', () => { + let assignmentsController: AssignmentsController; + let assignmentsService: MockedInstance; + let groupsService: MockedInstance; + let mailService: MockedInstance; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [AssignmentsController], + providers: [ + MockFactory.createForService(AssignmentsService), + MockFactory.createForService(GroupsService), + MockFactory.createForService(MailService) + ] + }).compile(); + assignmentsController = moduleRef.get(AssignmentsController); + assignmentsService = moduleRef.get(AssignmentsService); + groupsService = moduleRef.get(GroupsService); + mailService = moduleRef.get(MailService); + }); + + it('should be defined', () => { + expect(assignmentsController).toBeDefined(); + }); + + describe('sendEmail', () => { + const sendEmail = (body: { language: MailLanguage; recipient: string; templateId?: null | string }) => + assignmentsController.sendEmail('assignment-1', body, ability); + + 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({ + body: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body.en, + expiresAt: '2026-08-01', + recipient: 'p@x.org', + subject: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en, + url: `${assignment.url}?lang=en` + }); + }); + + 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({ + body: customTemplate.body.en, + subject: customTemplate.subject.en + }); + }); + + 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({ + body: customTemplate.body.en, + subject: customTemplate.subject.en + }); + }); + + 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({ + body: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body.en, + subject: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en + }); + }); + + it('ignores a matching template of the wrong category', async () => { + assignmentsService.findById.mockResolvedValueOnce(assignment); + groupsService.findById.mockResolvedValueOnce({ + activeAssignmentEmailTemplateId: 'tpl-1', + emailTemplates: [{ ...customTemplate, category: 'INFORMATION' }] + }); + await sendEmail({ language: 'en', recipient: 'p@x.org' }); + expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({ + body: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body.en, + subject: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en + }); + }); + + it('sends the French content when language is fr', 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({ + body: customTemplate.body.fr, + subject: customTemplate.subject.fr + }); + }); + + 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..c051ce405 100644 --- a/apps/api/src/assignments/assignments.controller.ts +++ b/apps/api/src/assignments/assignments.controller.ts @@ -3,17 +3,27 @@ import type { RequestUser } from '@douglasneuroinformatics/libnest'; import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiOperation } from '@nestjs/swagger'; 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 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 { pickLocale } from '@/mail/mail.utils'; 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 groupsService: GroupsService, + private readonly mailService: MailService + ) {} @ApiOperation({ summary: 'Create Assignment' }) @Post() @@ -29,6 +39,37 @@ export class AssignmentsController { return this.assignmentsService.find({ subjectId }, { ability }); } + @ApiOperation({ summary: 'Email Assignment Link' }) + @Post(':id/email') + @RouteAccess({ action: 'update', subject: 'Assignment' }) + async sendEmail( + @Param('id') id: string, + @Body() { language, recipient, templateId }: SendAssignmentEmailDto, + @CurrentUser('ability') ability: AppAbility + ): Promise { + 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( + ({ category, id }) => category === 'REMOTE_ASSIGNMENT' && id === targetId + ); + if (chosen?.body && chosen?.subject) { + template = { body: chosen.body, subject: chosen.subject }; + } + } + return this.mailService.sendAssignmentEmail({ + body: pickLocale(template.body, language), + expiresAt: new Date(assignment.expiresAt).toISOString().slice(0, 10), + recipient, + subject: pickLocale(template.subject, language), + url: `${assignment.url}?lang=${language}` + }); + } + @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..bc748b736 --- /dev/null +++ b/apps/api/src/assignments/dto/send-assignment-email.dto.ts @@ -0,0 +1,19 @@ +import { ValidationSchema } from '@douglasneuroinformatics/libnest'; +import { ApiProperty } from '@nestjs/swagger'; +import { $SendAssignmentEmailData } from '@opendatacapture/schemas/mail'; +import type { MailLanguage, SendAssignmentEmailData } from '@opendatacapture/schemas/mail'; + +@ValidationSchema($SendAssignmentEmailData) +export class SendAssignmentEmailDto implements SendAssignmentEmailData { + @ApiProperty({ description: 'The language to send the email in', enum: ['en', 'es', 'fr'] }) + language: MailLanguage; + + @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/groups/dto/update-group.dto.ts b/apps/api/src/groups/dto/update-group.dto.ts index d123fa040..0bdc10875 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; + activeInformationTemplateId?: null | string; + emailTemplates?: GroupEmailTemplate[]; 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..6afb2d432 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -69,7 +69,7 @@ export class GroupsService { async updateById( id: string, - { accessibleInstrumentIds, instrumentRepoIds, settings, ...data }: UpdateGroupDto, + { accessibleInstrumentIds, emailTemplates, instrumentRepoIds, settings, ...data }: UpdateGroupDto, { ability }: EntityOperationOptions = {} ) { const where: Prisma.GroupWhereInput = { AND: [accessibleQuery(ability, 'update', 'Group')], id }; @@ -107,6 +107,8 @@ export class GroupsService { 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 })) 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..d10954f3f --- /dev/null +++ b/apps/api/src/mail/__tests__/mail.service.spec.ts @@ -0,0 +1,310 @@ +import { 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 { ServiceUnavailableException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { UpdateMailConfigData } from '@opendatacapture/schemas/mail'; +import { createTransport } from 'nodemailer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; + +import { MailService } from '../mail.service'; + +vi.mock('nodemailer', () => ({ createTransport: vi.fn() })); + +const validConfig: UpdateMailConfigData = { + apiKey: '', + awsAccessKeyId: '', + awsRegion: '', + domain: '', + enabled: true, + encryption: 'starttls', + host: 'smtp.example.org', + password: 'secret', + port: 587, + provider: 'mailgun', + region: 'us', + senderAddress: 'noreply@example.org', + senderName: 'ODC', + transport: 'smtp', + username: 'user' +}; + +const mailgunConfig: UpdateMailConfigData = { + apiKey: 'key-123', + awsAccessKeyId: '', + awsRegion: '', + domain: 'mg.example.org', + enabled: true, + encryption: 'starttls', + host: '', + password: '', + port: 587, + provider: 'mailgun', + region: 'us', + senderAddress: 'noreply@example.org', + senderName: 'ODC', + transport: 'http', + username: '' +}; + +const sesConfig: UpdateMailConfigData = { + ...mailgunConfig, + apiKey: 'aws-secret', + awsAccessKeyId: 'AKIAEXAMPLE', + awsRegion: 'us-east-1', + domain: '', + provider: 'ses' +}; + +const postmarkConfig: UpdateMailConfigData = { + ...mailgunConfig, + apiKey: 'postmark-token', + domain: '', + provider: 'postmark' +}; + +const newUserArgs = { + email: 'u@x.org', + firstName: 'Jane', + group: 'G', + lastName: 'Doe', + password: 'pw', + url: 'https://x', + username: 'jdoe' +}; + +/** A minimal `fetch` Response stand-in exposing only what the service reads. */ +const httpResponse = (status: number) => ({ ok: status >= 200 && status < 300, status }); + +describe('MailService', () => { + let mailService: MailService; + let setupStateModel: MockedInstance>; + let transporter: { sendMail: Mock; verify: Mock }; + let fetchMock: Mock; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + MailService, + MockFactory.createForModelToken(getModelToken('SetupState')), + MockFactory.createForService(LoggingService) + ] + }).compile(); + setupStateModel = moduleRef.get(getModelToken('SetupState')); + mailService = moduleRef.get(MailService); + transporter = { sendMail: vi.fn().mockResolvedValue({}), verify: vi.fn().mockResolvedValue(true) }; + (createTransport as Mock).mockReturnValue(transporter); + fetchMock = vi.fn().mockResolvedValue(httpResponse(200)); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('getSettings', () => { + it('strips secrets and exposes has-flags', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: { ...validConfig, apiKey: 'k' } }); + const settings = await mailService.getSettings(); + expect(settings.config).toMatchObject({ hasApiKey: true, hasPassword: true, host: 'smtp.example.org' }); + expect(settings.config).not.toHaveProperty('password'); + expect(settings.config).not.toHaveProperty('apiKey'); + }); + + it('exposes the transport and provider fields', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: mailgunConfig }); + const settings = await mailService.getSettings(); + expect(settings.config).toMatchObject({ + domain: 'mg.example.org', + provider: 'mailgun', + region: 'us', + transport: 'http' + }); + }); + + 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('isEnabled', () => { + it('is true when enabled and a host is set (smtp)', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: validConfig }); + expect(await mailService.isEnabled()).toBe(true); + }); + + it('is false when disabled', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: { ...validConfig, enabled: false } }); + expect(await mailService.isEnabled()).toBe(false); + }); + + it('is true for http when the api key and mailgun domain are set', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: mailgunConfig }); + expect(await mailService.isEnabled()).toBe(true); + }); + + it('is false for a mailgun config missing its sending domain', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: { ...mailgunConfig, domain: '' } }); + expect(await mailService.isEnabled()).toBe(false); + }); + + it('is true for an SES config with a key, access key ID, and region', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: sesConfig }); + expect(await mailService.isEnabled()).toBe(true); + }); + + it('is false for an SES config missing its region', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: { ...sesConfig, awsRegion: '' } }); + expect(await mailService.isEnabled()).toBe(false); + }); + + it('is true for a Postmark config with only a token', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: postmarkConfig }); + expect(await mailService.isEnabled()).toBe(true); + }); + }); + + 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', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: '1', isSetup: true, mailConfig: validConfig }); + await mailService.updateSettings({ config: { ...validConfig, password: undefined } }); + expect(setupStateModel.update.mock.lastCall?.[0]).toMatchObject({ + data: { mailConfig: { set: { password: 'secret' } } } + }); + }); + }); + + describe('test', () => { + it('returns success when the smtp connection verifies', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: validConfig }); + expect(await mailService.test({})).toEqual({ success: true }); + expect(transporter.verify).toHaveBeenCalled(); + }); + + it('returns a friendly error when smtp verification fails', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: validConfig }); + transporter.verify.mockRejectedValueOnce({ code: 'EAUTH' }); + const result = await mailService.test({}); + expect(result.success).toBe(false); + expect(result.error).toMatch(/authentication failed/i); + }); + + it('verifies an http config against the provider and can send a test message', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: mailgunConfig }); + const result = await mailService.test({ recipient: 'p@x.org' }); + expect(result).toEqual({ success: true }); + // First call verifies the domain, second delivers the message. + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toContain('api.mailgun.net/v3/domains/mg.example.org'); + expect(fetchMock.mock.calls[1]?.[0]).toContain('/v3/mg.example.org/messages'); + expect(transporter.verify).not.toHaveBeenCalled(); + }); + + it('maps an http auth failure to a friendly error', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: mailgunConfig }); + fetchMock.mockResolvedValueOnce(httpResponse(401)); + const result = await mailService.test({}); + expect(result.success).toBe(false); + expect(result.error).toMatch(/authentication failed/i); + }); + + 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: { ...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: validConfig }); + const result = await mailService.sendNewUserEmail({ ...newUserArgs, email: null }); + expect(result.status).toBe('NO_RECIPIENT'); + }); + + it('is SENT over smtp when delivery succeeds', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: validConfig }); + const result = await mailService.sendNewUserEmail(newUserArgs); + expect(result.status).toBe('SENT'); + expect(transporter.sendMail).toHaveBeenCalled(); + }); + + it('renders the French content when language is fr', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: validConfig }); + const result = await mailService.sendNewUserEmail({ ...newUserArgs, language: 'fr' }); + expect(result.status).toBe('SENT'); + expect(result.message).toContain('Bonjour'); + }); + + it('is SENT over the http provider when delivery succeeds', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: mailgunConfig }); + const result = await mailService.sendNewUserEmail(newUserArgs); + expect(result.status).toBe('SENT'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toContain('/v3/mg.example.org/messages'); + expect(transporter.sendMail).not.toHaveBeenCalled(); + }); + + it('is FAILED with a friendly error when smtp delivery throws', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: validConfig }); + transporter.sendMail.mockRejectedValueOnce({ code: 'ECONNREFUSED' }); + const result = await mailService.sendNewUserEmail(newUserArgs); + expect(result.status).toBe('FAILED'); + expect(result.error).toMatch(/refused/i); + }); + + it('is FAILED with a friendly error when the http provider rejects the request', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: mailgunConfig }); + fetchMock.mockResolvedValueOnce(httpResponse(401)); + const result = await mailService.sendNewUserEmail(newUserArgs); + expect(result.status).toBe('FAILED'); + expect(result.error).toMatch(/authentication failed/i); + }); + }); + + describe('sendAssignmentEmail', () => { + it('substitutes url/expiresAt and sends when enabled', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: validConfig }); + const result = await mailService.sendAssignmentEmail({ + body: 'Link: {{url}} (expires {{expiresAt}})', + expiresAt: '2026-01-01', + recipient: 'p@x.org', + subject: 'Assignment', + url: 'https://assign' + }); + expect(result.status).toBe('SENT'); + expect(result.message).toBe('Link: https://assign (expires 2026-01-01)'); + }); + + it('appends the link when the template body omits {{url}}', async () => { + setupStateModel.findFirst.mockResolvedValue({ mailConfig: validConfig }); + const result = await mailService.sendAssignmentEmail({ + body: 'Please complete your assignment.', + expiresAt: '2026-01-01', + recipient: 'p@x.org', + subject: '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..143be31d0 --- /dev/null +++ b/apps/api/src/mail/__tests__/mail.utils.spec.ts @@ -0,0 +1,262 @@ +import type { MailConfig } from '@opendatacapture/schemas/mail'; +import { describe, expect, it } from 'vitest'; + +import { + buildSendRequest, + buildVerifyRequest, + describeHttpMailError, + describeMailError, + encryptionToTransportFlags, + formatSender, + HttpMailError, + pickLocale, + renderTemplate +} from '../mail.utils'; + +const baseHttpConfig: MailConfig = { + apiKey: 'key-123', + awsAccessKeyId: '', + awsRegion: '', + domain: 'mg.example.org', + enabled: true, + encryption: 'starttls', + host: '', + password: '', + port: 587, + provider: 'mailgun', + region: 'us', + senderAddress: 'noreply@example.org', + senderName: 'ODC', + transport: 'http', + username: '' +}; + +const sesConfig: MailConfig = { + ...baseHttpConfig, + apiKey: 'aws-secret-key', + awsAccessKeyId: 'AKIAEXAMPLE', + awsRegion: 'us-east-1', + provider: 'ses' +}; + +const postmarkConfig: MailConfig = { ...baseHttpConfig, apiKey: 'postmark-token', provider: 'postmark' }; + +const message = { subject: 'Hi', text: 'Body', to: 'p@x.org' }; + +// A fixed instant so the SES SigV4 signature is deterministic across test runs. +const fixedNow = new Date('2026-07-08T12:00:00Z'); + +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('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 }); + }); +}); + +describe('describeMailError', () => { + it('maps auth failures', () => { + expect(describeMailError({ code: 'EAUTH' })).toMatch(/authentication failed/i); + }); + + it('maps unknown host (by code or message)', () => { + expect(describeMailError({ code: 'ENOTFOUND' })).toMatch(/could not be found/i); + expect(describeMailError({ message: 'getaddrinfo ENOTFOUND smtp.bad' })).toMatch(/could not be found/i); + }); + + it('maps a refused connection', () => { + expect(describeMailError({ code: 'ECONNREFUSED' })).toMatch(/refused/i); + }); + + it('maps TLS/version mismatch to an encryption hint', () => { + expect(describeMailError({ code: 'ESOCKET' })).toMatch(/secure connection/i); + expect(describeMailError({ message: 'SSL routines:tls_validate_record_header:wrong version number' })).toMatch( + /secure connection/i + ); + }); + + it('returns the generic message for timeouts (no technical wording)', () => { + const result = describeMailError({ code: 'ETIMEDOUT', message: 'Connection timeout' }); + expect(result).toBe('Please check and reconfigure your mail settings and try again.'); + expect(result).not.toMatch(/timeout|timed out/i); + }); + + it('never leaks a raw/unknown error', () => { + expect(describeMailError(new Error('0FC2C9C667C0000:error:0A00010B:SSL routines'))).not.toMatch(/0FC2C9/); + expect(describeMailError('something weird')).toBe('Please check and reconfigure your mail settings and try again.'); + }); + + it('delegates HttpMailError to the http status mapping', () => { + expect(describeMailError(new HttpMailError(401))).toMatch(/authentication failed/i); + expect(describeMailError(new HttpMailError(404))).toMatch(/domain could not be found/i); + }); +}); + +describe('describeHttpMailError', () => { + it('maps auth failures', () => { + expect(describeHttpMailError(401)).toMatch(/authentication failed/i); + expect(describeHttpMailError(403)).toMatch(/authentication failed/i); + }); + + it('maps a missing domain', () => { + expect(describeHttpMailError(404)).toMatch(/domain could not be found/i); + }); + + it('maps a rejected request', () => { + expect(describeHttpMailError(400)).toMatch(/rejected the request/i); + expect(describeHttpMailError(422)).toMatch(/rejected the request/i); + }); + + it('maps rate limiting', () => { + expect(describeHttpMailError(429)).toMatch(/rate-limiting/i); + }); + + it('falls back to the generic message for other statuses', () => { + expect(describeHttpMailError(500)).toBe('Please check and reconfigure your mail settings and try again.'); + }); +}); + +describe('buildSendRequest', () => { + it('builds a form-encoded, basic-auth Mailgun request against the region endpoint', () => { + const request = buildSendRequest(baseHttpConfig, message); + expect(request.method).toBe('POST'); + expect(request.url).toBe('https://api.mailgun.net/v3/mg.example.org/messages'); + expect(request.headers.authorization).toMatch(/^Basic /); + expect(request.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + const params = new URLSearchParams(request.body); + expect(params.get('from')).toBe('"ODC" '); + expect(params.get('to')).toBe('p@x.org'); + expect(params.get('subject')).toBe('Hi'); + expect(params.get('text')).toBe('Body'); + }); + + it('uses the EU endpoint for eu-region Mailgun', () => { + const request = buildSendRequest({ ...baseHttpConfig, region: 'eu' }, message); + expect(request.url).toBe('https://api.eu.mailgun.net/v3/mg.example.org/messages'); + }); + + it('builds a bearer-auth JSON SendGrid request', () => { + const request = buildSendRequest({ ...baseHttpConfig, provider: 'sendgrid' }, message); + expect(request.url).toBe('https://api.sendgrid.com/v3/mail/send'); + expect(request.headers.authorization).toBe('Bearer key-123'); + expect(request.headers['Content-Type']).toBe('application/json'); + const payload = JSON.parse(request.body!); + expect(payload.from.email).toBe('noreply@example.org'); + expect(payload.personalizations[0].to[0].email).toBe('p@x.org'); + expect(payload.subject).toBe('Hi'); + }); + + it('builds a token-authenticated Postmark request', () => { + const request = buildSendRequest(postmarkConfig, message); + expect(request.url).toBe('https://api.postmarkapp.com/email'); + expect(request.headers['X-Postmark-Server-Token']).toBe('postmark-token'); + const payload = JSON.parse(request.body!); + expect(payload.From).toBe('"ODC" '); + expect(payload.To).toBe('p@x.org'); + expect(payload.Subject).toBe('Hi'); + expect(payload.TextBody).toBe('Body'); + }); + + it('builds a SigV4-signed SES request against the region endpoint', () => { + const request = buildSendRequest(sesConfig, message, fixedNow); + expect(request.method).toBe('POST'); + expect(request.url).toBe('https://email.us-east-1.amazonaws.com/v2/email/outbound-emails'); + expect(request.headers['x-amz-date']).toBe('20260708T120000Z'); + expect(request.headers.authorization).toContain( + 'AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20260708/us-east-1/ses/aws4_request' + ); + expect(request.headers.authorization).toContain('SignedHeaders=content-type;host;x-amz-date'); + expect(request.headers.authorization).toMatch(/Signature=[0-9a-f]{64}$/); + const payload = JSON.parse(request.body!); + expect(payload.FromEmailAddress).toBe('"ODC" '); + expect(payload.Destination.ToAddresses).toEqual(['p@x.org']); + expect(payload.Content.Simple.Subject.Data).toBe('Hi'); + expect(payload.Content.Simple.Body.Text.Data).toBe('Body'); + }); + + it('produces a stable SES signature for the same inputs and instant', () => { + const a = buildSendRequest(sesConfig, message, fixedNow); + const b = buildSendRequest(sesConfig, message, fixedNow); + expect(a.headers.authorization).toBe(b.headers.authorization); + }); +}); + +describe('buildVerifyRequest', () => { + it('builds a Mailgun domain lookup', () => { + const request = buildVerifyRequest(baseHttpConfig); + expect(request.method).toBe('GET'); + expect(request.url).toBe('https://api.mailgun.net/v3/domains/mg.example.org'); + expect(request.headers.authorization).toMatch(/^Basic /); + }); + + it('builds a SendGrid scopes lookup', () => { + const request = buildVerifyRequest({ ...baseHttpConfig, provider: 'sendgrid' }); + expect(request.url).toBe('https://api.sendgrid.com/v3/scopes'); + expect(request.headers.authorization).toBe('Bearer key-123'); + }); + + it('builds a Postmark server lookup', () => { + const request = buildVerifyRequest(postmarkConfig); + expect(request.url).toBe('https://api.postmarkapp.com/server'); + expect(request.headers['X-Postmark-Server-Token']).toBe('postmark-token'); + }); + + it('builds a SigV4-signed SES account lookup without a content-type header', () => { + const request = buildVerifyRequest(sesConfig, fixedNow); + expect(request.method).toBe('GET'); + expect(request.url).toBe('https://email.us-east-1.amazonaws.com/v2/account'); + expect(request.headers['x-amz-date']).toBe('20260708T120000Z'); + expect(request.headers.authorization).toContain('SignedHeaders=host;x-amz-date'); + }); +}); 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..44b5d014c --- /dev/null +++ b/apps/api/src/mail/mail.service.ts @@ -0,0 +1,361 @@ +import { InjectModel, LoggingService } from '@douglasneuroinformatics/libnest'; +import type { Model } from '@douglasneuroinformatics/libnest'; +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { $MailConfig, DEFAULT_NEW_USER_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; +import type { + EmailDeliveryResult, + MailConfig, + MailLanguage, + MailSettings, + MailTemplate, + TestMailData, + TestMailResult, + UpdateMailConfigData, + UpdateMailSettingsData +} from '@opendatacapture/schemas/mail'; +import { createTransport } from 'nodemailer'; +import type { Transporter } from 'nodemailer'; + +import { + buildSendRequest, + buildVerifyRequest, + describeMailError, + encryptionToTransportFlags, + formatSender, + HttpMailError, + pickLocale, + renderTemplate +} from './mail.utils'; + +type SendOptions = { + body: { + html?: string; + text: string; + }; + subject: string; + to: string; +}; + +/** + * 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 loggingService: LoggingService + ) {} + + /** The full SMTP configuration including the secret password, or null if never configured. */ + async getConfig(): Promise { + const setupState = await this.setupStateModel.findFirst(); + if (!setupState?.mailConfig) { + return null; + } + // Validate the stored value so scalar columns (e.g. `encryption`) are narrowed + // from `string` to their expected literal union types. + const result = $MailConfig.safeParse(setupState.mailConfig); + return result.success ? result.data : null; + } + + /** The new-user welcome template, falling back to the seeded default. */ + async getNewUserEmailTemplate(): Promise { + const setupState = await this.setupStateModel.findFirst(); + const body = setupState?.newUserEmailTemplate?.body; + const subject = setupState?.newUserEmailTemplate?.subject; + if (!body || !subject) { + return { ...DEFAULT_NEW_USER_EMAIL_TEMPLATE }; + } + return { body, subject }; + } + + /** Admin-facing settings: secrets are replaced by `hasPassword`/`hasApiKey` flags. */ + async getSettings(): Promise { + const config = await this.getConfig(); + return { + config: config + ? { + awsAccessKeyId: config.awsAccessKeyId, + awsRegion: config.awsRegion, + domain: config.domain, + enabled: config.enabled, + encryption: config.encryption, + hasApiKey: Boolean(config.apiKey), + hasPassword: Boolean(config.password), + host: config.host, + port: config.port, + provider: config.provider, + region: config.region, + senderAddress: config.senderAddress, + senderName: config.senderName, + transport: config.transport, + username: config.username + } + : null, + newUserEmailTemplate: await this.getNewUserEmailTemplate() + }; + } + + /** Whether outgoing email is both configured and switched on. */ + async isEnabled(): Promise { + const config = await this.getConfig(); + if (!config?.enabled) { + return false; + } + if (config.transport === 'http') { + // Each provider needs its secret plus any provider-specific fields: Mailgun a sending + // domain, SES an access key ID and region; SendGrid and Postmark need only the key/token. + if (config.provider === 'mailgun') { + return Boolean(config.apiKey && config.domain); + } + if (config.provider === 'ses') { + return Boolean(config.apiKey && config.awsAccessKeyId && config.awsRegion); + } + return Boolean(config.apiKey); + } + return Boolean(config.host); + } + + /** + * Email a remote-assignment link to a participant. The subject/body come from the + * caller (the participant's group active template, or a default), and `{{url}}` / + * `{{expiresAt}}` placeholders are substituted here. + */ + async sendAssignmentEmail({ + body, + expiresAt, + recipient, + subject: subjectTemplate, + url + }: { + body: string; + expiresAt: string; + recipient: string; + subject: string; + url: string; + }): Promise { + const variables = { expiresAt, url }; + const rendered = renderTemplate(body, variables); + // 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. + const message = rendered.includes(url) ? rendered : `${rendered}\n\n${url}`; + const subject = renderTemplate(subjectTemplate, variables); + + if (!(await this.isEnabled())) { + return { message, recipient, status: 'DISABLED' }; + } + try { + await this.sendMail({ body: { text: message }, subject, to: recipient }); + return { message, recipient, status: 'SENT' }; + } catch (err) { + this.loggingService.error(`Failed to send assignment email to ${recipient}: ${String(err)}`); + return { error: describeMailError(err), message, recipient, status: 'FAILED' }; + } + } + + /** Build and send the welcome email for a newly created user, in the requested language. */ + async sendNewUserEmail({ + email, + firstName, + group, + language = 'en', + lastName, + password, + url, + username + }: { + email?: null | string; + firstName: string; + group: string; + language?: MailLanguage; + lastName: string; + password: string; + url: string; + username: string; + }): Promise { + const template = await this.getNewUserEmailTemplate(); + const variables = { firstName, group, lastName, password, url, username }; + const message = renderTemplate(pickLocale(template.body, language), variables); + const subject = renderTemplate(pickLocale(template.subject, language), variables); + + if (!(await this.isEnabled())) { + return { message, recipient: email, status: 'DISABLED' }; + } + if (!email) { + return { message, recipient: null, status: 'NO_RECIPIENT' }; + } + try { + await this.sendMail({ body: { text: message }, subject, to: email }); + return { message, recipient: email, status: 'SENT' }; + } catch (err) { + this.loggingService.error(`Failed to send welcome email to ${email}: ${String(err)}`); + return { error: describeMailError(err), message, recipient: email, status: 'FAILED' }; + } + } + + /** + * 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 resolved = await this.resolveConfig(config); + if (!resolved) { + return { error: 'Mail has not been configured', success: false }; + } + const testMessage: SendOptions = { + body: { + text: 'This is a test email from Open Data Capture. Your mail server is configured correctly.' + }, + subject: 'Open Data Capture — test email', + to: recipient ?? '' + }; + try { + if (resolved.transport === 'http') { + await this.verifyHttp(resolved); + if (recipient) { + await this.sendViaHttp(resolved, testMessage); + } + } else { + const transporter = this.createTransporter(resolved); + await transporter.verify(); + if (recipient) { + await this.send(transporter, resolved, testMessage); + } + } + 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 previously 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 nextConfig = data.config ? await this.resolveConfig(data.config) : undefined; + await this.setupStateModel.update({ + data: { + ...(nextConfig ? { mailConfig: { set: nextConfig } } : {}), + ...(data.newUserEmailTemplate ? { newUserEmailTemplate: { set: data.newUserEmailTemplate } } : {}) + }, + where: { id: setupState.id } + }); + return this.getSettings(); + } + + 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) + }); + } + + /** + * Resolve a (possibly partial) update payload into a complete config. A blank or + * omitted secret (`password`/`apiKey`) keeps the previously saved one, so secrets + * never have to round-trip to the client. + */ + private async resolveConfig(partial?: UpdateMailConfigData): Promise { + const saved = await this.getConfig(); + if (!partial) { + return saved; + } + // A blank/omitted secret means "keep the saved one"; only a non-empty value replaces it. + let apiKey = saved?.apiKey ?? ''; + if (partial.apiKey) { + apiKey = partial.apiKey; + } + let password = saved?.password ?? ''; + if (partial.password) { + password = partial.password; + } + return { + apiKey, + awsAccessKeyId: partial.awsAccessKeyId, + awsRegion: partial.awsRegion, + domain: partial.domain, + enabled: partial.enabled, + encryption: partial.encryption, + host: partial.host, + password, + port: partial.port, + provider: partial.provider, + region: partial.region, + senderAddress: partial.senderAddress, + senderName: partial.senderName ?? null, + transport: partial.transport, + username: partial.username + }; + } + + private async send(transporter: Transporter, config: MailConfig, options: SendOptions): Promise { + await transporter.sendMail({ + from: formatSender(config), + html: options.body.html, + subject: options.subject, + text: options.body.text, + to: options.to + }); + } + + /** Send a message using the currently saved configuration and its active transport. */ + private async sendMail(options: SendOptions): Promise { + const config = await this.getConfig(); + if (!config) { + throw new Error('Mail has not been configured'); + } + if (config.transport === 'http') { + await this.sendViaHttp(config, options); + } else { + await this.send(this.createTransporter(config), config, options); + } + } + + /** Deliver a message through the provider's HTTP API. Throws {@link HttpMailError} on a non-2xx. */ + private async sendViaHttp(config: MailConfig, options: SendOptions): Promise { + const request = buildSendRequest(config, { + html: options.body.html, + subject: options.subject, + text: options.body.text, + to: options.to + }); + const response = await fetch(request.url, { + body: request.body, + headers: request.headers, + method: request.method + }); + if (!response.ok) { + throw new HttpMailError(response.status); + } + } + + /** Validate HTTP-transport credentials (and Mailgun domain/region) without sending a message. */ + private async verifyHttp(config: MailConfig): Promise { + const request = buildVerifyRequest(config); + const response = await fetch(request.url, { headers: request.headers, method: request.method }); + if (!response.ok) { + throw new HttpMailError(response.status); + } + } +} diff --git a/apps/api/src/mail/mail.utils.ts b/apps/api/src/mail/mail.utils.ts new file mode 100644 index 000000000..a1431298f --- /dev/null +++ b/apps/api/src/mail/mail.utils.ts @@ -0,0 +1,344 @@ +import { createHash, createHmac } from 'node:crypto'; + +import type { LocalizedString, MailConfig, MailEncryption, MailLanguage } from '@opendatacapture/schemas/mail'; + +// ── Internal helpers (must precede all exports for import/exports-last) ─────── + +/** Base URL for the Mailgun API, which differs by region (EU-hosted domains use a separate host). */ +function mailgunBaseUrl(region: MailConfig['region']): string { + return region === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net'; +} + +/** HTTP Basic `Authorization` header value for the given credentials. */ +function basicAuth(user: string, pass: string): string { + return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`; +} + +/** Lowercase hex SHA-256 of a UTF-8 string. */ +function sha256Hex(data: string): string { + return createHash('sha256').update(data, 'utf8').digest('hex'); +} + +/** HMAC-SHA256 of `data` under `key`, returned as raw bytes so signing keys can be chained. */ +function hmacSha256(key: Buffer | string, data: string): Buffer { + return createHmac('sha256', key).update(data, 'utf8').digest(); +} + +/** + * Compute the AWS Signature Version 4 `Authorization` and `x-amz-date` headers for a request. + * AWS's HTTP APIs require every request to be signed with the secret access key over a canonical + * form of the request, so we sign here (with `node:crypto`) rather than pull in the AWS SDK. + * `extraHeaders` are additional headers to include in the signature beyond the mandatory + * `host`/`x-amz-date` (e.g. `content-type` on a POST); their keys must be lowercase. + */ +function signAwsV4(params: { + accessKeyId: string; + body: string; + extraHeaders: { [key: string]: string }; + host: string; + method: 'GET' | 'POST'; + now: Date; + path: string; + region: string; + secretAccessKey: string; + service: string; +}): { authorization: string; 'x-amz-date': string } { + const { accessKeyId, body, extraHeaders, host, method, now, path, region, secretAccessKey, service } = params; + const amzDate = `${now.toISOString().slice(0, 19).replace(/[-:]/g, '')}Z`; + const dateStamp = amzDate.slice(0, 8); + + const headers: { [key: string]: string } = { host, 'x-amz-date': amzDate, ...extraHeaders }; + const signedHeaderNames = Object.keys(headers).sort(); + const canonicalHeaders = `${signedHeaderNames.map((name) => `${name}:${headers[name]!.trim()}`).join('\n')}\n`; + const signedHeaders = signedHeaderNames.join(';'); + const canonicalRequest = [method, path, '', canonicalHeaders, signedHeaders, sha256Hex(body)].join('\n'); + + const scope = `${dateStamp}/${region}/${service}/aws4_request`; + const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n'); + + const kDate = hmacSha256(`AWS4${secretAccessKey}`, dateStamp); + const kRegion = hmacSha256(kDate, region); + const kService = hmacSha256(kRegion, service); + const kSigning = hmacSha256(kService, 'aws4_request'); + const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex'); + + return { + authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + 'x-amz-date': amzDate + }; +} + +/** A fully-described outbound HTTP request to a provider's mail API. */ +export type MailHttpRequest = { + body?: string; + headers: { [key: string]: string }; + method: 'GET' | 'POST'; + url: string; +}; + +/** A non-2xx response from a provider's HTTP API, carrying the status for {@link describeMailError}. */ +export class HttpMailError extends Error { + readonly status: number; + + constructor(status: number, message?: string) { + super(message ?? `Mail API responded with status ${status}`); + this.name = 'HttpMailError'; + this.status = status; + } +} + +/** What an outgoing message contains, independent of transport. */ +export type MailMessage = { + html?: string; + subject: string; + text: string; + to: string; +}; + +/** + * 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}>`; +} + +/** 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: MailLanguage): 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) || ''; +} + +/** + * Translate a nodemailer/SMTP error into a clear, actionable message — matching on the + * error code and, as a fallback, the message text (SMTP libraries are inconsistent about + * codes). The raw technical error is never surfaced; anything unrecognized (including + * timeouts) returns a friendly generic message. + */ +export function describeMailError(err: unknown): string { + if (err instanceof HttpMailError) { + return describeHttpMailError(err.status); + } + 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 — check the username and password or API key.'; + } + if (matches(['EDNS', 'ENOTFOUND'], /getaddrinfo|enotfound|not be found|unknown host/)) { + return 'The mail server could not be found — check the host name.'; + } + if (matches(['ECONNREFUSED'], /econnrefused|refused/)) { + return 'The mail server refused the connection — check the host and port.'; + } + if (matches(['ESOCKET'], /wrong version number|ssl routines|tls|certificate|self[- ]signed|esocket/)) { + return 'Could not establish a secure connection — check the port and encryption method.'; + } + if (matches(['EENVELOPE'], /envelope|sender address|from address|mail from/)) { + return 'The sender address was rejected — check the sender address.'; + } + // Timeouts and anything else: keep it simple and non-technical. + return 'Please check and reconfigure your mail settings and try again.'; +} + +/** + * 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' + }; +} + +/** + * Build the provider-specific HTTP request that sends a single message. Mailgun expects a + * form-encoded POST with basic auth (`api:{apiKey}`); SendGrid expects a JSON POST with a + * bearer token. The sender is formatted identically to the SMTP path via {@link formatSender}. + */ +export function buildSendRequest(config: MailConfig, message: MailMessage, now: Date = new Date()): MailHttpRequest { + switch (config.provider) { + case 'mailgun': { + const params = new URLSearchParams(); + params.set('from', formatSender(config)); + params.set('to', message.to); + params.set('subject', message.subject); + params.set('text', message.text); + if (message.html) { + params.set('html', message.html); + } + return { + body: params.toString(), + headers: { + authorization: basicAuth('api', config.apiKey), + 'Content-Type': 'application/x-www-form-urlencoded' + }, + method: 'POST', + url: `${mailgunBaseUrl(config.region)}/v3/${encodeURIComponent(config.domain)}/messages` + }; + } + case 'postmark': { + return { + body: JSON.stringify({ + From: formatSender(config), + HtmlBody: message.html, + Subject: message.subject, + TextBody: message.text, + To: message.to + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Postmark-Server-Token': config.apiKey + }, + method: 'POST', + url: 'https://api.postmarkapp.com/email' + }; + } + case 'ses': { + const host = `email.${config.awsRegion}.amazonaws.com`; + const path = '/v2/email/outbound-emails'; + const body = JSON.stringify({ + Content: { + Simple: { + Body: message.html + ? { Html: { Data: message.html }, Text: { Data: message.text } } + : { Text: { Data: message.text } }, + Subject: { Data: message.subject } + } + }, + Destination: { ToAddresses: [message.to] }, + FromEmailAddress: formatSender(config) + }); + const signed = signAwsV4({ + accessKeyId: config.awsAccessKeyId, + body, + extraHeaders: { 'content-type': 'application/json' }, + host, + method: 'POST', + now, + path, + region: config.awsRegion, + secretAccessKey: config.apiKey, + service: 'ses' + }); + return { + body, + headers: { + authorization: signed.authorization, + 'Content-Type': 'application/json', + 'x-amz-date': signed['x-amz-date'] + }, + method: 'POST', + url: `https://${host}${path}` + }; + } + default: { + const content = [{ type: 'text/plain', value: message.text }]; + if (message.html) { + content.push({ type: 'text/html', value: message.html }); + } + return { + body: JSON.stringify({ + content, + from: { email: config.senderAddress, ...(config.senderName ? { name: config.senderName } : {}) }, + personalizations: [{ to: [{ email: message.to }] }], + subject: message.subject + }), + headers: { authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }, + method: 'POST', + url: 'https://api.sendgrid.com/v3/mail/send' + }; + } + } +} + +/** + * Build a lightweight authenticated GET that validates the credentials (and, for Mailgun, the + * sending domain and region) without delivering a message — the HTTP equivalent of SMTP's + * `verify()`. Mailgun's domain endpoint 404s on a wrong domain/region; SendGrid's scopes + * endpoint 401s on a bad key. + */ +export function buildVerifyRequest(config: MailConfig, now: Date = new Date()): MailHttpRequest { + switch (config.provider) { + case 'mailgun': { + return { + headers: { authorization: basicAuth('api', config.apiKey) }, + method: 'GET', + url: `${mailgunBaseUrl(config.region)}/v3/domains/${encodeURIComponent(config.domain)}` + }; + } + case 'postmark': { + return { + headers: { Accept: 'application/json', 'X-Postmark-Server-Token': config.apiKey }, + method: 'GET', + url: 'https://api.postmarkapp.com/server' + }; + } + case 'ses': { + const host = `email.${config.awsRegion}.amazonaws.com`; + const path = '/v2/account'; + const signed = signAwsV4({ + accessKeyId: config.awsAccessKeyId, + body: '', + extraHeaders: {}, + host, + method: 'GET', + now, + path, + region: config.awsRegion, + secretAccessKey: config.apiKey, + service: 'ses' + }); + return { + headers: { authorization: signed.authorization, 'x-amz-date': signed['x-amz-date'] }, + method: 'GET', + url: `https://${host}${path}` + }; + } + default: { + return { + headers: { authorization: `Bearer ${config.apiKey}` }, + method: 'GET', + url: 'https://api.sendgrid.com/v3/scopes' + }; + } + } +} + +/** + * Translate an HTTP status from a provider API into a clear, actionable message, mirroring + * {@link describeMailError}'s treatment of SMTP errors. The raw response body is never surfaced. + */ +export function describeHttpMailError(status: number): string { + if (status === 401 || status === 403) { + return 'Authentication failed — check the API key.'; + } + if (status === 404) { + return 'The sending domain could not be found — check the domain and region.'; + } + if (status === 400 || status === 422) { + return 'The provider rejected the request — check the sending domain and sender address.'; + } + if (status === 429) { + return 'The provider is rate-limiting requests — please wait and try again.'; + } + return 'Please check and reconfigure your mail settings and try again.'; +} 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 85a131d74..71b0f0591 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -51,10 +51,14 @@ export class SetupService { // older $BrandingConfig will silently drop newer branding fields on read. const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null); return { + activeLanguages: savedOptions?.activeLanguages?.length ? savedOptions.activeLanguages : ['en', 'fr'], branding: branding.success ? branding.data : null, 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: Boolean(savedOptions?.mailConfig?.enabled && savedOptions.mailConfig.host), 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..973164678 --- /dev/null +++ b/apps/api/src/users/__tests__/users.controller.spec.ts @@ -0,0 +1,111 @@ +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 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) => 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, + password: createUserData.password, + url: 'https://odc.example.org/auth/login', + username: createUserData.username + }); + }); + + 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: '' }); + }); + + it('defaults to English and honors the fr language query param', async () => { + await usersController.create(createUserData, ability); + expect(mailService.sendNewUserEmail.mock.lastCall?.[0]).toMatchObject({ language: 'en' }); + 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' + }); + }); + + it('omits groups that cannot be resolved', async () => { + groupsService.findById.mockImplementation((id: string) => + id === 'group-1' ? Promise.reject(new Error('Forbidden')) : 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('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..51e7ccb2c 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -1,11 +1,14 @@ import { CurrentUser } 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, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { MailLanguage } from '@opendatacapture/schemas/mail'; 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 +17,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 +33,27 @@ 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') language?: MailLanguage + ) { + 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: language === 'fr' ? 'fr' : 'en', + lastName: user.lastName, + password: user.password, + url: origin ? `${origin}/auth/login` : '', + username: user.username + }); + return { ...created, welcomeEmail }; } @ApiOperation({ summary: 'Delete User' }) @@ -69,4 +95,18 @@ export class UsersController { ) { return this.usersService.updateSelfById(id, update, currentUser); } + + /** Resolve accessible group names for the welcome-email `{{group}}` variable. */ + 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(() => null)) + ); + 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/components/AssignmentEmailForm/AssignmentEmailForm.tsx b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx new file mode 100644 index 000000000..41c934b62 --- /dev/null +++ b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx @@ -0,0 +1,227 @@ +import React, { useState } from 'react'; + +import { Button, Input, Label, Select, Tooltip } from '@douglasneuroinformatics/libui/components'; +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { $Group } from '@opendatacapture/schemas/group'; +import type { LocalizedString, MailLanguage } from '@opendatacapture/schemas/mail'; +import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { CircleHelpIcon } from 'lucide-react'; + +import { useSendAssignmentEmailMutation } from '@/hooks/useSendAssignmentEmailMutation'; +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; +import { useAppStore } from '@/store'; +import { ALL_LANGUAGES } from '@/utils/languages'; + +const DEFAULT_TEMPLATE_OPTION = '__default__'; + +function getBodyLanguages(body: LocalizedString | null | undefined): string[] { + if (!body) { + return []; + } + return (Object.keys(body) as (keyof LocalizedString)[]).filter((k) => body[k]); +} + +export const AssignmentEmailForm: React.FC<{ + assignmentId: string | undefined; + instrumentLanguages?: string[]; +}> = ({ assignmentId, instrumentLanguages }) => { + const { resolvedLanguage, t } = useTranslation(); + const setupStateQuery = useSetupStateQuery(); + const sendEmailMutation = useSendAssignmentEmailMutation(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const currentGroup = useAppStore((store) => store.currentGroup); + const activeLanguages = setupStateQuery.data.activeLanguages ?? ['en', 'fr']; + const [recipient, setRecipient] = useState(''); + const [language, setLanguage] = useState( + activeLanguages.includes(resolvedLanguage) ? resolvedLanguage : (activeLanguages[0] ?? 'en') + ); + const [templateChoice, setTemplateChoice] = useState(null); + const [feedback, setFeedback] = useState(null); + + const groupId = currentGroup?.id; + const groupQuery = useQuery({ + enabled: Boolean(groupId && setupStateQuery.data.isMailEnabled), + queryFn: async () => $Group.parseAsync((await axios.get(`/v1/groups/${groupId}`)).data), + queryKey: ['group', groupId] + }); + const remoteTemplates = (groupQuery.data?.emailTemplates ?? []).filter((tpl) => tpl.category === 'REMOTE_ASSIGNMENT'); + 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 + }, + ...remoteTemplates.map((tpl) => ({ label: tpl.name, value: tpl.id })) + ].sort((a, b) => (a.value === activeValue ? -1 : b.value === activeValue ? 1 : 0)); + + const selectedTemplateBody = + selectedTemplate === DEFAULT_TEMPLATE_OPTION + ? DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body + : remoteTemplates.find((tpl) => tpl.id === selectedTemplate)?.body; + + const availableLanguages = getBodyLanguages(selectedTemplateBody).filter((l) => activeLanguages.includes(l)); + + const sendEmail = () => { + if (!assignmentId || !recipient) { + return; + } + setFeedback(null); + sendEmailMutation.mutate( + { + assignmentId, + language: language as MailLanguage, + recipient, + templateId: selectedTemplate === DEFAULT_TEMPLATE_OPTION ? null : selectedTemplate + }, + { + onError: () => { + const message = t({ + en: 'The email could not be sent', + fr: "Le courriel n'a pas pu être envoyé" + }); + setFeedback({ message, tone: 'error' }); + addNotification({ + message, + title: t({ en: 'Email failed', fr: 'Échec du courriel' }), + type: 'error' + }); + }, + onSuccess: (result) => { + if (result.status === 'SENT') { + const message = t({ + en: `Assignment link sent to ${recipient}`, + fr: `Lien d'évaluation envoyé à ${recipient}` + }); + setFeedback({ message, tone: 'success' }); + addNotification({ + message, + title: t({ en: 'Email sent', fr: 'Courriel envoyé' }), + type: 'success' + }); + setRecipient(''); + } else { + const message = + result.error ?? + t({ + en: 'The email could not be sent', + fr: "Le courriel n'a pas pu être envoyé" + }); + setFeedback({ message, tone: 'error' }); + addNotification({ + message, + title: t({ en: 'Email failed', fr: 'Échec du courriel' }), + type: 'error' + }); + } + } + } + ); + }; + + if (!setupStateQuery.data.isMailEnabled) { + return null; + } + + const templateLanguages = availableLanguages.length > 0 ? availableLanguages : activeLanguages; + const languageDropdownOptions = instrumentLanguages + ? templateLanguages.filter((l) => instrumentLanguages.includes(l)) + : templateLanguages; + + return ( +
+ {remoteTemplates.length > 0 && ( +
+ + +
+ )} +
+
+ + + + + + +

+ {t({ + en: 'Emails and assignments are sent in the selected language when available. However, subjects may still choose a different preferred language on the gateway.', + fr: "Les courriels et les évaluations sont envoyés dans la langue sélectionnée lorsqu'elle est disponible. Cependant, les sujets peuvent toujours choisir une langue préférée différente sur le portail." + })} +

+
+
+
+ +
+ +
+ 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/GroupEmailTemplates/GroupEmailTemplates.tsx b/apps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsx new file mode 100644 index 000000000..5c0baa945 --- /dev/null +++ b/apps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsx @@ -0,0 +1,693 @@ +import React from 'react'; + +import { Button, Dialog, Heading, Input, Label, Select, TextArea } from '@douglasneuroinformatics/libui/components'; +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { $Group } from '@opendatacapture/schemas/group'; +import type { GroupEmailTemplate, GroupEmailTemplateCategory } from '@opendatacapture/schemas/group'; +import { checkTemplateIssue, DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; +import type { LocalizedString, TemplateIssue } from '@opendatacapture/schemas/mail'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { EyeIcon, PencilIcon, Trash2Icon } from 'lucide-react'; + +import { SaveStatus } from '@/components/SaveStatus'; +import { SegmentedControl } from '@/components/SegmentedControl'; +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; +import { useUpdateGroupMutation } from '@/hooks/useUpdateGroupMutation'; +import { useAppStore } from '@/store'; +import { ALL_LANGUAGES } from '@/utils/languages'; + +type ActiveIds = { assignment: null | string; information: null | string }; + +const AVAILABLE_VARS: { [K in GroupEmailTemplateCategory]: string[] } = { + INFORMATION: [], + REMOTE_ASSIGNMENT: ['url', 'expiresAt'] +}; + +const templateIssue = ( + category: GroupEmailTemplateCategory, + subject: LocalizedString, + body: LocalizedString, + languages?: string[] +): TemplateIssue => checkTemplateIssue(subject, body, AVAILABLE_VARS[category], languages); + +const templateErrorMessage = ( + t: (value: { en: string; es?: string; fr: string }) => string, + issue: TemplateIssue +): string | undefined => { + if (issue === 'incomplete') { + return t({ + en: 'Fill in the subject and body in each language.', + fr: "Remplissez l'objet et le corps dans chaque langue." + }); + } + if (issue === 'missing-vars') { + return t({ + en: 'Remote assignment templates must include {{url}} and {{expiresAt}}.', + fr: "Les modèles d'évaluation à distance doivent inclure {{url}} et {{expiresAt}}." + }); + } + return undefined; +}; + +const EmptyState = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +function getTemplateLangs(body: LocalizedString | null | undefined, fallback: string): string[] { + if (!body) return [fallback]; + const langs = (Object.keys(body) as (keyof LocalizedString)[]).filter((k) => body[k] != null); + return langs.length > 0 ? langs : [fallback]; +} + +const cleanLocalized = (obj: LocalizedString | null | undefined): LocalizedString => + Object.fromEntries(Object.entries(obj ?? {}).filter(([, v]) => v)); + +const TemplateFields = ({ + activeLanguages, + body, + category, + categoryLabel, + error, + name, + setBody, + setCategory, + setName, + setSubject, + subject +}: { + activeLanguages: string[]; + body: LocalizedString; + category: GroupEmailTemplateCategory; + categoryLabel: (value: GroupEmailTemplateCategory) => string; + error?: string; + name: string; + setBody: (value: LocalizedString) => void; + setCategory: (value: GroupEmailTemplateCategory) => void; + setName: (value: string) => void; + setSubject: (value: LocalizedString) => void; + subject: LocalizedString; +}) => { + const { resolvedLanguage, t } = useTranslation(); + const [lang, setLang] = React.useState( + activeLanguages.includes(resolvedLanguage) ? resolvedLanguage : activeLanguages[0]! + ); + const bodyCursorRef = React.useRef(null); + + const currentBody = body[lang as keyof LocalizedString] ?? ''; + const currentSubject = subject[lang as keyof LocalizedString] ?? ''; + + return ( + +
+ + + className="w-full max-w-md" + options={[ + { label: categoryLabel('REMOTE_ASSIGNMENT'), value: 'REMOTE_ASSIGNMENT' }, + { label: categoryLabel('INFORMATION'), value: 'INFORMATION' } + ]} + value={category} + onChange={setCategory} + /> +
+
+ + setName(event.target.value)} /> +
+
+ + +
+
+ + setSubject({ ...subject, [lang]: event.target.value })} + /> +
+
+
+ + {AVAILABLE_VARS[category].length > 0 && ( +
+ {t({ en: 'Insert:', fr: 'Insérer :' })} + {AVAILABLE_VARS[category].map((variable) => ( + + ))} +
+ )} +
+