mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-09 11:19:36 +01:00
This is what makes a project a "Clara project"... going the extra mile for customizability <3
286 lines
9.6 KiB
JavaScript
286 lines
9.6 KiB
JavaScript
const config = require('./config')
|
|
const debug = require('debug')('48hr-email:helper')
|
|
const crypto = require('crypto')
|
|
|
|
class Helper {
|
|
|
|
/**
|
|
* Normalize our config into a proper timestamp, so we know what emails to purge
|
|
* @returns {Date}
|
|
*/
|
|
purgeTimeStamp() {
|
|
// Calculate cutoff time using native Date
|
|
const now = new Date()
|
|
let cutoffMs = now.getTime()
|
|
|
|
// Subtract the purge time based on unit
|
|
const time = config.email.purgeTime.time
|
|
const unit = config.email.purgeTime.unit
|
|
|
|
switch (unit) {
|
|
case 'minutes':
|
|
cutoffMs -= time * 60 * 1000
|
|
break
|
|
case 'hours':
|
|
cutoffMs -= time * 60 * 60 * 1000
|
|
break
|
|
case 'days':
|
|
cutoffMs -= time * 24 * 60 * 60 * 1000
|
|
break
|
|
default:
|
|
throw new Error(`Unknown time unit: ${unit}`)
|
|
}
|
|
|
|
const cutoff = new Date(cutoffMs)
|
|
debug(`Purge cutoff calculated: ${cutoff} (${time} ${unit} ago)`)
|
|
return cutoff
|
|
}
|
|
|
|
/**
|
|
* Check if time difference between now and purgeTimeStamp is more than one day
|
|
* @param {number|Date} now
|
|
* @param {Date} past
|
|
* @returns {Boolean}
|
|
*/
|
|
moreThanOneDay(now, past) {
|
|
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const nowMs = now instanceof Date ? now.getTime() : now;
|
|
const pastMs = past instanceof Date ? past.getTime() : new Date(past).getTime();
|
|
|
|
const diffMs = nowMs - pastMs;
|
|
const result = diffMs >= DAY_IN_MS;
|
|
debug(`Time difference check: ${diffMs}ms >= ${DAY_IN_MS}ms = ${result}`)
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Convert time to highest possible unit (minutes → hours → days),
|
|
* rounding if necessary and prefixing "~" when rounded.
|
|
*
|
|
* @param {number} time
|
|
* @param {string} unit "minutes" | "hours" | "days"
|
|
* @returns {string}
|
|
*/
|
|
convertAndRound(time, unit) {
|
|
let value = time;
|
|
let u = unit;
|
|
|
|
// upgrade units
|
|
const units = [
|
|
["minutes", 60, "hours"],
|
|
["hours", 24, "days"]
|
|
];
|
|
|
|
for (const [from, factor, to] of units) {
|
|
if (u === from && value > factor) {
|
|
value = value / factor;
|
|
u = to;
|
|
}
|
|
}
|
|
|
|
// determine if rounding is needed
|
|
const rounded = !Number.isSafeInteger(value);
|
|
if (rounded) value = Math.round(value);
|
|
|
|
return `${rounded ? "~" : ""}${value} ${u}`;
|
|
}
|
|
|
|
/**
|
|
* Build a purgeTime html element for the page to keep the clutter outside of the twig template
|
|
* @returns {String}
|
|
*/
|
|
purgeTimeElemetBuilder() {
|
|
let time = `${config.email.purgeTime.time} ${config.email.purgeTime.unit}`
|
|
let Tooltip = ''
|
|
if (config.email.purgeTime.convert) {
|
|
time = this.convertAndRound(config.email.purgeTime.time, config.email.purgeTime.unit)
|
|
if (time !== `${config.email.purgeTime.time} ${config.email.purgeTime.unit}`) {
|
|
Tooltip = `Config: ${config.email.purgeTime.time} ${config.email.purgeTime.unit}`
|
|
}
|
|
}
|
|
|
|
const footer = `<label title="${Tooltip}">
|
|
<h4 style="display: inline;"><u><i>${time}</i></u></h4>
|
|
</Label>`
|
|
return footer
|
|
}
|
|
|
|
/**
|
|
* Build a mail count html element with tooltip for the footer
|
|
* @param {number} count - Current mail count
|
|
* @param {number} largestUid - Largest UID from IMAP
|
|
* @returns {String}
|
|
*/
|
|
mailCountBuilder(count, largestUid = null) {
|
|
let tooltip = ''
|
|
|
|
if (largestUid && largestUid > 0) {
|
|
tooltip = `All-time total: ${largestUid} emails`
|
|
}
|
|
|
|
return `<label title="${tooltip}">
|
|
<h4 style="display: inline;"><u><i>${count} mails</i></u></h4>
|
|
</label>`
|
|
}
|
|
|
|
/**
|
|
* Shuffle an array using the Durstenfeld shuffle algorithm
|
|
* @param {Array} array
|
|
* @returns {Array}
|
|
*/
|
|
shuffleArray(array) {
|
|
for (let i = array.length - 1; i >= 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
return array
|
|
}
|
|
|
|
/**
|
|
* Shuffle first item of array, keeping original order afterwards
|
|
* @param {Array} array
|
|
* @returns {Array}
|
|
*/
|
|
shuffleFirstItem(array) {
|
|
let first = array[Math.floor(Math.random() * array.length)]
|
|
array = array.filter((value) => value != first);
|
|
array = [first].concat(array)
|
|
return array
|
|
}
|
|
|
|
/**
|
|
* Hide other emails in the list and only show first (true) or show all (false)
|
|
* @param {Array} array
|
|
* @returns {Array}
|
|
*/
|
|
hideOther(array) {
|
|
if (config.http.features.hideOther) {
|
|
return array && array.length > 0 ? [array[0]] : []
|
|
} else {
|
|
return array || []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a domain list from config for use
|
|
* @returns {Array}
|
|
*/
|
|
getDomains() {
|
|
debug(`Getting domains with displaySort: ${config.http.features.displaySort}`)
|
|
if (!config.email.domains || !Array.isArray(config.email.domains) || !config.email.domains.length) {
|
|
debug('ERROR: config.email.domains is not a valid array')
|
|
return []
|
|
}
|
|
let result;
|
|
switch (config.http.features.displaySort) {
|
|
case 0:
|
|
result = this.hideOther(config.email.domains) // No modification
|
|
debug(`Domain sort 0: no modification, ${result.length} domains`)
|
|
return result
|
|
case 1:
|
|
result = this.hideOther(config.email.domains.sort()) // Sort alphabetically
|
|
debug(`Domain sort 1: alphabetical sort, ${result.length} domains`)
|
|
return result
|
|
case 2:
|
|
result = this.hideOther(this.shuffleFirstItem(config.email.domains.sort())) // Sort alphabetically and shuffle first item
|
|
debug(`Domain sort 2: alphabetical + shuffle first, ${result.length} domains`)
|
|
return result
|
|
case 3:
|
|
result = this.hideOther(this.shuffleArray(config.email.domains)) // Shuffle all
|
|
debug(`Domain sort 3: shuffle all, ${result.length} domains`)
|
|
return result
|
|
default:
|
|
debug(`Unknown displaySort value: ${config.http.features.displaySort}, using case 0`)
|
|
return this.hideOther(config.email.domains)
|
|
}
|
|
}
|
|
|
|
async getLargestUid(imapService) {
|
|
const uid = await imapService.getLargestUid();
|
|
return uid || 0;
|
|
}
|
|
|
|
/**
|
|
* Generate a cryptographically secure random verification token
|
|
* @returns {string} - 32-byte hex token (64 characters)
|
|
*/
|
|
generateVerificationToken() {
|
|
const token = crypto.randomBytes(32).toString('hex')
|
|
debug('Generated verification token')
|
|
return token
|
|
}
|
|
|
|
/**
|
|
* Sign an email address for use in a cookie
|
|
* Uses HMAC-SHA256 with the session secret
|
|
* @param {string} email - Email address to sign
|
|
* @returns {string} - HMAC signature (hex)
|
|
*/
|
|
signCookie(email) {
|
|
const secret = config.http.sessionSecret
|
|
const hmac = crypto.createHmac('sha256', secret)
|
|
hmac.update(email.toLowerCase())
|
|
const signature = hmac.digest('hex')
|
|
debug(`Signed cookie for email: ${email}`)
|
|
return signature
|
|
}
|
|
|
|
/**
|
|
* Verify a cookie signature for an email address
|
|
* @param {string} email - Email address to verify
|
|
* @param {string} signature - HMAC signature to verify
|
|
* @returns {boolean} - True if signature is valid
|
|
*/
|
|
verifyCookieSignature(email, signature) {
|
|
if (!email || !signature) {
|
|
return false
|
|
}
|
|
|
|
const expectedSignature = this.signCookie(email)
|
|
|
|
// Use timing-safe comparison to prevent timing attacks
|
|
try {
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(signature, 'hex'),
|
|
Buffer.from(expectedSignature, 'hex')
|
|
)
|
|
} catch (error) {
|
|
// timingSafeEqual throws if buffers are different lengths
|
|
debug(`Cookie signature verification failed: ${error.message}`)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrate legacy database files for backwards compatibility
|
|
* - Renames users.db to data.db if it exists
|
|
* - Logs if locked-inboxes.db exists (no longer needed)
|
|
* @param {string} dbPath - Path to the current database (data.db)
|
|
*/
|
|
static migrateDatabase(dbPath) {
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
|
|
const dbDir = path.dirname(dbPath)
|
|
const legacyUsersDb = path.join(dbDir, 'users.db')
|
|
const legacyLockedInboxesDb = path.join(dbDir, 'locked-inboxes.db')
|
|
|
|
// Migrate users.db to data.db
|
|
if (fs.existsSync(legacyUsersDb) && !fs.existsSync(dbPath)) {
|
|
console.log(`Migrating ${legacyUsersDb} → ${dbPath}`)
|
|
fs.renameSync(legacyUsersDb, dbPath)
|
|
debug(`Database migrated: users.db → data.db`)
|
|
}
|
|
|
|
// Warn about old locked-inboxes.db
|
|
if (fs.existsSync(legacyLockedInboxesDb)) {
|
|
console.log(`WARNING: Found legacy ${legacyLockedInboxesDb}`)
|
|
console.log(` This database is no longer used. Locks are now stored in ${path.basename(dbPath)}.`)
|
|
console.log(` You can safely delete ${legacyLockedInboxesDb} after verifying your locks are working.`)
|
|
debug('Legacy locked-inboxes.db detected but not migrated (data already in user_locked_inboxes table)')
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = Helper
|