-
Notifications
You must be signed in to change notification settings - Fork 17
feat(mail): add outgoing email, group email templates, and assignment emails #1439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a53b04b
068886e
a42d229
0e16f66
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AssignmentsService>; | ||
| let groupsService: MockedInstance<GroupsService>; | ||
| let mailService: MockedInstance<MailService>; | ||
|
|
||
| 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' }); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two things on this endpoint: Recipient is unconstrained.
|
||
| @Param('id') id: string, | ||
| @Body() { language, recipient, templateId }: SendAssignmentEmailDto, | ||
| @CurrentUser('ability') ability: AppAbility | ||
| ): Promise<EmailDeliveryResult> { | ||
| 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' }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,16 @@ | ||
| 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'; | ||
|
|
||
| @Module({ | ||
| controllers: [AssignmentsController], | ||
| exports: [AssignmentsService], | ||
| imports: [forwardRef(() => GatewayModule)], | ||
| imports: [forwardRef(() => GatewayModule), GroupsModule, MailModule], | ||
| providers: [AssignmentsService] | ||
| }) | ||
| export class AssignmentsModule {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
passwordandapiKeyare persisted in plaintext. For SES,apiKeyholds the AWS secret access key, so a database backup or a read-only mongo credential leaks credentials that can send mail as the institution — and, for a broadly-scoped IAM key, potentially more.The comment above correctly notes these are never returned to clients, but that isn't the exposure that matters; the DB is.
Options, roughly in order of effort: encrypt at rest with a key from
$Env(envelope-encrypt just these two fields), or read the secret from an env var and store only non-secret config here. If plaintext is a deliberate accepted risk for this deployment model, please say so explicitly in the comment so the next reader doesn't have to re-derive the decision.