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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ next-env.d.ts

# Claude Code local settings
.claude/settings.local.json

# Local-only email template preview output
email-preview.html
43 changes: 23 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,26 +186,29 @@ 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 |
| `APP_URL` | Public base URL used to build links and asset paths in outbound email | No (default: `NEXTAUTH_URL`) |
| `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 |
| `ZAPDESK_LOGO_URL` | Override URL for the ZapDesk logo in outbound email | No (default: `${APP_URL}/email/zapdesk-logo.png`) |
| `KNOWALL_LOGO_URL` | Override URL for the KnowAll AI logo in the outbound email footer | No (default: `${APP_URL}/email/knowall-logo.png`) |
| `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
Binary file added public/email/knowall-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/email/zapdesk-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions scripts/generate-email-assets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// One-off generator for branded email logos.
// Run with: bun run scripts/generate-email-assets.mjs
import sharp from 'sharp';
import { existsSync, readFileSync } from 'node:fs';
import { mkdirSync } from 'node:fs';

mkdirSync('public/email', { recursive: true });

const svg = readFileSync('public/assets/logo.svg');
await sharp(svg, { density: 300 })
.resize({ width: 480, height: 120, fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toFile('public/email/zapdesk-logo.png');
console.log('wrote public/email/zapdesk-logo.png (480x120, transparent)');

// The KnowAll AI logo is committed as a real asset, not generated from SVG.
// Only write a transparent placeholder if the file is missing, so re-running
// this script never silently clobbers the real logo.
const knowallPath = 'public/email/knowall-logo.png';
if (existsSync(knowallPath)) {
console.log(`skipped ${knowallPath} (already exists — leaving in place)`);
} else {
await sharp({
create: {
width: 480,
height: 120,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.png()
.toFile(knowallPath);
console.log(`wrote ${knowallPath} (480x120 transparent placeholder — replace with real logo)`);
}
78 changes: 78 additions & 0 deletions scripts/preview-email-templates.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Render every outbound email template into a single HTML file for visual
// review. Output path: email-preview.html (gitignored). Open in any browser.
// Run with: bun run scripts/preview-email-templates.mjs
import {
ticketConfirmationTemplate,
agentReplyTemplate,
statusChangeTemplate,
layoutWrapper,
} from '../src/lib/email-templates.ts';
import { writeFileSync } from 'node:fs';

const sections = [
{
title: 'ticketConfirmationTemplate',
html: ticketConfirmationTemplate({
ticketId: 1234,
subject: 'Login flow broken on staging',
requesterName: 'Akash Jadhav',
}),
},
{
title: 'agentReplyTemplate (with history)',
html: agentReplyTemplate({
ticketId: 1234,
agentName: 'Sarah Patel',
replyContent:
'<p>Thanks for the detail — we reproduced this on staging too. Pushing a fix in the next deploy.</p>',
history: [
{
authorName: 'Akash Jadhav',
createdAt: new Date('2026-05-08T14:30:00Z'),
contentHtml: '<p>Login still failing in incognito.</p>',
},
{
authorName: 'Sarah Patel',
createdAt: new Date('2026-05-08T15:10:00Z'),
contentHtml: '<p>Got it — checking now.</p>',
},
],
}),
},
{
title: 'statusChangeTemplate',
html: statusChangeTemplate({
ticketId: 1234,
subject: 'Login flow broken on staging',
requesterName: 'Akash Jadhav',
oldStatus: 'Active',
newStatus: 'Resolved',
}),
},
{
title: 'sendTestEmail body (via layoutWrapper)',
html: layoutWrapper(
`<div class="content"><p>This is a test email from your ZapDesk instance.</p></div>`
),
},
];

const wrapper = `
<!doctype html>
<html><head><title>ZapDesk email preview</title>
<style>
body { margin: 0; padding: 24px; font-family: system-ui, sans-serif; background: #fafafa; }
.section { max-width: 720px; margin: 0 auto 48px; }
.section h2 { font-size: 14px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; }
iframe { width: 100%; height: 720px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff; }
</style></head><body>
${sections
.map(
(s) =>
`<div class="section"><h2>${s.title}</h2><iframe srcdoc="${s.html.replace(/"/g, '&quot;')}"></iframe></div>`
)
.join('')}
</body></html>`.trim();

writeFileSync('email-preview.html', wrapper);
console.log('wrote email-preview.html — open in a browser to review');
28 changes: 21 additions & 7 deletions src/lib/email-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,28 @@

const APP_NAME = process.env.APP_NAME || 'ZapDesk';
const APP_URL = process.env.APP_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000';
// Logo URLs: env-overridable for deployments where outbound mail can't reach
// the app server (e.g. internal-only APP_URL). Default to assets shipped under
// public/email/ so local dev works out of the box.
const ZAPDESK_LOGO_URL = process.env.ZAPDESK_LOGO_URL || `${APP_URL}/email/zapdesk-logo.png`;
const KNOWALL_LOGO_URL = process.env.KNOWALL_LOGO_URL || `${APP_URL}/email/knowall-logo.png`;
const KNOWALL_URL = 'https://knowall.ai';

function layoutWrapper(content: string): string {
export function layoutWrapper(content: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f4f4f5; color: #18181b; }
.container { max-width: 600px; margin: 0 auto; padding: 24px; }
.card { background: #ffffff; border-radius: 8px; padding: 24px; border: 1px solid #e4e4e7; }
.card { background: #ffffff; border-radius: 12px; padding: 32px 24px; border: 1px solid #e4e4e7; }
.header { text-align: center; margin-bottom: 24px; }
.header h1 { font-size: 20px; color: #22c55e; margin: 0; }
.header img { display: block; margin: 0 auto; max-width: 100%; height: auto; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
.badge-new { background: #3b82f6; color: #fff; }
.badge-active { background: #22c55e; color: #fff; }
Expand All @@ -28,21 +36,27 @@ function layoutWrapper(content: string): string {
.quoted { border-left: 3px solid #d4d4d8; padding-left: 12px; margin: 16px 0; color: #71717a; }
.footer { text-align: center; margin-top: 24px; font-size: 12px; color: #a1a1aa; }
.footer a { color: #22c55e; text-decoration: none; }
.btn { display: inline-block; padding: 10px 20px; background: #22c55e; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px; }
.footer img { display: block; margin: 8px auto 4px; max-width: 100%; height: auto; }
.btn { display: inline-block; padding: 12px 24px; background: #22c55e; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px; line-height: 1; }
.meta { font-size: 13px; color: #71717a; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="header">
<h1>⚡ ${APP_NAME}</h1>
<a href="${APP_URL}" style="text-decoration: none;">
<img src="${ZAPDESK_LOGO_URL}" width="240" height="60" alt="${APP_NAME}" style="border: 0;" />
</a>
</div>
${content}
</div>
<div class="footer">
<p>Powered by <a href="${APP_URL}">${APP_NAME}</a></p>
<p>Please reply to this email to update your ticket.</p>
<a href="${KNOWALL_URL}" style="text-decoration: none;">
<img src="${KNOWALL_LOGO_URL}" width="120" height="30" alt="KnowAll AI" style="border: 0;" />
</a>
<p style="margin: 4px 0;">Powered by <a href="${KNOWALL_URL}">KnowAll AI</a></p>
<p style="margin: 4px 0;">Please reply to this email to update your ticket.</p>
</div>
</div>
</body>
Expand Down
23 changes: 11 additions & 12 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,
layoutWrapper,
type HistoryEntry,
} from './email-templates';

Expand Down Expand Up @@ -279,19 +280,17 @@ export async function sendStatusChangeNotification(

export async function sendTestEmail(to: string): Promise<void> {
const from = MAIL_FROM();
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 500px; margin: 0 auto; padding: 24px;">
<div style="background: #ffffff; border-radius: 8px; padding: 24px; border: 1px solid #e4e4e7;">
<h2 style="color: #22c55e; text-align: center; margin-top: 0;">⚡ ZapDesk</h2>
<p style="color: #18181b; line-height: 1.6;">This is a test email from your ZapDesk instance.</p>
<p style="color: #18181b; line-height: 1.6;">If you received this, your email configuration is working correctly.</p>
<div style="margin-top: 16px; padding: 12px; background: #f4f4f5; border-radius: 6px; font-size: 13px; color: #71717a;">
<strong>Method:</strong> Microsoft Graph API<br/>
<strong>From:</strong> ${from}<br/>
<strong>Sent at:</strong> ${new Date().toISOString()}
const html = layoutWrapper(`
<div class="content">
<p>This is a test email from your ZapDesk instance.</p>
<p>If you received this, your email configuration is working correctly.</p>
<div style="margin-top: 16px; padding: 12px; background: #f4f4f5; border-radius: 6px; font-size: 13px; color: #71717a;">
<strong>Method:</strong> Microsoft Graph API<br/>
<strong>From:</strong> ${from}<br/>
<strong>Sent at:</strong> ${new Date().toISOString()}
</div>
</div>
</div>
</div>`.trim();
`);

await sendViaGraph({ to, subject: 'ZapDesk — Test Email', html });
}
Loading