Skip to content

Commit 427ebc4

Browse files
committed
v3: Cyber Neon UI redesign with mobile-first responsive design
- Complete visual redesign with dark theme, orange/cyan accents - Mobile responsive layout (600px, 380px breakpoints) - New screenshots for landing, inbox, and email modal - Setup.sh improvements: beautiful progress output, auto IP detection - Fixed single-character email prefix support - Fixed DELETE method for email deletion endpoint
1 parent 8e807eb commit 427ebc4

File tree

12 files changed

+1037
-892
lines changed

12 files changed

+1037
-892
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,6 @@ docker/build.log
4545
# Claude Code
4646
.claude/
4747
CLAUDE.md
48+
49+
# Development mockups
50+
mockups/

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212

1313
SpamEater is a self-hosted, open-source disposable email server that prioritizes privacy and security. Perfect for testing, sign-ups, and protecting your real email from spam. All emails are automatically deleted after 24 hours with no recovery option.
1414

15+
## What's New in v3
16+
17+
**Cyber Neon UI** - Complete visual redesign with a modern dark theme featuring vibrant orange and cyan accents, smooth animations, and enhanced visual feedback.
18+
19+
**Mobile-First Design** - Fully responsive interface optimized for all screen sizes, from small phones to large desktop monitors.
20+
1521
## Screenshots
1622

1723
<details>
@@ -25,6 +31,10 @@ SpamEater is a self-hosted, open-source disposable email server that prioritizes
2531
<img src="screenshots/2.png" alt="SpamEater Inbox View" width="600">
2632
<br>
2733
<em>Real-time email reception with clean interface</em>
34+
<br><br>
35+
<img src="screenshots/3.png" alt="SpamEater Email Modal" width="600">
36+
<br>
37+
<em>View email details with one-click deletion</em>
2838
</div>
2939
</details>
3040

@@ -50,6 +60,7 @@ SpamEater is a self-hosted, open-source disposable email server that prioritizes
5060
- High-performance Haraka SMTP server
5161
- Lightweight SQLite database
5262
- Vanilla JavaScript frontend (no frameworks)
63+
- Mobile-friendly responsive design
5364
- Real-time email updates via polling
5465
- RESTful API
5566
- Docker support with prebuilt images

deploy/nginx.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ server {
1818
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
1919

2020
# Enhanced Content Security Policy
21-
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' https: data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
21+
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' https://cdnjs.cloudflare.com; img-src 'self' https: data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdnjs.cloudflare.com; frame-src blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
2222

2323
# Remove server version
2424
server_tokens off;

docker/docker-setup.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ mkdir -p /tmp/npm-cache
4040

4141
# Install for Haraka
4242
cd $INSTALL_DIR/haraka
43-
npm install sqlite3 --cache /tmp/npm-cache || exit 1
43+
npm install sqlite3 isomorphic-dompurify --cache /tmp/npm-cache || exit 1
4444

4545
# Install for API server
4646
cd $INSTALL_DIR

frontend/app.js

Lines changed: 149 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ class SpamEater {
129129
const modalOverlay = document.getElementById('modalOverlay');
130130
const modalClose = document.getElementById('modalClose');
131131
const modalDelete = document.getElementById('modalDelete');
132-
132+
const modalFullscreen = document.getElementById('modalFullscreen');
133+
133134
modalOverlay?.addEventListener('click', (e) => {
134135
if (e.target === modalOverlay) this.closeModal();
135136
});
@@ -139,7 +140,8 @@ class SpamEater {
139140
this.deleteEmail(this.currentEmailData.id, true);
140141
}
141142
});
142-
143+
modalFullscreen?.addEventListener('click', () => this.toggleFullscreen());
144+
143145
// Headers toggle
144146
const toggleHeaders = document.getElementById('toggleHeaders');
145147
toggleHeaders?.addEventListener('click', () => this.toggleHeaders());
@@ -280,59 +282,30 @@ class SpamEater {
280282
return sanitized;
281283
}
282284

283-
// Security: Enhanced HTML sanitization to prevent XSS
285+
// Security: HTML sanitization using DOMPurify
286+
// Keeps <style> tags for proper email rendering, displayed in sandboxed iframe
284287
sanitizeHtml(html) {
285288
if (!html) return '';
286-
287-
// Create a temporary element to parse HTML
288-
const temp = document.createElement('div');
289-
temp.innerHTML = html;
290-
291-
// Remove all script tags
292-
const scripts = temp.querySelectorAll('script');
293-
scripts.forEach(script => script.remove());
294-
295-
// Remove all elements with event handlers
296-
const allElements = temp.querySelectorAll('*');
297-
allElements.forEach(el => {
298-
// Remove all event attributes
299-
for (let attr of Array.from(el.attributes)) {
300-
if (attr.name.startsWith('on') || attr.name === 'href' && attr.value.startsWith('javascript:')) {
301-
el.removeAttribute(attr.name);
302-
}
303-
}
304-
305-
// Remove javascript: URLs
306-
if (el.href && el.href.startsWith('javascript:')) {
307-
el.removeAttribute('href');
308-
}
309-
if (el.src && el.src.startsWith('javascript:')) {
310-
el.removeAttribute('src');
311-
}
312-
313-
// Remove data: URLs from images (prevent tracking pixels)
314-
if (el.tagName === 'IMG' && el.src && el.src.startsWith('data:')) {
315-
el.removeAttribute('src');
316-
el.setAttribute('alt', '[Image removed for security]');
317-
}
318-
});
319-
320-
// Remove dangerous tags
321-
const dangerousTags = ['iframe', 'object', 'embed', 'link', 'meta', 'style', 'base', 'form'];
322-
dangerousTags.forEach(tag => {
323-
const elements = temp.querySelectorAll(tag);
324-
elements.forEach(el => el.remove());
325-
});
326-
327-
// Remove SVG with scripts
328-
const svgs = temp.querySelectorAll('svg');
329-
svgs.forEach(svg => {
330-
if (svg.innerHTML.includes('script') || svg.innerHTML.includes('onload')) {
331-
svg.remove();
332-
}
289+
290+
// Use DOMPurify for battle-tested XSS prevention
291+
// - ADD_TAGS: ['style'] keeps CSS styling for proper email rendering
292+
// - Content is displayed in sandboxed iframe for extra security
293+
return DOMPurify.sanitize(html, {
294+
ADD_TAGS: ['style'], // Keep style tags for email CSS
295+
FORBID_TAGS: [
296+
'script', 'iframe', 'frame', 'frameset',
297+
'object', 'embed', 'applet', 'form',
298+
'input', 'button', 'select', 'textarea',
299+
'link', 'meta', 'base'
300+
],
301+
FORBID_ATTR: [
302+
'onerror', 'onload', 'onclick', 'onmouseover',
303+
'onfocus', 'onblur', 'onchange', 'onsubmit'
304+
],
305+
ALLOW_DATA_ATTR: false, // No data-* attributes
306+
ALLOW_ARIA_ATTR: true, // Keep accessibility attributes
307+
KEEP_CONTENT: true // Keep text content when removing tags
333308
});
334-
335-
return temp.innerHTML;
336309
}
337310

338311
async createEmail() {
@@ -667,19 +640,21 @@ class SpamEater {
667640
const div = document.createElement('div');
668641
div.className = 'email-item';
669642
div.setAttribute('data-email-id', email.id);
670-
643+
671644
const timeAgo = this.formatTimeAgo(email.receivedAt);
672645
const sender = this.sanitizeText(email.sender || 'Unknown sender');
673646
const senderName = email.senderName ? this.sanitizeText(email.senderName) : sender;
674647
const subject = this.sanitizeText(email.subject || '(No subject)');
675-
648+
676649
div.innerHTML = `
677650
<div class="email-content-wrapper" data-email-id="${email.id}">
678-
<div class="email-sender">${senderName}</div>
679-
<div class="email-subject">${subject}</div>
651+
<div class="email-info">
652+
<div class="email-sender">${senderName}</div>
653+
<div class="email-subject">${subject}</div>
654+
</div>
680655
<div class="email-meta">
681-
<span class="email-size">${this.formatBytes(email.size || 0)}</span>
682656
<span class="email-time">${timeAgo}</span>
657+
<span class="email-size">${this.formatBytes(email.size || 0)}</span>
683658
</div>
684659
</div>
685660
<button class="delete-btn" title="Delete email" data-email-id="${email.id}">
@@ -728,47 +703,152 @@ class SpamEater {
728703
const modalSubject = document.getElementById('modalSubject');
729704
const modalSender = document.getElementById('modalSender');
730705
const modalTime = document.getElementById('modalTime');
731-
const modalContent = document.getElementById('modalContent');
706+
const emailFrame = document.getElementById('emailFrame');
732707
const toggleText = document.getElementById('toggleText');
733708
const emailHeaders = document.getElementById('emailHeaders');
734-
709+
const emailModal = document.getElementById('emailModal');
710+
735711
if (modalSubject) modalSubject.textContent = emailData.subject;
736712
if (modalSender) modalSender.textContent = emailData.senderName || emailData.sender;
737713
if (modalTime) modalTime.textContent = emailData.time;
738-
if (modalContent) {
739-
// Display HTML content if available, otherwise plain text
714+
715+
// Display email content in sandboxed iframe for security
716+
if (emailFrame) {
717+
let content;
740718
if (emailData.isHtml) {
741-
// Sanitize HTML before displaying
742-
modalContent.innerHTML = this.sanitizeHtml(emailData.content);
719+
// Sanitize HTML with DOMPurify before putting in iframe
720+
const sanitizedHtml = this.sanitizeHtml(emailData.content);
721+
// Wrap in a basic HTML document with dark theme styling
722+
content = `<!DOCTYPE html>
723+
<html>
724+
<head>
725+
<meta charset="UTF-8">
726+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
727+
<style>
728+
* { box-sizing: border-box; }
729+
html, body {
730+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
731+
font-size: 14px !important;
732+
line-height: 1.6 !important;
733+
color: #e0e0e0 !important;
734+
background: #0a0a0f !important;
735+
margin: 0 !important;
736+
padding: 16px !important;
737+
word-wrap: break-word;
738+
}
739+
/* Force light text on all elements */
740+
div, span, p, td, th, li, label, h1, h2, h3, h4, h5, h6 {
741+
color: #e0e0e0 !important;
742+
}
743+
a { color: #00ff88 !important; }
744+
img { max-width: 100%; height: auto; }
745+
pre, code {
746+
background: #1a1a1f !important;
747+
padding: 2px 6px;
748+
border-radius: 4px;
749+
font-family: 'JetBrains Mono', monospace;
750+
color: #e0e0e0 !important;
751+
}
752+
blockquote {
753+
border-left: 3px solid #00ff88 !important;
754+
margin: 1em 0;
755+
padding-left: 1em;
756+
color: #aaa !important;
757+
}
758+
table { border-collapse: collapse; width: 100%; background: transparent !important; }
759+
td, th { border: 1px solid #333 !important; padding: 8px; color: #e0e0e0 !important; }
760+
</style>
761+
</head>
762+
<body>${sanitizedHtml}</body>
763+
</html>`;
743764
} else {
744-
// Display plain text (already sanitized)
745-
modalContent.textContent = emailData.content;
765+
// Plain text - wrap in pre tag for proper formatting
766+
const escapedText = emailData.content
767+
.replace(/&/g, '&amp;')
768+
.replace(/</g, '&lt;')
769+
.replace(/>/g, '&gt;');
770+
content = `<!DOCTYPE html>
771+
<html>
772+
<head>
773+
<meta charset="UTF-8">
774+
<style>
775+
body {
776+
font-family: 'JetBrains Mono', monospace;
777+
font-size: 14px;
778+
line-height: 1.6;
779+
color: #e0e0e0;
780+
background: #0a0a0f;
781+
margin: 0;
782+
padding: 16px;
783+
white-space: pre-wrap;
784+
word-wrap: break-word;
785+
}
786+
</style>
787+
</head>
788+
<body>${escapedText}</body>
789+
</html>`;
746790
}
791+
792+
// Use srcdoc for sandboxed content
793+
emailFrame.srcdoc = content;
747794
}
748-
795+
749796
// Reset headers display
750797
if (toggleText) toggleText.textContent = 'Show Headers';
751798
if (emailHeaders) {
752799
emailHeaders.style.display = 'none';
753800
emailHeaders.innerHTML = '';
754801
}
755-
802+
803+
// Reset fullscreen state
804+
if (emailModal) {
805+
emailModal.classList.remove('modal-fullscreen');
806+
}
807+
756808
if (modalOverlay) {
757809
modalOverlay.style.display = 'flex';
758810
document.body.style.overflow = 'hidden';
759811
}
760812
}
761-
813+
762814
closeModal() {
763815
const modalOverlay = document.getElementById('modalOverlay');
816+
const emailFrame = document.getElementById('emailFrame');
817+
const emailModal = document.getElementById('emailModal');
818+
764819
if (modalOverlay) {
765820
modalOverlay.style.display = 'none';
766821
document.body.style.overflow = 'auto';
767822
}
768-
823+
824+
// Clear iframe content
825+
if (emailFrame) {
826+
emailFrame.srcdoc = '';
827+
}
828+
829+
// Reset fullscreen
830+
if (emailModal) {
831+
emailModal.classList.remove('modal-fullscreen');
832+
}
833+
769834
// Clear current email data
770835
this.currentEmailData = null;
771836
}
837+
838+
toggleFullscreen() {
839+
const emailModal = document.getElementById('emailModal');
840+
const fullscreenBtn = document.getElementById('modalFullscreen');
841+
842+
if (emailModal) {
843+
emailModal.classList.toggle('modal-fullscreen');
844+
845+
// Update button icon
846+
if (fullscreenBtn) {
847+
fullscreenBtn.textContent = emailModal.classList.contains('modal-fullscreen') ? '⛶' : '⛶';
848+
fullscreenBtn.title = emailModal.classList.contains('modal-fullscreen') ? 'Exit fullscreen' : 'Toggle fullscreen';
849+
}
850+
}
851+
}
772852

773853
toggleHeaders() {
774854
const emailHeaders = document.getElementById('emailHeaders');

0 commit comments

Comments
 (0)