diff --git a/apps/api/src/email/templates/access-granted.spec.ts b/apps/api/src/email/templates/access-granted.spec.ts
new file mode 100644
index 0000000000..d872d46111
--- /dev/null
+++ b/apps/api/src/email/templates/access-granted.spec.ts
@@ -0,0 +1,39 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { AccessGrantedEmail } from './access-granted';
+
+const baseProps = {
+ toName: 'Chang Liu',
+ organizationName: 'Acme Security',
+ expiresAt: new Date('2026-12-31T00:00:00Z'),
+ portalUrl: 'https://portal.example.com/access/token',
+};
+
+describe('AccessGrantedEmail', () => {
+ it('omits all NDA copy when access was granted via allow-list bypass', () => {
+ const html = renderToStaticMarkup(
+ AccessGrantedEmail({ ...baseProps, ndaBypassed: true }),
+ );
+
+ // No NDA was signed, so the email must not reference one.
+ expect(html).not.toContain('NDA');
+ expect(html).not.toContain('signed');
+ // The access confirmation itself is still present.
+ expect(html).toContain('is now active');
+ expect(html).toContain('Acme Security');
+ });
+
+ it('includes NDA copy for the standard NDA-signed flow', () => {
+ const html = renderToStaticMarkup(
+ AccessGrantedEmail({ ...baseProps, ndaBypassed: false }),
+ );
+
+ expect(html).toContain('Your NDA has been signed');
+ expect(html).toContain('download your signed NDA');
+ });
+
+ it('defaults to the NDA-signed copy when ndaBypassed is omitted', () => {
+ const html = renderToStaticMarkup(AccessGrantedEmail({ ...baseProps }));
+
+ expect(html).toContain('Your NDA has been signed');
+ });
+});
diff --git a/apps/api/src/email/templates/access-granted.tsx b/apps/api/src/email/templates/access-granted.tsx
index a94bfce64b..39f156ab05 100644
--- a/apps/api/src/email/templates/access-granted.tsx
+++ b/apps/api/src/email/templates/access-granted.tsx
@@ -18,6 +18,11 @@ interface Props {
organizationName: string;
expiresAt: Date;
portalUrl: string;
+ /**
+ * When true, access was granted without an NDA (the requester's email or
+ * domain is on the trust portal allow list). NDA-specific copy is omitted.
+ */
+ ndaBypassed?: boolean;
}
export const AccessGrantedEmail = ({
@@ -25,6 +30,7 @@ export const AccessGrantedEmail = ({
organizationName,
expiresAt,
portalUrl,
+ ndaBypassed = false,
}: Props) => {
return (
@@ -60,9 +66,18 @@ export const AccessGrantedEmail = ({
- Your NDA has been signed and your access to{' '}
- {organizationName}'s policy documentation is now
- active.
+ {ndaBypassed ? (
+ <>
+ Your access to {organizationName}'s policy
+ documentation is now active.
+ >
+ ) : (
+ <>
+ Your NDA has been signed and your access to{' '}
+ {organizationName}'s policy documentation is
+ now active.
+ >
+ )}
@@ -85,10 +100,12 @@ export const AccessGrantedEmail = ({
-
- You can download your signed NDA for your records from the
- confirmation page or by accessing the portal above.
-
+ {!ndaBypassed && (
+
+ You can download your signed NDA for your records from the
+ confirmation page or by accessing the portal above.
+
+ )}
{
- const { toEmail, toName, organizationName, expiresAt, portalUrl } = params;
+ const {
+ toEmail,
+ toName,
+ organizationName,
+ expiresAt,
+ portalUrl,
+ ndaBypassed,
+ } = params;
const { id } = await triggerEmail({
to: toEmail,
@@ -48,6 +56,7 @@ export class TrustEmailService {
organizationName,
expiresAt,
portalUrl,
+ ndaBypassed,
}),
trustPortal: true,
});
diff --git a/apps/api/src/trust-portal/trust-access.service.spec.ts b/apps/api/src/trust-portal/trust-access.service.spec.ts
index 87fb26a09d..f5bab9ac1a 100644
--- a/apps/api/src/trust-portal/trust-access.service.spec.ts
+++ b/apps/api/src/trust-portal/trust-access.service.spec.ts
@@ -14,6 +14,8 @@ jest.mock('@db', () => ({
},
trustAccessGrant: {
findUnique: jest.fn(),
+ findFirst: jest.fn(),
+ update: jest.fn(),
},
trustAccessRequest: {
findFirst: jest.fn(),
@@ -62,6 +64,8 @@ const mockDb = db as unknown as {
};
trustAccessGrant: {
findUnique: jest.Mock;
+ findFirst: jest.Mock;
+ update: jest.Mock;
};
trustAccessRequest: {
findFirst: jest.Mock;
@@ -262,6 +266,10 @@ describe('TrustAccessService approveRequest NDA bypass', () => {
expect(txMock.trustAccessGrant.create).toHaveBeenCalledTimes(1);
expect(txMock.trustNDAAgreement.create).not.toHaveBeenCalled();
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1);
+ // The granted email must omit NDA copy since no NDA was signed.
+ expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ ndaBypassed: true }),
+ );
expect(emailService.sendNdaSigningEmail).not.toHaveBeenCalled();
expect(txMock.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
@@ -286,6 +294,9 @@ describe('TrustAccessService approveRequest NDA bypass', () => {
expect(txMock.trustAccessGrant.create).toHaveBeenCalledTimes(1);
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1);
+ expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ ndaBypassed: true }),
+ );
expect(txMock.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
@@ -310,3 +321,127 @@ describe('TrustAccessService approveRequest NDA bypass', () => {
expect(result.message).toBe('NDA signing email sent');
});
});
+
+describe('TrustAccessService resendAccessGrantEmail NDA copy', () => {
+ const emailService = {
+ sendAccessGrantedEmail: jest.fn(),
+ };
+ const service = new TrustAccessService(
+ {} as any,
+ emailService as any,
+ {} as any,
+ {} as any,
+ {} as any,
+ );
+ jest
+ .spyOn(service as any, 'buildPortalAccessUrl')
+ .mockResolvedValue('https://portal.example.com/access/token');
+
+ const baseGrant = {
+ id: 'tag_1',
+ subjectEmail: 'chang.liu@client.com',
+ status: 'active',
+ expiresAt: new Date(Date.now() + 86_400_000),
+ accessToken: 'existing-token',
+ accessTokenExpiresAt: new Date(Date.now() + 86_400_000),
+ accessRequest: {
+ name: 'Chang Liu',
+ organization: { name: 'Acme Security' },
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('marks the resent email as bypassed when the grant has no NDA agreement', async () => {
+ mockDb.trustAccessGrant.findFirst.mockResolvedValue({
+ ...baseGrant,
+ ndaAgreement: null,
+ });
+
+ await service.resendAccessGrantEmail('org_1', 'tag_1');
+
+ expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ ndaBypassed: true }),
+ );
+ });
+
+ it('keeps NDA copy when the grant has a signed NDA agreement', async () => {
+ mockDb.trustAccessGrant.findFirst.mockResolvedValue({
+ ...baseGrant,
+ ndaAgreement: { status: 'signed' },
+ });
+
+ await service.resendAccessGrantEmail('org_1', 'tag_1');
+
+ expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ ndaBypassed: false }),
+ );
+ });
+});
+
+describe('TrustAccessService signNda NDA copy', () => {
+ const ndaPdfService = {
+ generateNdaPdf: jest.fn().mockResolvedValue(Buffer.from('pdf')),
+ uploadNdaPdf: jest.fn().mockResolvedValue('org_1/nda/nda_1.pdf'),
+ getSignedUrl: jest.fn().mockResolvedValue('https://s3.example.com/nda.pdf'),
+ };
+ const emailService = {
+ sendAccessGrantedEmail: jest.fn(),
+ };
+ const service = new TrustAccessService(
+ ndaPdfService as any,
+ emailService as any,
+ {} as any,
+ {} as any,
+ {} as any,
+ );
+ jest
+ .spyOn(service as any, 'buildPortalAccessUrl')
+ .mockResolvedValue('https://portal.example.com/access/token');
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockDb.trustNDAAgreement.findUnique.mockResolvedValue({
+ id: 'nda_1',
+ organizationId: 'org_1',
+ accessRequestId: 'tar_1',
+ status: 'pending',
+ signTokenExpiresAt: new Date(Date.now() + 86_400_000),
+ grant: null,
+ accessRequest: {
+ requestedDurationDays: 30,
+ organization: { name: 'Acme Security' },
+ },
+ });
+ mockDb.$transaction.mockImplementation(
+ (cb: (tx: unknown) => Promise) =>
+ cb({
+ trustAccessGrant: {
+ create: jest
+ .fn()
+ .mockResolvedValue({ id: 'tag_1', expiresAt: new Date() }),
+ },
+ trustNDAAgreement: {
+ update: jest.fn().mockResolvedValue({ id: 'nda_1' }),
+ },
+ }),
+ );
+ });
+
+ it('sends the granted email with NDA copy (ndaBypassed: false) after signing', async () => {
+ await service.signNda(
+ 'sign-token',
+ 'Chang Liu',
+ 'chang.liu@client.com',
+ '1.2.3.4',
+ 'jest-agent',
+ );
+
+ expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1);
+ expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ ndaBypassed: false }),
+ );
+ });
+});
diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts
index e2b9ee3824..6af1bc5c04 100644
--- a/apps/api/src/trust-portal/trust-access.service.ts
+++ b/apps/api/src/trust-portal/trust-access.service.ts
@@ -763,6 +763,7 @@ export class TrustAccessService {
organizationName: request.organization.name,
expiresAt: result.grant.expiresAt,
portalUrl,
+ ndaBypassed: true,
});
return {
@@ -976,6 +977,9 @@ export class TrustAccessService {
},
},
},
+ ndaAgreement: {
+ select: { status: true },
+ },
},
});
@@ -1028,6 +1032,9 @@ export class TrustAccessService {
organizationName: grant.accessRequest.organization.name,
expiresAt: grant.expiresAt,
portalUrl,
+ // A bypassed grant has no NDA agreement; an NDA-signed grant links a
+ // 'signed' one. Mirror the original email's copy when resending.
+ ndaBypassed: grant.ndaAgreement?.status !== 'signed',
});
return { message: 'Access email resent successfully' };
@@ -1239,6 +1246,7 @@ export class TrustAccessService {
organizationName: nda.accessRequest.organization.name,
expiresAt: result.grant.expiresAt,
portalUrl,
+ ndaBypassed: false,
});
const pdfUrl = await this.ndaPdfService.getSignedUrl(pdfKey);