function initTheme() {
const saved = localStorage.getItem('missive-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
}
updateThemeIcon();
}
function toggleTheme() {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('missive-theme', isDark ? 'dark' : 'light');
updateThemeIcon();
}
function updateThemeIcon() {
const isDark = document.documentElement.classList.contains('dark');
const sunIcon = document.getElementById('sun-icon');
const moonIcon = document.getElementById('moon-icon');
if (sunIcon && moonIcon) {
sunIcon.style.display = isDark ? 'block' : 'none';
moonIcon.style.display = isDark ? 'none' : 'block';
}
}
function formatDate(isoString) {
if (!isoString) return '';
const date = new Date(isoString);
return date.toLocaleString();
}
function formatRelativeDate(isoString) {
if (!isoString) return '';
const date = new Date(isoString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
let currentEmailId = null;
const basePath = window.location.pathname.replace(/\/?$/, '');
async function selectEmail(id) {
document.querySelectorAll('.email-item').forEach(item => {
item.classList.toggle('selected', item.dataset.id === id);
});
currentEmailId = id;
try {
const response = await fetch(`${basePath}/${id}`);
if (!response.ok) throw new Error('Failed to load email');
const email = await response.json();
renderEmail(email);
} catch (error) {
console.error('Error loading email:', error);
document.getElementById('email-view').innerHTML = `
<div class="no-selection">
<p>Error loading email</p>
</div>
`;
}
}
function renderEmail(email) {
const hasHtml = email.html_body != null;
const hasText = email.text_body != null;
const metadataHtml = renderMetadata(email);
const headersHtml = renderExtraMetadata('Headers', email.headers);
const providerOptionsHtml = renderProviderOptions(email.provider_options);
const textBodyHtml = hasText ? renderTextBody(email.text_body, !hasHtml) : '';
const htmlBodyHtml = hasHtml ? renderHtmlBody(email.id) : '';
const attachmentsHtml = renderAttachments(email);
document.getElementById('email-view').innerHTML = `
<div class="email-detail">
${metadataHtml}
${headersHtml}
${providerOptionsHtml}
${textBodyHtml}
${htmlBodyHtml}
${attachmentsHtml}
</div>
`;
}
function renderMetadata(email) {
const rows = [
{ label: 'From', value: email.from },
{ label: 'To', value: email.to?.join(', ') },
{ label: 'Subject', value: email.subject, fallback: 'No subject' },
{ label: 'Cc', value: email.cc?.join(', ') },
{ label: 'Bcc', value: email.bcc?.join(', ') },
{ label: 'Reply-To', value: email.reply_to },
{ label: 'Sent at', value: formatDate(email.sent_at) },
];
const rowsHtml = rows
.filter(row => row.value || row.fallback)
.map(row => {
const isEmpty = !row.value;
const displayValue = row.value || row.fallback || 'n/a';
return `
<div class="metadata-row">
<dt class="metadata-label">${row.label}</dt>
<dd class="metadata-value${isEmpty ? ' empty' : ''}">${escapeHtml(displayValue)}</dd>
</div>
`;
})
.join('');
return `<dl class="email-metadata">${rowsHtml}</dl>`;
}
function renderExtraMetadata(title, data) {
if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
return '';
}
let items = '';
if (typeof data === 'object' && !Array.isArray(data)) {
items = Object.entries(data)
.map(([key, value]) => `
<div class="extra-metadata-item">
<span class="extra-metadata-key">${escapeHtml(key)}:</span>
<span class="extra-metadata-value">${escapeHtml(String(value))}</span>
</div>
`)
.join('');
}
if (!items) return '';
return `
<div class="extra-metadata">
<div class="extra-metadata-title">${title}</div>
<div class="extra-metadata-grid">${items}</div>
</div>
`;
}
function renderProviderOptions(options) {
if (!options || options.length === 0) return '';
const items = options
.map(opt => `
<div class="extra-metadata-item">
<span class="extra-metadata-key">${escapeHtml(opt.key)}:</span>
<span class="extra-metadata-value">${escapeHtml(opt.value)}</span>
</div>
`)
.join('');
return `
<div class="extra-metadata">
<div class="extra-metadata-title">Provider Options</div>
<div class="extra-metadata-grid">${items}</div>
</div>
`;
}
function renderTextBody(textBody, expanded = false) {
const expandedClass = expanded ? ' expanded' : '';
const chevronSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`;
return `
<div class="collapsible-header${expandedClass}" onclick="toggleCollapsible(this)">
${chevronSvg}
<span>Text body</span>
</div>
<div class="collapsible-content${expandedClass}">
<div class="text-body-content">${escapeHtml(textBody)}</div>
</div>
`;
}
function renderHtmlBody(emailId) {
const externalSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`;
return `
<div class="html-body-section">
<div class="section-header">
<span>HTML body</span>
<a href="${basePath}/${emailId}/html" target="_blank" class="open-external" title="Open in new tab">
${externalSvg}
</a>
</div>
<div class="html-body-content">
<iframe src="${basePath}/${emailId}/html" sandbox="allow-same-origin"></iframe>
</div>
</div>
`;
}
function renderAttachments(email) {
if (!email.attachments || email.attachments.length === 0) return '';
const paperclipSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>`;
const attachmentsHtml = email.attachments
.map(att => `
<a href="${basePath}/${email.id}/attachments/${att.index}"
class="attachment-card"
download="${escapeHtml(att.filename)}"
target="_blank">
${paperclipSvg}
<div>
<div class="attachment-name">${escapeHtml(att.filename)}</div>
<div class="attachment-meta">${escapeHtml(att.content_type)} - ${formatBytes(att.size)}</div>
</div>
</a>
`)
.join('');
return `
<div class="attachments-section">
<div class="attachments-title">Attachments (${email.attachments.length})</div>
<div class="attachments-grid">${attachmentsHtml}</div>
</div>
`;
}
function toggleCollapsible(header) {
header.classList.toggle('expanded');
const content = header.nextElementSibling;
if (content) {
content.classList.toggle('expanded');
}
}
async function clearAll() {
if (!confirm('Clear all emails?')) return;
try {
await fetch(`${basePath}/clear`, { method: 'POST' });
location.reload();
} catch (error) {
console.error('Error clearing emails:', error);
}
}
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function formatBytes(bytes) {
if (bytes === 0 || bytes == null) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
initTheme();
const firstItem = document.querySelector('.email-item');
if (firstItem) {
selectEmail(firstItem.dataset.id);
}