Skip to content

Commit 0461cee

Browse files
pktikkaniclaude
andcommitted
feat: Add Microsoft Graph email integration for campaign reports
- Created EmailClient using Microsoft Graph API - sendCampaignReport now sends actual emails via Graph API - Uses app-only permissions (Mail.Send) - Beautiful HTML email template with campaign stats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 79c3327 commit 0461cee

2 files changed

Lines changed: 237 additions & 6 deletions

File tree

src/lib/microsoft/email.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { TokenManager } from './token-manager.js';
2+
3+
const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
4+
5+
interface EmailAttachment {
6+
name: string;
7+
contentType: string;
8+
contentBytes: string; // Base64 encoded
9+
}
10+
11+
interface EmailMessage {
12+
to: string[];
13+
cc?: string[];
14+
subject: string;
15+
body: string;
16+
bodyType?: 'html' | 'text';
17+
attachments?: EmailAttachment[];
18+
}
19+
20+
/**
21+
* Microsoft Graph Email Client
22+
* Uses app-only permissions to send emails
23+
*/
24+
export class EmailClient {
25+
private tokenManager: TokenManager;
26+
27+
constructor(userId: string, tenantId?: string) {
28+
this.tokenManager = new TokenManager(userId, tenantId);
29+
}
30+
31+
/**
32+
* Send an email using Microsoft Graph API
33+
* Requires Mail.Send application permission
34+
*/
35+
async sendEmail(
36+
fromEmail: string,
37+
message: EmailMessage
38+
): Promise<{ success: boolean; messageId?: string; error?: string }> {
39+
try {
40+
const accessToken = await this.tokenManager.getAppOnlyGraphToken();
41+
42+
const emailPayload = {
43+
message: {
44+
subject: message.subject,
45+
body: {
46+
contentType: message.bodyType || 'html',
47+
content: message.body,
48+
},
49+
toRecipients: message.to.map(email => ({
50+
emailAddress: { address: email },
51+
})),
52+
ccRecipients: message.cc?.map(email => ({
53+
emailAddress: { address: email },
54+
})) || [],
55+
attachments: message.attachments?.map(att => ({
56+
'@odata.type': '#microsoft.graph.fileAttachment',
57+
name: att.name,
58+
contentType: att.contentType,
59+
contentBytes: att.contentBytes,
60+
})) || [],
61+
},
62+
saveToSentItems: true,
63+
};
64+
65+
const response = await fetch(
66+
`${GRAPH_BASE_URL}/users/${fromEmail}/sendMail`,
67+
{
68+
method: 'POST',
69+
headers: {
70+
Authorization: `Bearer ${accessToken}`,
71+
'Content-Type': 'application/json',
72+
},
73+
body: JSON.stringify(emailPayload),
74+
}
75+
);
76+
77+
if (!response.ok) {
78+
const errorText = await response.text();
79+
console.error('Graph API email error:', response.status, errorText);
80+
return {
81+
success: false,
82+
error: `Failed to send email: ${response.status} - ${errorText}`,
83+
};
84+
}
85+
86+
return { success: true };
87+
} catch (error) {
88+
console.error('Email send error:', error);
89+
return {
90+
success: false,
91+
error: error instanceof Error ? error.message : 'Unknown error',
92+
};
93+
}
94+
}
95+
96+
/**
97+
* Send access review report email
98+
*/
99+
async sendAccessReviewReport(
100+
fromEmail: string,
101+
toEmails: string[],
102+
campaignName: string,
103+
summary: {
104+
total: number;
105+
retained: number;
106+
removed: number;
107+
pending: number;
108+
},
109+
dashboardUrl: string
110+
): Promise<{ success: boolean; error?: string }> {
111+
const htmlBody = this.generateAccessReviewEmailHtml(campaignName, summary, dashboardUrl);
112+
113+
return this.sendEmail(fromEmail, {
114+
to: toEmails,
115+
subject: `Access Review Report: ${campaignName}`,
116+
body: htmlBody,
117+
bodyType: 'html',
118+
});
119+
}
120+
121+
/**
122+
* Generate HTML email body for access review report
123+
*/
124+
private generateAccessReviewEmailHtml(
125+
campaignName: string,
126+
summary: { total: number; retained: number; removed: number; pending: number },
127+
dashboardUrl: string
128+
): string {
129+
const completionRate = summary.total > 0
130+
? Math.round(((summary.retained + summary.removed) / summary.total) * 100)
131+
: 0;
132+
133+
return `
134+
<!DOCTYPE html>
135+
<html>
136+
<head>
137+
<meta charset="utf-8">
138+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
139+
</head>
140+
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
141+
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
142+
<!-- Header -->
143+
<div style="background: linear-gradient(135deg, #ea580c 0%, #f97316 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
144+
<h1 style="color: white; margin: 0; font-size: 24px; font-weight: 600;">Access Review Report</h1>
145+
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0; font-size: 14px;">${campaignName}</p>
146+
</div>
147+
148+
<!-- Content -->
149+
<div style="background: white; padding: 30px; border-radius: 0 0 12px 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
150+
<!-- Progress Bar -->
151+
<div style="margin-bottom: 25px;">
152+
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
153+
<span style="font-size: 14px; color: #6b7280;">Completion</span>
154+
<span style="font-size: 14px; font-weight: 600; color: #ea580c;">${completionRate}%</span>
155+
</div>
156+
<div style="background: #e5e7eb; border-radius: 9999px; height: 8px; overflow: hidden;">
157+
<div style="background: linear-gradient(90deg, #10b981 0%, #059669 100%); height: 100%; width: ${completionRate}%; border-radius: 9999px;"></div>
158+
</div>
159+
</div>
160+
161+
<!-- Summary Cards -->
162+
<table width="100%" cellpadding="0" cellspacing="8" style="margin-bottom: 25px;">
163+
<tr>
164+
<td style="background: #f9fafb; border-radius: 8px; padding: 15px; text-align: center; width: 25%;">
165+
<div style="font-size: 28px; font-weight: 700; color: #111827;">${summary.total}</div>
166+
<div style="font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px;">Total</div>
167+
</td>
168+
<td style="background: #dcfce7; border-radius: 8px; padding: 15px; text-align: center; width: 25%;">
169+
<div style="font-size: 28px; font-weight: 700; color: #166534;">${summary.retained}</div>
170+
<div style="font-size: 12px; color: #166534; text-transform: uppercase; letter-spacing: 0.5px;">Retained</div>
171+
</td>
172+
<td style="background: #fee2e2; border-radius: 8px; padding: 15px; text-align: center; width: 25%;">
173+
<div style="font-size: 28px; font-weight: 700; color: #991b1b;">${summary.removed}</div>
174+
<div style="font-size: 12px; color: #991b1b; text-transform: uppercase; letter-spacing: 0.5px;">Removed</div>
175+
</td>
176+
<td style="background: #fef3c7; border-radius: 8px; padding: 15px; text-align: center; width: 25%;">
177+
<div style="font-size: 28px; font-weight: 700; color: #92400e;">${summary.pending}</div>
178+
<div style="font-size: 12px; color: #92400e; text-transform: uppercase; letter-spacing: 0.5px;">Pending</div>
179+
</td>
180+
</tr>
181+
</table>
182+
183+
<!-- Message -->
184+
<div style="background: #fff7ed; border-left: 4px solid #f97316; padding: 15px; border-radius: 0 8px 8px 0; margin-bottom: 25px;">
185+
<p style="margin: 0; color: #c2410c; font-size: 14px;">
186+
<strong>Review Summary</strong><br>
187+
This report summarizes the access review decisions made for this campaign.
188+
</p>
189+
</div>
190+
191+
<!-- CTA Button -->
192+
<div style="text-align: center; margin-bottom: 25px;">
193+
<a href="${dashboardUrl}" style="display: inline-block; background: linear-gradient(135deg, #ea580c 0%, #f97316 100%); color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 14px;">View in Dashboard</a>
194+
</div>
195+
196+
<!-- Footer -->
197+
<div style="text-align: center; padding-top: 20px; border-top: 1px solid #e5e7eb;">
198+
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
199+
Generated by AuditSphere Access Review
200+
</p>
201+
</div>
202+
</div>
203+
</div>
204+
</body>
205+
</html>
206+
`;
207+
}
208+
}

src/trpc/routers/accessReview.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from 'zod';
22
import { createTRPCRouter, protectedProcedure } from '../init.js';
33
import { db } from '../../lib/db/prisma.js';
44
import { PermissionsClient, ResourcePermission } from '../../lib/microsoft/permissions.js';
5+
import { EmailClient } from '../../lib/microsoft/email.js';
56
import { TRPCError } from '@trpc/server';
67
import type { Prisma } from '@prisma/client';
78

@@ -1299,12 +1300,34 @@ export const accessReviewRouter = createTRPCRouter({
12991300
}),
13001301
]);
13011302

1302-
// TODO: Integrate with email service (SendGrid, SES, etc.)
1303-
// For now, log the email that would be sent
1304-
console.log(`[SendCampaignReport] Would send report for campaign "${campaign.name}" to:`, input.recipientEmails);
1305-
console.log(`[SendCampaignReport] Stats: ${totalItems} items, ${retainCount} retained, ${removeCount} removed`);
1303+
const pending = totalItems - retainCount - removeCount;
1304+
1305+
// Send email via Microsoft Graph API
1306+
const emailClient = new EmailClient(ctx.user.id);
1307+
const dashboardUrl = `${process.env.DASHBOARD_URL || 'https://pragmatic706.sharepoint.com'}/SitePages/AccessReview.aspx`;
1308+
1309+
const result = await emailClient.sendAccessReviewReport(
1310+
ctx.user.email, // Send from the user's email
1311+
input.recipientEmails,
1312+
campaign.name,
1313+
{
1314+
total: totalItems,
1315+
retained: retainCount,
1316+
removed: removeCount,
1317+
pending,
1318+
},
1319+
dashboardUrl
1320+
);
1321+
1322+
if (!result.success) {
1323+
console.error(`[SendCampaignReport] Failed to send email:`, result.error);
1324+
throw new TRPCError({
1325+
code: 'INTERNAL_SERVER_ERROR',
1326+
message: result.error || 'Failed to send email',
1327+
});
1328+
}
13061329

1307-
// Create notifications for the recipients
1330+
// Create notifications for tracking
13081331
for (const email of input.recipientEmails) {
13091332
await db.accessReviewNotification.create({
13101333
data: {
@@ -1319,7 +1342,7 @@ export const accessReviewRouter = createTRPCRouter({
13191342

13201343
return {
13211344
success: true,
1322-
message: `Report queued for ${input.recipientEmails.length} recipient(s)`,
1345+
message: `Report sent to ${input.recipientEmails.length} recipient(s)`,
13231346
recipientCount: input.recipientEmails.length,
13241347
};
13251348
}),

0 commit comments

Comments
 (0)