|
|
@@ -36,11 +36,24 @@ const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_
|
|
|
const auditDescriptorWrapperKeyPattern = /^(change|context|descriptor|meta)$/i;
|
|
|
const auditValueLikeKeyPattern = /^(value|from|to|old|new|old_?value|new_?value|before|after)$/i;
|
|
|
const maxAccountTokenTtlMinutes = 7 * 24 * 60;
|
|
|
+const defaultWebmailTicketTtlSeconds = 60;
|
|
|
+const minWebmailTicketTtlSeconds = 10;
|
|
|
+const maxWebmailTicketTtlSeconds = 5 * 60;
|
|
|
+const defaultWebmailCredentialTtlSeconds = 12 * 60 * 60;
|
|
|
+const minWebmailCredentialTtlSeconds = 5 * 60;
|
|
|
+const maxWebmailCredentialTtlSeconds = 24 * 60 * 60;
|
|
|
+const webmailTicketRateWindowSeconds = 60;
|
|
|
+const maxWebmailTicketsPerRateWindow = 20;
|
|
|
+const maxActiveWebmailSessionsPerMailbox = 5;
|
|
|
+const webmailSessionCleanupIntervalMs = 60 * 1000;
|
|
|
+const webmailSessionRetentionSeconds = 60 * 60;
|
|
|
const defaultApiTokenScopes = ['send'];
|
|
|
const pendingLegacyMailboxPasswordUpgrades = new Map();
|
|
|
+let lastWebmailSessionCleanupAtMs = 0;
|
|
|
|
|
|
export function initDatabase(dataDir, secret = '') {
|
|
|
secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
|
|
|
+ lastWebmailSessionCleanupAtMs = 0;
|
|
|
const databasePath = path.join(dataDir, 'mailhub.sqlite');
|
|
|
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
|
chmodSync(dataDir, 0o700);
|
|
|
@@ -293,6 +306,23 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
|
);
|
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS webmail_sessions (
|
|
|
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
+ actor_user_id INTEGER NOT NULL,
|
|
|
+ mailbox_id INTEGER NOT NULL,
|
|
|
+ audience TEXT NOT NULL,
|
|
|
+ ticket_hash TEXT NOT NULL UNIQUE,
|
|
|
+ ticket_expires_at TEXT NOT NULL,
|
|
|
+ exchanged_at TEXT,
|
|
|
+ credential_hash TEXT UNIQUE,
|
|
|
+ credential_expires_at TEXT NOT NULL,
|
|
|
+ revoked_at TEXT,
|
|
|
+ last_used_at TEXT,
|
|
|
+ created_at TEXT NOT NULL,
|
|
|
+ FOREIGN KEY(actor_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
|
+ FOREIGN KEY(mailbox_id) REFERENCES inbound_mailboxes(id) ON DELETE CASCADE
|
|
|
+ );
|
|
|
+
|
|
|
CREATE TABLE IF NOT EXISTS dns_credentials (
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
user_id INTEGER NOT NULL,
|
|
|
@@ -355,6 +385,10 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_account_tokens_user_purpose ON account_tokens(user_id, purpose);
|
|
|
CREATE INDEX IF NOT EXISTS idx_account_tokens_expires_at ON account_tokens(expires_at);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_webmail_sessions_actor_mailbox ON webmail_sessions(actor_user_id, mailbox_id);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_webmail_sessions_actor_created ON webmail_sessions(actor_user_id, created_at);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_webmail_sessions_ticket_expiry ON webmail_sessions(ticket_expires_at);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_webmail_sessions_credential_expiry ON webmail_sessions(credential_expires_at);
|
|
|
CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_user_id ON audit_logs(actor_user_id);
|
|
|
@@ -1414,7 +1448,266 @@ export function getInboundMailboxForSender(userId, address, {
|
|
|
return hasInboundMailboxGrantPermission(mailbox.id, userId, 'send') ? mailbox : null;
|
|
|
}
|
|
|
|
|
|
+export function createWebmailLoginTicket(actorUserId, mailboxId, {
|
|
|
+ audience,
|
|
|
+ ticketTtlSeconds = defaultWebmailTicketTtlSeconds,
|
|
|
+ credentialTtlSeconds = defaultWebmailCredentialTtlSeconds
|
|
|
+} = {}) {
|
|
|
+ const actorId = normalizeWebmailEntityId(actorUserId, '用户不存在。');
|
|
|
+ const cleanMailboxId = normalizeWebmailEntityId(mailboxId, '收信邮箱不存在。');
|
|
|
+ const cleanAudience = normalizeWebmailAudience(audience);
|
|
|
+ const ticketTtl = normalizeWebmailTtlSeconds(
|
|
|
+ ticketTtlSeconds,
|
|
|
+ minWebmailTicketTtlSeconds,
|
|
|
+ maxWebmailTicketTtlSeconds,
|
|
|
+ 'Webmail 登录票据有效期不正确。'
|
|
|
+ );
|
|
|
+ const credentialTtl = normalizeWebmailTtlSeconds(
|
|
|
+ credentialTtlSeconds,
|
|
|
+ minWebmailCredentialTtlSeconds,
|
|
|
+ maxWebmailCredentialTtlSeconds,
|
|
|
+ 'Webmail 临时凭据有效期不正确。'
|
|
|
+ );
|
|
|
+ const createdAt = now();
|
|
|
+ const mailboxRow = webmailAuthorizedMailboxRow(actorId, cleanMailboxId, 'receive', createdAt);
|
|
|
+ if (!mailboxRow) throw webmailMailboxAccessDeniedError();
|
|
|
+
|
|
|
+ maybeCleanupWebmailSessions();
|
|
|
+ return withTransaction(() => {
|
|
|
+ const recentSince = new Date(
|
|
|
+ Date.parse(createdAt) - webmailTicketRateWindowSeconds * 1000
|
|
|
+ ).toISOString();
|
|
|
+ const recentCount = Number(requireDb()
|
|
|
+ .prepare(`
|
|
|
+ SELECT COUNT(*) AS count
|
|
|
+ FROM webmail_sessions
|
|
|
+ WHERE actor_user_id = ? AND created_at > ?
|
|
|
+ `)
|
|
|
+ .get(actorId, recentSince)?.count || 0);
|
|
|
+ if (recentCount >= maxWebmailTicketsPerRateWindow) {
|
|
|
+ throw webmailTicketRateLimitError();
|
|
|
+ }
|
|
|
+
|
|
|
+ // Keep one pending launch per actor/mailbox. Revoking instead of deleting
|
|
|
+ // preserves the short rate-limit window while preventing ticket replay.
|
|
|
+ requireDb()
|
|
|
+ .prepare(`
|
|
|
+ UPDATE webmail_sessions
|
|
|
+ SET revoked_at = ?
|
|
|
+ WHERE actor_user_id = ?
|
|
|
+ AND mailbox_id = ?
|
|
|
+ AND exchanged_at IS NULL
|
|
|
+ AND revoked_at IS NULL
|
|
|
+ `)
|
|
|
+ .run(createdAt, actorId, cleanMailboxId);
|
|
|
+
|
|
|
+ const ticket = `mht_${crypto.randomBytes(32).toString('base64url')}`;
|
|
|
+ const ticketExpiresAt = new Date(Date.parse(createdAt) + ticketTtl * 1000).toISOString();
|
|
|
+ const credentialExpiresAt = new Date(Date.parse(createdAt) + credentialTtl * 1000).toISOString();
|
|
|
+ const result = requireDb()
|
|
|
+ .prepare(`
|
|
|
+ INSERT INTO webmail_sessions (
|
|
|
+ actor_user_id, mailbox_id, audience, ticket_hash, ticket_expires_at,
|
|
|
+ credential_expires_at, created_at
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
|
+ `)
|
|
|
+ .run(
|
|
|
+ actorId,
|
|
|
+ cleanMailboxId,
|
|
|
+ cleanAudience,
|
|
|
+ webmailTokenHash('ticket', ticket),
|
|
|
+ ticketExpiresAt,
|
|
|
+ credentialExpiresAt,
|
|
|
+ createdAt
|
|
|
+ );
|
|
|
+ return {
|
|
|
+ id: Number(result.lastInsertRowid),
|
|
|
+ actorUserId: actorId,
|
|
|
+ mailboxId: cleanMailboxId,
|
|
|
+ address: mailboxRow.address,
|
|
|
+ audience: cleanAudience,
|
|
|
+ ticket,
|
|
|
+ ticketExpiresAt,
|
|
|
+ credentialExpiresAt,
|
|
|
+ mailbox: publicInboundMailbox(mailboxRow)
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+export function exchangeWebmailLoginTicket(ticket, { audience } = {}) {
|
|
|
+ const rawTicket = normalizeWebmailToken(ticket, 'mht_');
|
|
|
+ if (!rawTicket) return null;
|
|
|
+ const cleanAudience = normalizeWebmailAudience(audience);
|
|
|
+ const exchangedAt = now();
|
|
|
+ const credential = `mhw_${crypto.randomBytes(32).toString('base64url')}`;
|
|
|
+ return withTransaction(() => {
|
|
|
+ const row = requireDb()
|
|
|
+ .prepare(`
|
|
|
+ UPDATE webmail_sessions
|
|
|
+ SET exchanged_at = ?, credential_hash = ?
|
|
|
+ WHERE ticket_hash = ?
|
|
|
+ AND audience = ?
|
|
|
+ AND exchanged_at IS NULL
|
|
|
+ AND revoked_at IS NULL
|
|
|
+ AND ticket_expires_at > ?
|
|
|
+ AND credential_expires_at > ?
|
|
|
+ AND EXISTS (
|
|
|
+ ${webmailCurrentPermissionSql('receive')}
|
|
|
+ )
|
|
|
+ RETURNING *
|
|
|
+ `)
|
|
|
+ .get(
|
|
|
+ exchangedAt,
|
|
|
+ webmailTokenHash('credential', credential),
|
|
|
+ webmailTokenHash('ticket', rawTicket),
|
|
|
+ cleanAudience,
|
|
|
+ exchangedAt,
|
|
|
+ exchangedAt,
|
|
|
+ exchangedAt
|
|
|
+ );
|
|
|
+ if (!row) return null;
|
|
|
+
|
|
|
+ // Retire only after the new ticket was successfully exchanged, so a
|
|
|
+ // failed Roundcube launch never logs out an otherwise valid session.
|
|
|
+ requireDb()
|
|
|
+ .prepare(`
|
|
|
+ UPDATE webmail_sessions
|
|
|
+ SET revoked_at = ?
|
|
|
+ WHERE id IN (
|
|
|
+ SELECT id
|
|
|
+ FROM webmail_sessions
|
|
|
+ WHERE actor_user_id = ?
|
|
|
+ AND mailbox_id = ?
|
|
|
+ AND exchanged_at IS NOT NULL
|
|
|
+ AND revoked_at IS NULL
|
|
|
+ AND credential_expires_at > ?
|
|
|
+ ORDER BY COALESCE(last_used_at, exchanged_at, created_at) DESC, id DESC
|
|
|
+ LIMIT -1 OFFSET ?
|
|
|
+ )
|
|
|
+ `)
|
|
|
+ .run(
|
|
|
+ exchangedAt,
|
|
|
+ row.actor_user_id,
|
|
|
+ row.mailbox_id,
|
|
|
+ exchangedAt,
|
|
|
+ maxActiveWebmailSessionsPerMailbox
|
|
|
+ );
|
|
|
+
|
|
|
+ const mailboxRow = webmailAuthorizedMailboxRow(row.actor_user_id, row.mailbox_id, 'receive', exchangedAt);
|
|
|
+ if (!mailboxRow) return null;
|
|
|
+ return {
|
|
|
+ id: row.id,
|
|
|
+ actorUserId: row.actor_user_id,
|
|
|
+ mailboxId: row.mailbox_id,
|
|
|
+ address: mailboxRow.address,
|
|
|
+ audience: row.audience,
|
|
|
+ credential,
|
|
|
+ expiresAt: row.credential_expires_at,
|
|
|
+ credentialExpiresAt: row.credential_expires_at,
|
|
|
+ mailbox: publicInboundMailbox(mailboxRow)
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+export function verifyWebmailCredential(username, credential, { permission = 'receive' } = {}) {
|
|
|
+ const address = normalizeInboundAddress(username);
|
|
|
+ const rawCredential = normalizeWebmailToken(credential, 'mhw_');
|
|
|
+ if (!address || !rawCredential) return null;
|
|
|
+ const cleanPermission = normalizeWebmailPermission(permission);
|
|
|
+ const usedAt = now();
|
|
|
+ const consumed = requireDb()
|
|
|
+ .prepare(`
|
|
|
+ UPDATE webmail_sessions
|
|
|
+ SET last_used_at = ?
|
|
|
+ WHERE credential_hash = ?
|
|
|
+ AND exchanged_at IS NOT NULL
|
|
|
+ AND revoked_at IS NULL
|
|
|
+ AND credential_expires_at > ?
|
|
|
+ AND EXISTS (
|
|
|
+ ${webmailCurrentPermissionSql(cleanPermission, { address: true })}
|
|
|
+ )
|
|
|
+ RETURNING id
|
|
|
+ `)
|
|
|
+ .get(
|
|
|
+ usedAt,
|
|
|
+ webmailTokenHash('credential', rawCredential),
|
|
|
+ usedAt,
|
|
|
+ address,
|
|
|
+ usedAt
|
|
|
+ );
|
|
|
+ if (!consumed) return null;
|
|
|
+ const row = webmailCredentialResultRow(consumed.id, address, cleanPermission, usedAt);
|
|
|
+ if (!row) return null;
|
|
|
+ return {
|
|
|
+ user: {
|
|
|
+ id: row.auth_user_id,
|
|
|
+ username: row.auth_username,
|
|
|
+ email: row.auth_email,
|
|
|
+ role: row.auth_role,
|
|
|
+ status: row.auth_status
|
|
|
+ },
|
|
|
+ mailbox: publicInboundMailbox(row),
|
|
|
+ webmailSession: {
|
|
|
+ id: row.webmail_session_id,
|
|
|
+ actorUserId: row.actor_user_id,
|
|
|
+ mailboxId: row.mailbox_id,
|
|
|
+ audience: row.audience,
|
|
|
+ expiresAt: row.credential_expires_at,
|
|
|
+ lastUsedAt: row.last_used_at
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export async function verifyWebmailCredentialAsync(username, credential, options = {}) {
|
|
|
+ return verifyWebmailCredential(username, credential, options);
|
|
|
+}
|
|
|
+
|
|
|
+export function webmailSessionHasPermission(sessionId, address, { permission = 'receive' } = {}) {
|
|
|
+ const id = Number(sessionId);
|
|
|
+ const cleanAddress = normalizeInboundAddress(address);
|
|
|
+ if (!Number.isSafeInteger(id) || id <= 0 || !cleanAddress) return false;
|
|
|
+ const cleanPermission = normalizeWebmailPermission(permission);
|
|
|
+ return Boolean(webmailCredentialResultRow(id, cleanAddress, cleanPermission, now()));
|
|
|
+}
|
|
|
+
|
|
|
+export function revokeWebmailCredential(credential, { audience } = {}) {
|
|
|
+ const rawCredential = normalizeWebmailToken(credential, 'mhw_');
|
|
|
+ if (!rawCredential) return false;
|
|
|
+ const hasAudience = audience !== undefined && audience !== null && audience !== '';
|
|
|
+ const cleanAudience = hasAudience ? normalizeWebmailAudience(audience) : '';
|
|
|
+ const result = requireDb()
|
|
|
+ .prepare(`
|
|
|
+ UPDATE webmail_sessions
|
|
|
+ SET revoked_at = ?
|
|
|
+ WHERE credential_hash = ?
|
|
|
+ AND exchanged_at IS NOT NULL
|
|
|
+ AND revoked_at IS NULL
|
|
|
+ ${hasAudience ? 'AND audience = ?' : ''}
|
|
|
+ `)
|
|
|
+ .run(now(), webmailTokenHash('credential', rawCredential), ...(hasAudience ? [cleanAudience] : []));
|
|
|
+ return result.changes > 0;
|
|
|
+}
|
|
|
+
|
|
|
+export function cleanupWebmailSessions({ retentionSeconds = 24 * 60 * 60 } = {}) {
|
|
|
+ const retention = Number(retentionSeconds);
|
|
|
+ if (!Number.isSafeInteger(retention) || retention < 0 || retention > 30 * 24 * 60 * 60) {
|
|
|
+ throw new Error('Webmail 会话保留时间不正确。');
|
|
|
+ }
|
|
|
+ const cutoff = new Date(Date.now() - retention * 1000).toISOString();
|
|
|
+ return requireDb()
|
|
|
+ .prepare(`
|
|
|
+ DELETE FROM webmail_sessions
|
|
|
+ WHERE credential_expires_at <= ?
|
|
|
+ OR (revoked_at IS NOT NULL AND revoked_at <= ?)
|
|
|
+ OR (exchanged_at IS NULL AND ticket_expires_at <= ?)
|
|
|
+ `)
|
|
|
+ .run(cutoff, cutoff, cutoff).changes;
|
|
|
+}
|
|
|
+
|
|
|
export function verifyInboundMailboxCredential(username, password) {
|
|
|
+ if (normalizeWebmailToken(password, 'mhw_')) {
|
|
|
+ return verifyWebmailCredential(username, password, { permission: 'receive' });
|
|
|
+ }
|
|
|
const mailboxAddress = normalizeInboundAddress(username);
|
|
|
if (!mailboxAddress) {
|
|
|
consumeDummyPasswordVerification(password);
|
|
|
@@ -1450,6 +1743,9 @@ export function verifyInboundMailboxCredential(username, password) {
|
|
|
}
|
|
|
|
|
|
export async function verifyInboundMailboxCredentialAsync(username, password) {
|
|
|
+ if (normalizeWebmailToken(password, 'mhw_')) {
|
|
|
+ return verifyWebmailCredentialAsync(username, password, { permission: 'receive' });
|
|
|
+ }
|
|
|
const mailboxAddress = normalizeInboundAddress(username);
|
|
|
if (!mailboxAddress) {
|
|
|
await consumeDummyPasswordVerificationAsync(password);
|
|
|
@@ -4154,6 +4450,18 @@ function clearDefaultSmtpRelay(userId) {
|
|
|
|
|
|
export function verifySmtpCredential(username, password) {
|
|
|
const cleanUsername = String(username || '').trim();
|
|
|
+ if (normalizeWebmailToken(password, 'mhw_')) {
|
|
|
+ const webmailAuth = verifyWebmailCredential(cleanUsername, password, { permission: 'send' });
|
|
|
+ return webmailAuth ? {
|
|
|
+ user: webmailAuth.user,
|
|
|
+ mailbox: webmailAuth.mailbox,
|
|
|
+ webmailSession: webmailAuth.webmailSession,
|
|
|
+ credential: {
|
|
|
+ username: webmailAuth.mailbox.address,
|
|
|
+ type: 'webmail_session'
|
|
|
+ }
|
|
|
+ } : null;
|
|
|
+ }
|
|
|
const row = requireDb()
|
|
|
.prepare(`
|
|
|
SELECT c.*, u.id AS auth_user_id, u.username AS auth_username, u.email, u.role, u.status
|
|
|
@@ -5529,6 +5837,178 @@ function truncateWebhookBodyPreview(value, maxLength = 2048) {
|
|
|
return text.slice(0, maxLength);
|
|
|
}
|
|
|
|
|
|
+function normalizeWebmailEntityId(value, message) {
|
|
|
+ const id = Number(value);
|
|
|
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error(message);
|
|
|
+ return id;
|
|
|
+}
|
|
|
+
|
|
|
+function webmailMailboxAccessDeniedError() {
|
|
|
+ const error = new Error('收信邮箱不存在或无权登录 Webmail。');
|
|
|
+ error.code = 'WEBMAIL_MAILBOX_ACCESS_DENIED';
|
|
|
+ return error;
|
|
|
+}
|
|
|
+
|
|
|
+function webmailTicketRateLimitError() {
|
|
|
+ const error = new Error('Webmail 登录请求过于频繁,请稍后重试。');
|
|
|
+ error.code = 'WEBMAIL_TICKET_RATE_LIMIT';
|
|
|
+ error.retryAfterSeconds = webmailTicketRateWindowSeconds;
|
|
|
+ return error;
|
|
|
+}
|
|
|
+
|
|
|
+function maybeCleanupWebmailSessions() {
|
|
|
+ const currentTimeMs = Date.now();
|
|
|
+ if (currentTimeMs - lastWebmailSessionCleanupAtMs < webmailSessionCleanupIntervalMs) return;
|
|
|
+ cleanupWebmailSessions({ retentionSeconds: webmailSessionRetentionSeconds });
|
|
|
+ lastWebmailSessionCleanupAtMs = currentTimeMs;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeWebmailAudience(value) {
|
|
|
+ const rawAudience = String(value || '').trim();
|
|
|
+ if (!rawAudience || rawAudience.length > 2048) throw new Error('Webmail 受众地址不正确。');
|
|
|
+ let audienceUrl;
|
|
|
+ try {
|
|
|
+ audienceUrl = new URL(rawAudience);
|
|
|
+ } catch {
|
|
|
+ throw new Error('Webmail 受众地址不正确。');
|
|
|
+ }
|
|
|
+ if (!['http:', 'https:'].includes(audienceUrl.protocol)
|
|
|
+ || audienceUrl.username
|
|
|
+ || audienceUrl.password
|
|
|
+ || audienceUrl.pathname !== '/'
|
|
|
+ || audienceUrl.search
|
|
|
+ || audienceUrl.hash) {
|
|
|
+ throw new Error('Webmail 受众地址不正确。');
|
|
|
+ }
|
|
|
+ return audienceUrl.origin;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeWebmailTtlSeconds(value, min, max, message) {
|
|
|
+ const ttl = Number(value);
|
|
|
+ if (!Number.isSafeInteger(ttl) || ttl < min || ttl > max) throw new Error(message);
|
|
|
+ return ttl;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeWebmailPermission(value) {
|
|
|
+ const permission = String(value || '').trim().toLowerCase();
|
|
|
+ if (!['receive', 'send'].includes(permission)) throw new Error('Webmail 邮箱权限不正确。');
|
|
|
+ return permission;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeWebmailToken(value, prefix) {
|
|
|
+ const token = String(value || '').trim();
|
|
|
+ return new RegExp(`^${prefix}[A-Za-z0-9_-]{43}$`).test(token) ? token : '';
|
|
|
+}
|
|
|
+
|
|
|
+function webmailTokenHash(kind, token) {
|
|
|
+ return crypto
|
|
|
+ .createHmac('sha256', encryptionKey())
|
|
|
+ .update(`mailhub-webmail-${kind}\u0000${String(token || '')}`)
|
|
|
+ .digest('hex');
|
|
|
+}
|
|
|
+
|
|
|
+function webmailAuthorizedMailboxRow(actorUserId, mailboxId, permission, currentTime) {
|
|
|
+ const cleanPermission = normalizeWebmailPermission(permission);
|
|
|
+ const grantColumn = cleanPermission === 'send' ? 'can_send' : 'can_receive';
|
|
|
+ return requireDb()
|
|
|
+ .prepare(`
|
|
|
+ SELECT
|
|
|
+ m.*,
|
|
|
+ d.domain,
|
|
|
+ 0 AS message_count,
|
|
|
+ 0 AS unread_count,
|
|
|
+ NULL AS last_message_at
|
|
|
+ FROM inbound_mailboxes m
|
|
|
+ JOIN domains d ON d.id = m.domain_id
|
|
|
+ JOIN users mailbox_owner ON mailbox_owner.id = m.user_id
|
|
|
+ JOIN users webmail_actor ON webmail_actor.id = ?
|
|
|
+ LEFT JOIN inbound_mailbox_grants webmail_grant
|
|
|
+ ON webmail_grant.mailbox_id = m.id AND webmail_grant.user_id = webmail_actor.id
|
|
|
+ WHERE m.id = ?
|
|
|
+ AND m.status = 'active'
|
|
|
+ AND m.deleted_at IS NULL
|
|
|
+ AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
|
|
|
+ AND mailbox_owner.status = 'active'
|
|
|
+ AND webmail_actor.status = 'active'
|
|
|
+ AND (m.user_id = webmail_actor.id OR webmail_grant.${grantColumn} = 'true')
|
|
|
+ LIMIT 1
|
|
|
+ `)
|
|
|
+ .get(actorUserId, mailboxId, currentTime);
|
|
|
+}
|
|
|
+
|
|
|
+function webmailCurrentPermissionSql(permission, { address = false } = {}) {
|
|
|
+ const cleanPermission = normalizeWebmailPermission(permission);
|
|
|
+ const grantColumn = cleanPermission === 'send' ? 'can_send' : 'can_receive';
|
|
|
+ return `
|
|
|
+ SELECT 1
|
|
|
+ FROM inbound_mailboxes webmail_mailbox
|
|
|
+ JOIN users webmail_actor ON webmail_actor.id = webmail_sessions.actor_user_id
|
|
|
+ JOIN users webmail_owner ON webmail_owner.id = webmail_mailbox.user_id
|
|
|
+ LEFT JOIN inbound_mailbox_grants webmail_grant
|
|
|
+ ON webmail_grant.mailbox_id = webmail_mailbox.id
|
|
|
+ AND webmail_grant.user_id = webmail_sessions.actor_user_id
|
|
|
+ WHERE webmail_mailbox.id = webmail_sessions.mailbox_id
|
|
|
+ ${address ? 'AND webmail_mailbox.address = ?' : ''}
|
|
|
+ AND webmail_mailbox.status = 'active'
|
|
|
+ AND webmail_mailbox.deleted_at IS NULL
|
|
|
+ AND (
|
|
|
+ webmail_mailbox.expires_at IS NULL
|
|
|
+ OR webmail_mailbox.expires_at = ''
|
|
|
+ OR webmail_mailbox.expires_at > ?
|
|
|
+ )
|
|
|
+ AND webmail_actor.status = 'active'
|
|
|
+ AND webmail_owner.status = 'active'
|
|
|
+ AND (
|
|
|
+ webmail_mailbox.user_id = webmail_sessions.actor_user_id
|
|
|
+ OR webmail_grant.${grantColumn} = 'true'
|
|
|
+ )
|
|
|
+ `;
|
|
|
+}
|
|
|
+
|
|
|
+function webmailCredentialResultRow(sessionId, address, permission, currentTime) {
|
|
|
+ const cleanPermission = normalizeWebmailPermission(permission);
|
|
|
+ const grantColumn = cleanPermission === 'send' ? 'can_send' : 'can_receive';
|
|
|
+ return requireDb()
|
|
|
+ .prepare(`
|
|
|
+ SELECT
|
|
|
+ m.*,
|
|
|
+ d.domain,
|
|
|
+ 0 AS message_count,
|
|
|
+ 0 AS unread_count,
|
|
|
+ NULL AS last_message_at,
|
|
|
+ s.id AS webmail_session_id,
|
|
|
+ s.actor_user_id,
|
|
|
+ s.audience,
|
|
|
+ s.credential_expires_at,
|
|
|
+ s.last_used_at,
|
|
|
+ actor.id AS auth_user_id,
|
|
|
+ actor.username AS auth_username,
|
|
|
+ actor.email AS auth_email,
|
|
|
+ actor.role AS auth_role,
|
|
|
+ actor.status AS auth_status
|
|
|
+ FROM webmail_sessions s
|
|
|
+ JOIN inbound_mailboxes m ON m.id = s.mailbox_id
|
|
|
+ JOIN domains d ON d.id = m.domain_id
|
|
|
+ JOIN users actor ON actor.id = s.actor_user_id
|
|
|
+ JOIN users mailbox_owner ON mailbox_owner.id = m.user_id
|
|
|
+ LEFT JOIN inbound_mailbox_grants webmail_grant
|
|
|
+ ON webmail_grant.mailbox_id = m.id AND webmail_grant.user_id = s.actor_user_id
|
|
|
+ WHERE s.id = ?
|
|
|
+ AND m.address = ?
|
|
|
+ AND s.exchanged_at IS NOT NULL
|
|
|
+ AND s.revoked_at IS NULL
|
|
|
+ AND s.credential_expires_at > ?
|
|
|
+ AND m.status = 'active'
|
|
|
+ AND m.deleted_at IS NULL
|
|
|
+ AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
|
|
|
+ AND actor.status = 'active'
|
|
|
+ AND mailbox_owner.status = 'active'
|
|
|
+ AND (m.user_id = s.actor_user_id OR webmail_grant.${grantColumn} = 'true')
|
|
|
+ LIMIT 1
|
|
|
+ `)
|
|
|
+ .get(sessionId, address, currentTime, currentTime);
|
|
|
+}
|
|
|
+
|
|
|
function normalizeUsername(value) {
|
|
|
const username = String(value || '').trim().toLowerCase();
|
|
|
return /^[a-z0-9][a-z0-9_.-]{2,31}$/.test(username) ? username : '';
|