Add email integration feature

- Add /api/emails/unread endpoint to fetch unread emails from sales@cardoza.construction
- Add 'Review Unread Sales Emails' button in filter buttons section
- Implement draggable modal window to display emails
- Show unread count badge on email button
- Click emails to open in Outlook web
- Add permission error handling for Azure AD Mail.Read permission
- Position modal with high z-index to ensure visibility
This commit is contained in:
2026-01-02 06:07:51 +00:00
parent ae306e946b
commit 9ca7d2e5c2
2 changed files with 304 additions and 2 deletions
+232
View File
@@ -244,9 +244,22 @@
<!-- View Buttons Section -->
<div class="bg-white rounded-lg shadow-2xl px-4 py-2 mt-2">
<div class="flex items-center justify-between gap-4">
<div id="viewButtonsContainer" class="flex items-center space-x-2 flex-wrap gap-y-2">
<!-- View buttons will be dynamically generated here -->
</div>
<button
id="emailButton"
class="px-3 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium transition-colors flex items-center gap-1 whitespace-nowrap"
onclick="openEmailModal()"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
Review Unread Sales Emails
<span id="emailCountBadge" class="hidden bg-red-500 text-white text-xs rounded-full px-2 py-0.5 ml-1"></span>
</button>
</div>
</div>
<!-- Search Section -->
@@ -337,6 +350,25 @@
</div>
</div>
<!-- Email Modal (Hidden by default) -->
<div id="emailModal" style="display: none; position: fixed; z-index: 99999; background: white; border: 2px solid #4c51bf; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 700px; max-width: 90vw; max-height: 80vh; flex-direction: column;">
<div id="emailModalHeader" style="background: linear-gradient(to right, #4c51bf, #5b21b6); color: white; padding: 12px 16px; border-radius: 10px 10px 0 0; cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center;">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<span class="font-semibold">Unread Sales Emails</span>
<span id="emailModalCount" class="text-sm opacity-90"></span>
</div>
<button onclick="closeEmailModal()" class="text-white hover:text-gray-200 text-2xl font-bold leading-none">&times;</button>
</div>
<div id="emailModalBody" style="flex: 1; overflow-y: auto; padding: 16px;">
<div class="flex justify-center items-center py-8">
<div class="text-gray-400">Loading emails...</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
<script>
// Suppress QUIC protocol errors (cosmetic browser issue with PocketBase realtime)
@@ -978,6 +1010,8 @@
: 'px-3 py-1.5 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm font-medium transition-colors';
button.textContent = view.label;
container.appendChild(button);
// Generate tooltip showing filter configuration
if (view.filters && view.filters.length > 0) {
const filterDescriptions = view.filters.map(filter => {
@@ -1998,6 +2032,204 @@
// Initialize
checkAuth();
// Email Modal Functions
let isDraggingModal = false;
let modalOffsetX = 0;
let modalOffsetY = 0;
// Make email modal draggable
function initEmailModalDragging() {
const modal = document.getElementById('emailModal');
const header = document.getElementById('emailModalHeader');
header.addEventListener('mousedown', (e) => {
isDraggingModal = true;
const rect = modal.getBoundingClientRect();
modalOffsetX = e.clientX - rect.left;
modalOffsetY = e.clientY - rect.top;
header.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (!isDraggingModal) return;
const modal = document.getElementById('emailModal');
// Reset transform when dragging starts
if (modal.style.transform) {
modal.style.transform = '';
}
let newLeft = e.clientX - modalOffsetX;
let newTop = e.clientY - modalOffsetY;
// Keep modal within viewport
const maxLeft = window.innerWidth - modal.offsetWidth;
const maxTop = window.innerHeight - modal.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
modal.style.left = newLeft + 'px';
modal.style.top = newTop + 'px';
});
document.addEventListener('mouseup', () => {
if (isDraggingModal) {
isDraggingModal = false;
header.style.cursor = 'move';
}
});
}
initEmailModalDragging();
async function openEmailModal() {
const modal = document.getElementById('emailModal');
const body = document.getElementById('emailModalBody');
const countSpan = document.getElementById('emailModalCount');
// Center modal on screen
modal.style.top = '50%';
modal.style.left = '50%';
modal.style.transform = 'translate(-50%, -50%)';
// Show modal
modal.style.display = 'flex';
// Show loading state
body.innerHTML = `
<div class="flex justify-center items-center py-8">
<div class="text-gray-400">Loading emails...</div>
</div>
`;
try {
const response = await fetch('/api/emails/unread', {
headers: {
'X-PB-Token': pb.authStore.token
}
});
const data = await response.json();
if (!response.ok) {
const error = new Error(data.message || 'Failed to fetch emails');
error.response = data;
throw error;
}
const emails = data.emails || [];
countSpan.textContent = `(${emails.length})`;
if (emails.length === 0) {
body.innerHTML = `
<div class="flex flex-col items-center justify-center py-12 text-gray-500">
<svg class="w-16 h-16 mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p class="text-lg">No unread emails</p>
</div>
`;
} else {
body.innerHTML = emails.map(email => {
const date = new Date(email.receivedDateTime);
const dateStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `
<div class="email-item" onclick="openEmailInOutlook('${email.webLink}')">
<div class="flex justify-between items-start mb-1">
<div class="font-semibold text-gray-900 flex-1">${escapeHtml(email.subject)}</div>
<div class="text-xs text-gray-500 ml-2 whitespace-nowrap">${dateStr}</div>
</div>
<div class="text-sm text-gray-600 mb-2">From: ${escapeHtml(email.from)}</div>
<div class="text-sm text-gray-500 line-clamp-2">${escapeHtml(email.bodyPreview)}</div>
</div>
`;
}).join('');
// Update badge on button
updateEmailBadge(emails.length);
}
} catch (error) {
console.error('Error fetching emails:', error);
let errorMessage = error.message;
let errorHint = '';
// Check if it's a response error with details
if (error.response) {
errorMessage = error.response.message || error.message;
errorHint = error.response.hint || '';
}
body.innerHTML = `
<div class="flex flex-col items-center justify-center py-12 text-red-500">
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-lg font-semibold">Error loading emails</p>
<p class="text-sm text-gray-600 mt-2 max-w-md text-center">${escapeHtml(errorMessage)}</p>
${errorHint ? `<p class="text-xs text-gray-500 mt-2 max-w-md text-center italic">${escapeHtml(errorHint)}</p>` : ''}
</div>
`;
}
}
function closeEmailModal() {
const modal = document.getElementById('emailModal');
modal.style.display = 'none';
}
function openEmailInOutlook(webLink) {
if (webLink) {
window.open(webLink, '_blank');
}
}
function updateEmailBadge(count) {
const badge = document.getElementById('emailCountBadge');
if (count > 0) {
badge.textContent = count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load email count on startup
async function loadEmailCount() {
try {
const response = await fetch('/api/emails/unread', {
headers: {
'X-PB-Token': pb.authStore.token
}
});
if (response.ok) {
const data = await response.json();
const count = (data.emails || []).length;
updateEmailBadge(count);
}
} catch (error) {
console.error('Error loading email count:', error);
}
}
// Load email count after auth
setTimeout(() => {
if (pb.authStore.isValid) {
loadEmailCount();
}
}, 2000);
// Clean up subscription on unload
window.addEventListener('beforeunload', () => {
try {
+70
View File
@@ -50,6 +50,76 @@ app.get('/health', (c) => {
return c.json({ ok: true, pbDB: process.env.PB_DB });
});
// Email endpoint - fetch unread emails from sales@cardoza.construction
app.get('/api/emails/unread', async (c) => {
try {
const pbToken = c.req.header('X-PB-Token');
if (!pbToken) {
return c.json({ success: false, message: 'Missing pbToken' }, 401);
}
// Validate user token
setUserPocketBaseAuth(pbToken);
try {
await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authRefresh();
} catch (e) {
console.error('auth_validation_failed', { message: (e as Error)?.message });
return c.json({ success: false, message: 'Invalid token' }, 401);
}
// Get Graph API token
const graphToken = await getGraphToken();
// Fetch unread emails from sales@cardoza.construction mailbox
const mailboxEmail = 'sales@cardoza.construction';
const graphUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(mailboxEmail)}/messages?$filter=isRead eq false&$select=id,subject,from,receivedDateTime,bodyPreview,webLink&$orderby=receivedDateTime desc&$top=50`;
const response = await fetch(graphUrl, {
headers: {
'Authorization': `Bearer ${graphToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('graph_api_error', { status: response.status, error: errorText });
// Provide helpful error message
if (response.status === 403) {
return c.json({
success: false,
message: 'Permission denied. The application needs Mail.Read permission in Azure AD to access the sales mailbox.',
hint: 'Ask your Azure AD administrator to grant "Mail.Read" application permission to this app.',
error: errorText
}, 403);
}
return c.json({ success: false, message: 'Failed to fetch emails', error: errorText }, response.status);
}
const data = await response.json();
const emails = data.value || [];
return c.json({
success: true,
emails: emails.map((email: any) => ({
id: email.id,
subject: email.subject || '(No Subject)',
from: email.from?.emailAddress?.name || email.from?.emailAddress?.address || 'Unknown',
fromEmail: email.from?.emailAddress?.address || '',
receivedDateTime: email.receivedDateTime,
bodyPreview: email.bodyPreview || '',
webLink: email.webLink || ''
}))
});
} catch (err) {
console.error('email_fetch_error', { message: (err as Error)?.message, stack: (err as Error)?.stack });
return c.json({ success: false, message: 'Internal error: ' + (err as Error)?.message }, 500);
}
});
// Submit endpoint
app.post('/api/submit', async (c) => {
try {