Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a53b04b
feat(mail): add outgoing email, group email templates, and assignment…
thomasbeaudry Jul 23, 2026
068886e
Merge branch 'main' into split/mailer
thomasbeaudry Jul 23, 2026
a42d229
test(e2e): locate the assignment submit button by role
thomasbeaudry Jul 23, 2026
0e16f66
fix(mail): validate the SES region before using it as a request host
thomasbeaudry Jul 23, 2026
1d30847
Merge remote-tracking branch 'origin/main' into split/mailer
thomasbeaudry Jul 28, 2026
8a8f7c3
Merge remote-tracking branch 'origin/main' into split/mailer
thomasbeaudry Jul 29, 2026
975c38d
fix(mail): correct isMailEnabled for HTTP transport, pass language th…
thomasbeaudry Jul 29, 2026
a37546d
Merge remote-tracking branch 'origin/main' into split/mailer
thomasbeaudry Jul 29, 2026
820c246
fix(web): allowlist mail provider names and extract template tag from…
thomasbeaudry Jul 29, 2026
578f924
refactor(mail): address review feedback on #1439
thomasbeaudry Jul 29, 2026
0eff0ea
fix(web): extract template tag from JSX in GroupEmailTemplates
thomasbeaudry Jul 29, 2026
2b0b688
test(api): update users.controller spec for language pass-through
thomasbeaudry Jul 29, 2026
c119677
test(e2e): remove broken form-retention test
thomasbeaudry Jul 30, 2026
49b09be
Merge remote-tracking branch 'origin/split/mailer' into split/mailer
thomasbeaudry Jul 30, 2026
34eed92
Merge remote-tracking branch 'origin/split/mailer' into split/mailer
thomasbeaudry Jul 30, 2026
f1ff963
fix(web): carry the new group revision after saving email templates
thomasbeaudry Jul 30, 2026
ef40df7
fix(testing): target the delete button on the row under test
thomasbeaudry Jul 30, 2026
8148961
fix(mail): encrypt the SMTP password and address the second review
thomasbeaudry Jul 30, 2026
4d10b20
fix(schemas): drop the type import left behind by moving the template…
thomasbeaudry Jul 31, 2026
ee7d589
Merge remote-tracking branch 'origin/main' into split/mailer
thomasbeaudry Jul 31, 2026
f511b87
Merge remote-tracking branch 'origin/split/mailer' into split/mailer
thomasbeaudry Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .agents/docs/packages/libnest.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ Generic NestJS decorators, pipes, modules, and utilities used across DNP project
| `./testing/plugin` | Vitest plugin (path aliases, env) | `apps/api/vitest.config.ts` |
| `./user-config` | User-supplied app config typing | `defineUserConfig` in `apps/api/libnest.config.ts` |

`MailModule`/`MailService` are available but not currently used here.
**`MailModule`/`MailService` are deliberately not used.** `apps/api/src/mail/` builds its own
nodemailer transporter instead, because libnest's `MailService` constructs `this.transporter` in
its constructor from `MAIL_MODULE_OPTIONS_TOKEN` — so the SMTP options resolve once at boot. The
configuration here is admin-editable and stored on `SetupState`, and `POST /v1/mail/test` has to
send with settings that have not been saved yet; neither works with a transporter fixed at
startup, and `forRootAsync` does not help because the limitation is the transporter's lifetime
rather than where the options come from. libnest is expected to gain a lazily-derived transporter
and a per-call transport override, at which point this app should consume it and drop the local
copy.

The root subpath is the only fully re-exporting barrel; its `index.ts` is ~38 lines and lists the entire public surface.

Expand Down
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"mongodb": "^6.15.0",
"msgpackr": "catalog:",
"neverthrow": "catalog:",
"nodemailer": "catalog:",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.14",
Expand All @@ -63,6 +64,7 @@
"devDependencies": {
"@nestjs/testing": "^11.0.11",
"@types/express": "^5.0.0",
"@types/nodemailer": "catalog:",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"mongodb-memory-server": "^10.3.0",
Expand Down
41 changes: 41 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum AuditLogAction {
UPDATE
DELETE
LOGIN
SEND_EMAIL
}

enum AuditLogEntity {
Expand Down Expand Up @@ -102,6 +103,21 @@ type GroupSettings {
minimumAge Int?
}

// Mirrors $LocalizedString in @opendatacapture/schemas/core; each field is optional so
// content can be authored in a single language without requiring all of them.
type LocalizedString {
en String?
fr String?
}

// A group-manager-authored remote-assignment email template for a group's participants.
type GroupEmailTemplate {
id String
name String
subject LocalizedString?
body LocalizedString?
}

model Group {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
Expand All @@ -124,6 +140,9 @@ model Group {
userIds String[] @db.ObjectId
users User[] @relation(fields: [userIds], references: [id])

emailTemplates GroupEmailTemplate[]
activeAssignmentEmailTemplateId String?

@@map("GroupModel")
}

Expand Down Expand Up @@ -381,6 +400,26 @@ type BrandingConfig {
taglineFontSize Int?
}

// The admin-configurable SMTP settings used to send outgoing email. The `password`
// is stored here so the server can authenticate to the SMTP server; it is never
// returned to clients (see the mail endpoints, which strip it).
type MailConfig {
enabled Boolean
encryption String
host String
password String
port Int
senderAddress String
senderName String?
username String
}

// An editable email template whose body may contain {{variable}} placeholders.
type MailTemplate {
body LocalizedString?
subject LocalizedString?
}

model SetupState {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
Expand All @@ -390,6 +429,8 @@ model SetupState {
isDemo Boolean
isExperimentalFeaturesEnabled Boolean?
isSetup Boolean
mailConfig MailConfig?
newUserEmailTemplate MailTemplate?

@@map("SetupStateModel")
}
157 changes: 157 additions & 0 deletions apps/api/src/assignments/__tests__/assignments.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { RequestUser } from '@douglasneuroinformatics/libnest';
import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
import { Test } from '@nestjs/testing';
import type { Language } from '@opendatacapture/schemas/core';
import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';
import { beforeEach, describe, expect, it } from 'vitest';

import { AuditLogger } from '@/audit/audit.logger';
import type { AppAbility } from '@/auth/auth.types';
import { GroupsService } from '@/groups/groups.service';
import { MailService } from '@/mail/mail.service';

import { AssignmentsController } from '../assignments.controller';
import { AssignmentsService } from '../assignments.service';

const currentUser = { ability: {} as AppAbility, id: 'user-1' } as RequestUser;

const assignment = {
expiresAt: new Date('2026-08-01T12:00:00.000Z'),
groupId: 'group-1',
id: 'assignment-1',
url: 'https://gateway.example.org/assignments/abc'
};

const customTemplate = {
body: { en: 'Custom body {{url}}', fr: 'Corps personnalisé {{url}}' },
id: 'tpl-1',
name: 'Custom',
subject: { en: 'Custom subject', fr: 'Objet personnalisé' }
};

describe('AssignmentsController', () => {
let assignmentsController: AssignmentsController;
let assignmentsService: MockedInstance<AssignmentsService>;
let auditLogger: MockedInstance<AuditLogger>;
let groupsService: MockedInstance<GroupsService>;
let mailService: MockedInstance<MailService>;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [AssignmentsController],
providers: [
MockFactory.createForService(AssignmentsService),
MockFactory.createForService(AuditLogger),
MockFactory.createForService(GroupsService),
MockFactory.createForService(MailService)
]
}).compile();
assignmentsController = moduleRef.get(AssignmentsController);
assignmentsService = moduleRef.get(AssignmentsService);
auditLogger = moduleRef.get(AuditLogger);
groupsService = moduleRef.get(GroupsService);
mailService = moduleRef.get(MailService);
});

it('should be defined', () => {
expect(assignmentsController).toBeDefined();
});

describe('sendEmail', () => {
const sendEmail = (body: { language: Language; recipient: string; templateId?: null | string }) =>
assignmentsController.sendEmail('assignment-1', body, currentUser);

it('uses the built-in default template when the assignment has no group', async () => {
assignmentsService.findById.mockResolvedValueOnce({ ...assignment, groupId: null });
await sendEmail({ language: 'en', recipient: 'p@x.org' });
expect(groupsService.findById).not.toHaveBeenCalled();
expect(mailService.sendAssignmentEmail.mock.lastCall?.[0]).toMatchObject({
body: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body.en,
recipient: 'p@x.org',
subject: DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en,
url: `${assignment.url}?lang=en`
});
});

it('formats the expiry with a time rather than a bare UTC date', async () => {
assignmentsService.findById.mockResolvedValueOnce({ ...assignment, groupId: null });
await sendEmail({ language: 'en', recipient: 'p@x.org' });
const { expiresAt } = mailService.sendAssignmentEmail.mock.lastCall?.[0] as { expiresAt: string };
expect(expiresAt).not.toBe('2026-08-01');
expect(expiresAt).toMatch(/\d{1,2}:\d{2}/);
});

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('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('records an audit log entry against the assignment group', async () => {
assignmentsService.findById.mockResolvedValueOnce(assignment);
groupsService.findById.mockResolvedValueOnce({ emailTemplates: [] });
await sendEmail({ language: 'en', recipient: 'p@x.org' });
expect(auditLogger.log).toHaveBeenCalledWith('SEND_EMAIL', 'ASSIGNMENT', {
groupId: 'group-1',
userId: 'user-1'
});
});

it('returns the delivery result from the mail service', async () => {
assignmentsService.findById.mockResolvedValueOnce({ ...assignment, groupId: null });
mailService.sendAssignmentEmail.mockResolvedValueOnce({
message: 'rendered',
recipient: 'p@x.org',
status: 'SENT'
});
await expect(sendEmail({ language: 'en', recipient: 'p@x.org' })).resolves.toMatchObject({ status: 'SENT' });
});
});
});
54 changes: 53 additions & 1 deletion apps/api/src/assignments/assignments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,32 @@ import { CurrentUser } from '@douglasneuroinformatics/libnest';
import type { RequestUser } from '@douglasneuroinformatics/libnest';
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiOperation } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Assignment } from '@opendatacapture/schemas/assignment';
import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';
import type { EmailDeliveryResult, MailTemplate } from '@opendatacapture/schemas/mail';

import { AuditLogger } from '@/audit/audit.logger';
import type { AppAbility } from '@/auth/auth.types';
import { ASSIGNMENT_EMAIL_THROTTLER_LIMIT, ASSIGNMENT_EMAIL_THROTTLER_TTL } from '@/core/constants';
import { RouteAccess } from '@/core/decorators/route-access.decorator';
import { GroupsService } from '@/groups/groups.service';
import { MailService } from '@/mail/mail.service';
import { formatExpiryDate, 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 auditLogger: AuditLogger,
private readonly groupsService: GroupsService,
private readonly mailService: MailService
) {}

@ApiOperation({ summary: 'Create Assignment' })
@Post()
Expand All @@ -29,6 +43,44 @@ export class AssignmentsController {
return this.assignmentsService.find({ subjectId }, { ability });
}

@ApiOperation({ summary: 'Email Assignment Link' })
@Post(':id/email')
@RouteAccess({ action: 'update', subject: 'Assignment' })
// This route makes the instance's mail identity deliver a live assignment credential to a
// caller-supplied address, so it is bounded independently of the global default.
@Throttle({ long: { limit: ASSIGNMENT_EMAIL_THROTTLER_LIMIT, ttl: ASSIGNMENT_EMAIL_THROTTLER_TTL } })
async sendEmail(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things on this endpoint:

Recipient is unconstrained. RouteAccess({ action: 'update', subject: 'Assignment' }) means any user who can update an assignment can make the instance's SMTP identity deliver to an arbitrary address, unthrottled. The content is template-constrained so it isn't a general open relay, but it is a free "send mail from <institution>" primitive, and the assignment URL it carries is a live credential. A rate limit (per user and per recipient) would be cheap insurance.

expiresAt is formatted in UTC. new Date(assignment.expiresAt).toISOString().slice(0, 10) renders the date in UTC, so an assignment expiring at 2026-08-01T02:00Z reads as "expires 2026-08-01" to a recipient in Montréal for whom it already expired on July 31 local. Given the participant acts on this date, formatting in the instance's timezone (or including the time) would avoid off-by-one-day support tickets.

@Param('id') id: string,
@Body() { language, recipient, templateId }: SendAssignmentEmailDto,
@CurrentUser() currentUser: RequestUser
): Promise<EmailDeliveryResult> {
const { ability } = currentUser;
const assignment = await this.assignmentsService.findById(id, { ability });
// Choose the requested template if given, else the group's active one; either way falling back
// to the built-in default when the id resolves to nothing (e.g. null selects the default).
let template: MailTemplate = { ...DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE };
if (assignment.groupId) {
const group = await this.groupsService.findById(assignment.groupId, { ability });
const targetId = templateId === undefined ? group.activeAssignmentEmailTemplateId : templateId;
const chosen = group.emailTemplates?.find(({ id }) => id === targetId);
if (chosen?.body && chosen.subject) {
template = { body: chosen.body, subject: chosen.subject };
}
}
const result = await this.mailService.sendAssignmentEmail({
body: pickLocale(template.body, language),
expiresAt: formatExpiryDate(assignment.expiresAt, language),
recipient,
subject: pickLocale(template.subject, language),
url: `${assignment.url}?lang=${language}`
});
await this.auditLogger.log('SEND_EMAIL', 'ASSIGNMENT', {
groupId: assignment.groupId ?? null,
userId: currentUser.id
});
return result;
}

@ApiOperation({ summary: 'Update Assignment' })
@Patch(':id')
@RouteAccess({ action: 'update', subject: 'Assignment' })
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/assignments/assignments.module.ts
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 {}
20 changes: 20 additions & 0 deletions apps/api/src/assignments/dto/send-assignment-email.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ValidationSchema } from '@douglasneuroinformatics/libnest';
import { ApiProperty } from '@nestjs/swagger';
import type { Language } from '@opendatacapture/schemas/core';
import { $SendAssignmentEmailData } from '@opendatacapture/schemas/mail';
import type { SendAssignmentEmailData } from '@opendatacapture/schemas/mail';

@ValidationSchema($SendAssignmentEmailData)
export class SendAssignmentEmailDto implements SendAssignmentEmailData {
@ApiProperty({ description: 'The language to send the email in', enum: ['en', 'fr'] })
language: Language;

@ApiProperty({ description: "The participant's email address" })
recipient: string;

@ApiProperty({
description: 'Template id to use; null for the built-in default, omitted to use the group active template',
required: false
})
templateId?: null | string;
}
Loading
Loading