|
@@ -20,12 +20,14 @@ let db;
|
|
|
let secretKey = '';
|
|
let secretKey = '';
|
|
|
export const USER_STATUSES = new Set(['pending_email', 'pending_review', 'active', 'disabled']);
|
|
export const USER_STATUSES = new Set(['pending_email', 'pending_review', 'active', 'disabled']);
|
|
|
export const STANDARD_INBOUND_FOLDERS = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
|
|
export const STANDARD_INBOUND_FOLDERS = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
|
|
|
|
|
+export const API_TOKEN_SCOPES = new Set(['send', 'mailboxes:read', 'mailboxes:write']);
|
|
|
const auditSecretKeyPattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
|
|
const auditSecretKeyPattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
|
|
|
const auditDescriptorKeyPattern = /^(field|name|path|key|header)$/i;
|
|
const auditDescriptorKeyPattern = /^(field|name|path|key|header)$/i;
|
|
|
const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
|
|
const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
|
|
|
const auditDescriptorWrapperKeyPattern = /^(change|context|descriptor|meta)$/i;
|
|
const auditDescriptorWrapperKeyPattern = /^(change|context|descriptor|meta)$/i;
|
|
|
const auditValueLikeKeyPattern = /^(value|from|to|old|new|old_?value|new_?value|before|after)$/i;
|
|
const auditValueLikeKeyPattern = /^(value|from|to|old|new|old_?value|new_?value|before|after)$/i;
|
|
|
const maxAccountTokenTtlMinutes = 7 * 24 * 60;
|
|
const maxAccountTokenTtlMinutes = 7 * 24 * 60;
|
|
|
|
|
+const defaultApiTokenScopes = ['send'];
|
|
|
|
|
|
|
|
export function initDatabase(dataDir, secret = '') {
|
|
export function initDatabase(dataDir, secret = '') {
|
|
|
secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
|
|
secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
|
|
@@ -169,6 +171,7 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
created_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
|
updated_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
|
deleted_at TEXT,
|
|
deleted_at TEXT,
|
|
|
|
|
+ expires_at TEXT,
|
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
|
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
|
|
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
|
|
|
);
|
|
);
|
|
@@ -217,6 +220,10 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
name TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
|
token_prefix TEXT NOT NULL,
|
|
token_prefix TEXT NOT NULL,
|
|
|
|
|
+ scopes_json TEXT NOT NULL DEFAULT '["send"]',
|
|
|
|
|
+ expires_at TEXT,
|
|
|
|
|
+ revoked_at TEXT,
|
|
|
|
|
+ revoked_reason TEXT NOT NULL DEFAULT '',
|
|
|
last_used_at TEXT,
|
|
last_used_at TEXT,
|
|
|
created_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
@@ -330,7 +337,12 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
ensureColumn('inbound_mailboxes', 'forward_to_json', "TEXT NOT NULL DEFAULT '[]'");
|
|
ensureColumn('inbound_mailboxes', 'forward_to_json', "TEXT NOT NULL DEFAULT '[]'");
|
|
|
ensureColumn('inbound_mailboxes', 'keep_forwarded', "TEXT NOT NULL DEFAULT 'true'");
|
|
ensureColumn('inbound_mailboxes', 'keep_forwarded', "TEXT NOT NULL DEFAULT 'true'");
|
|
|
ensureColumn('inbound_mailboxes', 'quota_mb', 'INTEGER');
|
|
ensureColumn('inbound_mailboxes', 'quota_mb', 'INTEGER');
|
|
|
|
|
+ ensureColumn('inbound_mailboxes', 'expires_at', 'TEXT');
|
|
|
ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
|
|
ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
|
|
|
|
|
+ ensureColumn('api_tokens', 'scopes_json', "TEXT NOT NULL DEFAULT '[\"send\"]'");
|
|
|
|
|
+ ensureColumn('api_tokens', 'expires_at', 'TEXT');
|
|
|
|
|
+ ensureColumn('api_tokens', 'revoked_at', 'TEXT');
|
|
|
|
|
+ ensureColumn('api_tokens', 'revoked_reason', "TEXT NOT NULL DEFAULT ''");
|
|
|
db.exec(`
|
|
db.exec(`
|
|
|
CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_domains_smtp_relay_id ON domains(smtp_relay_id);
|
|
CREATE INDEX IF NOT EXISTS idx_domains_smtp_relay_id ON domains(smtp_relay_id);
|
|
@@ -343,6 +355,7 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
WHERE tracking_token_hash IS NOT NULL AND tracking_token_hash != '';
|
|
WHERE tracking_token_hash IS NOT NULL AND tracking_token_hash != '';
|
|
|
CREATE INDEX IF NOT EXISTS idx_smtp_credentials_user_id ON smtp_credentials(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_smtp_credentials_user_id ON smtp_credentials(user_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_smtp_relays_user_id ON smtp_relays(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_smtp_relays_user_id ON smtp_relays(user_id);
|
|
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_user_status ON api_tokens(user_id, revoked_at, expires_at);
|
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
|
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
|
|
|
`);
|
|
`);
|
|
@@ -878,13 +891,14 @@ export function createInboundMailbox(userId, mailbox = {}) {
|
|
|
const forwardTo = normalizeRecipientList(mailbox.forwardTo);
|
|
const forwardTo = normalizeRecipientList(mailbox.forwardTo);
|
|
|
const keepForwarded = boolString(mailbox.keepForwarded ?? true);
|
|
const keepForwarded = boolString(mailbox.keepForwarded ?? true);
|
|
|
const quotaMb = normalizeQuotaMb(mailbox.quotaMb);
|
|
const quotaMb = normalizeQuotaMb(mailbox.quotaMb);
|
|
|
|
|
+ const expiresAt = normalizeInboundMailboxExpiresAt(mailbox.expiresAt);
|
|
|
const createdAt = now();
|
|
const createdAt = now();
|
|
|
const result = requireDb()
|
|
const result = requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
INSERT INTO inbound_mailboxes (
|
|
INSERT INTO inbound_mailboxes (
|
|
|
user_id, domain_id, address, local_part, display_name, password_hash, password_secret,
|
|
user_id, domain_id, address, local_part, display_name, password_hash, password_secret,
|
|
|
- aliases_json, forward_to_json, keep_forwarded, quota_mb, status, created_at, updated_at
|
|
|
|
|
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
|
|
|
|
|
|
+ aliases_json, forward_to_json, keep_forwarded, quota_mb, status, expires_at, created_at, updated_at
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
|
|
`)
|
|
`)
|
|
|
.run(
|
|
.run(
|
|
|
userId,
|
|
userId,
|
|
@@ -898,6 +912,7 @@ export function createInboundMailbox(userId, mailbox = {}) {
|
|
|
JSON.stringify(forwardTo),
|
|
JSON.stringify(forwardTo),
|
|
|
keepForwarded,
|
|
keepForwarded,
|
|
|
quotaMb,
|
|
quotaMb,
|
|
|
|
|
+ expiresAt,
|
|
|
createdAt,
|
|
createdAt,
|
|
|
createdAt
|
|
createdAt
|
|
|
);
|
|
);
|
|
@@ -918,6 +933,7 @@ export function updateInboundMailbox(userId, id, patch = {}) {
|
|
|
forwardTo: Object.hasOwn(patch, 'forwardTo') ? normalizeRecipientList(patch.forwardTo) : current.forwardTo,
|
|
forwardTo: Object.hasOwn(patch, 'forwardTo') ? normalizeRecipientList(patch.forwardTo) : current.forwardTo,
|
|
|
keepForwarded: Object.hasOwn(patch, 'keepForwarded') ? Boolean(patch.keepForwarded) : current.keepForwarded,
|
|
keepForwarded: Object.hasOwn(patch, 'keepForwarded') ? Boolean(patch.keepForwarded) : current.keepForwarded,
|
|
|
quotaMb: Object.hasOwn(patch, 'quotaMb') ? normalizeQuotaMb(patch.quotaMb) : current.quotaMb,
|
|
quotaMb: Object.hasOwn(patch, 'quotaMb') ? normalizeQuotaMb(patch.quotaMb) : current.quotaMb,
|
|
|
|
|
+ expiresAt: Object.hasOwn(patch, 'expiresAt') ? normalizeInboundMailboxExpiresAt(patch.expiresAt) : current.expiresAt,
|
|
|
status: patch.status === undefined ? current.status : normalizeInboundMailboxStatus(patch.status),
|
|
status: patch.status === undefined ? current.status : normalizeInboundMailboxStatus(patch.status),
|
|
|
updatedAt: now()
|
|
updatedAt: now()
|
|
|
};
|
|
};
|
|
@@ -926,7 +942,7 @@ export function updateInboundMailbox(userId, id, patch = {}) {
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
UPDATE inbound_mailboxes
|
|
UPDATE inbound_mailboxes
|
|
|
SET display_name = ?, password_hash = ?, password_secret = ?, aliases_json = ?, forward_to_json = ?,
|
|
SET display_name = ?, password_hash = ?, password_secret = ?, aliases_json = ?, forward_to_json = ?,
|
|
|
- keep_forwarded = ?, quota_mb = ?, status = ?, updated_at = ?
|
|
|
|
|
|
|
+ keep_forwarded = ?, quota_mb = ?, expires_at = ?, status = ?, updated_at = ?
|
|
|
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
|
|
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
|
|
|
`)
|
|
`)
|
|
|
.run(
|
|
.run(
|
|
@@ -937,6 +953,7 @@ export function updateInboundMailbox(userId, id, patch = {}) {
|
|
|
JSON.stringify(next.forwardTo),
|
|
JSON.stringify(next.forwardTo),
|
|
|
boolString(next.keepForwarded),
|
|
boolString(next.keepForwarded),
|
|
|
next.quotaMb,
|
|
next.quotaMb,
|
|
|
|
|
+ next.expiresAt,
|
|
|
next.status,
|
|
next.status,
|
|
|
next.updatedAt,
|
|
next.updatedAt,
|
|
|
Number(id),
|
|
Number(id),
|
|
@@ -996,10 +1013,11 @@ export function getInboundMailboxByAddress(address, { includeHash = false, inclu
|
|
|
WHERE m.address = ?
|
|
WHERE m.address = ?
|
|
|
AND m.status = 'active'
|
|
AND m.status = 'active'
|
|
|
AND m.deleted_at IS NULL
|
|
AND m.deleted_at IS NULL
|
|
|
|
|
+ AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
|
|
|
AND u.status = 'active'
|
|
AND u.status = 'active'
|
|
|
LIMIT 1
|
|
LIMIT 1
|
|
|
`)
|
|
`)
|
|
|
- .get(cleanAddress);
|
|
|
|
|
|
|
+ .get(cleanAddress, now());
|
|
|
return publicInboundMailbox(row, { includeHash, includeSecret });
|
|
return publicInboundMailbox(row, { includeHash, includeSecret });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1025,9 +1043,10 @@ export function verifyInboundMailboxCredential(username, password) {
|
|
|
WHERE m.address = ?
|
|
WHERE m.address = ?
|
|
|
AND m.status = 'active'
|
|
AND m.status = 'active'
|
|
|
AND m.deleted_at IS NULL
|
|
AND m.deleted_at IS NULL
|
|
|
|
|
+ AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
|
|
|
LIMIT 1
|
|
LIMIT 1
|
|
|
`)
|
|
`)
|
|
|
- .get(mailboxAddress);
|
|
|
|
|
|
|
+ .get(mailboxAddress, now());
|
|
|
if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
|
|
if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
|
|
|
return {
|
|
return {
|
|
|
user: {
|
|
user: {
|
|
@@ -2794,15 +2813,25 @@ export function verifySmtpCredential(username, password) {
|
|
|
} : null;
|
|
} : null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export function createApiToken(userId, name) {
|
|
|
|
|
|
|
+export function createApiToken(userId, name, { scopes, expiresAt } = {}) {
|
|
|
const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
|
|
const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
|
|
|
const createdAt = now();
|
|
const createdAt = now();
|
|
|
|
|
+ const cleanScopes = normalizeApiTokenScopes(scopes);
|
|
|
|
|
+ const cleanExpiresAt = normalizeApiTokenExpiresAt(expiresAt);
|
|
|
const result = requireDb()
|
|
const result = requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
- INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, created_at)
|
|
|
|
|
- VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
|
|
+ INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes_json, expires_at, created_at)
|
|
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
|
`)
|
|
`)
|
|
|
- .run(userId, String(name || 'API Token').trim() || 'API Token', tokenHash(token), token.slice(0, 12), createdAt);
|
|
|
|
|
|
|
+ .run(
|
|
|
|
|
+ userId,
|
|
|
|
|
+ normalizeApiTokenName(name),
|
|
|
|
|
+ tokenHash(token),
|
|
|
|
|
+ token.slice(0, 12),
|
|
|
|
|
+ JSON.stringify(cleanScopes),
|
|
|
|
|
+ cleanExpiresAt,
|
|
|
|
|
+ createdAt
|
|
|
|
|
+ );
|
|
|
return {
|
|
return {
|
|
|
...getApiToken(result.lastInsertRowid, userId),
|
|
...getApiToken(result.lastInsertRowid, userId),
|
|
|
token
|
|
token
|
|
@@ -2823,12 +2852,46 @@ export function getApiToken(id, userId) {
|
|
|
return publicApiToken(row);
|
|
return publicApiToken(row);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export function updateApiToken(id, userId, patch = {}) {
|
|
|
|
|
+ const current = requireDb()
|
|
|
|
|
+ .prepare('SELECT * FROM api_tokens WHERE id = ? AND user_id = ?')
|
|
|
|
|
+ .get(Number(id), userId);
|
|
|
|
|
+ if (!current) return null;
|
|
|
|
|
+ if (current.revoked_at) throw new Error('已撤销的 API Token 不能修改。');
|
|
|
|
|
+ const name = Object.hasOwn(patch, 'name') ? normalizeApiTokenName(patch.name) : current.name;
|
|
|
|
|
+ const scopes = Object.hasOwn(patch, 'scopes')
|
|
|
|
|
+ ? normalizeApiTokenScopes(patch.scopes)
|
|
|
|
|
+ : storedApiTokenScopes(current.scopes_json);
|
|
|
|
|
+ const expiresAt = Object.hasOwn(patch, 'expiresAt')
|
|
|
|
|
+ ? normalizeApiTokenExpiresAt(patch.expiresAt)
|
|
|
|
|
+ : current.expires_at || null;
|
|
|
|
|
+ requireDb()
|
|
|
|
|
+ .prepare('UPDATE api_tokens SET name = ?, scopes_json = ?, expires_at = ? WHERE id = ? AND user_id = ?')
|
|
|
|
|
+ .run(name, JSON.stringify(scopes), expiresAt, Number(id), userId);
|
|
|
|
|
+ return getApiToken(id, userId);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function revokeApiToken(id, userId, reason = '') {
|
|
|
|
|
+ const result = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ UPDATE api_tokens
|
|
|
|
|
+ SET revoked_at = ?, revoked_reason = ?
|
|
|
|
|
+ WHERE id = ? AND user_id = ? AND revoked_at IS NULL
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(now(), String(reason || '').trim().slice(0, 200), Number(id), userId);
|
|
|
|
|
+ return result.changes > 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export function deleteApiToken(id, userId) {
|
|
export function deleteApiToken(id, userId) {
|
|
|
const result = requireDb().prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(id, userId);
|
|
const result = requireDb().prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(id, userId);
|
|
|
return result.changes > 0;
|
|
return result.changes > 0;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function verifyApiToken(token) {
|
|
export function verifyApiToken(token) {
|
|
|
|
|
+ return authenticateApiToken(token)?.user || null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function authenticateApiToken(token) {
|
|
|
const hash = tokenHash(token);
|
|
const hash = tokenHash(token);
|
|
|
const row = requireDb()
|
|
const row = requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
@@ -2838,14 +2901,17 @@ export function verifyApiToken(token) {
|
|
|
WHERE t.token_hash = ?
|
|
WHERE t.token_hash = ?
|
|
|
`)
|
|
`)
|
|
|
.get(hash);
|
|
.get(hash);
|
|
|
- if (!row || row.status !== 'active') return null;
|
|
|
|
|
|
|
+ if (!row || row.status !== 'active' || apiTokenStatus(row) !== 'active') return null;
|
|
|
requireDb().prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id);
|
|
requireDb().prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id);
|
|
|
return {
|
|
return {
|
|
|
- id: row.auth_user_id,
|
|
|
|
|
- username: row.username,
|
|
|
|
|
- email: row.email,
|
|
|
|
|
- role: row.role,
|
|
|
|
|
- status: row.status
|
|
|
|
|
|
|
+ user: {
|
|
|
|
|
+ id: row.auth_user_id,
|
|
|
|
|
+ username: row.username,
|
|
|
|
|
+ email: row.email,
|
|
|
|
|
+ role: row.role,
|
|
|
|
|
+ status: row.status
|
|
|
|
|
+ },
|
|
|
|
|
+ token: publicApiToken(row)
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -3333,6 +3399,7 @@ function privateDomainRow(row) {
|
|
|
function publicInboundMailbox(row, { includeHash = false, includeSecret = false } = {}) {
|
|
function publicInboundMailbox(row, { includeHash = false, includeSecret = false } = {}) {
|
|
|
if (!row) return null;
|
|
if (!row) return null;
|
|
|
const passwordRecoverable = Boolean(row.password_secret && decryptSecret(row.password_secret));
|
|
const passwordRecoverable = Boolean(row.password_secret && decryptSecret(row.password_secret));
|
|
|
|
|
+ const expiresAt = row.expires_at || null;
|
|
|
return {
|
|
return {
|
|
|
id: row.id,
|
|
id: row.id,
|
|
|
userId: row.user_id,
|
|
userId: row.user_id,
|
|
@@ -3347,7 +3414,9 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
|
|
|
quotaMb: row.quota_mb === null || row.quota_mb === undefined ? null : Number(row.quota_mb),
|
|
quotaMb: row.quota_mb === null || row.quota_mb === undefined ? null : Number(row.quota_mb),
|
|
|
passwordSet: Boolean(row.password_hash),
|
|
passwordSet: Boolean(row.password_hash),
|
|
|
passwordRecoverable,
|
|
passwordRecoverable,
|
|
|
- status: row.status,
|
|
|
|
|
|
|
+ status: inboundMailboxStatus(row),
|
|
|
|
|
+ expiresAt,
|
|
|
|
|
+ temporary: Boolean(expiresAt),
|
|
|
messageCount: Number(row.message_count || 0),
|
|
messageCount: Number(row.message_count || 0),
|
|
|
unreadCount: Number(row.unread_count || 0),
|
|
unreadCount: Number(row.unread_count || 0),
|
|
|
lastMessageAt: row.last_message_at || null,
|
|
lastMessageAt: row.last_message_at || null,
|
|
@@ -3431,6 +3500,11 @@ function publicApiToken(row) {
|
|
|
userId: row.user_id,
|
|
userId: row.user_id,
|
|
|
name: row.name,
|
|
name: row.name,
|
|
|
tokenPrefix: row.token_prefix,
|
|
tokenPrefix: row.token_prefix,
|
|
|
|
|
+ scopes: storedApiTokenScopes(row.scopes_json),
|
|
|
|
|
+ expiresAt: row.expires_at || null,
|
|
|
|
|
+ revokedAt: row.revoked_at || null,
|
|
|
|
|
+ revokedReason: row.revoked_reason || '',
|
|
|
|
|
+ status: apiTokenStatus(row),
|
|
|
lastUsedAt: row.last_used_at,
|
|
lastUsedAt: row.last_used_at,
|
|
|
createdAt: row.created_at
|
|
createdAt: row.created_at
|
|
|
};
|
|
};
|
|
@@ -3803,6 +3877,60 @@ function normalizeInboundMailboxStatus(value) {
|
|
|
throw new Error('收信邮箱状态不正确。');
|
|
throw new Error('收信邮箱状态不正确。');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function normalizeInboundMailboxExpiresAt(value) {
|
|
|
|
|
+ if (value === null || value === undefined || value === '') return null;
|
|
|
|
|
+ const timestamp = Date.parse(String(value));
|
|
|
|
|
+ if (!Number.isFinite(timestamp) || timestamp <= Date.now()) throw new Error('临时邮箱到期时间不正确。');
|
|
|
|
|
+ return new Date(timestamp).toISOString();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function inboundMailboxStatus(row) {
|
|
|
|
|
+ if (!row) return 'disabled';
|
|
|
|
|
+ const expiresAt = String(row.expires_at || '');
|
|
|
|
|
+ if (expiresAt && Date.parse(expiresAt) <= Date.now()) return 'expired';
|
|
|
|
|
+ return row.status;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeApiTokenName(value) {
|
|
|
|
|
+ const name = String(value || '').trim();
|
|
|
|
|
+ if (!name) throw new Error('API Token 名称不能为空。');
|
|
|
|
|
+ if (name.length > 100) throw new Error('API Token 名称不能超过 100 个字符。');
|
|
|
|
|
+ return name;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeApiTokenScopes(value) {
|
|
|
|
|
+ const candidates = value === undefined ? defaultApiTokenScopes : (Array.isArray(value) ? value : [value]);
|
|
|
|
|
+ const scopes = [...new Set(candidates.map((item) => String(item || '').trim()).filter(Boolean))];
|
|
|
|
|
+ if (!scopes.length || scopes.some((scope) => !API_TOKEN_SCOPES.has(scope))) {
|
|
|
|
|
+ throw new Error('API Token 权限范围不正确。');
|
|
|
|
|
+ }
|
|
|
|
|
+ return scopes;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function storedApiTokenScopes(value) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(value || '');
|
|
|
|
|
+ const scopes = Array.isArray(parsed) ? parsed.filter((scope) => API_TOKEN_SCOPES.has(scope)) : [];
|
|
|
|
|
+ return scopes.length ? scopes : [...defaultApiTokenScopes];
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return [...defaultApiTokenScopes];
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeApiTokenExpiresAt(value) {
|
|
|
|
|
+ if (value === null || value === undefined || value === '') return null;
|
|
|
|
|
+ const timestamp = Date.parse(String(value));
|
|
|
|
|
+ if (!Number.isFinite(timestamp) || timestamp <= Date.now()) throw new Error('API Token 到期时间不正确。');
|
|
|
|
|
+ return new Date(timestamp).toISOString();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function apiTokenStatus(row) {
|
|
|
|
|
+ if (row?.revoked_at) return 'revoked';
|
|
|
|
|
+ const expiresAt = String(row?.expires_at || '');
|
|
|
|
|
+ if (expiresAt && Date.parse(expiresAt) <= Date.now()) return 'expired';
|
|
|
|
|
+ return 'active';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function normalizeMailboxAliases(values, domain, ownLocalPart) {
|
|
function normalizeMailboxAliases(values, domain, ownLocalPart) {
|
|
|
const list = Array.isArray(values)
|
|
const list = Array.isArray(values)
|
|
|
? values
|
|
? values
|
|
@@ -3847,9 +3975,10 @@ function getInboundMailboxByAliasAddress(address) {
|
|
|
WHERE d.domain = ?
|
|
WHERE d.domain = ?
|
|
|
AND m.status = 'active'
|
|
AND m.status = 'active'
|
|
|
AND m.deleted_at IS NULL
|
|
AND m.deleted_at IS NULL
|
|
|
|
|
+ AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
|
|
|
AND u.status = 'active'
|
|
AND u.status = 'active'
|
|
|
`)
|
|
`)
|
|
|
- .all(domainName);
|
|
|
|
|
|
|
+ .all(domainName, now());
|
|
|
const row = rows.find((item) => safeJson(item.aliases_json, []).includes(localPart));
|
|
const row = rows.find((item) => safeJson(item.aliases_json, []).includes(localPart));
|
|
|
return publicInboundMailbox(row);
|
|
return publicInboundMailbox(row);
|
|
|
}
|
|
}
|