Compare commits
7 commits
72c22f9815
...
cdce7e1e46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdce7e1e46 | ||
|
|
49e4e6eaf9 | ||
|
|
985c36920b | ||
|
|
8a80134ee9 | ||
|
|
e91fdeb827 | ||
|
|
68dda6880a | ||
|
|
fd993eb272 |
|
|
@ -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
|
||||||
|
|
|
||||||
BIN
.github/assets/html.png
vendored
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 224 KiB |
BIN
.github/assets/inbox.png
vendored
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 266 KiB |
BIN
.github/assets/keys.png
vendored
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
.github/assets/raw.png
vendored
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 265 KiB |
129
README.md
|
|
@ -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 >.<
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
@ -84,4 +85,4 @@ if (!config.email.domains.length) {
|
||||||
|
|
||||||
debug(`Configuration validated successfully: ${config.email.domains.length} domains, IMAP host: ${config.imap.host}`)
|
debug(`Configuration validated successfully: ${config.email.domains.length} domains, IMAP host: ${config.imap.host}`)
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|
|
||||||
411
application/crypto-detector.js
Normal 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
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -210,4 +224,4 @@ class MailProcessingService extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = MailProcessingService
|
module.exports = MailProcessingService
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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('') }}">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
@ -78,4 +78,4 @@
|
||||||
]
|
]
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||