Compare commits

...

3 commits

Author SHA1 Message Date
ClaraCrazy
2f2af239fa
[Refactor]: Optimize cache
Implement own caching system, since mem doesnt allow for fine enough control. I think im done for the year.
2025-12-30 21:33:07 +01:00
ClaraCrazy
30ec16f610
[Chore]: CSS fix 2025-12-30 17:51:08 +01:00
ClaraCrazy
89003f0d26
[Feat]: Add light/dark-mode toggle
INcluding localstorage token and quick-load functionality to prevent flashing on initial canvas paint.
2025-12-30 17:42:30 +01:00
12 changed files with 289 additions and 23 deletions

View file

@ -299,10 +299,9 @@ class ImapService extends EventEmitter {
* @param uid delete specific mail per UID
*/
async deleteSpecificEmail(uid) {
debug(`Deleting mails ${uid}`)
if (!this.config.email.examples.uids.includes(parseInt(uid))) {
await this.connection.deleteMessage(uid)
debug(`Deleted mail with UID: ${uid}.`)
debug(`Deleted UID ${uid}`)
this.emit(ImapService.EVENT_DELETED_MAIL, uid)
}
}

View file

@ -1,6 +1,5 @@
const EventEmitter = require('events')
const debug = require('debug')('48hr-email:imap-processor')
const mem = require('mem')
const ImapService = require('./imap-service')
const Helper = require('./helper')
const config = require('./config')
@ -16,9 +15,7 @@ class MailProcessingService extends EventEmitter {
this.config = config
// Cached methods:
this.cachedFetchFullMail = mem(
this.imapService.fetchOneFullMail.bind(this.imapService), { maxAge: 10 * 60 * 1000 }
)
this._initCache()
this.initialLoadDone = false
@ -32,21 +29,97 @@ class MailProcessingService extends EventEmitter {
}, this.config.imap.refreshIntervalSeconds * 1000)
}
_initCache() {
// Create a cache storage to track entries by UID
this.cacheStorage = new Map() // Map of "address:uid:raw" -> cached result
// Wrapper that maintains our own cache with selective deletion
this.cachedFetchFullMail = async(address, uid, raw) => {
const cacheKey = `${address}:${uid}:${raw}`
// Check our cache first
if (this.cacheStorage.has(cacheKey)) {
const entry = this.cacheStorage.get(cacheKey)
if (Date.now() - entry.timestamp < 10 * 60 * 1000) {
return entry.value
} else {
this.cacheStorage.delete(cacheKey)
}
}
// Fetch and cache
const result = await this.imapService.fetchOneFullMail(address, uid, raw)
this.cacheStorage.set(cacheKey, {
value: result,
timestamp: Date.now(),
uid: uid
})
return result
}
// Wrap it to use in sync context
this._wrappedCachedFetch = (address, uid, raw) => {
return this.cachedFetchFullMail(address, uid, raw)
}
}
_clearCache() {
// Clear entire cache
debug('Clearing entire email cache')
this.cacheStorage.clear()
this._initCache()
}
_clearCacheForUid(uid) {
// Selectively clear cache entries for a specific UID
// Normalize UID to integer for comparison
const normalizedUid = parseInt(uid)
let cleared = 0
for (const [key, entry] of this.cacheStorage.entries()) {
if (parseInt(entry.uid) === normalizedUid) {
this.cacheStorage.delete(key)
cleared++
}
}
if (cleared > 0) {
debug(`Cleared ${cleared} cache entries for UID ${uid}`)
} else {
debug(`No cache entries found for UID ${uid}`)
}
}
getMailSummaries(address) {
debug('Getting mail summaries for', address)
return this.mailRepository.getForRecipient(address)
}
deleteSpecificEmail(adress, uid) {
debug('Deleting specific email', adress, uid)
if (this.mailRepository.removeUid(uid, adress) == true) {
// Clear cache immediately for this UID
debug('Clearing cache for uid', uid)
this._clearCacheForUid(uid)
this.imapService.deleteSpecificEmail(uid)
} else {
debug('Repository removeUid returned false for', uid)
}
}
getOneFullMail(address, uid, raw = false) {
debug('Cache lookup for', address + ':' + uid, raw ? '(raw)' : '(parsed)')
return this.cachedFetchFullMail(address, uid, raw)
// Check if this UID exists in repository before fetching
const summaries = this.mailRepository.getForRecipient(address)
const exists = summaries.some(mail => mail.uid === parseInt(uid))
if (!exists) {
debug(`UID ${uid} not found in repository for ${address}, returning null`)
return Promise.resolve(null)
}
return this._wrappedCachedFetch(address, uid, raw)
}
getAllMailSummaries() {
@ -87,7 +160,15 @@ class MailProcessingService extends EventEmitter {
}
onMailDeleted(uid) {
debug('Mail deleted with uid', uid)
debug('Mail deleted:', uid)
// Clear cache for this specific UID
try {
this._clearCacheForUid(uid)
} catch (err) {
debug('Failed to clear email cache:', err.message)
}
this.mailRepository.removeUid(uid)
}

View file

@ -54,7 +54,6 @@ class MailRepository {
const mailToDelete = mails.find(mail => mail.uid === parseInt(uid))
if (mailToDelete) {
this.mailSummaries.remove(address, mailToDelete)
debug('Removed ', mailToDelete.date, address, mailToDelete.subject)
deleted = true
}
} else {
@ -79,4 +78,4 @@ class MailRepository {
}
}
module.exports = MailRepository
module.exports = MailRepository

View file

@ -1,5 +1,21 @@
// Consolidated utilities: date formatting and lock modals
// Load theme immediately to prevent flash
(function() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light') {
document.documentElement.classList.add('light-mode');
// Also add to body if it exists
if (document.body) {
document.body.classList.add('light-mode');
}
}
})();
document.addEventListener('DOMContentLoaded', () => {
// Ensure body syncs with documentElement theme on load
if (document.documentElement.classList.contains('light-mode') && !document.body.classList.contains('light-mode')) {
document.body.classList.add('light-mode');
}
function getExpiryMs(time, unit) {
switch (unit) {
case 'minutes':
@ -310,12 +326,34 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
function initThemeToggle() {
const themeToggle = document.getElementById('themeToggle');
if (!themeToggle) return;
// Sync body with documentElement if theme was loaded early
if (document.documentElement.classList.contains('light-mode')) {
document.body.classList.add('light-mode');
}
themeToggle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
document.body.classList.toggle('light-mode');
document.documentElement.classList.toggle('light-mode');
// Save preference
const isLight = document.body.classList.contains('light-mode');
localStorage.setItem('theme', isLight ? 'light' : 'dark');
});
}
// Expose utilities and run them
window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu };
window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle };
formatEmailDates();
formatMailDate();
initLockModals();
initCopyAddress();
initQrModal();
initHamburgerMenu();
initThemeToggle();
});

View file

@ -52,6 +52,60 @@
--overlay-warning-10: rgba(255, 140, 0, 0.1);
}
/* Light mode theme */
hr {
border: none;
border-top: 0.1rem solid var(--overlay-white-15);
}
body.light-mode hr {
border-top: 0.1rem solid var(--overlay-black-80);
}
body.light-mode {
--color-bg-dark: #f5f5f5;
--color-bg-medium: #e4e4e4;
--color-text-primary: #2c2c2c;
--color-text-light: #1a1a1a;
--color-text-dim: #666666;
--color-text-dimmer: #888888;
--color-text-gray: #555555;
--color-text-white: #2c2c2c;
--color-text-white-alt: #2c2c2c;
--color-text-muted: #666666;
--color-text-secondary: #444444;
--color-border-dark: #cccccc;
--color-button-cancel: #aaaaaa;
--overlay-white-15: rgba(0, 0, 0, 0.08);
--overlay-white-12: rgba(0, 0, 0, 0.06);
--overlay-white-10: rgba(0, 0, 0, 0.05);
--overlay-white-08: rgba(0, 0, 0, 0.04);
--overlay-white-06: rgba(0, 0, 0, 0.03);
--overlay-white-05: rgba(0, 0, 0, 0.025);
--overlay-white-04: rgba(0, 0, 0, 0.02);
--overlay-white-03: rgba(0, 0, 0, 0.015);
--overlay-white-02: rgba(0, 0, 0, 0.01);
--overlay-black-80: rgba(0, 0, 0, 0.15);
--overlay-black-45: rgba(0, 0, 0, 0.08);
--overlay-black-40: rgba(0, 0, 0, 0.07);
--overlay-black-35: rgba(0, 0, 0, 0.06);
--overlay-black-30: rgba(0, 0, 0, 0.05);
--overlay-purple-50: rgba(155, 77, 202, 0.25);
--overlay-purple-40: rgba(155, 77, 202, 0.2);
--overlay-purple-35: rgba(155, 77, 202, 0.18);
--overlay-purple-30: rgba(155, 77, 202, 0.15);
--overlay-purple-25: rgba(155, 77, 202, 0.12);
--overlay-purple-20: rgba(155, 77, 202, 0.1);
--overlay-purple-15: rgba(155, 77, 202, 0.08);
--overlay-purple-12: rgba(155, 77, 202, 0.06);
--overlay-purple-10: rgba(155, 77, 202, 0.05);
--overlay-purple-08: rgba(155, 77, 202, 0.04);
--overlay-purple-04: rgba(155, 77, 202, 0.02);
--overlay-warning-10: rgba(255, 140, 0, 0.05);
}
body {
margin: 0;
min-height: 100vh;
@ -65,6 +119,16 @@ body {
box-shadow: 0 10px 30px var(--overlay-black-45), inset 0 1px 0 var(--overlay-white-06);
}
button:focus {
background: var(--overlay-purple-20);
border-color: var(--overlay-purple-30);
}
button:focus,
button:hover {
color: var(--color-accent-purple-light);
}
body::-webkit-scrollbar {
display: none;
}
@ -194,6 +258,7 @@ text-muted {
}
.action-links a {
height: 42px;
display: inline-block;
padding: 12px 24px;
border-radius: 15px;
@ -422,15 +487,17 @@ label {
transform: translateY(-4px);
}
.email-card,
.mail-content,
.mail-attachments {
border-radius: 18px;
border: 1px solid var(--overlay-white-08);
border: 2px solid var(--overlay-purple-30);
border-radius: 15px;
box-shadow: 0 8px 30px var(--overlay-black-30);
}
.email-card {
border: 1px solid var(--overlay-purple-30);
border-radius: 15px;
box-shadow: 0 8px 30px var(--overlay-black-30);
padding: 24px;
transition: all 0.4s ease;
position: relative;
@ -564,6 +631,8 @@ label {
.mail-header {
padding: 30px;
margin-bottom: 30px;
border: 2px solid var(--overlay-purple-30);
border-radius: 15px;
box-shadow: 0 12px 40px var(--overlay-black-40);
}
@ -588,7 +657,6 @@ label {
.mail-content {
background: var(--overlay-white-02);
border: 1px solid var(--overlay-white-06);
padding: 30px;
margin-bottom: 30px;
}
@ -621,6 +689,8 @@ label {
}
.mail-attachments {
border: 2px solid var(--overlay-purple-30);
border-radius: 15px;
background: var(--overlay-white-03);
padding: 25px;
}
@ -931,6 +1001,52 @@ body.loading-page .logo {
}
/* Theme Toggle Button */
.theme-toggle {
background: var(--overlay-purple-20);
border: 1px solid var(--overlay-purple-30);
border-radius: 15px;
height: 42px;
color: var(--color-accent-purple-light);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.theme-toggle:hover {
background: var(--overlay-purple-30);
border-color: var(--overlay-purple-40);
box-shadow: 0 4px 15px var(--overlay-purple-25);
transform: translateY(-2px);
}
.theme-toggle svg {
margin: auto;
width: 20px;
height: 20px;
}
.theme-icon {
display: none;
}
body:not(.light-mode) .theme-icon-dark {
display: block;
}
body.light-mode .theme-icon-light {
display: block;
}
/* Responsive Styles */
@media (max-width: 768px) {
@ -940,6 +1056,10 @@ body.loading-page .logo {
.hamburger-menu {
display: flex;
}
.action-links.mobile-open .theme-toggle {
justify-content: center;
width: 100%;
}
.action-links.mobile-hidden>a,
.action-links.mobile-hidden>button:not(.hamburger-menu) {
display: none;

View file

@ -154,9 +154,7 @@ router.get(
async(req, res, next) => {
try {
const mailProcessingService = req.app.get('mailProcessingService')
debug(`Deleting email ${req.params.uid} for ${req.params.address}`)
await mailProcessingService.deleteSpecificEmail(req.params.address, req.params.uid)
debug(`Successfully deleted email ${req.params.uid} for ${req.params.address}`)
mailProcessingService.deleteSpecificEmail(req.params.address, req.params.uid)
res.redirect(`/inbox/${req.params.address}`)
} catch (error) {
debug(`Error deleting email ${req.params.uid} for ${req.params.address}:`, error.message)

View file

@ -6,6 +6,14 @@
<a href="#" id="unlockBtn" aria-label="Unlock inbox">Unlock</a>
{% endif %}
<a href="/" aria-label="Return to home">Logout</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
{% endblock %}

View file

@ -17,12 +17,19 @@
{% else %}
<a href="/logout" aria-label="Logout">Logout</a>
{% endif %}
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
{% endblock %}
{% block body %}
<script src="/javascripts/qrcode.js"></script>
<script src="/javascripts/utils.js" defer></script>
<script src="/javascripts/inbox-init.js" defer data-address="{{ address }}" data-expiry-time="{{ expiryTime }}" data-expiry-unit="{{ expiryUnit }}"></script>
<div class="inbox-container">
<div class="inbox-header">

View file

@ -71,6 +71,7 @@
</script>
<!-- Scripts -->
<script src="/javascripts/utils.js"></script>
<script src="/socket.io/socket.io.js" defer="true"></script>
<script src="/javascripts/notifications.js" defer="true"></script>

View file

@ -3,6 +3,14 @@
{% block header %}
<div class="action-links">
<a href="/inbox/{{ example }}">Example Inbox</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
{% endblock %}

View file

@ -10,11 +10,18 @@
{% else %}
<a href="/logout" aria-label="Logout">Logout</a>
{% endif %}
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
{% endblock %}
{% block body %}
<script src="/javascripts/utils.js" defer></script>
<div class="mail-container">
<div class="mail-header">
<h1 class="mail-subject">{{ mail.subject }}</h1>

View file

@ -1,6 +1,6 @@
{
"name": "48hr.email",
"version": "1.7.3",
"version": "1.7.4",
"private": false,
"description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [