pc-live/nui/js/apps/mail.js

1154 lines
48 KiB
JavaScript
Raw Normal View History

IC-Computer: Schreibtisch, Fenster und Apps Ein Computer im Spiel. Man tritt an ein Terminal, drueckt E, und bekommt eine Oberflaeche mit Mail, Browser, Adressbuch, Kalender und weiteren Anwendungen. Andere Resources haengen sich als App ein. Auf ESX umgestellt: - Fuenf harte Abhaengigkeiten zeigten auf Resources, die es hier nicht gibt. Eine fehlende Abhaengigkeit verhindert den Start vollstaendig - die Resource waere nie hochgekommen. - Die Bruecke zum Framework neu geschrieben; Charakterdaten, Aktenverwaltung und Immobilien ausgebaut, weil die zugehoerigen Systeme fehlen. - pc_live_devices fehlte im Schema, wird vom Code aber gelesen. Ohne die Tabelle ist kein fester PC ansprechbar. Ein PC braucht ausserdem zwei Eintraege mit derselben Kennung: Standort in der Datenbank, Kamerafahrt in der config.lua. Mail ueberarbeitet: - Postfaecher haengen an einer Anmeldung statt an der Person. Mehrere Leute koennen dasselbe Firmenpostfach gleichzeitig offen haben. - Ordner gehoeren zum Postfach, nicht zur Person. Eine Mail an ein geteiltes Postfach wird je Empfaenger einmal gespeichert; damit das Einsortieren trotzdem fuer alle gilt, tragen alle Kopien einer Zustellung dieselbe Kennung. Gelesen und geloescht bleibt persoenlich - das ist keine Eigenschaft der Nachricht, sondern der Person. - Signaturen gehoeren ebenfalls zum Postfach. - Kalender koennen privat, geteilt oder oeffentlich sein. Ein oeffentlicher Termin haengt bewusst auch an einem Postfach, sonst koennte ihn spaeter niemand mehr aendern oder absagen. Neue Apps: Webhosting, Bleeter und dessen Verwaltung. Behoben: - prompt(), confirm() und alert() froren das NUI ein. FiveMs CEF hat keinen Handler fuer die eingebauten Browserdialoge - der Aufruf oeffnet nichts und kehrt nie zurueck. Ersetzt durch eigene Dialoge. - Die Fenster tragen data-wid, keine id. Wer sie mit getElementById sucht, schreibt ins Leere und das Fenster bleibt leer. Enthaelt README.md mit Einrichtung und PLUGINS.md: eine Anleitung, wie man eine eigene App baut und einhaengt, mit vollstaendigem Beispiel von der Tabelle bis zum Schreibtischsymbol.
2026-08-09 12:02:11 +00:00
/**
* pc-live | Mail App
* Multi-mailbox: persönliches Postfach + geteilte Postfächer (ic-mail)
*/
const MailApp = (() => {
const WIN_ID = 'app-mail';
// ── Mail state ──────────────────────────────────────────
let _inbox = [];
let _sent = [];
// Ordner gehören zum Postfach, nicht zur Person: ein geteiltes Postfach
// wird von mehreren bedient und hat für alle dieselbe Struktur.
// Schlüssel: Adresse, '' = persönliches Postfach.
let _foldersByBox = {};
let _folderMails = {};
let _calendar = [];
let _active = null;
let _view = 'inbox';
let _editEvent = null;
let _contacts = [];
// ── Mailbox state (ic-mail) ─────────────────────────────
let _mailboxes = []; // [{address, display_name, type, realm_type}]
let _personalAddr = ''; // primäre persönliche Adresse
let _activeAddr = null; // null = persönlich, sonst Adress-String
let _personalSig = ''; // Signatur des persönlichen Postfachs
let _sigAddr = ''; // Postfach, dessen Signatur gerade bearbeitet wird
let _openBoxes = []; // per Passwort freigeschaltete Postfächer (ic-web)
/* ── Helpers ──────────────────────────────────────────── */
function fmtDate(d) {
if (!d) return '';
const dt = new Date(d);
return isNaN(dt) ? d : dt.toLocaleString('de-DE', {
day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit'
});
}
function fmtDateInput(d) {
if (!d) return '';
const dt = new Date(d);
if (isNaN(dt)) return '';
return dt.toISOString().slice(0, 16);
}
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* Adresse des gerade gewählten Postfachs, '' = persönlich. */
function boxKey() { return _activeAddr || ''; }
/* Ordner des gewählten Postfachs. */
function currentFolders() { return _foldersByBox[boxKey()] || []; }
/* Signatur eines Postfachs. '' wenn keine gepflegt ist. */
function signatureFor(address) {
if (!address || address === _personalAddr) return _personalSig || '';
const mb = _mailboxes.find(m => m.address === address);
return (mb && mb.signature) || '';
}
/* Postfächer, die über "Postfach hinzufügen" freigeschaltet wurden und sich
deshalb auch wieder entfernen lassen. Das eigene und dienstlich
zugewiesene gehören einem nicht. */
function isRemovable(address) {
return _openBoxes.includes(address);
}
/* Trennzeile vor der Signatur wie in jedem Mailprogramm. */
const SIG_SEP = '\n\n-- \n';
/* Alten Signaturblock abschneiden und den neuen anhängen. So bleibt der
geschriebene Text erhalten, wenn man den Absender wechselt. */
function applySignature(text, address) {
const idx = text.lastIndexOf(SIG_SEP);
const body = idx >= 0 ? text.slice(0, idx) : text;
const sig = signatureFor(address);
return sig ? body + SIG_SEP + sig : body;
}
function currentFolderId() {
if (_view.startsWith('folder:')) return parseInt(_view.split(':')[1]);
return null;
}
// Mails des aktiven Postfachs aus dem gesamten Inbox-Array filtern
function _mailboxFilter(mails) {
const sharedSet = new Set(_mailboxes.filter(m => m.type !== 'personal').map(m => m.address));
if (!_activeAddr) {
// Persönlich: alles was NICHT zu einem geteilten Postfach gehört
return mails.filter(m => !m.to_address || !sharedSet.has(m.to_address));
}
return mails.filter(m => m.to_address === _activeAddr);
}
function currentMails() {
if (_view === 'inbox') return _mailboxFilter(_inbox);
if (_view === 'sent') {
if (!_activeAddr) {
const sharedSet = new Set(_mailboxes.filter(m => m.type !== 'personal').map(m => m.address));
return _sent.filter(m => !sharedSet.has(m.from_identifier));
}
return _sent.filter(m => m.from_identifier === _activeAddr);
}
const fid = currentFolderId();
if (fid != null) return _mailboxFilter(_folderMails[fid] || []);
return [];
}
/* ── Mailbox icon ─────────────────────────────────────── */
function _mbIcon(mb) {
if (mb.type === 'personal') return '✉';
if (mb.realm_type === 'faction') return '🏛';
if (mb.realm_type === 'company') return '🏢';
return '📮';
}
/* ── Sidebar ──────────────────────────────────────────── */
function renderSidebar() {
const fid = currentFolderId();
// Unread pro Postfach berechnen
const sharedSet = new Set(_mailboxes.filter(m => m.type !== 'personal').map(m => m.address));
const personalUnread = _inbox.filter(m => !m.is_read && (!m.to_address || !sharedSet.has(m.to_address))).length;
// Persönliches Postfach
const personalLabel = _personalAddr || 'Mein Postfach';
const personalActive = !_activeAddr;
let mailboxItems = `
<div class="sidebar-item ${personalActive ? 'active' : ''}" onclick="MailApp._setMailbox(null)">
<span> ${esc(personalLabel)}</span>
${personalUnread > 0 ? `<span class="badge" style="margin-left:auto">${personalUnread}</span>` : ''}
</div>`;
// Geteilte Postfächer
for (const mb of _mailboxes) {
if (mb.type === 'personal') continue;
const mbUnread = _inbox.filter(m => m.to_address === mb.address && !m.is_read).length;
const isActive = _activeAddr === mb.address;
const label = mb.display_name || mb.address;
const removeBtn = isRemovable(mb.address) ? `
<button class="sidebar-folder-del" title="Postfach entfernen"
onclick="event.stopPropagation();MailApp._removeMailbox('${esc(mb.address)}')"></button>` : '';
mailboxItems += `
<div class="sidebar-item ${isActive ? 'active' : ''}" onclick="MailApp._setMailbox('${esc(mb.address)}')">
<span>${_mbIcon(mb)} ${esc(label)}</span>
${mbUnread > 0 ? `<span class="badge" style="margin-left:auto">${mbUnread}</span>` : ''}
${removeBtn}
</div>`;
}
mailboxItems += `
<div class="sidebar-item" style="color:var(--text-dim)" onclick="MailApp._addMailbox()">
<span> Postfach hinzufügen</span>
</div>`;
// Navigation für aktives Postfach
const viewMails = currentMails();
const viewUnread = _view === 'inbox' ? viewMails.filter(m => !m.is_read).length : 0;
// Ordner
let folderItems = currentFolders().map(f => `
<div class="sidebar-item sidebar-folder-item ${_view === 'folder:' + f.id ? 'active' : ''}"
onclick="MailApp._setView('folder:${f.id}')">
<span>📁 ${esc(f.name)}</span>
<button class="sidebar-folder-del" title="Ordner löschen"
onclick="event.stopPropagation();MailApp._deleteFolder(${f.id})"></button>
</div>`).join('');
return `
<div class="app-sidebar" style="min-width:175px;max-width:215px">
<div class="sidebar-section" style="display:flex;align-items:center;justify-content:space-between">
<span>Postfächer</span>
<button class="btn-ghost" style="padding:1px 6px;font-size:.65rem" title="Postfächer aktualisieren"
onclick="fetchNui('getSharedMailboxes',{})"></button>
</div>
${mailboxItems}
<div class="divider" style="margin:8px 12px"></div>
<div class="sidebar-section">Navigation</div>
<div class="sidebar-item ${_view==='inbox' ? 'active':''}" onclick="MailApp._setView('inbox')">
📥 Posteingang ${viewUnread > 0 ? `<span class="badge" style="margin-left:auto">${viewUnread}</span>` : ''}
</div>
<div class="sidebar-item ${_view==='sent' ? 'active':''}" onclick="MailApp._setView('sent')">
📤 Gesendet
</div>
<div class="sidebar-item ${_view==='compose' ? 'active':''}" onclick="MailApp._setView('compose')">
Schreiben
</div>
<div class="sidebar-item ${_view==='signature' ? 'active':''}" onclick="MailApp._setView('signature')">
Signatur
</div>
<div class="sidebar-section" style="margin-top:8px;display:flex;align-items:center;justify-content:space-between">
<span>Ordner</span>
<button class="btn-ghost" style="padding:1px 6px;font-size:.7rem" onclick="MailApp._promptNewFolder()">+</button>
</div>
${folderItems}
<div class="divider" style="margin:8px 12px"></div>
<div class="sidebar-section">Kalender</div>
<div class="sidebar-item ${_view==='calendar'? 'active':''}" onclick="MailApp._setView('calendar')">
📅 Kalender
</div>
</div>`;
}
/* ── Mail list ────────────────────────────────────────── */
function renderMailList(mails, isSent) {
if (!mails.length)
return `<div class="empty-state"><div class="empty-icon">📭</div><p>Keine Nachrichten</p></div>`;
const fid = currentFolderId();
const folderOptions = currentFolders().map(f =>
`<option value="${f.id}">${esc(f.name)}</option>`).join('');
const moveSelect = (fid == null && !isSent) ? `
<select class="mail-move-select" title="In Ordner verschieben"
onchange="MailApp._moveMail(${'{id}'}, this.value); this.selectedIndex=0">
<option value="">📁 Verschieben</option>
${folderOptions}
</select>` : '';
const toInboxBtn = (fid != null) ? `
<button class="btn-ghost btn-sm" onclick="MailApp._moveMail(${'{id}'}, null)" title="Zurück in Posteingang"> Posteingang</button>` : '';
return `<div class="mail-list">` + mails.map(m => {
const addr = isSent
? esc(m.to_address || m.to_identifier)
: esc(m.from_identifier);
const label = isSent ? `An: ${addr}` : addr;
const moveBtn = moveSelect.replaceAll('{id}', m.id);
const inboxBtn = toInboxBtn.replaceAll('{id}', m.id);
return `
<div class="mail-item ${m.is_read || isSent ? '' : 'unread'} ${_active===m.id ? 'active' : ''}"
onclick="MailApp._openMail(${m.id})">
<div class="mail-from">${label}</div>
<div class="mail-subject">${esc(m.subject)}</div>
<div style="display:flex;align-items:center;gap:4px;margin-top:2px">
<div class="mail-date" style="flex:1">${fmtDate(m.sent_at)}</div>
${moveBtn}${inboxBtn}
</div>
</div>`;
}).join('') + `</div>`;
}
/* ── Mail detail ──────────────────────────────────────── */
function renderMailDetail(mail, isSent) {
if (!mail)
return `<div class="empty-state"><div class="empty-icon">📧</div><p>Nachricht auswählen</p></div>`;
const fid = currentFolderId();
const folderOptions = currentFolders().map(f =>
`<option value="${f.id}">${esc(f.name)}</option>`).join('');
const moveRow = (!isSent) ? `
<select class="btn-ghost btn-sm" style="cursor:pointer"
onchange="MailApp._moveMail(${mail.id}, this.value||null); this.selectedIndex=0">
<option value="">📁 Verschieben</option>
${folderOptions}
${fid!=null ? `<option value="">↩ Posteingang</option>` : ''}
</select>` : '';
const replyBtn = !isSent ? `<button class="btn-ghost btn-sm" onclick="MailApp._reply()">↩ Antworten</button>` : '';
const contactMatch = _contacts.find(c => c.email === (isSent ? mail.to_identifier : mail.from_identifier));
const contactBtn = !contactMatch ? `
<button class="btn-ghost btn-sm" onclick="MailApp._saveAsContact('${esc(isSent ? mail.to_identifier : mail.from_identifier)}')">
👤 Als Kontakt speichern
</button>` : '';
// Zeige Postfach-Badge wenn Shared-Mail
const mbBadge = mail.to_address && mail.to_address !== _personalAddr ? `
<span style="font-size:.7rem;background:#1a2a1a;border:1px solid #2a4a2a;color:#4caf50;
padding:2px 8px;border-radius:4px;margin-left:4px">
📮 ${esc(mail.to_address)}
</span>` : '';
return `
<div class="mail-detail fade-in">
<div class="mail-detail-header">
<div class="mail-detail-subject">${esc(mail.subject)}${mbBadge}</div>
<div class="mail-detail-meta">
<span>${isSent ? 'An' : 'Von'}: ${esc(isSent ? (mail.to_address || mail.to_identifier) : mail.from_identifier)}</span>
<span>Datum: ${fmtDate(mail.sent_at)}</span>
</div>
<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">
${replyBtn}
${moveRow}
${contactBtn}
${!isSent ? `<button class="btn-danger btn-sm" onclick="MailApp._deleteMail(${mail.id})">🗑 Löschen</button>` : ''}
</div>
</div>
<div class="mail-detail-body">${esc(mail.body)}</div>
</div>`;
}
/* ── Signatur ─────────────────────────────────────────── */
function renderSignature() {
// Alle Postfächer, für die eine Signatur gepflegt werden kann.
const boxes = [];
if (_personalAddr) boxes.push({ address: _personalAddr, label: _personalAddr });
for (const mb of _mailboxes) {
if (mb.address === _personalAddr) continue;
boxes.push({ address: mb.address, label: mb.display_name
? `${mb.display_name} (${mb.address})` : mb.address });
}
if (!boxes.length) {
return `<div class="empty-state"><div class="empty-icon">✒</div>
<p>Für dieses Postfach gibt es keine Adresse.</p></div>`;
}
// Vorauswahl: das gewählte Postfach, sonst das zuletzt bearbeitete.
if (!boxes.some(b => b.address === _sigAddr)) {
_sigAddr = _activeAddr || _personalAddr || boxes[0].address;
}
const addr = _sigAddr;
const opts = boxes.map(b =>
`<option value="${esc(b.address)}" ${b.address === addr ? 'selected' : ''}>${esc(b.label)}</option>`
).join('');
return `
<div class="app-content fade-in" style="max-width:640px;padding:16px">
<h3 style="color:#fff;font-size:.9rem;margin-bottom:4px"> Signatur</h3>
<div class="form-group">
<label>Postfach</label>
<select id="mail-sig-box" onchange="MailApp._sigBoxChanged()">${opts}</select>
</div>
<div style="font-size:.72rem;color:var(--text-muted);margin-bottom:12px">
Die Signatur gehört zum Postfach: Wer es bedient, schreibt mit derselben
Fußzeile.
</div>
<div class="form-group">
<textarea id="mail-signature" rows="7" maxlength="1000"
placeholder="z. B.&#10;Mit freundlichen Grüßen&#10;Redaktion Weazel News&#10;presse@weazel-news.ls"
>${esc(signatureFor(addr))}</textarea>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" onclick="MailApp._saveSignature()">Speichern</button>
<button class="btn-ghost" onclick="MailApp._setView('inbox')">Zurück</button>
</div>
<div id="mail-sig-status" style="font-size:.72rem;margin-top:6px;color:var(--text-muted)"></div>
<div style="font-size:.68rem;color:var(--text-dim);margin-top:14px;line-height:1.5">
Beim Schreiben wird die Signatur unter den Text gesetzt, getrennt durch eine
Zeile mit <code>--</code>. Wechselst du den Absender, tauscht sie sich mit.
</div>
</div>`;
}
function _sigBoxChanged() {
const sel = document.getElementById('mail-sig-box');
if (!sel) return;
_sigAddr = sel.value;
_refresh();
}
function _saveSignature() {
const addr = _sigAddr || _activeAddr || _personalAddr;
const el = document.getElementById('mail-signature');
const st = document.getElementById('mail-sig-status');
if (!addr || !el) return;
if (st) st.textContent = 'Wird gespeichert…';
fetchNui('setSignature', { address: addr, signature: el.value });
}
function onSignatureSaved(data) {
const st = document.getElementById('mail-sig-status');
if (!st) return;
st.textContent = (data && data.ok)
? 'Gespeichert.'
: '⚠ ' + ((data && data.error) || 'Speichern fehlgeschlagen.');
}
/* ── Postfächer freischalten (ic-web) ─────────────────── */
/* Ein Postfach ist ein Zugang mit Passwort. Wer die Daten kennt, bekommt es
hier dazu dieselbe Prüfung wie bei der Anmeldung am Webhosting. */
function _addMailbox() {
if (typeof ICWebRender === 'undefined') {
return Desktop.showNotification('⚠ Postfachverwaltung nicht verfügbar.');
}
ICWebRender.modal('Postfach hinzufügen', (box, close) => {
const el = ICWebRender.el;
box.appendChild(el('div', 'icweb-modal-sub',
'Adresse und Passwort des Postfachs. Es bleibt geöffnet, bis du es '
+ 'entfernst oder den Server verlässt.'));
const mkField = (label, type, placeholder) => {
const row = el('div', 'wh-field');
row.appendChild(el('label', 'wh-label', label));
const input = el('input', 'icweb-input');
input.setAttribute('type', type);
if (placeholder) input.setAttribute('placeholder', placeholder);
row.appendChild(input);
box.appendChild(row);
return input;
};
const addr = mkField('Adresse', 'text', 'presse@weazel-news.ls');
const pass = mkField('Passwort', 'password', '');
const err = el('div', 'wh-login-error');
box.appendChild(err);
const row = el('div', 'icweb-modal-actions');
const cancel = el('button', 'icweb-btn-ghost', 'Abbrechen');
cancel.addEventListener('click', close);
const go = el('button', 'icweb-btn-primary', 'Hinzufügen');
const submit = async () => {
err.textContent = '';
if (!addr.value || !pass.value) {
err.textContent = 'Bitte Adresse und Passwort eingeben.';
return;
}
go.disabled = true;
const res = await ICWebNet.call('openMailbox', addr.value, pass.value);
go.disabled = false;
pass.value = '';
if (!res.ok) { err.textContent = res.error || 'Fehlgeschlagen.'; return; }
close();
refreshMailboxes();
};
go.addEventListener('click', submit);
[addr, pass].forEach(i =>
i.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }));
row.append(cancel, go);
box.appendChild(row);
setTimeout(() => addr.focus(), 0);
});
}
function _removeMailbox(address) {
ICWebRender.confirmBox('Postfach entfernen',
'Postfach ' + address + ' entfernen?\n'
+ 'Die Nachrichten bleiben erhalten, du siehst sie nur nicht mehr.', async () => {
const res = await ICWebNet.call('closeMailbox', address);
if (!res.ok) return Desktop.showNotification('⚠ ' + (res.error || 'Fehlgeschlagen.'));
if (_activeAddr === address) _activeAddr = null;
refreshMailboxes();
}, { danger: true, okLabel: 'Entfernen' });
}
/* Liste der freigeschalteten Postfächer holen und die Mailboxliste neu
laden. Beides zusammen, damit die -Knöpfe zur Anzeige passen. */
async function refreshMailboxes() {
if (typeof ICWebNet !== 'undefined') {
const res = await ICWebNet.call('listMailboxes');
_openBoxes = (res.ok && Array.isArray(res.data)) ? res.data : [];
}
fetchNui('getSharedMailboxes', {});
}
/* ── Compose ──────────────────────────────────────────── */
function renderCompose(prefillTo = '', prefillSubject = '') {
// Von-Dropdown: alle Postfächer aus denen der Spieler senden darf
const fromAddresses = [];
if (_personalAddr) fromAddresses.push({ address: _personalAddr, label: _personalAddr });
for (const mb of _mailboxes) {
if (mb.type !== 'personal') {
const lbl = mb.display_name ? `${mb.display_name} (${mb.address})` : mb.address;
fromAddresses.push({ address: mb.address, label: lbl });
}
}
const defaultFrom = _activeAddr || _personalAddr || '';
let fromField = '';
if (fromAddresses.length > 1) {
const opts = fromAddresses.map(o =>
`<option value="${esc(o.address)}" ${o.address === defaultFrom ? 'selected' : ''}>${esc(o.label)}</option>`
).join('');
fromField = `
<div class="form-group">
<label>Von</label>
<select id="mail-from" onchange="MailApp._fromChanged()">${opts}</select>
</div>`;
} else if (fromAddresses.length === 1) {
fromField = `<input type="hidden" id="mail-from" value="${esc(fromAddresses[0].address)}"/>`;
}
const contactOptions = _contacts.map(c =>
`<option value="${esc(c.email || c.name)}">${esc(c.name)}${c.email ? ' &lt;' + esc(c.email) + '&gt;' : ''}</option>`
).join('');
const datalist = _contacts.length ? `
<datalist id="mail-contacts-list">${contactOptions}</datalist>` : '';
return `
<div class="app-content fade-in" style="max-width:640px;padding:16px">
<h3 style="color:#fff;font-size:.9rem;margin-bottom:12px"> Neue Nachricht</h3>
${datalist}
${fromField}
<div class="form-group">
<label>An (Adresse oder Kontakt)</label>
<input id="mail-to" type="text" list="mail-contacts-list"
placeholder="name@domain… oder Kontakt wählen"
value="${esc(prefillTo)}"/>
</div>
<div class="form-group">
<label>Betreff</label>
<input id="mail-subject" type="text" placeholder="Betreff…" maxlength="80"
value="${esc(prefillSubject)}"/>
</div>
<div class="form-group">
<label>Nachricht</label>
<textarea id="mail-body" rows="9" placeholder="Nachricht schreiben…" maxlength="2000"
>${esc(applySignature('', defaultFrom))}</textarea>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" onclick="MailApp._sendMail()">📤 Senden</button>
<button class="btn-ghost" onclick="MailApp._setView('inbox')">Abbrechen</button>
</div>
<div id="mail-send-status" style="font-size:.72rem;margin-top:6px;color:var(--text-muted)"></div>
</div>`;
}
/* ── Calendar ─────────────────────────────────────────── */
/* Kalender, in die diese Person eintragen darf: privat und jedes Postfach. */
function writableCalendars() {
const out = [{ address: '', label: '🔒 Privat' }];
for (const mb of _mailboxes) {
out.push({
address: mb.address,
label: '👥 ' + (mb.display_name || mb.address),
});
}
return out;
}
/* Kurzes Schild am Termin, damit man sieht, wessen Kalender er gehört. */
function calBadge(e) {
if (e.visibility === 'public') return { text: '🌐 Öffentlich', title: e.address || '' };
if (e.address) {
const mb = _mailboxes.find(m => m.address === e.address);
return { text: '👥 ' + ((mb && mb.display_name) || e.address), title: e.address };
}
return { text: '🔒 Privat', title: '' };
}
function renderCalendar() {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthNames = ['Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember'];
let cells = '';
const dayHeaders = ['Mo','Di','Mi','Do','Fr','Sa','So'].map(d =>
`<div class="cal-header-cell">${d}</div>`).join('');
const offset = (firstDay + 6) % 7;
for (let i = 0; i < offset; i++) cells += `<div class="cal-cell cal-cell-empty"></div>`;
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const dayEvents = _calendar.filter(e => e.start_at && e.start_at.startsWith(dateStr));
const dots = dayEvents.map(e =>
`<div class="cal-dot" style="background:${esc(e.color||'#00aaff')}" title="${esc(e.title)}"></div>`
).join('');
const isToday = (d === now.getDate());
cells += `
<div class="cal-cell ${isToday ? 'cal-today' : ''}" onclick="MailApp._calDayClick('${dateStr}')">
<span class="cal-day-num">${d}</span>
<div class="cal-dots">${dots}</div>
</div>`;
}
const upcomingItems = _calendar
.filter(e => new Date(e.start_at) >= new Date(year, month, 1))
.slice(0, 8)
.map(e => {
const badge = calBadge(e);
// Fremde öffentliche Termine sieht man, ändern darf sie nur, wer das
// zugehörige Postfach bedient.
const actions = e.can_edit ? `
<div style="margin-top:4px;display:flex;gap:4px">
<button class="btn-ghost btn-sm" onclick="MailApp._openCalEdit(${e.id})"></button>
<button class="btn-danger btn-sm" onclick="MailApp._deleteCalEvent(${e.id})">🗑</button>
</div>` : '';
return `
<div class="cal-event-item" style="border-left:3px solid ${esc(e.color||'#00aaff')}">
<div style="display:flex;align-items:center;gap:6px">
<div style="font-weight:600;font-size:.78rem;flex:1">${esc(e.title)}</div>
<span class="cal-badge" title="${esc(badge.title)}">${esc(badge.text)}</span>
</div>
<div style="font-size:.7rem;color:var(--text-muted)">${fmtDate(e.start_at)}${e.end_at ? ' ' + fmtDate(e.end_at) : ''}</div>
${e.description ? `<div style="font-size:.7rem;margin-top:2px;color:var(--text-muted)">${esc(e.description)}</div>` : ''}
${actions}
</div>`;
}).join('');
const editForm = _editEvent ? `
<div class="cal-edit-form fade-in">
<h4 style="font-size:.82rem;margin-bottom:8px;color:#fff">
${_editEvent.id ? 'Eintrag bearbeiten' : 'Neuer Eintrag'}
</h4>
<div class="form-group">
<label>Titel</label>
<input id="cal-title" type="text" value="${esc(_editEvent.title||'')}" maxlength="120"/>
</div>
<div class="form-group">
<label>Beschreibung</label>
<input id="cal-desc" type="text" value="${esc(_editEvent.description||'')}" maxlength="500"/>
</div>
<div class="form-group" style="display:flex;gap:8px">
<div style="flex:1">
<label>Von</label>
<input id="cal-start" type="datetime-local" value="${fmtDateInput(_editEvent.start_at)}"/>
</div>
<div style="flex:1">
<label>Bis (optional)</label>
<input id="cal-end" type="datetime-local" value="${fmtDateInput(_editEvent.end_at)}"/>
</div>
</div>
<div class="form-group">
<label>Kalender</label>
<select id="cal-address" onchange="MailApp._calAddressChanged()">
${writableCalendars().map(c =>
`<option value="${esc(c.address)}" ${c.address === (_editEvent.address||'') ? 'selected' : ''}>${esc(c.label)}</option>`
).join('')}
</select>
</div>
${(_editEvent.address || '') !== '' ? `
<div class="form-group">
<label>Sichtbar für</label>
<select id="cal-visibility">
<option value="shared" ${_editEvent.visibility !== 'public' ? 'selected' : ''}>Nur wer dieses Postfach bedient</option>
<option value="public" ${_editEvent.visibility === 'public' ? 'selected' : ''}>Alle (öffentlicher Termin)</option>
</select>
</div>` : `
<div style="font-size:.68rem;color:var(--text-dim);margin:-4px 0 8px">
Ein privater Termin ist nur für dich sichtbar.
</div>`}
<div class="form-group">
<label>Farbe</label>
<input id="cal-color" type="color" value="${_editEvent.color||'#00aaff'}" style="height:28px;cursor:pointer"/>
</div>
<div style="display:flex;gap:6px">
<button class="btn-primary" onclick="MailApp._saveCalEvent()">💾 Speichern</button>
<button class="btn-ghost" onclick="MailApp._cancelCalEdit()">Abbrechen</button>
</div>
</div>` : `
<button class="btn-primary" style="margin-top:8px;width:100%" onclick="MailApp._newCalEvent()">
+ Neuer Eintrag
</button>`;
return `
<div style="display:flex;height:100%;gap:0;overflow:hidden">
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;border-right:1px solid var(--border)">
<div class="cal-month-header">📅 ${monthNames[month]} ${year}</div>
<div class="cal-grid">
${dayHeaders}
${cells}
</div>
</div>
<div style="width:280px;display:flex;flex-direction:column;overflow-y:auto;padding:10px;gap:8px">
${editForm}
<div class="sidebar-section" style="margin-top:8px">Kommende Termine</div>
${upcomingItems || '<div style="color:var(--text-dim);font-size:.75rem;padding:4px">Keine Termine</div>'}
</div>
</div>`;
}
/* ── Full layout refresh ──────────────────────────────── */
function _refresh() {
const body = WindowManager.getBody(WIN_ID);
if (!body) return;
const isSent = _view === 'sent';
const mails = currentMails();
const activeMail = mails.find(m => m.id === _active) || null;
let rightContent;
if (_view === 'compose') {
rightContent = renderCompose();
} else if (_view === 'signature') {
rightContent = renderSignature();
} else if (_view === 'calendar') {
rightContent = renderCalendar();
} else {
rightContent = `
<div style="display:flex;height:100%;overflow:hidden">
<div style="width:280px;border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0">
${renderMailList(mails, isSent)}
</div>
<div style="flex:1;overflow-y:auto">
${renderMailDetail(activeMail, isSent)}
</div>
</div>`;
}
body.innerHTML = `
<div class="app-layout" style="height:100%">
${renderSidebar()}
<div style="flex:1;overflow:hidden;display:flex;flex-direction:column">
${rightContent}
</div>
</div>`;
}
/* ── Mailbox actions ──────────────────────────────────── */
function _setMailbox(address) {
_activeAddr = address || null;
_view = 'inbox';
_active = null;
_editEvent = null;
// Jedes Postfach hat eigene Ordner die des vorherigen passen nicht.
if (_foldersByBox[boxKey()] === undefined) {
fetchNui('getFolders', { address: boxKey() });
}
_refresh();
}
function initMailboxes(mailboxes, personalAddress, personalSignature) {
_mailboxes = mailboxes || [];
_personalAddr = personalAddress || '';
_personalSig = personalSignature || '';
const body = WindowManager.getBody(WIN_ID);
if (body) _refresh();
}
function onSharedMailboxes(mailboxes, personalAddress, personalSignature) {
_mailboxes = mailboxes || [];
_personalAddr = personalAddress || '';
_personalSig = personalSignature || '';
// Ein neu geöffnetes Postfach bringt seinen Kalender mit.
fetchNui('getCalendar', {});
const body = WindowManager.getBody(WIN_ID);
if (body) _refresh();
}
/* ── View actions ─────────────────────────────────────── */
function _setView(v) {
_view = v;
_active = null;
_editEvent = null;
if (v === 'sent' && !_sent.length) fetchNui('getSent', {});
if (v === 'calendar' && !_calendar.length) fetchNui('getCalendar', {});
if (v.startsWith('folder:')) {
const fid = parseInt(v.split(':')[1]);
if (!_folderMails[fid]) fetchNui('getFolderMails', { folderId: fid, address: boxKey() });
}
_refresh();
}
function _openMail(id) {
_active = id;
const isSent = _view === 'sent';
if (!isSent) {
fetchNui('readMail', { id });
const m = currentMails().find(m => m.id === id);
if (m) m.is_read = 1;
}
_refresh();
}
/* Absender gewechselt: alten Signaturblock ersetzen, Text behalten. */
function _fromChanged() {
const from = document.getElementById('mail-from')?.value || '';
const body = document.getElementById('mail-body');
if (!body) return;
const pos = body.selectionStart;
body.value = applySignature(body.value, from);
if (pos != null) { try { body.setSelectionRange(pos, pos); } catch (e) {} }
}
function _sendMail() {
const to = (document.getElementById('mail-to')?.value || '').trim();
const subject = (document.getElementById('mail-subject')?.value || '').trim();
const body = (document.getElementById('mail-body')?.value || '').trim();
const from = document.getElementById('mail-from')?.value || '';
const status = document.getElementById('mail-send-status');
if (!to || !subject || !body) {
if (status) status.textContent = 'Bitte alle Felder ausfüllen.';
return;
}
if (status) status.textContent = 'Wird gesendet…';
fetchNui('sendMail', { to, subject, body, fromMailbox: from });
}
function _reply() {
const mail = currentMails().find(m => m.id === _active);
if (!mail) return;
_view = 'compose';
_refresh();
setTimeout(() => {
const t = document.getElementById('mail-to');
const s = document.getElementById('mail-subject');
if (t) t.value = mail.from_identifier || '';
if (s) s.value = 'RE: ' + (mail.subject || '');
}, 0);
}
function _deleteMail(id) { fetchNui('deleteMail', { id }); }
function _moveMail(id, folderId) {
fetchNui('moveMail', {
id,
folderId: folderId === '' || folderId === 'null' ? null : folderId,
address: boxKey(),
});
}
function _promptNewFolder() {
// Kein prompt(): FiveMs CEF hat keinen Handler für die eingebauten
// JS-Dialoge, das NUI bliebe stehen.
const box = boxKey();
ICWebRender.promptText('Neuer Ordner', {
hint: box ? 'Der Ordner gehört zu ' + box + ' alle, die dieses Postfach '
+ 'bedienen, sehen ihn.'
: 'Der Ordner gehört zu deinem persönlichen Postfach.',
placeholder: 'z. B. Anfragen',
okLabel: 'Anlegen',
}, (name) => {
fetchNui('createFolder', { name, address: box });
});
}
function _deleteFolder(id) {
const box = boxKey();
ICWebRender.confirmBox('Ordner löschen',
'E-Mails kommen zurück in den Posteingang.'
+ (box ? '\nDer Ordner verschwindet für alle, die ' + box + ' bedienen.' : ''),
() => fetchNui('deleteFolder', { id, address: box }),
{ danger: true, okLabel: 'Löschen' });
}
function _saveAsContact(emailOrId) {
AddressBookApp.openAddNew({ email: emailOrId });
}
/* ── Calendar actions ─────────────────────────────────── */
/* Vorbelegung: der Kalender des gerade gewählten Postfachs. Wer im
Firmenpostfach arbeitet, trägt meist auch dort ein. */
function newCalDraft(start) {
return {
title: '', description: '', start_at: start || '', end_at: '',
color: '#00aaff', address: boxKey(),
visibility: boxKey() ? 'shared' : 'private',
};
}
function _newCalEvent(prefillDate) {
_editEvent = newCalDraft(prefillDate);
_refresh();
}
function _calDayClick(dateStr) {
if (_view !== 'calendar') return;
_editEvent = newCalDraft(dateStr + 'T08:00');
_refresh();
}
/* Kalender gewechselt: Formular neu zeichnen, damit die Sichtbarkeitsauswahl
erscheint bzw. verschwindet. Eingaben bleiben erhalten. */
function _calAddressChanged() {
if (!_editEvent) return;
readCalForm();
const sel = document.getElementById('cal-address');
_editEvent.address = sel ? sel.value : '';
if (!_editEvent.address) _editEvent.visibility = 'private';
else if (_editEvent.visibility === 'private') _editEvent.visibility = 'shared';
_refresh();
}
/* Aktuelle Formularwerte in den Entwurf übernehmen. */
function readCalForm() {
if (!_editEvent) return;
const v = (id) => document.getElementById(id)?.value;
_editEvent.title = v('cal-title') ?? _editEvent.title;
_editEvent.description = v('cal-desc') ?? _editEvent.description;
_editEvent.start_at = v('cal-start') ?? _editEvent.start_at;
_editEvent.end_at = v('cal-end') ?? _editEvent.end_at;
_editEvent.color = v('cal-color') ?? _editEvent.color;
const vis = v('cal-visibility');
if (vis) _editEvent.visibility = vis;
}
function _openCalEdit(id) {
const ev = _calendar.find(e => e.id === id);
if (!ev) return;
if (!ev.can_edit) {
return Desktop.showNotification('⚠ Diesen Termin darfst du nicht ändern.');
}
_editEvent = { ...ev, address: ev.address || '' };
_refresh();
}
function _deleteCalEvent(id) {
const ev = _calendar.find(e => e.id === id);
if (!ev || !ev.can_edit) {
return Desktop.showNotification('⚠ Diesen Termin darfst du nicht löschen.');
}
const badge = calBadge(ev);
ICWebRender.confirmBox('Termin löschen',
'„' + ev.title + '" wirklich löschen?'
+ (ev.address ? '\nDer Termin verschwindet für alle in ' + badge.text + '.' : ''),
() => fetchNui('deleteCalendarEvent', { id }),
{ danger: true, okLabel: 'Löschen' });
}
function _cancelCalEdit() {
_editEvent = null;
_refresh();
}
function _saveCalEvent() {
const title = document.getElementById('cal-title')?.value.trim();
const desc = document.getElementById('cal-desc')?.value.trim();
const start = document.getElementById('cal-start')?.value;
const end = document.getElementById('cal-end')?.value;
const color = document.getElementById('cal-color')?.value || '#00aaff';
const address = document.getElementById('cal-address')?.value || '';
const visibility = address
? (document.getElementById('cal-visibility')?.value || 'shared')
: 'private';
if (!title || !start) return;
const payload = { title, description: desc, start_at: start, end_at: end || null,
color, address, visibility };
if (_editEvent && _editEvent.id) {
fetchNui('updateCalendarEvent', { id: _editEvent.id, ...payload });
} else {
fetchNui('addCalendarEvent', payload);
}
_editEvent = null;
}
/* ── NUI events ───────────────────────────────────────── */
function onInbox(payload) {
_inbox = (payload && payload.mails) ? payload.mails : (Array.isArray(payload) ? payload : []);
_foldersByBox[''] = (payload && payload.folders) ? payload.folders : [];
if (_view === 'inbox' || _view.startsWith('folder:')) _refresh();
}
function onMailContent(mail) {
const idx = _inbox.findIndex(m => m.id === mail.id);
if (idx !== -1) _inbox[idx] = { ..._inbox[idx], ...mail };
if (_active === mail.id && _view === 'inbox') _refresh();
}
function onMailSent() {
const status = document.getElementById('mail-send-status');
if (status) status.textContent = 'Gesendet!';
_sent = [];
setTimeout(() => _setView('inbox'), 800);
fetchNui('getInbox', {});
}
function onMailDeleted(id) {
_inbox = _inbox.filter(m => m.id !== id);
for (const fid in _folderMails) _folderMails[fid] = _folderMails[fid].filter(m => m.id !== id);
if (_active === id) _active = null;
_refresh();
}
function onSetSent(rows) {
// Duplikate entfernen (Rundmails an mehrere Empfänger)
const seen = new Set();
_sent = (rows || []).filter(m => {
const key = `${m.from_identifier}|${m.subject}|${m.sent_at}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (_view === 'sent') _refresh();
}
function onFolderCreated(folder) {
const key = (folder && folder.address) || '';
_foldersByBox[key] = (_foldersByBox[key] || []).concat([folder]);
_folderMails[folder.id] = [];
_refresh();
}
function onFolderDeleted(folderId, address) {
const key = address || '';
_foldersByBox[key] = (_foldersByBox[key] || []).filter(f => f.id !== folderId);
delete _folderMails[folderId];
_inbox = [];
fetchNui('getInbox', {});
if (_view === 'folder:' + folderId) _view = 'inbox';
_refresh();
}
/* Ordnerliste eines Postfachs vom Server. Kommt beim Wechsel und wenn
jemand anderes im geteilten Postfach einen Ordner anlegt oder löscht. */
function onFoldersData(address, folders) {
_foldersByBox[address || ''] = folders || [];
_refresh();
}
function onFolderMailsData(folderId, rows) {
_folderMails[folderId] = rows || [];
if (_view === 'folder:' + folderId) _refresh();
}
function onMailMoved(id, folderId) {
_inbox = _inbox.filter(m => m.id !== id);
for (const fid in _folderMails) _folderMails[fid] = _folderMails[fid].filter(m => m.id !== id);
if (folderId) fetchNui('getFolderMails', { folderId });
if (_active === id) _active = null;
_refresh();
}
function onCalendarData(rows) { _calendar = rows || []; if (_view === 'calendar') _refresh(); }
function onCalendarEventAdded(ev) { _calendar.push(ev); _calendar.sort((a,b)=>new Date(a.start_at)-new Date(b.start_at)); if (_view==='calendar') _refresh(); }
function onCalendarEventUpdated(ev) { const i=_calendar.findIndex(e=>e.id===ev.id); if(i!==-1)_calendar[i]=ev; else _calendar.push(ev); _calendar.sort((a,b)=>new Date(a.start_at)-new Date(b.start_at)); if(_view==='calendar') _refresh(); }
function onCalendarEventDeleted(id) { _calendar=_calendar.filter(e=>e.id!==id); if(_view==='calendar') _refresh(); }
function setContacts(contacts) { _contacts = contacts || []; }
/* ── Setup wizard ─────────────────────────────────────── */
function _showSetupWizard(realms) {
const body = WindowManager.getBody(WIN_ID);
if (!body) return;
const defaultRealm = (realms && realms.length > 0) ? realms[0].realm : 'mail.ls';
body.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;gap:18px;padding:40px 32px;text-align:center;">
<div style="font-size:3rem">📧</div>
<div style="font-size:1.05rem;font-weight:700;color:#fff">E-Mail-Adresse einrichten</div>
<div style="font-size:0.82rem;color:var(--text-muted);line-height:1.6;max-width:340px">
Du hast noch keine persönliche E-Mail-Adresse.<br>
Wähle einen Benutzernamen für <strong style="color:#ccc">@${defaultRealm}</strong>.
</div>
<div style="display:flex;align-items:center;background:#1a1a1a;border:1px solid var(--border);
border-radius:8px;overflow:hidden;width:100%;max-width:360px">
<input id="mail-setup-input" type="text" maxlength="40" placeholder="benutzername"
style="flex:1;background:transparent;border:none;color:#fff;padding:11px 14px;
font-size:0.9rem;outline:none;"
oninput="MailApp._setupPreview('${defaultRealm}')">
<span style="padding:0 14px;color:#555;font-size:0.85rem">@${defaultRealm}</span>
</div>
<div id="mail-setup-preview" style="font-size:0.75rem;color:#555;min-height:14px"></div>
<button onclick="MailApp._submitSetup('${defaultRealm}')"
style="background:var(--accent,#3b82f6);color:#fff;border:none;border-radius:8px;
padding:11px 36px;font-size:0.88rem;font-weight:700;cursor:pointer">
Adresse erstellen
</button>
<div id="mail-setup-error" style="color:#f44;font-size:0.78rem;min-height:14px"></div>
</div>`;
}
function _setupPreview(realm) {
const input = document.getElementById('mail-setup-input');
const preview = document.getElementById('mail-setup-preview');
if (!input || !preview) return;
const val = input.value.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');
if (val.length >= 2) {
preview.textContent = '✓ ' + val + '@' + realm;
preview.style.color = '#4caf50';
} else {
preview.textContent = val.length ? 'Mindestens 2 Zeichen' : '';
preview.style.color = '#888';
}
}
function _submitSetup(realm) {
const input = document.getElementById('mail-setup-input');
const errEl = document.getElementById('mail-setup-error');
if (!input) return;
const username = input.value.trim();
if (username.length < 2) {
if (errEl) errEl.textContent = 'Bitte mindestens 2 Zeichen eingeben';
return;
}
if (errEl) errEl.textContent = '';
input.disabled = true;
fetchNui('createMailAddress', { username });
}
function onMailAddressStatus(data) {
if (data.hasAddress) {
// Adresse vorhanden → normal laden
fetchNui('getInbox', {});
fetchNui('getSharedMailboxes', {});
_refresh();
} else {
_showSetupWizard(data.realms || []);
}
}
function onMailCreateResult(data) {
if (data.success) {
const body = WindowManager.getBody(WIN_ID);
if (body) {
body.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;gap:14px;text-align:center;padding:40px">
<div style="font-size:2.5rem"></div>
<div style="font-size:1rem;font-weight:700;color:#fff">Adresse erstellt!</div>
<div style="font-family:monospace;font-size:0.95rem;color:var(--accent,#3b82f6);
background:#0a1020;border:1px solid #1a2a40;padding:8px 20px;border-radius:6px">
${data.address}
</div>
<div style="font-size:0.78rem;color:var(--text-muted)">Postfach wird geladen</div>
</div>`;
}
_personalAddr = data.address;
setTimeout(() => {
fetchNui('getInbox', {});
fetchNui('getSharedMailboxes', {});
_refresh();
}, 1500);
} else {
const errEl = document.getElementById('mail-setup-error');
const input = document.getElementById('mail-setup-input');
if (errEl) errEl.textContent = data.error || 'Fehler beim Erstellen';
if (input) input.disabled = false;
}
}
/* ── Open window ──────────────────────────────────────── */
function open() {
const created = WindowManager.create({
id: WIN_ID,
title: 'Mail',
icon: '✉',
width: 920,
height: 580,
content: '',
});
if (created) {
_view = 'inbox';
_active = null;
_activeAddr = null;
// Erst prüfen ob eine Mailadresse existiert
fetchNui('checkMailAddress', {});
}
}
return {
open, initMailboxes, onSharedMailboxes,
onMailAddressStatus, onMailCreateResult,
_setupPreview, _submitSetup,
_setMailbox, _setView, _openMail, _sendMail, _reply, _deleteMail,
_addMailbox, _removeMailbox, _saveSignature, _sigBoxChanged, _fromChanged,
onSignatureSaved, refreshMailboxes,
_moveMail, _promptNewFolder, _deleteFolder, _saveAsContact,
_newCalEvent, _calDayClick, _openCalEdit, _deleteCalEvent, _cancelCalEdit, _saveCalEvent,
_calAddressChanged,
onInbox, onMailContent, onMailSent, onMailDeleted,
onSetSent, onFolderCreated, onFolderDeleted, onFoldersData, onFolderMailsData, onMailMoved,
onCalendarData, onCalendarEventAdded, onCalendarEventUpdated, onCalendarEventDeleted,
setContacts,
};
})();