Compare commits

...

7 commits

Author SHA1 Message Date
ClaraCrazy
cdce7e1e46
[Chore]: Update Readme 2026-01-01 02:12:09 +01:00
ClaraCrazy
49e4e6eaf9
[Feat]: Add blacklisted Sender list
Allows admin to stop receiving mails from specific senders, for example to prevent account verification emails to arrive
2026-01-01 02:04:41 +01:00
ClaraCrazy
985c36920b
[Chore]: Fix tables 2026-01-01 01:40:10 +01:00
ClaraCrazy
8a80134ee9
[Chore]: Readme Update 2026-01-01 01:37:35 +01:00
ClaraCrazy
e91fdeb827
[Chore]: Clarify timer meaning
It only fetches when the timer hits zero, reloading the page has zero effect on anything.
2026-01-01 01:06:19 +01:00
ClaraCrazy
68dda6880a
[Fix]: Fix wipe inbox button 2026-01-01 00:37:14 +01:00
ClaraCrazy
fd993eb272
[AI][Feat]: Display Cryptographic keys in extra section
Thanks @aurorasmiles for that wonderful idea <3
2026-01-01 00:00:21 +01:00
15 changed files with 749 additions and 64 deletions

View file

@ -10,6 +10,7 @@ EMAIL_PURGE_CONVERT=true # Convert to hig
# --- Example emails to keep clean --- # --- Example emails to keep clean ---
EMAIL_EXAMPLE_ACCOUNT="example@48hr.email" # example email to preserve EMAIL_EXAMPLE_ACCOUNT="example@48hr.email" # example email to preserve
EMAIL_EXAMPLE_UIDS=[1,2,3] # example UIDs to preserve EMAIL_EXAMPLE_UIDS=[1,2,3] # example UIDs to preserve
EMAIL_BLACKLISTED_SENDERS=[] # List of email addresses to block (Useful to prevent account generators) ["noreply@facebook.com", "noreply@amazon.com"]
# --- IMAP CONFIGURATION --- # --- IMAP CONFIGURATION ---
IMAP_USER="user@example.com" # IMAP username IMAP_USER="user@example.com" # IMAP username

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 266 KiB

BIN
.github/assets/keys.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
.github/assets/raw.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 265 KiB

129
README.md
View file

@ -4,63 +4,92 @@
<img align="center" src="https://i.imgur.com/zPzvSQJ.png" width="100%"> <img align="center" src="https://i.imgur.com/zPzvSQJ.png" width="100%">
</p> </p>
<p align="center">
<a href="https://48hr.email" target="_blank">Official Instance</a>
<a href="https://48hr.email/inbox/example@48hr.email" target="_blank">Example Inbox</a>
<a href="https://discord.gg/crazyco" target="_blank">Discord</a>
</p>
<br>
----- -----
### What is this? ## What is this?
48hr.email is my very own tempmail service. You can create emails on the fly with one click, not needing to worry about corporations do with your email. They can sell that one all they want! 48hr.email is my very own tempmail service. You can create emails on the fly with one click, not needing to worry about corporations do with your email. They can sell that one all they want!
All data is being removed 48hrs after they have reached the mail server. All data is being removed 48hrs after they have reached the mail server.
<p align="center"><a href="https://48hr.email" target="_blank">Try now</a> | <a href="https://48hr.email/inbox/example@48hr.email" target="_blank">Example Inbox</a> | <a href="https://discord.gg/crazyco" target="_blank">Discord</a></p> <br>
<br><br>
----- -----
### What are its features? ## Features
- Create a custom inbox with select name and domain, or get a fully randomized one - Create a custom inbox with select name and domain, or get a fully randomized one
- Receive emails with a clean preview in your inbox, with optional browser notifications - Receive emails with a clean preview in your inbox, with optional browser notifications
- Read emails, with support for HTML, CSS & JS just like you are used to from regular email providers - Read emails, with support for HTML, CSS & JS just like you are used to from regular email providers
- Automatic detection and display of cryptographic keys and signatures
- Delete your emails ahead of time by pressing the delete button - Delete your emails ahead of time by pressing the delete button
- View the raw email, showing all the headers etc. - View the raw email, showing all the headers etc.
- Download Attachments - Download Attachments with one click
- Password-protected inboxes
- and more... - and more...
<br><br> <br>
----- -----
### How does this work? ## Screenshots
| Inbox | Email using HTML and CSS |
|:---:|:---:|
| <img src=".github/assets/inbox.png" width="500px" height="300px" style="object-fit: cover;"> | <img src=".github/assets/html.png" width="500px" height="300px" style="object-fit: cover;"> |
| Email without CSS | Dropdown for cryptographic Keys and Signatures |
|:---:|:---:|
| <img src=".github/assets/raw.png" width="500px" height="300px" style="object-fit: cover;"> | <img src=".github/assets/keys.png" width="500px" height="300px" style="object-fit: cover;"> |
<br>
-----
## How does this work?
48hr.email uses an existing IMAP server for its handling. A single catch-all account and the accompanying credentials handle all the emails. 48hr.email uses an existing IMAP server for its handling. A single catch-all account and the accompanying credentials handle all the emails.
<br><br> <br>
----- -----
### How can I set this up myself? ## How can I set this up myself?
- Prerequisites: **Prerequisites:**
- Mail server with IMAP - Mail server with IMAP
- One or multiple domains dedicated to this - One or multiple domains dedicated to this
- git & nodejs - git & nodejs
<br> <br>
<details> <details>
<summary>Option 1 - bare-metal install:</summary> <summary>Option 1 - bare-metal install</summary>
- #### Setup: #### Setup:
- `git clone https://github.com/Crazyco-xyz/48hr.email.git`
- `cd 48hr.email`
- `npm i`
- Change all settings to the desired values:
- Either use environmental variables, or modify `.env` (see `.env.example`)
- `npm run start`
- #### Service file example:
```bash ```bash
git clone https://github.com/Crazyco-xyz/48hr.email.git
cd 48hr.email
npm i
```
Change all settings to the desired values:
- Either use environmental variables, or modify `.env` (see `.env.example`)
```bash
npm run start
```
#### Service file example:
```ini
[Unit] [Unit]
Description=48hr-email Description=48hr-email
After=network-online.target After=network-online.target
@ -83,48 +112,40 @@ WantedBy=multi-user.target
</details> </details>
<details> <details>
<summary>Option 2 - Docker:</summary> <summary>Option 2 - Docker</summary>
- #### Setup: #### Setup:
- `git clone https://github.com/Crazyco-xyz/48hr.email.git` ```bash
- `cd 48hr.email` git clone https://github.com/Crazyco-xyz/48hr.email.git
- Change all settings to the desired values: cd 48hr.email
- Either use environmental variables, or modify `.env`, see `.env.example` ```
- `docker compose up -d`
- If desired, you can also move the config file somewhere else (change volume mount accordingly) Change all settings to the desired values:
- Either use environmental variables, or modify `.env`, see `.env.example`
```bash
docker compose up -d
```
If desired, you can also move the config file somewhere else (change volume mount accordingly)
</details> </details>
<br><br> <br>
----- -----
### TODO (PRs welcome):
## TODO (PRs welcome)
- Add user registration: - Add user registration:
- Optional "premium" domains that arent visible to the public to prevent them from being scraped and flagged - Allow people to forward single emails, or an inbox in its current state
- Allow people to set up forwarding
#### Unsure: <br>
- Possible payment integration once registration exists, to lock one or more of these new features behind a paywall (configurable, ofc)
<br><br>
----- -----
### Screenshots: ## Support me
- #### Inbox: If you find this project useful, consider supporting its development!
<img align="center" src=".github/assets/inbox.png">
- #### Email using HTML and CSS:
<img align="center" src=".github/assets/html.png">
- #### Email without CSS:
<img align="center" src=".github/assets/raw.png">
<br><br>
-----
## ❤️ Support me
<!-- <!--
Pwease support me >.< Pwease support me >.<

View file

@ -40,7 +40,8 @@ const config = {
examples: { examples: {
account: parseValue(process.env.EMAIL_EXAMPLE_ACCOUNT), account: parseValue(process.env.EMAIL_EXAMPLE_ACCOUNT),
uids: parseValue(process.env.EMAIL_EXAMPLE_UIDS) uids: parseValue(process.env.EMAIL_EXAMPLE_UIDS)
} },
blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || []
}, },
imap: { imap: {

View file

@ -0,0 +1,411 @@
const debug = require('debug')('48hr-email:crypto-detector')
/**
* Detects cryptographic keys and signatures in email attachments
*/
class CryptoDetector {
constructor() {
// Common cryptographic file extensions
this.cryptoExtensions = [
'.pgp', '.gpg', '.asc', '.pub', '.key', '.pem',
'.crt', '.cer', '.sig', '.sign', '.p7s', '.p7m',
'.pkcs7', '.pkcs12', '.pfx', '.p12'
]
// Patterns to detect key blocks in content
this.keyPatterns = [
// PGP/GPG keys
/-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]*?-----END PGP PUBLIC KEY BLOCK-----/g,
/-----BEGIN PGP PRIVATE KEY BLOCK-----[\s\S]*?-----END PGP PRIVATE KEY BLOCK-----/g,
/-----BEGIN PGP MESSAGE-----[\s\S]*?-----END PGP MESSAGE-----/g,
/-----BEGIN PGP SIGNATURE-----[\s\S]*?-----END PGP SIGNATURE-----/g,
// SSH keys
/-----BEGIN (RSA|DSA|EC|OPENSSH) PRIVATE KEY-----[\s\S]*?-----END (RSA|DSA|EC|OPENSSH) PRIVATE KEY-----/g,
/ssh-(rsa|dss|ed25519|ecdsa) [A-Za-z0-9+/=]+/g,
// SSL/TLS certificates and keys
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
/-----BEGIN (RSA|EC) PRIVATE KEY-----[\s\S]*?-----END (RSA|EC) PRIVATE KEY-----/g,
/-----BEGIN ENCRYPTED PRIVATE KEY-----[\s\S]*?-----END ENCRYPTED PRIVATE KEY-----/g,
/-----BEGIN PUBLIC KEY-----[\s\S]*?-----END PUBLIC KEY-----/g,
// PKCS7/CMS signatures
/-----BEGIN PKCS7-----[\s\S]*?-----END PKCS7-----/g,
]
}
/**
* Checks if a filename suggests a cryptographic file
* @param {string} filename
* @returns {boolean}
*/
isCryptoFilename(filename) {
if (!filename) return false
const lowerName = filename.toLowerCase()
return this.cryptoExtensions.some(ext => lowerName.endsWith(ext)) ||
lowerName.includes('signature') ||
lowerName.includes('publickey') ||
lowerName.includes('privatekey') ||
lowerName.includes('certificate')
}
/**
* Detects the type of cryptographic content
* @param {string} content
* @param {string} filename
* @returns {string|null} The detected key type or null
*/
detectKeyType(content, filename) {
if (!content) return null
const contentStr = content.toString('utf8', 0, Math.min(content.length, 10000)) // Check first 10KB
if (contentStr.includes('BEGIN PGP PUBLIC KEY')) return 'PGP Public Key'
if (contentStr.includes('BEGIN PGP PRIVATE KEY')) return 'PGP Private Key'
if (contentStr.includes('BEGIN PGP MESSAGE')) return 'PGP Encrypted Message'
if (contentStr.includes('BEGIN PGP SIGNATURE')) return 'PGP Signature'
if (contentStr.match(/ssh-(rsa|dss|ed25519|ecdsa)/)) return 'SSH Public Key'
if (contentStr.includes('BEGIN RSA PRIVATE KEY')) return 'RSA Private Key'
if (contentStr.includes('BEGIN EC PRIVATE KEY')) return 'EC Private Key'
if (contentStr.includes('BEGIN OPENSSH PRIVATE KEY')) return 'OpenSSH Private Key'
if (contentStr.includes('BEGIN CERTIFICATE')) return 'X.509 Certificate'
if (contentStr.includes('BEGIN PUBLIC KEY')) return 'Public Key'
if (contentStr.includes('BEGIN ENCRYPTED PRIVATE KEY')) return 'Encrypted Private Key'
if (contentStr.includes('BEGIN PKCS7')) return 'PKCS#7 Signature'
// Check by filename if content detection fails
if (filename) {
const lower = filename.toLowerCase()
if (lower.endsWith('.pub')) return 'Public Key'
if (lower.endsWith('.sig') || lower.endsWith('.sign')) return 'Detached Signature'
if (lower.endsWith('.asc')) return 'ASCII Armored Key/Signature'
if (lower.endsWith('.pgp') || lower.endsWith('.gpg')) return 'PGP Key/Message'
if (lower.endsWith('.pem')) return 'PEM Encoded Key/Certificate'
if (lower.endsWith('.crt') || lower.endsWith('.cer')) return 'Certificate'
}
return null
}
/**
* Extracts cryptographic keys from content
* @param {string|Buffer} content
* @returns {Array<{type: string, content: string}>}
*/
extractKeys(content) {
if (!content) return []
const contentStr = content.toString('utf8')
const keys = []
this.keyPatterns.forEach(pattern => {
const matches = contentStr.match(pattern)
if (matches) {
matches.forEach(match => {
// Determine key type from the match
let type = 'Cryptographic Key'
if (match.includes('PGP PUBLIC KEY')) type = 'PGP Public Key'
else if (match.includes('PGP PRIVATE KEY')) type = 'PGP Private Key'
else if (match.includes('PGP MESSAGE')) type = 'PGP Message'
else if (match.includes('PGP SIGNATURE')) type = 'PGP Signature'
else if (match.includes('ssh-')) type = 'SSH Public Key'
else if (match.includes('CERTIFICATE')) type = 'Certificate'
else if (match.includes('PUBLIC KEY')) type = 'Public Key'
else if (match.includes('PRIVATE KEY')) type = 'Private Key'
keys.push({
type,
content: match
})
})
}
})
return keys
}
/**
* Processes email attachments to detect and extract cryptographic content
* @param {Array} attachments - Array of email attachments
* @returns {Array<{filename: string, type: string, content: string, preview: string}>}
*/
detectCryptoAttachments(attachments) {
if (!attachments || !Array.isArray(attachments)) {
return []
}
const cryptoFiles = []
attachments.forEach(attachment => {
// Check if it's a potential crypto file
if (this.isCryptoFilename(attachment.filename)) {
const keyType = this.detectKeyType(attachment.content, attachment.filename)
if (keyType) {
// Extract actual keys from content
const extractedKeys = this.extractKeys(attachment.content)
if (extractedKeys.length > 0) {
extractedKeys.forEach(key => {
cryptoFiles.push({
filename: attachment.filename,
type: key.type,
content: key.content,
preview: this._generatePreview(key.content, key.type),
info: this._extractKeyInfo(key.content, key.type)
})
})
} else {
// File has crypto extension/name but no extractable key blocks
// Still show it as it might be binary encoded
const contentStr = attachment.content.toString('utf8', 0, Math.min(attachment.content.length, 500))
cryptoFiles.push({
filename: attachment.filename,
type: keyType,
content: contentStr + (attachment.content.length > 500 ? '\n...[truncated]' : ''),
preview: this._generatePreview(contentStr, keyType),
info: this._extractKeyInfo(contentStr, keyType)
})
}
}
}
})
debug(`Detected ${cryptoFiles.length} cryptographic files in attachments`)
return cryptoFiles
}
/**
* Extract specific information from the key content
* @param {string} content
* @param {string} type
* @returns {string}
* @private
*/
_extractKeyInfo(content, type) {
if (!content) return ''
// For SSH keys, extract the key comment/user
if (type.includes('SSH')) {
const sshMatch = content.match(/ssh-\S+\s+\S+\s+(.+?)[\r\n]/)
if (sshMatch && sshMatch[1] && sshMatch[1].trim()) {
return sshMatch[1].trim()
}
// Show algorithm if available
const algoMatch = content.match(/ssh-(rsa|dss|ed25519|ecdsa-sha2-nistp(\d+))/)
if (algoMatch) {
return `${algoMatch[1].toUpperCase()}`
}
}
// For PGP keys and signatures, extract user info
if (type.includes('PGP')) {
// For signatures, try to extract key ID from the signature packet
if (type.includes('Signature')) {
try {
// Extract base64 content
const lines = content.split('\n')
let base64Content = ''
let inSig = false
for (const line of lines) {
if (line.includes('BEGIN PGP')) {
inSig = true
continue
}
if (line.includes('END PGP')) {
break
}
if (inSig && line.trim() && !line.startsWith('=')) {
base64Content += line.trim()
}
}
if (base64Content) {
const decoded = Buffer.from(base64Content, 'base64')
// Try to find key ID in signature packet
// OpenPGP signature packets typically have key ID at specific offsets
// Look for 8-byte key ID patterns
for (let i = 0; i < decoded.length - 8; i++) {
// Check if this looks like a key ID section
// Key IDs are often preceded by specific packet headers
if (decoded[i] === 0x00 && i + 8 < decoded.length) {
const keyIdBytes = decoded.slice(i + 1, i + 9)
const keyId = keyIdBytes.toString('hex').toUpperCase()
// Validate it looks like a reasonable key ID (not all zeros, not all FFs)
if (keyId.match(/^[0-9A-F]{16}$/) &&
keyId !== '0000000000000000' &&
keyId !== 'FFFFFFFFFFFFFFFF') {
return `Key ID: ${keyId.slice(-16)}`
}
}
}
// Alternative: look for the issuer key ID in a more reliable way
// The key ID is usually in the last 8 bytes before certain markers
if (decoded.length > 20) {
// Try to extract from common positions
const possibleKeyId = decoded.slice(decoded.length - 20, decoded.length - 12).toString('hex').toUpperCase()
if (possibleKeyId.match(/^[0-9A-F]{16}$/)) {
return `Key ID: ${possibleKeyId}`
}
}
}
} catch (err) {
debug(`Error extracting signature key ID: ${err.message}`)
}
return 'PGP detached signature'
}
// For keys, extract user info
try {
// Extract base64 content between BEGIN and END lines
const lines = content.split('\n')
let base64Content = ''
let inKey = false
for (const line of lines) {
if (line.includes('BEGIN PGP')) {
inKey = true
continue
}
if (line.includes('END PGP')) {
break
}
if (inKey && line.trim() && !line.startsWith('=')) {
base64Content += line.trim()
}
}
if (base64Content) {
// Decode base64 to binary buffer
const decoded = Buffer.from(base64Content, 'base64')
// Extract printable ASCII strings from the buffer
let printableStr = ''
for (let i = 0; i < decoded.length; i++) {
const byte = decoded[i]
// Keep printable ASCII characters
if (byte >= 0x20 && byte <= 0x7E) {
printableStr += String.fromCharCode(byte)
} else {
// Add separator for non-printable bytes
if (printableStr.length > 0 && !printableStr.endsWith('|')) {
printableStr += '|'
}
}
}
debug(`Extracted printable from PGP: ${printableStr.substring(0, 200)}`)
// Look for email with optional name before it
const emailPattern = /([A-Za-z][A-Za-z\s]{0,40}?)<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/
const emailMatch = printableStr.match(emailPattern)
if (emailMatch) {
const name = emailMatch[1].replace(/\|/g, '').trim()
const email = emailMatch[2]
debug(`Found PGP user: ${name} <${email}>`)
if (name.length > 0) {
return `${name} <${email}>`
}
return email
}
// Just look for bare email
const bareEmailMatch = printableStr.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/)
if (bareEmailMatch) {
debug(`Found PGP email: ${bareEmailMatch[1]}`)
return bareEmailMatch[1]
}
}
} catch (err) {
debug(`Error extracting PGP info: ${err.message}`)
}
return ''
}
// For detached signatures, show signature type
if (type.includes('Signature')) {
if (type.includes('PKCS')) {
return 'PKCS#7/CMS signature'
}
return 'Detached signature'
}
// For certificates, extract subject Common Name or issuer
if (type.includes('Certificate')) {
const cnPatterns = [
/CN\s*=\s*([^,\n/]+)/,
/commonName\s*=\s*([^,\n/]+)/i,
/Subject:.*?CN\s*=\s*([^,\n/]+)/
]
for (const pattern of cnPatterns) {
const match = content.match(pattern)
if (match && match[1]) {
return match[1].trim()
}
}
}
// For private keys, check encryption
if (type.includes('Private')) {
if (content.includes('ENCRYPTED') || content.includes('Proc-Type: 4,ENCRYPTED')) {
return 'Encrypted'
}
}
return ''
}
/**
* Generates a preview/fingerprint for the key
* @param {string} content
* @param {string} type
* @returns {string}
* @private
*/
_generatePreview(content, type) {
if (!content) return ''
// For SSH keys, extract the key comment if available
if (type.includes('SSH')) {
const sshMatch = content.match(/ssh-\S+\s+\S+\s+(.+)/)
if (sshMatch && sshMatch[1]) {
return `Comment: ${sshMatch[1].trim()}`
}
}
// For PGP keys, try to extract key ID or user info
if (type.includes('PGP')) {
// This is a simplified preview - proper parsing would require OpenPGP library
const lines = content.split('\n')
const infoLines = lines.filter(line =>
line.includes('User-ID') ||
line.includes('Key-ID') ||
line.includes('Fingerprint')
)
if (infoLines.length > 0) {
return infoLines.slice(0, 2).join(', ')
}
}
// For certificates, try to extract subject/issuer
if (type.includes('Certificate')) {
const subjectMatch = content.match(/Subject:.*?CN=([^,\n]+)/)
const issuerMatch = content.match(/Issuer:.*?CN=([^,\n]+)/)
const parts = []
if (subjectMatch) parts.push(`Subject: ${subjectMatch[1]}`)
if (issuerMatch) parts.push(`Issuer: ${issuerMatch[1]}`)
if (parts.length > 0) return parts.join(', ')
}
// Generic preview: show first and last few characters
const preview = content.replace(/[\r\n]+/g, ' ').slice(0, 100)
return preview.length < content.length ? preview + '...' : preview
}
}
module.exports = CryptoDetector

View file

@ -144,6 +144,20 @@ class MailProcessingService extends EventEmitter {
onNewMail(mail) { onNewMail(mail) {
debug('onNewMail called for:', mail.to) debug('onNewMail called for:', mail.to)
// Check if sender is blacklisted
const senderAddress = mail.from && mail.from[0] && mail.from[0].address
if (senderAddress && this.config.email.blacklistedSenders.length > 0) {
const isBlacklisted = this.config.email.blacklistedSenders.some(blocked =>
blocked.toLowerCase() === senderAddress.toLowerCase()
)
if (isBlacklisted) {
debug(`Blacklisted sender detected: ${senderAddress}, deleting UID ${mail.uid}`)
this.imapService.deleteSpecificEmail(mail.uid)
return
}
}
if (this.initialLoadDone) { if (this.initialLoadDone) {
// For now, only log messages if they arrive after the initial load // For now, only log messages if they arrive after the initial load
debug('New mail for', mail.to[0]) debug('New mail for', mail.to[0])

View file

@ -347,6 +347,20 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
function initCryptoKeysToggle() {
const cryptoHeader = document.getElementById('cryptoHeader');
const cryptoContent = document.getElementById('cryptoContent');
const cryptoToggle = cryptoHeader ? cryptoHeader.querySelector('.crypto-toggle') : null;
if (cryptoHeader && cryptoContent && cryptoToggle) {
cryptoHeader.addEventListener('click', () => {
const isCurrentlyHidden = cryptoContent.style.display === 'none';
cryptoContent.style.display = isCurrentlyHidden ? 'block' : 'none';
cryptoToggle.setAttribute('aria-expanded', isCurrentlyHidden ? 'true' : 'false');
});
}
}
function initRefreshCountdown(refreshInterval) { function initRefreshCountdown(refreshInterval) {
const refreshTimer = document.getElementById('refreshTimer'); const refreshTimer = document.getElementById('refreshTimer');
if (!refreshTimer || !refreshInterval) return; if (!refreshTimer || !refreshInterval) return;
@ -363,7 +377,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// Expose utilities and run them // Expose utilities and run them
window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown }; window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle };
formatEmailDates(); formatEmailDates();
formatMailDate(); formatMailDate();
initLockModals(); initLockModals();

View file

@ -701,6 +701,176 @@ label {
font-size: 1.4rem; font-size: 1.4rem;
} }
/* Cryptographic Keys Section */
.mail-crypto-keys {
border: 2px solid var(--overlay-purple-30);
border-radius: 15px;
background: var(--overlay-white-03);
padding: 0;
margin-bottom: 30px;
overflow: hidden;
}
.crypto-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 25px;
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
}
.crypto-header:hover {
background: var(--overlay-purple-05);
}
.crypto-header h4 {
color: var(--color-accent-purple);
margin: 0;
font-size: 1.4rem;
display: flex;
align-items: center;
gap: 10px;
}
.crypto-toggle {
background: none;
border: none;
padding: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.crypto-toggle:hover {
transform: scale(1.1);
}
.crypto-icon {
width: 24px;
height: 24px;
stroke: var(--color-accent-purple);
}
.crypto-icon-collapsed {
display: block;
}
.crypto-icon-expanded {
display: none;
}
.crypto-toggle[aria-expanded="true"] .crypto-icon-collapsed {
display: none;
}
.crypto-toggle[aria-expanded="true"] .crypto-icon-expanded {
display: block;
}
.crypto-content {
padding: 0 25px 25px 25px;
}
.crypto-item {
background: var(--overlay-white-02);
border: 1px solid var(--overlay-purple-20);
border-radius: 10px;
padding: 20px;
margin-bottom: 15px;
}
.crypto-item:last-child {
margin-bottom: 0;
}
.crypto-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
gap: 10px;
}
.crypto-type {
display: inline-block;
background: var(--color-accent-purple);
color: var(--color-background);
padding: 5px 12px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
}
.crypto-filename {
color: var(--color-text-dim);
font-size: 0.95rem;
font-family: monospace;
word-break: break-word;
}
.crypto-preview {
color: var(--color-text-light);
font-size: 0.9rem;
font-style: italic;
margin-bottom: 10px;
padding: 8px 12px;
background: var(--overlay-white-03);
border-left: 3px solid var(--color-accent-purple-light);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.crypto-preview:hover {
background: var(--overlay-purple-10);
border-left-color: var(--color-accent-purple);
}
.crypto-key-content {
background: var(--color-background);
border: 1px solid var(--overlay-white-10);
border-radius: 8px;
padding: 15px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.85rem;
color: var(--color-text-light);
overflow-x: auto;
white-space: pre;
line-height: 1.4;
margin: 0;
}
.crypto-key-content::-webkit-scrollbar {
height: 8px;
}
.crypto-key-content::-webkit-scrollbar-track {
background: var(--overlay-white-05);
border-radius: 4px;
}
.crypto-key-content::-webkit-scrollbar-thumb {
background: var(--overlay-purple-30);
border-radius: 4px;
}
.crypto-key-content::-webkit-scrollbar-thumb:hover {
background: var(--color-accent-purple);
}
.mail-attachments h4 {
color: var(--color-accent-purple);
margin: 0 0 20px 0;
font-size: 1.4rem;
}
.attachments-list { .attachments-list {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

@ -5,7 +5,9 @@ const debug = require('debug')('48hr-email:routes')
const config = require('../../../application/config') const config = require('../../../application/config')
const Helper = require('../../../application/helper') const Helper = require('../../../application/helper')
const CryptoDetector = require('../../../application/crypto-detector')
const helper = new(Helper) const helper = new(Helper)
const cryptoDetector = new CryptoDetector()
const { checkLockAccess } = require('../middleware/lock') const { checkLockAccess } = require('../middleware/lock')
const purgeTime = helper.purgeTimeElemetBuilder() const purgeTime = helper.purgeTimeElemetBuilder()
@ -112,6 +114,10 @@ router.get(
// Emails are immutable, cache if found // Emails are immutable, cache if found
res.set('Cache-Control', 'private, max-age=600') res.set('Cache-Control', 'private, max-age=600')
// Detect cryptographic keys in attachments
const cryptoAttachments = cryptoDetector.detectCryptoAttachments(mail.attachments)
debug(`Found ${cryptoAttachments.length} cryptographic attachments`)
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const isLocked = inboxLock && inboxLock.isLocked(req.params.address) const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
const hasAccess = req.session && req.session.lockedInbox === req.params.address const hasAccess = req.session && req.session.lockedInbox === req.params.address
@ -124,6 +130,7 @@ router.get(
count: count, count: count,
totalcount: totalcount, totalcount: totalcount,
mail, mail,
cryptoAttachments: cryptoAttachments,
uid: req.params.uid, uid: req.params.uid,
branding: config.http.branding, branding: config.http.branding,
lockEnabled: config.lock.enabled, lockEnabled: config.lock.enabled,
@ -154,10 +161,17 @@ router.get(
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
debug(`Deleting all emails for ${req.params.address}`) debug(`Deleting all emails for ${req.params.address}`)
const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address) const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address)
for (mail in mailSummaries) { // Create a copy of the array to avoid modification during iteration
await mailProcessingService.deleteSpecificEmail(req.params.address, mailSummaries[mail].uid) const summariesToDelete = [...mailSummaries]
let deletedCount = 0
for (const mail of summariesToDelete) {
await mailProcessingService.deleteSpecificEmail(req.params.address, mail.uid)
deletedCount++
debug(`Successfully deleted UID ${mail.uid}`)
} }
debug(`Deleted all emails for ${req.params.address}`)
debug(`Deleted all ${deletedCount} emails for ${req.params.address}`)
res.redirect(`/inbox/${req.params.address}`) res.redirect(`/inbox/${req.params.address}`)
} catch (error) { } catch (error) {
debug(`Error deleting all emails for ${req.params.address}:`, error.message) debug(`Error deleting all emails for ${req.params.address}:`, error.message)

View file

@ -71,7 +71,7 @@
There are no mails yet. There are no mails yet.
</blockquote> </blockquote>
{% endif %} {% endif %}
<div class="refresh-countdown" id="refreshCountdown">Auto-refresh in <span id="refreshTimer">--</span>s</div> <div class="refresh-countdown" id="refreshCountdown" title="New emails are fetched from the server only when the timer hits zero. Reloading this page has no effect on fetching.">Fetching new mails in <span id="refreshTimer">--</span>s</div>
{% if lockEnabled and not isLocked %} {% if lockEnabled and not isLocked %}
<!-- Lock Modal --> <!-- Lock Modal -->
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}"> <div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">

View file

@ -47,6 +47,33 @@
{% endif %} {% endif %}
</div> </div>
{% if cryptoAttachments and cryptoAttachments|length > 0 %}
<div class="mail-crypto-keys">
<div class="crypto-header" id="cryptoHeader">
<h4>Cryptographic Keys & Signatures ({{ cryptoAttachments|length }})</h4>
<button class="crypto-toggle" aria-label="Toggle cryptographic keys visibility" aria-expanded="false">
<svg class="crypto-icon crypto-icon-collapsed" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<svg class="crypto-icon crypto-icon-expanded" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
</div>
<div class="crypto-content" id="cryptoContent" style="display: none;">
{% for crypto in cryptoAttachments %}
<div class="crypto-item">
<div class="crypto-item-header">
<span class="crypto-type">{{ crypto.type }}</span>
<span class="crypto-filename">{{ crypto.filename }}{% if crypto.info %} · {{ crypto.info }}{% endif %}</span>
</div>
<pre class="crypto-key-content">{{ crypto.content }}</pre>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if mail.attachments %} {% if mail.attachments %}
<div class="mail-attachments"> <div class="mail-attachments">
<h4>Attachments</h4> <h4>Attachments</h4>
@ -62,3 +89,15 @@
</div> </div>
{% endblock %} {% endblock %}
{% block footer %}
{{ parent() }}
<script>
// Initialize crypto keys toggle
document.addEventListener('DOMContentLoaded', () => {
if (window.utils && typeof window.utils.initCryptoKeysToggle === 'function') {
window.utils.initCryptoKeysToggle();
}
});
</script>
{% endblock %}

View file

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