Compare commits

..

2 commits

Author SHA1 Message Date
ClaraCrazy
7ac2ef7b76
[Feat]: Add expiry timer 2025-12-27 16:17:23 +01:00
ClaraCrazy
2c7b3ead3c
[Fix]: Fix Notifications 2025-12-27 16:17:01 +01:00
5 changed files with 141 additions and 15 deletions

View file

@ -6,9 +6,21 @@ require('events').defaultMaxListeners = 50;
* Receives sign-ins from users and notifies them when new mails are available. * Receives sign-ins from users and notifies them when new mails are available.
*/ */
class ClientNotification extends EventEmitter { class ClientNotification extends EventEmitter {
constructor() {
super();
this.pendingNotifications = new Map(); // address -> count
}
use(io) { use(io) {
io.on('connection', socket => { io.on('connection', socket => {
socket.on('sign in', address => this._signIn(socket, address)) debug(`[SOCKET] New connection: id=${socket.id}`);
socket.on('sign in', address => {
debug(`[SOCKET] sign in received for address: ${address}, socket id: ${socket.id}`);
this._signIn(socket, address.toLowerCase())
});
socket.on('disconnect', reason => {
debug(`[SOCKET] Disconnected: id=${socket.id}, reason=${reason}`);
});
}) })
} }
@ -23,11 +35,33 @@ class ClientNotification extends EventEmitter {
this.on(address, newMailListener) this.on(address, newMailListener)
// Deliver any pending notifications
const pending = this.pendingNotifications.get(address) || 0;
if (pending > 0) {
debug(`Delivering ${pending} pending notifications to ${address}`);
for (let i = 0; i < pending; i++) {
socket.emit('new emails');
}
this.pendingNotifications.delete(address);
}
socket.on('disconnect', reason => { socket.on('disconnect', reason => {
debug(`client disconnect: ${address} (${reason})`) debug(`client disconnect: ${address} (${reason})`)
this.removeListener(address, newMailListener) this.removeListener(address, newMailListener)
}) })
} }
emit(address) {
address = address.toLowerCase();
const hadListeners = super.emit(address);
if (!hadListeners) {
// Queue notification for later delivery
const prev = this.pendingNotifications.get(address) || 0;
this.pendingNotifications.set(address, prev + 1);
debug(`No listeners for ${address}, queued notification (${prev + 1} pending)`);
}
return hadListeners;
}
} }
module.exports = ClientNotification module.exports = ClientNotification

View file

@ -1,7 +1,63 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const script = document.currentScript; const script = document.querySelector('script[data-address]');
const address = script ? script.dataset.address : ''; const address = script ? script.dataset.address : '';
// Get expiry config from data attributes
const expiryTime = script && script.dataset.expiryTime ? Number(script.dataset.expiryTime) : 48;
const expiryUnit = script && script.dataset.expiryUnit ? script.dataset.expiryUnit : 'hours';
if (address) { if (address) {
enableNewMessageNotifications(address, true); enableNewMessageNotifications(address, true);
} }
// Copy address on click
const copyAddress = document.getElementById('copyAddress');
const copyFeedback = document.getElementById('copyFeedback');
if (copyAddress) {
copyAddress.addEventListener('click', () => {
navigator.clipboard.writeText(copyAddress.textContent.trim()).then(() => {
if (copyFeedback) {
copyFeedback.style.display = 'inline';
setTimeout(() => { copyFeedback.style.display = 'none'; }, 1200);
}
});
});
}
// Expiry timer for each email
function getExpiryMs(time, unit) {
switch (unit) {
case 'minutes':
return time * 60 * 1000;
case 'hours':
return time * 60 * 60 * 1000;
case 'days':
return time * 24 * 60 * 60 * 1000;
default:
return 48 * 60 * 60 * 1000; // fallback 48h
}
}
function updateExpiryTimers() {
const timers = document.querySelectorAll('.expiry-timer');
timers.forEach(el => {
const dateStr = el.dataset.date;
if (!dateStr) return;
const mailDate = new Date(dateStr);
// Use config-driven expiry
const expiry = new Date(mailDate.getTime() + getExpiryMs(expiryTime, expiryUnit));
const now = new Date();
let diff = Math.floor((expiry - now) / 1000);
if (diff <= 0) {
el.textContent = 'Expired';
el.style.color = '#b00';
return;
}
const hours = Math.floor(diff / 3600);
diff %= 3600;
const minutes = Math.floor(diff / 60);
const seconds = diff % 60;
el.textContent = `Expires in ${hours}h ${minutes}m ${seconds}s`;
});
}
setInterval(updateExpiryTimers, 1000);
updateExpiryTimers();
}); });

View file

@ -375,7 +375,8 @@ label {
opacity: 0.8; opacity: 0.8;
} }
.email-date { .email-date,
.email-expiry {
font-size: 0.85rem; font-size: 0.85rem;
color: #666; color: #666;
white-space: nowrap; white-space: nowrap;
@ -389,6 +390,23 @@ label {
font-weight: 400; font-weight: 400;
} }
/* Subject row with right-bound expiry */
.email-subject-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.email-date {
color: #666;
margin-left: auto;
text-align: right;
white-space: nowrap;
}
.empty-state { .empty-state {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -448,14 +466,6 @@ label {
line-height: 1.3; line-height: 1.3;
} }
.mail-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.mail-from { .mail-from {
font-size: 1.5rem; font-size: 1.5rem;
color: #b366e6; color: #b366e6;
@ -651,4 +661,22 @@ label {
.modal-button-cancel { .modal-button-cancel {
background-color: #555; background-color: #555;
}
/* Copy address feedback */
#copyAddress {
cursor: pointer;
}
#copyFeedback {
display: none;
color: #9b4cda;
font-size: 0.9em;
margin-left: 10px;
}
.email-expiry .expiry-timer[style*="color: #b00"] {
color: #b00 !important;
} }

View file

@ -56,7 +56,9 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, checkLockAccess, async(
unlockError: unlockErrorSession, unlockError: unlockErrorSession,
locktimer: config.lock.releaseHours, locktimer: config.lock.releaseHours,
error: lockError, error: lockError,
redirectTo: req.originalUrl redirectTo: req.originalUrl,
expiryTime: config.email.purgeTime.time,
expiryUnit: config.email.purgeTime.unit
}) })
} catch (error) { } catch (error) {
debug(`Error loading inbox for ${req.params.address}:`, error.message) debug(`Error loading inbox for ${req.params.address}:`, error.message)

View file

@ -21,11 +21,12 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<script src="/javascripts/inbox-init.js" defer data-address="{{ address }}"></script> <script src="/javascripts/inbox-init.js" defer data-address="{{ address }}" data-expiry-time="{{ expiryTime }}" data-expiry-unit="{{ expiryUnit }}"></script>
<script src="/javascripts/lock-modals.js" defer></script> <script src="/javascripts/lock-modals.js" defer></script>
<div class="inbox-container"> <div class="inbox-container">
<div class="inbox-header"> <div class="inbox-header">
<h1 class="inbox-title">{{ address }}</h1> <h1 class="inbox-title" id="copyAddress" title="Click to copy address">{{ address }}</h1>
<span id="copyFeedback">Copied!</span>
</div> </div>
<div class="emails-container"> <div class="emails-container">
@ -39,7 +40,12 @@
</div> </div>
<div class="email-date">{{ mail.date | date }}</div> <div class="email-date">{{ mail.date | date }}</div>
</div> </div>
<div class="email-subject">{{ mail.subject }}</div> <div class="email-subject-row">
<div class="email-subject">{{ mail.subject }}</div>
<div class="email-expiry">
<span class="expiry-timer" data-date="{{ mail.date|date('Y-m-d\TH:i:s\Z') }}">Expires in ...</span>
</div>
</div>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}