Files
gowebmail/web/templates/message.html
T
2026-08-30 08:17:03 +01:00

194 lines
9.7 KiB
HTML

{{template "base" .}}
{{define "title"}}Message — GoWebMail{{end}}
{{define "body_class"}}{{end}}
{{define "body"}}
<div id="msg-page" style="width:100%;box-sizing:border-box;margin:0 auto;padding:20px 32px;min-height:100vh">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid var(--border)">
<a href="/" style="color:var(--accent);text-decoration:none;font-size:13px;display:flex;align-items:center;gap:4px">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back to GoWebMail
</a>
<span style="color:var(--border);font-size:16px">|</span>
<div id="msg-actions" style="display:flex;gap:8px"></div>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn-secondary" id="btn-reply" style="font-size:12px" onclick="replyFromPage()">↩ Reply</button>
<button class="btn-secondary" id="btn-forward" style="font-size:12px" onclick="forwardFromPage()">↪ Forward</button>
</div>
</div>
<div id="msg-content">
<div class="spinner" style="margin-top:80px"></div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
const msgId = parseInt(location.pathname.split('/').pop());
let remoteWhitelist = new Set(), remoteImagePolicy = 'manual', contactsCache = null;
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const r = await fetch('/api' + path, opts);
return r.ok ? r.json() : null;
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── Remote-image policy — same rules as the main reading pane (app.js) ──
function stripUnresolvedCID(h){ return h.replace(/src\s*=\s*(['"])cid:[^'"]*\1/gi,'src=""').replace(/src\s*=\s*cid:\S+/gi,'src=""'); }
function stripEmbeddedFrames(h){ return h.replace(/<iframe[\s\S]*?<\/iframe>/gi,'').replace(/<iframe[^>]*>/gi,''); }
function stripRemoteImages(h){
return h.replace(/<img(\s[^>]*?)src\s*=\s*(['"])(https?:\/\/[^'"]+)\2/gi,'<img$1src="" data-blocked-src="$3"')
.replace(/url\s*\(\s*(['"]?)https?:\/\/[^)'"]+\1\s*\)/gi,'url()')
.replace(/<link[^>]*>/gi,'').replace(/<script[\s\S]*?<\/script>/gi,'');
}
function isContactEmail(fromEmail) {
if (!fromEmail || !contactsCache) return false;
const e = fromEmail.toLowerCase();
return contactsCache.some(c => (c.email||'').toLowerCase() === e);
}
function isRemoteContentAllowed(fromEmail) {
if (remoteImagePolicy === 'always') return true;
if (remoteImagePolicy === 'never') return false;
if (remoteImagePolicy === 'contacts') return isContactEmail(fromEmail) || remoteWhitelist.has(fromEmail);
return remoteWhitelist.has(fromEmail); // manual (default)
}
async function whitelistSender(sender) {
const r = await api('POST', '/remote-content-whitelist', { sender });
if (r?.ok) { remoteWhitelist.add(sender); render(window._msg, true); }
}
const cssReset = `<style>html,body{background:#ffffff!important;color:#1a1a1a!important;` +
`font-family:Arial,sans-serif;font-size:14px;line-height:1.5;margin:8px}a{color:#1a5fb4}` +
`img{max-width:100%;height:auto}iframe{display:none!important}</style>`;
// Content-aware height report (leaf elements only — see app.js renderMessageDetail for why
// document.documentElement.scrollHeight is wrong: it counts trailing structural dead space
// some email templates leave behind) + link-click interception.
const heightScript = `<script>
function _reportH(){
try{
var maxBottom=0;
var all=document.body?document.body.getElementsByTagName('*'):[];
for(var i=0;i<all.length;i++){
var el=all[i];
if(el.children.length>0) continue;
var cs=getComputedStyle(el);
if(cs.display==='none'||cs.visibility==='hidden'||parseFloat(cs.opacity||'1')===0) continue;
var hasText=(el.textContent||'').replace(/[\\s\\u00A0]/g,'').length>0;
if(!hasText && el.tagName!=='IMG') continue;
var r=el.getBoundingClientRect();
if(r.bottom>maxBottom) maxBottom=r.bottom;
}
var h=maxBottom>0?maxBottom:document.documentElement.scrollHeight;
parent.postMessage({type:'gomail-frame-h',h:h},'*');
}catch(ex){parent.postMessage({type:'gomail-frame-h',h:0},'*');}
}
document.addEventListener('DOMContentLoaded',_reportH);
window.addEventListener('load',_reportH);
new MutationObserver(_reportH).observe(document.documentElement,{subtree:true,childList:true,attributes:true});
if(window.ResizeObserver) new ResizeObserver(_reportH).observe(document.documentElement);
[50,150,400,900,1800,3000].forEach(function(ms){ setTimeout(_reportH, ms); });
document.addEventListener('click',function(e){
var el=e.target; while(el&&el.tagName!=='A') el=el.parentElement;
if(!el) return;
var href=el.getAttribute('href');
if(!href||href.startsWith('#')||href.startsWith('mailto:')) return;
e.preventDefault(); e.stopPropagation();
parent.postMessage({type:'gomail-open-url',url:href},'*');
},true);
<\/script>`;
const sandboxAttr = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
window.addEventListener('message', e => {
if (e.data?.type === 'gomail-frame-h' && e.data.h > 50) {
const frame = document.getElementById('msg-frame');
if (frame) frame.style.height = (e.data.h + 24) + 'px';
} else if (e.data?.type === 'gomail-open-url' && e.data.url) {
window.open(e.data.url, '_blank', 'noopener,noreferrer');
}
});
function render(msg, showRemoteContent) {
window._msg = msg;
const allowed = showRemoteContent || isRemoteContentAllowed(msg.from_email);
const atts = msg.attachments || [];
const attHtml = atts.length ? `
<div style="padding:12px 0;border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:8px">
${atts.map(a => `<a href="/api/messages/${msgId}/attachments/${a.id}" download="${esc(a.filename)}"
style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:var(--surface3);
border:1px solid var(--border2);border-radius:6px;font-size:12px;color:var(--text);text-decoration:none">
📎 ${esc(a.filename)} <span style="color:var(--muted)">(${(a.size/1024).toFixed(0)}KB)</span></a>`).join('')}
</div>` : '';
let bodyHtml = '';
if (msg.body_html) {
let html = stripUnresolvedCID(stripEmbeddedFrames(msg.body_html));
if (!allowed) {
const alwaysAllowBtn = remoteImagePolicy === 'never' ? '' :
`<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>`;
bodyHtml = `<div class="remote-content-banner">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
Remote images blocked.
<button class="rcb-btn" onclick="render(window._msg,true)">Load images</button>
${alwaysAllowBtn}
</div>`;
html = stripRemoteImages(html);
}
const srcdoc = (cssReset + heightScript + html).replace(/"/g,'&quot;');
bodyHtml += `<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px">
<iframe id="msg-frame" title="Message content" sandbox="${sandboxAttr}" style="width:100%;border:none;min-height:200px;display:block" srcdoc="${srcdoc}"></iframe>
</div>`;
} else {
bodyHtml = `<div style="border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:12px;white-space:pre-wrap">${esc(msg.body_text||'(empty)')}</div>`;
}
document.getElementById('msg-content').innerHTML = `
<h1 style="font-size:22px;font-weight:600;margin-bottom:16px;line-height:1.3">${esc(msg.subject || '(no subject)')}</h1>
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<div>
<span style="font-size:14px;font-weight:500">${esc(msg.from_name || msg.from_email)}</span>
${msg.from_name ? `<span style="font-size:13px;color:var(--muted)">&lt;${esc(msg.from_email)}&gt;</span>` : ''}
<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to_list || '')}</div>
</div>
<span style="font-size:12px;color:var(--muted);white-space:nowrap">${esc(msg.date ? new Date(msg.date).toLocaleString() : '')}</span>
</div>
${bodyHtml}
${attHtml}`;
}
async function load() {
const [msg, folders, uiPrefs, wl] = await Promise.all([
api('GET', '/messages/' + msgId), api('GET', '/folders'),
api('GET', '/ui-prefs'), api('GET', '/remote-content-whitelist'),
]);
if (!msg) { document.getElementById('msg-content').innerHTML = '<p style="color:var(--danger)">Message not found or not accessible.</p>'; return; }
// A draft opened here (bookmark, typed URL, old link) should open editable, not read-only.
const folder = (folders||[]).find(f=>f.id===msg.folder_id);
if (folder?.folder_type === 'drafts') { location.replace('/compose?edit_draft_id=' + msgId); return; }
remoteImagePolicy = uiPrefs?.remoteImagePolicy || 'manual';
if (wl?.whitelist) remoteWhitelist = new Set(wl.whitelist);
if (remoteImagePolicy === 'contacts') contactsCache = await api('GET', '/contacts') || [];
// Mark read
await api('PUT', '/messages/' + msgId + '/read', { read: true });
document.title = (msg.subject || '(no subject)') + ' — GoWebMail';
render(msg, false);
}
function replyFromPage() {
window.location = '/compose?reply_id=' + msgId;
}
function forwardFromPage() {
window.location = '/compose?forward_id=' + msgId;
}
load();
</script>
{{end}}