diff --git a/domain/statistics-store.js b/domain/statistics-store.js index d0166c9..b7115f2 100644 --- a/domain/statistics-store.js +++ b/domain/statistics-store.js @@ -1,5 +1,6 @@ const debug = require('debug')('48hr-email:stats-store'); const config = require('../application/config'); +const crypto = require('crypto'); /** * Statistics Store - Tracks email metrics and historical data @@ -20,12 +21,57 @@ class StatisticsStore { this.enhancedStats = null; this.lastEnhancedStatsTime = 0; this.enhancedStatsCacheDuration = 5 * 60 * 1000; // Cache for 5 minutes + + // Compute IMAP hash (user/server/port) + this.imapHash = this._computeImapHash(); + if (this.db) { + this._autoMigrateInstanceId(); + this._autoMigrateImapHash(); this._loadFromDatabase(); } debug('Statistics store initialized'); } + _autoMigrateInstanceId() { + // Add and backfill instance_id for statistics table + try { + const pragma = this.db.prepare("PRAGMA table_info(statistics)").all(); + const hasInstanceId = pragma.some(col => col.name === 'instance_id'); + if (!hasInstanceId) { + this.db.prepare('ALTER TABLE statistics ADD COLUMN instance_id TEXT').run(); + } + // Backfill all rows + this.db.prepare('UPDATE statistics SET instance_id = ? WHERE instance_id IS NULL OR instance_id = ""').run(this.imapHash); + debug('Auto-migrated: instance_id column added and backfilled'); + } catch (e) { + debug('Auto-migration for instance_id failed:', e.message); + } + } + + _autoMigrateImapHash() { + // Check if imap_hash column exists, add and backfill if missing + try { + const pragma = this.db.prepare("PRAGMA table_info(statistics)").all(); + const hasImapHash = pragma.some(col => col.name === 'imap_hash'); + if (!hasImapHash) { + this.db.prepare('ALTER TABLE statistics ADD COLUMN imap_hash TEXT NULL').run(); + this.db.prepare('UPDATE statistics SET imap_hash = ?').run(this.imapHash); + debug('Auto-migrated: imap_hash column added and backfilled'); + } + } catch (e) { + debug('Auto-migration for imap_hash failed:', e.message); + } + } + + _computeImapHash() { + const user = config.imap.user || ''; + const server = config.imap.server || ''; + const port = config.imap.port || ''; + const hash = crypto.createHash('sha256').update(`${user}:${server}:${port}`).digest('hex'); + return hash; + } + _getPurgeCutoffMs() { const time = config.email.purgeTime.time; const unit = config.email.purgeTime.unit; @@ -48,8 +94,9 @@ class StatisticsStore { _loadFromDatabase() { try { - const stmt = this.db.prepare('SELECT largest_uid, hourly_data, last_updated FROM statistics WHERE id = 1'); - const row = stmt.get(); + // Try to load row for current imap_hash + const stmt = this.db.prepare('SELECT largest_uid, hourly_data, last_updated FROM statistics WHERE imap_hash = ?'); + const row = stmt.get(this.imapHash); if (row) { this.largestUid = row.largest_uid || 0; if (row.hourly_data) { @@ -64,6 +111,13 @@ class StatisticsStore { } } debug(`Loaded from database: largestUid=${this.largestUid}, hourlyData=${this.hourlyData.length} entries`); + } else { + // No row for this hash, insert new row + const insert = this.db.prepare('INSERT INTO statistics (imap_hash, largest_uid, hourly_data, last_updated) VALUES (?, ?, ?, ?)'); + insert.run(this.imapHash, 0, JSON.stringify([]), Date.now()); + this.largestUid = 0; + this.hourlyData = []; + debug('Created new statistics row for imap_hash'); } } catch (error) { debug('Failed to load statistics from database:', error.message); @@ -76,10 +130,17 @@ class StatisticsStore { const stmt = this.db.prepare(` UPDATE statistics SET largest_uid = ?, hourly_data = ?, last_updated = ? - WHERE id = 1 + WHERE imap_hash = ? `); - stmt.run(this.largestUid, JSON.stringify(this.hourlyData), Date.now()); - debug('Statistics saved to database'); + const result = stmt.run(this.largestUid, JSON.stringify(this.hourlyData), Date.now(), this.imapHash); + // If no row was updated, insert new row + if (result.changes === 0) { + const insert = this.db.prepare('INSERT INTO statistics (imap_hash, largest_uid, hourly_data, last_updated) VALUES (?, ?, ?, ?)'); + insert.run(this.imapHash, this.largestUid, JSON.stringify(this.hourlyData), Date.now()); + debug('Inserted new statistics row for imap_hash'); + } else { + debug('Statistics saved to database'); + } } catch (error) { debug('Failed to save statistics to database:', error.message); } @@ -891,4 +952,4 @@ class StatisticsStore { } } -module.exports = StatisticsStore \ No newline at end of file +module.exports = StatisticsStore diff --git a/schema.sql b/schema.sql index 2e6ba3a..7a7b94c 100644 --- a/schema.sql +++ b/schema.sql @@ -4,11 +4,13 @@ -- Users table CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL COLLATE NOCASE, + instance_id TEXT NOT NULL, + username TEXT NOT NULL COLLATE NOCASE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL, last_login INTEGER, - CHECK (length(username) >= 3 AND length(username) <= 20) + CHECK (length(username) >= 3 AND length(username) <= 20), + UNIQUE(instance_id, username) ); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); @@ -17,12 +19,13 @@ CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); -- User verified forwarding emails CREATE TABLE IF NOT EXISTS user_forward_emails ( id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id TEXT NOT NULL, user_id INTEGER NOT NULL, email TEXT NOT NULL COLLATE NOCASE, verified_at INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, email) + UNIQUE(instance_id, user_id, email) ); CREATE INDEX IF NOT EXISTS idx_forward_emails_user_id ON user_forward_emails(user_id); @@ -31,13 +34,14 @@ CREATE INDEX IF NOT EXISTS idx_forward_emails_email ON user_forward_emails(email -- User locked inboxes CREATE TABLE IF NOT EXISTS user_locked_inboxes ( id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id TEXT NOT NULL, user_id INTEGER NOT NULL, inbox_address TEXT NOT NULL COLLATE NOCASE, password_hash TEXT NOT NULL, locked_at INTEGER NOT NULL, last_accessed INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, inbox_address) + UNIQUE(instance_id, user_id, inbox_address) ); CREATE INDEX IF NOT EXISTS idx_locked_inboxes_user_id ON user_locked_inboxes(user_id); @@ -47,11 +51,13 @@ CREATE INDEX IF NOT EXISTS idx_locked_inboxes_last_accessed ON user_locked_inbox -- API tokens (one per user for programmatic access) CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL UNIQUE, + instance_id TEXT NOT NULL, + user_id INTEGER NOT NULL, token TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, last_used INTEGER, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(instance_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_api_tokens_token ON api_tokens(token); @@ -59,16 +65,14 @@ CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); -- Statistics storage for persistence across restarts CREATE TABLE IF NOT EXISTS statistics ( - id INTEGER PRIMARY KEY CHECK (id = 1), -- Single row table + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id TEXT NOT NULL, largest_uid INTEGER NOT NULL DEFAULT 0, hourly_data TEXT, -- JSON array of 24h rolling data - last_updated INTEGER NOT NULL + last_updated INTEGER NOT NULL, + imap_hash TEXT NULL ); --- Initialize with default row if not exists -INSERT OR IGNORE INTO statistics (id, largest_uid, hourly_data, last_updated) -VALUES (1, 0, '[]', 0); - -- Trigger to enforce max 5 locked inboxes per user CREATE TRIGGER IF NOT EXISTS check_locked_inbox_limit BEFORE INSERT ON user_locked_inboxes