diff --git a/CLAUDE.md b/CLAUDE.md index e808147..5f9f759 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/lib/email-ingest.ts b/src/lib/email-ingest.ts index b084326..d6296eb 100644 --- a/src/lib/email-ingest.ts +++ b/src/lib/email-ingest.ts @@ -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+)\]/; @@ -104,15 +104,19 @@ async function handleThreadReply( ): Promise { const devops = new AzureDevOpsServiceWithPAT(encodedPat); try { + const renderedBody = renderEmailBody(body); const commentHtml = `

Email reply from: ${escapeHtml(senderEmail)}


- ${renderEmailBody(body)} + ${renderedBody}
`.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); @@ -120,6 +124,42 @@ async function handleThreadReply( } } +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; diff --git a/src/lib/email-templates.ts b/src/lib/email-templates.ts index ff31c6b..cd7f149 100644 --- a/src/lib/email-templates.ts +++ b/src/lib/email-templates.ts @@ -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(` +

New customer reply on ticket #${opts.ticketId}

+

Subject: ${opts.ticketSubject}

+

From: ${opts.customerEmail}

+
+ ${opts.replyContentHtml} +
+

+ View Ticket #${opts.ticketId} +

+ `); +} + export function statusChangeTemplate(opts: { ticketId: number; subject: string; diff --git a/src/lib/email.ts b/src/lib/email.ts index fb6334a..b42e077 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -11,6 +11,7 @@ import { ticketConfirmationTemplate, agentReplyTemplate, statusChangeTemplate, + customerReplyNotificationTemplate, type HistoryEntry, } from './email-templates'; @@ -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 { + 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 { const from = MAIL_FROM(); const html = `