Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 21 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,26 +186,27 @@ When making changes:

## Environment Variables

| Variable | Description | Required |
| ----------------------------------------- | ------------------------------------------------------------ | ----------------------------- |
| `NEXTAUTH_URL` | Base URL of the application | Yes |
| `NEXTAUTH_SECRET` | Secret for NextAuth encryption | Yes |
| `AZURE_AD_CLIENT_ID` | Azure AD application client ID | Yes |
| `AZURE_AD_CLIENT_SECRET` | Azure AD application client secret | Yes |
| `AZURE_AD_TENANT_ID` | Azure AD tenant ID (or 'common') | Yes |
| `AZURE_DEVOPS_ORG` | Azure DevOps organization name | Yes |
| `AZURE_DEVOPS_PAT` | Personal Access Token for service account | For email integration |
| `EMAIL_WEBHOOK_SECRET` | Shared secret for `/api/email/poll` and `/api/email/webhook` | For email integration |
| `MAIL_POLL_MAILBOX` | Mailbox ZapDesk polls for inbound email | For inbound email |
| `MAIL_FROM` | Sender address for outbound mail | For outbound email |
| `MAIL_FROM_NAME` | Display name for outbound mail | No (default: ZapDesk Support) |
| `MAIL_TENANT_ID` | Tenant ID for the dedicated mail Azure AD app | For email integration |
| `MAIL_CLIENT_ID` | Client ID for the dedicated mail Azure AD app | For email integration |
| `MAIL_CLIENT_SECRET` | Client secret for the dedicated mail Azure AD app | For email integration |
| `TEAM_THRESHOLD_NEEDS_ATTENTION_PENDING` | Pending tickets threshold for "Needs Attention" | No (default: 5) |
| `TEAM_THRESHOLD_NEEDS_ATTENTION_ASSIGNED` | Assigned tickets threshold for "Needs Attention" | No (default: 15) |
| `TEAM_THRESHOLD_BEHIND_PENDING` | Pending tickets threshold for "Behind" | No (default: 2) |
| `TEAM_THRESHOLD_BEHIND_ASSIGNED` | Assigned tickets threshold for "Behind" | No (default: 10) |
| Variable | Description | Required |
| ----------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------- |
| `NEXTAUTH_URL` | Base URL of the application | Yes |
| `NEXTAUTH_SECRET` | Secret for NextAuth encryption | Yes |
| `AZURE_AD_CLIENT_ID` | Azure AD application client ID | Yes |
| `AZURE_AD_CLIENT_SECRET` | Azure AD application client secret | Yes |
| `AZURE_AD_TENANT_ID` | Azure AD tenant ID (or 'common') | Yes |
| `AZURE_DEVOPS_ORG` | Azure DevOps organization name | Yes |
| `AZURE_DEVOPS_PAT` | Personal Access Token for service account | For email integration |
| `EMAIL_WEBHOOK_SECRET` | Shared secret for `/api/email/poll` and `/api/email/webhook` | For email integration |
| `MAIL_POLL_MAILBOX` | Mailbox ZapDesk polls for inbound email | For inbound email |
| `MAIL_FROM` | Sender address for outbound mail | For outbound email |
| `MAIL_FROM_NAME` | Display name for outbound mail | No (default: ZapDesk Support) |
| `MAIL_TENANT_ID` | Tenant ID for the dedicated mail Azure AD app | For email integration |
| `MAIL_CLIENT_ID` | Client ID for the dedicated mail Azure AD app | For email integration |
| `MAIL_CLIENT_SECRET` | Client secret for the dedicated mail Azure AD app | For email integration |
| `SUPPORT_TEAM_NOTIFY_EMAIL` | Fallback recipient for customer-reply notifications when a ticket is unassigned | No |
| `TEAM_THRESHOLD_NEEDS_ATTENTION_PENDING` | Pending tickets threshold for "Needs Attention" | No (default: 5) |
| `TEAM_THRESHOLD_NEEDS_ATTENTION_ASSIGNED` | Assigned tickets threshold for "Needs Attention" | No (default: 15) |
| `TEAM_THRESHOLD_BEHIND_PENDING` | Pending tickets threshold for "Behind" | No (default: 2) |
| `TEAM_THRESHOLD_BEHIND_ASSIGNED` | Assigned tickets threshold for "Behind" | No (default: 10) |

## Deployment

Expand Down
46 changes: 43 additions & 3 deletions src/lib/email-ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { getProjectFromEmail } from '@/lib/devops';
import { sendTicketConfirmation } from '@/lib/email';
import { sendCustomerReplyNotification, sendTicketConfirmation } from '@/lib/email';
import { escapeHtml, renderEmailBody } from '@/lib/email-clean';

const TICKET_REF_REGEX = /\[ZapDesk #(\d+)\]/;
Expand Down Expand Up @@ -104,22 +104,62 @@ async function handleThreadReply(
): Promise<IngestResult> {
const devops = new AzureDevOpsServiceWithPAT(encodedPat);
try {
const renderedBody = renderEmailBody(body);
const commentHtml = `
<div style="font-family: sans-serif;">
<p><strong>Email reply from:</strong> ${escapeHtml(senderEmail)}</p>
<hr/>
${renderEmailBody(body)}
${renderedBody}
</div>`.trim();

await devops.addComment(ticketId, commentHtml);
const updatedWorkItem = await devops.addComment(ticketId, commentHtml);
console.log(`Added email reply to ticket #${ticketId} from ${senderEmail}`);

notifyAgentOfReply(updatedWorkItem, ticketId, senderEmail, renderedBody);

return { success: true, action: 'comment_added', ticketId };
} catch (error) {
console.error(`Failed to add comment to ticket #${ticketId}:`, error);
return { success: false, status: 500, error: 'Failed to add comment to ticket' };
}
}

interface WorkItemFieldsResponse {
fields?: {
'System.Title'?: string;
'System.AssignedTo'?: { uniqueName?: string; displayName?: string } | string;
};
}

function notifyAgentOfReply(
workItem: WorkItemFieldsResponse | null | undefined,
ticketId: number,
senderEmail: string,
renderedBodyHtml: string
): void {
const fields = workItem?.fields ?? {};
const title = fields['System.Title'] || `Ticket #${ticketId}`;
const assigned = fields['System.AssignedTo'];
const assignedEmail = typeof assigned === 'object' && assigned ? assigned.uniqueName : undefined;
// Groups and team identities have a uniqueName like `[Project]\Team Name`
// which won't contain `@`. Treat anything that doesn't look like an email
// address as "no assignee" and fall back to the configured team address.
const recipient =
assignedEmail && assignedEmail.includes('@')
? assignedEmail
: process.env.SUPPORT_TEAM_NOTIFY_EMAIL || '';
if (!recipient) {
console.log(
`[Notify] No agent assigned and SUPPORT_TEAM_NOTIFY_EMAIL not set — skipping notification for ticket #${ticketId}`
);
return;
}
// Fire-and-forget — must never block or fail the comment add.
sendCustomerReplyNotification(ticketId, title, recipient, senderEmail, renderedBodyHtml).catch(
() => {}
);
}

class AzureDevOpsServiceWithPAT {
private encodedPat: string;
private organization: string;
Expand Down
20 changes: 20 additions & 0 deletions src/lib/email-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,26 @@ export function agentReplyTemplate(opts: {
`);
}

export function customerReplyNotificationTemplate(opts: {
ticketId: number;
ticketSubject: string;
customerEmail: string;
replyContentHtml: string;
}): string {
const ticketUrl = `${APP_URL}/tickets/${opts.ticketId}`;
return layoutWrapper(`
<p class="meta">New customer reply on ticket <strong>#${opts.ticketId}</strong></p>
<p class="meta"><strong>Subject:</strong> ${opts.ticketSubject}</p>
<p class="meta"><strong>From:</strong> ${opts.customerEmail}</p>
<div class="content">
${opts.replyContentHtml}
</div>
<p style="text-align: center; margin-top: 24px;">
<a href="${ticketUrl}" class="btn">View Ticket #${opts.ticketId}</a>
</p>
`);
}

export function statusChangeTemplate(opts: {
ticketId: number;
subject: string;
Expand Down
40 changes: 40 additions & 0 deletions src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ticketConfirmationTemplate,
agentReplyTemplate,
statusChangeTemplate,
customerReplyNotificationTemplate,
type HistoryEntry,
} from './email-templates';

Expand Down Expand Up @@ -277,6 +278,45 @@ export async function sendStatusChangeNotification(
}
}

/**
* Notify the assigned agent (or fallback team) that a customer replied to a
* ticket via email. Sent on a fresh thread — no In-Reply-To pointing at the
* customer's email — so the internal conversation stays separate from the
* customer-facing one.
*/
export async function sendCustomerReplyNotification(
ticketId: number,
ticketSubject: string,
agentEmail: string,
customerEmail: string,
replyContentHtml: string
): Promise<void> {
if (!isEmailConfigured()) return;
try {
const messageId = generateMessageId(ticketId, 'agent-notify');
const html = customerReplyNotificationTemplate({
ticketId,
ticketSubject,
customerEmail,
replyContentHtml,
});
await sendViaGraph({
to: agentEmail,
subject: `[ZapDesk #${ticketId}] New customer reply — "${ticketSubject}"`,
html,
messageId,
});
console.log(
`[Email] Customer reply notification sent for ticket #${ticketId} to ${agentEmail}`
);
} catch (error) {
console.error(
`[Email] Failed to send customer reply notification for ticket #${ticketId}:`,
error
);
}
}

export async function sendTestEmail(to: string): Promise<void> {
const from = MAIL_FROM();
const html = `
Expand Down
Loading