Skip to content

Commit 74be7f6

Browse files
feat(email): implement email module and controller for sending emails (#2255)
- Added EmailModule and EmailController to handle email sending functionality. - Introduced SendEmailDto for structured email data input. - Updated app.module.ts to include EmailModule. - Enhanced service-token.config.ts to allow 'email:send' permission. - Refactored existing invitation and member management logic to utilize the new email sending capabilities. - Implemented tests for the new email functionality. Co-authored-by: Tofik Hasanov <annexcies@gmail.com> Co-authored-by: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com>
1 parent c40ad3d commit 74be7f6

17 files changed

Lines changed: 382 additions & 953 deletions

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { FrameworksModule } from './frameworks/frameworks.module';
4040
import { AuditModule } from './audit/audit.module';
4141
import { ControlsModule } from './controls/controls.module';
4242
import { RolesModule } from './roles/roles.module';
43+
import { EmailModule } from './email/email.module';
4344
import { SecretsModule } from './secrets/secrets.module';
4445
import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module';
4546
import { StripeModule } from './stripe/stripe.module';
@@ -95,6 +96,7 @@ import { StripeModule } from './stripe/stripe.module';
9596
RolesModule,
9697
AuditModule,
9798
ControlsModule,
99+
EmailModule,
98100
SecretsModule,
99101
SecurityPenetrationTestsModule,
100102
StripeModule,

apps/api/src/auth/service-token.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const SERVICE_DEFINITIONS: Record<string, ServiceDefinition> = {
2222
'integration:update',
2323
'cloud-security:update',
2424
'vendor:update',
25+
'email:send',
2526
],
2627
},
2728
portal: {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
IsString,
3+
IsOptional,
4+
IsArray,
5+
IsBoolean,
6+
ValidateNested,
7+
} from 'class-validator';
8+
import { Type } from 'class-transformer';
9+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
10+
11+
class EmailAttachmentDto {
12+
@ApiProperty()
13+
@IsString()
14+
filename: string;
15+
16+
@ApiProperty()
17+
@IsString()
18+
content: string;
19+
20+
@ApiPropertyOptional()
21+
@IsOptional()
22+
@IsString()
23+
contentType?: string;
24+
}
25+
26+
export class SendEmailDto {
27+
@ApiProperty({ description: 'Recipient email address' })
28+
@IsString()
29+
to: string;
30+
31+
@ApiProperty({ description: 'Email subject line' })
32+
@IsString()
33+
subject: string;
34+
35+
@ApiProperty({ description: 'Pre-rendered HTML content' })
36+
@IsString()
37+
html: string;
38+
39+
@ApiPropertyOptional({ description: 'Explicit FROM address override' })
40+
@IsOptional()
41+
@IsString()
42+
from?: string;
43+
44+
@ApiPropertyOptional({
45+
description: 'Use system sender address (RESEND_FROM_SYSTEM)',
46+
})
47+
@IsOptional()
48+
@IsBoolean()
49+
system?: boolean;
50+
51+
@ApiPropertyOptional({ description: 'CC recipients' })
52+
@IsOptional()
53+
cc?: string | string[];
54+
55+
@ApiPropertyOptional({ description: 'Schedule email for later delivery' })
56+
@IsOptional()
57+
@IsString()
58+
scheduledAt?: string;
59+
60+
@ApiPropertyOptional({ description: 'File attachments' })
61+
@IsOptional()
62+
@IsArray()
63+
@ValidateNested({ each: true })
64+
@Type(() => EmailAttachmentDto)
65+
attachments?: EmailAttachmentDto[];
66+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
2+
import {
3+
ApiOperation,
4+
ApiResponse,
5+
ApiSecurity,
6+
ApiTags,
7+
} from '@nestjs/swagger';
8+
import { tasks } from '@trigger.dev/sdk';
9+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
10+
import { PermissionGuard } from '../auth/permission.guard';
11+
import { RequirePermission } from '../auth/require-permission.decorator';
12+
import { SendEmailDto } from './dto/send-email.dto';
13+
import type { sendEmailTask } from '../trigger/email/send-email';
14+
15+
@ApiTags('Internal - Email')
16+
@Controller({ path: 'internal/email', version: '1' })
17+
@UseGuards(HybridAuthGuard, PermissionGuard)
18+
@ApiSecurity('apikey')
19+
export class EmailController {
20+
@Post('send')
21+
@HttpCode(200)
22+
@RequirePermission('email', 'send')
23+
@ApiOperation({
24+
summary: 'Send an email via the centralized Trigger task (internal)',
25+
})
26+
@ApiResponse({ status: 200, description: 'Email task triggered' })
27+
async sendEmail(@Body() dto: SendEmailDto) {
28+
const fromAddress = dto.system
29+
? (process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT)
30+
: (dto.from ?? process.env.RESEND_FROM_DEFAULT);
31+
32+
const handle = await tasks.trigger<typeof sendEmailTask>('send-email', {
33+
to: dto.to,
34+
subject: dto.subject,
35+
html: dto.html,
36+
from: fromAddress,
37+
cc: dto.cc,
38+
scheduledAt: dto.scheduledAt,
39+
attachments: dto.attachments,
40+
});
41+
42+
return { success: true, taskId: handle.id };
43+
}
44+
}

apps/api/src/email/email.module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { AuthModule } from '../auth/auth.module';
3+
import { EmailController } from './email.controller';
4+
5+
@Module({
6+
imports: [AuthModule],
7+
controllers: [EmailController],
8+
})
9+
export class EmailModule {}

apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts

Lines changed: 12 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,152 +1,31 @@
11
'use server';
22

3-
import { createTrainingVideoEntries } from '@/lib/db/employee';
4-
import { auth } from '@/utils/auth';
5-
import { sendInviteMemberEmail } from '@comp/email';
63
import type { Role } from '@db';
7-
import { db } from '@db';
8-
import { headers } from 'next/headers';
4+
import { inviteSingleMemberViaApi } from '@/lib/people-api';
95

106
export const addEmployeeWithoutInvite = async ({
117
email,
12-
organizationId,
8+
organizationId: _organizationId,
139
roles,
1410
}: {
1511
email: string;
1612
organizationId: string;
1713
roles: Role[];
1814
}) => {
1915
try {
20-
const session = await auth.api.getSession({ headers: await headers() });
21-
if (!session?.session) {
22-
throw new Error('Authentication required.');
23-
}
24-
const currentUserId = session.session.userId;
25-
const currentUserMember = await db.member.findFirst({
26-
where: {
27-
organizationId: organizationId,
28-
userId: currentUserId,
29-
deactivated: false,
30-
},
16+
const result = await inviteSingleMemberViaApi({
17+
email: email.toLowerCase(),
18+
roles,
3119
});
3220

33-
if (!currentUserMember) {
34-
throw new Error("You don't have permission to add members.");
35-
}
36-
37-
const isAdmin =
38-
currentUserMember.role.includes('admin') || currentUserMember.role.includes('owner');
39-
const isAuditor = currentUserMember.role.includes('auditor');
40-
41-
if (!isAdmin && !isAuditor) {
42-
throw new Error("You don't have permission to add members.");
43-
}
44-
45-
if (isAuditor && !isAdmin) {
46-
const onlyAuditorRole = roles.length === 1 && roles[0] === 'auditor';
47-
if (!onlyAuditorRole) {
48-
throw new Error("Auditors can only add users with the 'auditor' role.");
49-
}
50-
}
51-
52-
// Get organization name
53-
const organization = await db.organization.findUnique({
54-
where: { id: organizationId },
55-
select: { name: true },
56-
});
57-
58-
if (!organization) {
59-
throw new Error('Organization not found.');
60-
}
61-
62-
let userId = '';
63-
const existingUser = await db.user.findFirst({
64-
where: {
65-
email: {
66-
equals: email,
67-
mode: 'insensitive',
68-
},
69-
},
70-
});
71-
72-
if (!existingUser) {
73-
const newUser = await db.user.create({
74-
data: {
75-
emailVerified: false,
76-
email,
77-
name: email.split('@')[0],
78-
},
79-
});
80-
81-
userId = newUser.id;
82-
}
83-
84-
const finalUserId = existingUser?.id ?? userId;
85-
86-
// Check if there's an existing member (including deactivated ones) for this user and organization
87-
const existingMember = await db.member.findFirst({
88-
where: {
89-
userId: finalUserId,
90-
organizationId,
91-
},
92-
});
93-
94-
let member;
95-
if (existingMember) {
96-
// If member exists but is deactivated, reactivate it and update roles
97-
if (existingMember.deactivated) {
98-
const roleString = roles.sort().join(',');
99-
member = await db.member.update({
100-
where: { id: existingMember.id },
101-
data: {
102-
deactivated: false,
103-
role: roleString,
104-
},
105-
});
106-
} else {
107-
// Member already exists and is active, return existing member
108-
member = existingMember;
109-
}
110-
} else {
111-
// No existing member, create a new one
112-
member = await auth.api.addMember({
113-
headers: await headers(),
114-
body: {
115-
userId: finalUserId,
116-
organizationId,
117-
role: roles.join(','),
118-
},
119-
});
120-
}
121-
122-
// Create training video completion entries for the new member (only if member was just created/reactivated)
123-
if (member?.id && !existingMember) {
124-
await createTrainingVideoEntries(member.id);
125-
}
126-
127-
// Generate invite link
128-
const inviteLink = `${process.env.NEXT_PUBLIC_PORTAL_URL}/${organizationId}`;
129-
130-
// Send the invitation email (non-fatal: member is already created)
131-
let emailSent = true;
132-
let emailError: string | undefined;
133-
try {
134-
await sendInviteMemberEmail({
135-
inviteeEmail: email.toLowerCase(),
136-
inviteLink,
137-
organizationName: organization.name,
138-
});
139-
} catch (emailErr) {
140-
emailSent = false;
141-
emailError = emailErr instanceof Error ? emailErr.message : 'Failed to send invite email';
142-
console.error('Invite email failed after member was added:', { email, organizationId, error: emailErr });
143-
}
144-
14521
return {
146-
success: true,
147-
data: member,
148-
emailSent,
149-
...(emailError && { emailError }),
22+
success: result.success,
23+
data: undefined,
24+
emailSent: result.emailSent ?? result.success,
25+
...(result.error && {
26+
error: result.error,
27+
emailError: result.error,
28+
}),
15029
};
15130
} catch (error) {
15231
console.error('Error adding employee:', error);

0 commit comments

Comments
 (0)