|
@@ -190,6 +190,19 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
|
|
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS inbound_mailbox_grants (
|
|
|
|
|
+ mailbox_id INTEGER NOT NULL,
|
|
|
|
|
+ user_id INTEGER NOT NULL,
|
|
|
|
|
+ can_view TEXT NOT NULL DEFAULT 'false',
|
|
|
|
|
+ can_receive TEXT NOT NULL DEFAULT 'false',
|
|
|
|
|
+ can_send TEXT NOT NULL DEFAULT 'false',
|
|
|
|
|
+ created_at TEXT NOT NULL,
|
|
|
|
|
+ updated_at TEXT NOT NULL,
|
|
|
|
|
+ PRIMARY KEY (mailbox_id, user_id),
|
|
|
|
|
+ FOREIGN KEY(mailbox_id) REFERENCES inbound_mailboxes(id) ON DELETE CASCADE,
|
|
|
|
|
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
CREATE TABLE IF NOT EXISTS inbound_messages (
|
|
CREATE TABLE IF NOT EXISTS inbound_messages (
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
mailbox_id INTEGER NOT NULL,
|
|
mailbox_id INTEGER NOT NULL,
|
|
@@ -358,6 +371,7 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
CREATE INDEX IF NOT EXISTS idx_tracking_events_type_time ON tracking_events(event_type, occurred_at);
|
|
CREATE INDEX IF NOT EXISTS idx_tracking_events_type_time ON tracking_events(event_type, occurred_at);
|
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_user_id ON inbound_mailboxes(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_user_id ON inbound_mailboxes(user_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_domain_id ON inbound_mailboxes(domain_id);
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_domain_id ON inbound_mailboxes(domain_id);
|
|
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_inbound_mailbox_grants_user_mailbox ON inbound_mailbox_grants(user_id, mailbox_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_messages_user_received ON inbound_messages(user_id, received_at);
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_messages_user_received ON inbound_messages(user_id, received_at);
|
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_received ON inbound_messages(mailbox_id, received_at);
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_received ON inbound_messages(mailbox_id, 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);
|
|
@@ -643,6 +657,119 @@ export function getAdminResourceInventory() {
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export function listAdminInboundMailboxAccess() {
|
|
|
|
|
+ const ownersById = new Map(listUsers().map((user) => [user.id, user]));
|
|
|
|
|
+ const grantsByMailboxId = new Map();
|
|
|
|
|
+ const grantRows = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT
|
|
|
|
|
+ g.mailbox_id,
|
|
|
|
|
+ g.can_view,
|
|
|
|
|
+ g.can_receive,
|
|
|
|
|
+ g.can_send,
|
|
|
|
|
+ g.created_at AS grant_created_at,
|
|
|
|
|
+ g.updated_at AS grant_updated_at,
|
|
|
|
|
+ u.id,
|
|
|
|
|
+ u.username,
|
|
|
|
|
+ u.email,
|
|
|
|
|
+ u.role,
|
|
|
|
|
+ u.status,
|
|
|
|
|
+ u.created_at,
|
|
|
|
|
+ u.updated_at
|
|
|
|
|
+ FROM inbound_mailbox_grants g
|
|
|
|
|
+ JOIN inbound_mailboxes m ON m.id = g.mailbox_id AND m.deleted_at IS NULL
|
|
|
|
|
+ JOIN users u ON u.id = g.user_id
|
|
|
|
|
+ ORDER BY g.mailbox_id, u.username COLLATE NOCASE, u.id
|
|
|
|
|
+ `)
|
|
|
|
|
+ .all();
|
|
|
|
|
+ for (const row of grantRows) {
|
|
|
|
|
+ const grants = grantsByMailboxId.get(row.mailbox_id) || [];
|
|
|
|
|
+ grants.push({
|
|
|
|
|
+ user: publicUser(row),
|
|
|
|
|
+ permissions: storedInboundMailboxGrantPermissions(row),
|
|
|
|
|
+ createdAt: row.grant_created_at,
|
|
|
|
|
+ updatedAt: row.grant_updated_at
|
|
|
|
|
+ });
|
|
|
|
|
+ grantsByMailboxId.set(row.mailbox_id, grants);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT
|
|
|
|
|
+ m.*,
|
|
|
|
|
+ d.domain,
|
|
|
|
|
+ COUNT(msg.id) AS message_count,
|
|
|
|
|
+ COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
|
|
|
|
|
+ MAX(msg.received_at) AS last_message_at
|
|
|
|
|
+ FROM inbound_mailboxes m
|
|
|
|
|
+ JOIN domains d ON d.id = m.domain_id
|
|
|
|
|
+ LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
|
|
|
|
|
+ WHERE m.deleted_at IS NULL
|
|
|
|
|
+ GROUP BY m.id
|
|
|
|
|
+ ORDER BY COALESCE(last_message_at, m.created_at) DESC, m.id DESC
|
|
|
|
|
+ `)
|
|
|
|
|
+ .all()
|
|
|
|
|
+ .map((row) => ({
|
|
|
|
|
+ mailbox: publicInboundMailbox(row),
|
|
|
|
|
+ owner: ownersById.get(row.user_id) || null,
|
|
|
|
|
+ grants: grantsByMailboxId.get(row.id) || []
|
|
|
|
|
+ }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function replaceInboundMailboxGrants(mailboxId, grants) {
|
|
|
|
|
+ return withTransaction(() => {
|
|
|
|
|
+ const mailbox = requireDb()
|
|
|
|
|
+ .prepare('SELECT id, user_id FROM inbound_mailboxes WHERE id = ? AND deleted_at IS NULL')
|
|
|
|
|
+ .get(Number(mailboxId));
|
|
|
|
|
+ if (!mailbox) throw new Error('收信邮箱不存在。');
|
|
|
|
|
+ if (!Array.isArray(grants)) throw new Error('邮箱授权列表格式不正确。');
|
|
|
|
|
+
|
|
|
|
|
+ const normalized = [];
|
|
|
|
|
+ const userIds = new Set();
|
|
|
|
|
+ for (const grant of grants) {
|
|
|
|
|
+ const userId = Number(grant?.userId);
|
|
|
|
|
+ if (!Number.isSafeInteger(userId) || userId <= 0) throw new Error('授权用户不正确。');
|
|
|
|
|
+ if (userId === Number(mailbox.user_id)) throw new Error('邮箱所有者无需额外授权。');
|
|
|
|
|
+ if (userIds.has(userId)) throw new Error('邮箱授权用户不能重复。');
|
|
|
|
|
+ userIds.add(userId);
|
|
|
|
|
+ const permissions = normalizeInboundMailboxGrantPermissions(grant?.permissions || grant);
|
|
|
|
|
+ if (!permissions.view && !permissions.receive && !permissions.send) {
|
|
|
|
|
+ throw new Error('邮箱授权至少需要一项权限。');
|
|
|
|
|
+ }
|
|
|
|
|
+ normalized.push({ userId, permissions });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (normalized.length) {
|
|
|
|
|
+ const placeholders = normalized.map(() => '?').join(', ');
|
|
|
|
|
+ const users = requireDb()
|
|
|
|
|
+ .prepare(`SELECT id FROM users WHERE id IN (${placeholders})`)
|
|
|
|
|
+ .all(...normalized.map((grant) => grant.userId));
|
|
|
|
|
+ if (users.length !== normalized.length) throw new Error('用户不存在。');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ requireDb().prepare('DELETE FROM inbound_mailbox_grants WHERE mailbox_id = ?').run(mailbox.id);
|
|
|
|
|
+ const timestamp = now();
|
|
|
|
|
+ const insert = requireDb().prepare(`
|
|
|
|
|
+ INSERT INTO inbound_mailbox_grants (
|
|
|
|
|
+ mailbox_id, user_id, can_view, can_receive, can_send, created_at, updated_at
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
+ `);
|
|
|
|
|
+ for (const grant of normalized) {
|
|
|
|
|
+ insert.run(
|
|
|
|
|
+ mailbox.id,
|
|
|
|
|
+ grant.userId,
|
|
|
|
|
+ boolString(grant.permissions.view),
|
|
|
|
|
+ boolString(grant.permissions.receive),
|
|
|
|
|
+ boolString(grant.permissions.send),
|
|
|
|
|
+ timestamp,
|
|
|
|
|
+ timestamp
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === mailbox.id) || null;
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredentialMode = 'domain_only' }) {
|
|
export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredentialMode = 'domain_only' }) {
|
|
|
return withTransaction(() => {
|
|
return withTransaction(() => {
|
|
|
const target = requireTransferTargetUser(targetUserId);
|
|
const target = requireTransferTargetUser(targetUserId);
|
|
@@ -653,6 +780,7 @@ export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredent
|
|
|
.prepare('UPDATE domains SET user_id = ?, dns_credential_id = ?, updated_at = ? WHERE id = ?')
|
|
.prepare('UPDATE domains SET user_id = ?, dns_credential_id = ?, updated_at = ? WHERE id = ?')
|
|
|
.run(target.id, nextDnsCredentialId, now(), domain.id);
|
|
.run(target.id, nextDnsCredentialId, now(), domain.id);
|
|
|
const inboundCounts = moveInboundDomainResources(domain.id, target.id);
|
|
const inboundCounts = moveInboundDomainResources(domain.id, target.id);
|
|
|
|
|
+ deleteSelfInboundMailboxGrants({ domainId: domain.id });
|
|
|
if (mode === 'with_dns_credential' && domain.dns_credential_id) {
|
|
if (mode === 'with_dns_credential' && domain.dns_credential_id) {
|
|
|
const credential = requireDnsCredentialRow(domain.dns_credential_id);
|
|
const credential = requireDnsCredentialRow(domain.dns_credential_id);
|
|
|
if (credential.user_id !== domain.user_id) throw new Error('DNS 凭据归属不一致。');
|
|
if (credential.user_id !== domain.user_id) throw new Error('DNS 凭据归属不一致。');
|
|
@@ -813,6 +941,8 @@ export function executeUserMerge({ actorUserId, sourceUserId, targetUserId, opti
|
|
|
if (options.transferSmtpCredential !== false && preview.counts.smtpCredential > 0) {
|
|
if (options.transferSmtpCredential !== false && preview.counts.smtpCredential > 0) {
|
|
|
counts.smtpCredential = moveRows('smtp_credentials', sourceId, targetId);
|
|
counts.smtpCredential = moveRows('smtp_credentials', sourceId, targetId);
|
|
|
}
|
|
}
|
|
|
|
|
+ mergeInboundMailboxGrantsForUsers(sourceId, targetId);
|
|
|
|
|
+ deleteSelfInboundMailboxGrants({ ownerUserId: targetId });
|
|
|
if (options.disableSource !== false) {
|
|
if (options.disableSource !== false) {
|
|
|
requireDb()
|
|
requireDb()
|
|
|
.prepare("UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?")
|
|
.prepare("UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?")
|
|
@@ -1182,48 +1312,67 @@ export function updateInboundMailbox(userId, id, patch = {}) {
|
|
|
|
|
|
|
|
export function listInboundMailboxes(userId, access = {}) {
|
|
export function listInboundMailboxes(userId, access = {}) {
|
|
|
const accessFilter = inboundAccessFilter('m', userId, access);
|
|
const accessFilter = inboundAccessFilter('m', userId, access);
|
|
|
|
|
+ const exposeAccess = accessFilter.permission !== 'owner' || Boolean(access.includeAllUsers);
|
|
|
return requireDb()
|
|
return requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
SELECT
|
|
SELECT
|
|
|
m.*,
|
|
m.*,
|
|
|
d.domain,
|
|
d.domain,
|
|
|
|
|
+ access_grant.can_view AS access_can_view,
|
|
|
|
|
+ access_grant.can_receive AS access_can_receive,
|
|
|
|
|
+ access_grant.can_send AS access_can_send,
|
|
|
COUNT(msg.id) AS message_count,
|
|
COUNT(msg.id) AS message_count,
|
|
|
COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
|
|
COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
|
|
|
MAX(msg.received_at) AS last_message_at
|
|
MAX(msg.received_at) AS last_message_at
|
|
|
FROM inbound_mailboxes m
|
|
FROM inbound_mailboxes m
|
|
|
JOIN domains d ON d.id = m.domain_id
|
|
JOIN domains d ON d.id = m.domain_id
|
|
|
|
|
+ LEFT JOIN inbound_mailbox_grants access_grant
|
|
|
|
|
+ ON access_grant.mailbox_id = m.id AND access_grant.user_id = ?
|
|
|
LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
|
|
LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
|
|
|
WHERE ${accessFilter.clause} AND m.deleted_at IS NULL
|
|
WHERE ${accessFilter.clause} AND m.deleted_at IS NULL
|
|
|
GROUP BY m.id
|
|
GROUP BY m.id
|
|
|
ORDER BY COALESCE(last_message_at, m.created_at) DESC, m.id DESC
|
|
ORDER BY COALESCE(last_message_at, m.created_at) DESC, m.id DESC
|
|
|
`)
|
|
`)
|
|
|
- .all(...accessFilter.params)
|
|
|
|
|
- .map(publicInboundMailbox);
|
|
|
|
|
|
|
+ .all(Number(userId), ...accessFilter.params)
|
|
|
|
|
+ .map((row) => publicInboundMailbox(row, {
|
|
|
|
|
+ accessContext: exposeAccess ? { userId, includeAllUsers: access.includeAllUsers } : null
|
|
|
|
|
+ }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function getInboundMailbox(id, userId, {
|
|
export function getInboundMailbox(id, userId, {
|
|
|
includeHash = false,
|
|
includeHash = false,
|
|
|
includeSecret = false,
|
|
includeSecret = false,
|
|
|
includeAllUsers = false,
|
|
includeAllUsers = false,
|
|
|
- mailboxIds = null
|
|
|
|
|
|
|
+ mailboxIds = null,
|
|
|
|
|
+ permission = 'owner'
|
|
|
} = {}) {
|
|
} = {}) {
|
|
|
- const accessFilter = inboundAccessFilter('m', userId, { includeAllUsers, mailboxIds });
|
|
|
|
|
|
|
+ const accessFilter = inboundAccessFilter('m', userId, { includeAllUsers, mailboxIds, permission });
|
|
|
|
|
+ const exposeAccess = accessFilter.permission !== 'owner' || includeAllUsers;
|
|
|
const row = requireDb()
|
|
const row = requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
SELECT
|
|
SELECT
|
|
|
m.*,
|
|
m.*,
|
|
|
d.domain,
|
|
d.domain,
|
|
|
|
|
+ access_grant.can_view AS access_can_view,
|
|
|
|
|
+ access_grant.can_receive AS access_can_receive,
|
|
|
|
|
+ access_grant.can_send AS access_can_send,
|
|
|
COUNT(msg.id) AS message_count,
|
|
COUNT(msg.id) AS message_count,
|
|
|
COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
|
|
COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
|
|
|
MAX(msg.received_at) AS last_message_at
|
|
MAX(msg.received_at) AS last_message_at
|
|
|
FROM inbound_mailboxes m
|
|
FROM inbound_mailboxes m
|
|
|
JOIN domains d ON d.id = m.domain_id
|
|
JOIN domains d ON d.id = m.domain_id
|
|
|
|
|
+ LEFT JOIN inbound_mailbox_grants access_grant
|
|
|
|
|
+ ON access_grant.mailbox_id = m.id AND access_grant.user_id = ?
|
|
|
LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
|
|
LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
|
|
|
WHERE m.id = ? AND ${accessFilter.clause} AND m.deleted_at IS NULL
|
|
WHERE m.id = ? AND ${accessFilter.clause} AND m.deleted_at IS NULL
|
|
|
GROUP BY m.id
|
|
GROUP BY m.id
|
|
|
`)
|
|
`)
|
|
|
- .get(Number(id), ...accessFilter.params);
|
|
|
|
|
- return publicInboundMailbox(row, { includeHash, includeSecret });
|
|
|
|
|
|
|
+ .get(Number(userId), Number(id), ...accessFilter.params);
|
|
|
|
|
+ return publicInboundMailbox(row, {
|
|
|
|
|
+ includeHash,
|
|
|
|
|
+ includeSecret,
|
|
|
|
|
+ accessContext: exposeAccess ? { userId, includeAllUsers } : null
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function getInboundMailboxByAddress(address, { includeHash = false, includeSecret = false } = {}) {
|
|
export function getInboundMailboxByAddress(address, { includeHash = false, includeSecret = false } = {}) {
|
|
@@ -1246,11 +1395,23 @@ export function getInboundMailboxByAddress(address, { includeHash = false, inclu
|
|
|
return publicInboundMailbox(row, { includeHash, includeSecret });
|
|
return publicInboundMailbox(row, { includeHash, includeSecret });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export function getInboundMailboxForSender(userId, address) {
|
|
|
|
|
|
|
+export function getInboundMailboxForSender(userId, address, {
|
|
|
|
|
+ ownerOnly = false,
|
|
|
|
|
+ mailboxIds = null
|
|
|
|
|
+} = {}) {
|
|
|
const cleanAddress = normalizeInboundAddress(address);
|
|
const cleanAddress = normalizeInboundAddress(address);
|
|
|
if (!cleanAddress) return null;
|
|
if (!cleanAddress) return null;
|
|
|
const mailbox = getInboundMailboxByAddress(cleanAddress) || getInboundMailboxByAliasAddress(cleanAddress);
|
|
const mailbox = getInboundMailboxByAddress(cleanAddress) || getInboundMailboxByAliasAddress(cleanAddress);
|
|
|
- return mailbox?.userId === Number(userId) ? mailbox : null;
|
|
|
|
|
|
|
+ if (!mailbox) return null;
|
|
|
|
|
+ if (Array.isArray(mailboxIds)) {
|
|
|
|
|
+ const allowedMailboxIds = new Set(mailboxIds
|
|
|
|
|
+ .map((id) => Number(id))
|
|
|
|
|
+ .filter((id) => Number.isSafeInteger(id) && id > 0));
|
|
|
|
|
+ if (!allowedMailboxIds.has(Number(mailbox.id))) return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (mailbox.userId === Number(userId)) return mailbox;
|
|
|
|
|
+ if (ownerOnly) return null;
|
|
|
|
|
+ return hasInboundMailboxGrantPermission(mailbox.id, userId, 'send') ? mailbox : null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function verifyInboundMailboxCredential(username, password) {
|
|
export function verifyInboundMailboxCredential(username, password) {
|
|
@@ -1901,17 +2062,18 @@ export function markMissingInboundMaildirMessages(mailboxId, presentStorageKeys)
|
|
|
.run(updatedAt, updatedAt, updatedAt, Number(mailboxId), ...missing).changes || 0);
|
|
.run(updatedAt, updatedAt, updatedAt, Number(mailboxId), ...missing).changes || 0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export function getInboundMessageMaildirStorage(userId, id) {
|
|
|
|
|
|
|
+export function getInboundMessageMaildirStorage(userId, id, access = {}) {
|
|
|
|
|
+ const accessFilter = inboundAccessFilter('msg', userId, access);
|
|
|
const row = requireDb()
|
|
const row = requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
SELECT msg.id, msg.storage_backend, msg.storage_key, msg.storage_relpath,
|
|
SELECT msg.id, msg.storage_backend, msg.storage_key, msg.storage_relpath,
|
|
|
msg.flags_json, msg.read_state, m.address AS mailbox_address
|
|
msg.flags_json, msg.read_state, m.address AS mailbox_address
|
|
|
FROM inbound_messages msg
|
|
FROM inbound_messages msg
|
|
|
JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
|
- WHERE msg.id = ? AND msg.user_id = ? AND msg.deleted_at IS NULL
|
|
|
|
|
|
|
+ WHERE msg.id = ? AND ${accessFilter.clause} AND msg.deleted_at IS NULL
|
|
|
LIMIT 1
|
|
LIMIT 1
|
|
|
`)
|
|
`)
|
|
|
- .get(Number(id), Number(userId));
|
|
|
|
|
|
|
+ .get(Number(id), ...accessFilter.params);
|
|
|
if (!row) return null;
|
|
if (!row) return null;
|
|
|
return {
|
|
return {
|
|
|
id: Number(row.id),
|
|
id: Number(row.id),
|
|
@@ -2241,20 +2403,25 @@ export function getInboundMailboxProtocolMessage(mailbox, messageId, { folder =
|
|
|
return publicInboundMessage(row, { includeRawBytes: true });
|
|
return publicInboundMessage(row, { includeRawBytes: true });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export function markInboundMessageRead(userId, id, read = true) {
|
|
|
|
|
|
|
+export function markInboundMessageRead(userId, id, read = true, access = {}) {
|
|
|
|
|
+ const accessFilter = inboundAccessFilter('inbound_messages', userId, access);
|
|
|
const current = requireDb()
|
|
const current = requireDb()
|
|
|
- .prepare('SELECT flags_json FROM inbound_messages WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
- .get(Number(id), userId);
|
|
|
|
|
|
|
+ .prepare(`SELECT flags_json FROM inbound_messages WHERE id = ? AND ${accessFilter.clause} AND deleted_at IS NULL`)
|
|
|
|
|
+ .get(Number(id), ...accessFilter.params);
|
|
|
if (!current) return null;
|
|
if (!current) return null;
|
|
|
const flags = normalizeImportedStringList(safeJson(current.flags_json, []))
|
|
const flags = normalizeImportedStringList(safeJson(current.flags_json, []))
|
|
|
.filter((flag) => flag.toLowerCase() !== '\\seen');
|
|
.filter((flag) => flag.toLowerCase() !== '\\seen');
|
|
|
if (read) flags.push('\\Seen');
|
|
if (read) flags.push('\\Seen');
|
|
|
const updatedAt = now();
|
|
const updatedAt = now();
|
|
|
const result = requireDb()
|
|
const result = requireDb()
|
|
|
- .prepare('UPDATE inbound_messages SET read_state = ?, flags_json = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
- .run(read ? 'true' : 'false', JSON.stringify(flags), updatedAt, Number(id), userId);
|
|
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ UPDATE inbound_messages
|
|
|
|
|
+ SET read_state = ?, flags_json = ?, updated_at = ?
|
|
|
|
|
+ WHERE id = ? AND ${accessFilter.clause} AND deleted_at IS NULL
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(read ? 'true' : 'false', JSON.stringify(flags), updatedAt, Number(id), ...accessFilter.params);
|
|
|
if (!result.changes) return null;
|
|
if (!result.changes) return null;
|
|
|
- return getInboundMessage(userId, id);
|
|
|
|
|
|
|
+ return getInboundMessage(userId, id, access);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function softDeleteInboundMessages(userId, mailboxId, ids, { folder = null } = {}) {
|
|
export function softDeleteInboundMessages(userId, mailboxId, ids, { folder = null } = {}) {
|
|
@@ -4488,6 +4655,83 @@ function moveInboundResourcesForUserDomains(sourceUserId, targetUserId) {
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function deleteSelfInboundMailboxGrants({ domainId = null, ownerUserId = null } = {}) {
|
|
|
|
|
+ const filters = [];
|
|
|
|
|
+ const params = [];
|
|
|
|
|
+ if (domainId !== null) {
|
|
|
|
|
+ filters.push('m.domain_id = ?');
|
|
|
|
|
+ params.push(Number(domainId));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ownerUserId !== null) {
|
|
|
|
|
+ filters.push('m.user_id = ?');
|
|
|
|
|
+ params.push(Number(ownerUserId));
|
|
|
|
|
+ }
|
|
|
|
|
+ const extraWhere = filters.length ? `AND ${filters.join(' AND ')}` : '';
|
|
|
|
|
+ return requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ DELETE FROM inbound_mailbox_grants
|
|
|
|
|
+ WHERE EXISTS (
|
|
|
|
|
+ SELECT 1
|
|
|
|
|
+ FROM inbound_mailboxes m
|
|
|
|
|
+ WHERE m.id = inbound_mailbox_grants.mailbox_id
|
|
|
|
|
+ AND m.user_id = inbound_mailbox_grants.user_id
|
|
|
|
|
+ ${extraWhere}
|
|
|
|
|
+ )
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(...params).changes;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function mergeInboundMailboxGrantsForUsers(sourceUserId, targetUserId) {
|
|
|
|
|
+ const sourceGrants = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT g.*, m.user_id AS mailbox_owner_user_id
|
|
|
|
|
+ FROM inbound_mailbox_grants g
|
|
|
|
|
+ JOIN inbound_mailboxes m ON m.id = g.mailbox_id
|
|
|
|
|
+ WHERE g.user_id = ?
|
|
|
|
|
+ `)
|
|
|
|
|
+ .all(Number(sourceUserId));
|
|
|
|
|
+ if (!sourceGrants.length) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ const getTargetGrant = requireDb().prepare(`
|
|
|
|
|
+ SELECT * FROM inbound_mailbox_grants WHERE mailbox_id = ? AND user_id = ?
|
|
|
|
|
+ `);
|
|
|
|
|
+ const upsert = requireDb().prepare(`
|
|
|
|
|
+ INSERT INTO inbound_mailbox_grants (
|
|
|
|
|
+ mailbox_id, user_id, can_view, can_receive, can_send, created_at, updated_at
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
+ ON CONFLICT(mailbox_id, user_id) DO UPDATE SET
|
|
|
|
|
+ can_view = excluded.can_view,
|
|
|
|
|
+ can_receive = excluded.can_receive,
|
|
|
|
|
+ can_send = excluded.can_send,
|
|
|
|
|
+ updated_at = excluded.updated_at
|
|
|
|
|
+ `);
|
|
|
|
|
+ const timestamp = now();
|
|
|
|
|
+ let moved = 0;
|
|
|
|
|
+ for (const sourceGrant of sourceGrants) {
|
|
|
|
|
+ if (Number(sourceGrant.mailbox_owner_user_id) === Number(targetUserId)) continue;
|
|
|
|
|
+ const targetGrant = getTargetGrant.get(sourceGrant.mailbox_id, Number(targetUserId));
|
|
|
|
|
+ const sourcePermissions = storedInboundMailboxGrantPermissions(sourceGrant);
|
|
|
|
|
+ const targetPermissions = storedInboundMailboxGrantPermissions(targetGrant);
|
|
|
|
|
+ const permissions = normalizeInboundMailboxGrantPermissions({
|
|
|
|
|
+ view: sourcePermissions.view || targetPermissions.view,
|
|
|
|
|
+ receive: sourcePermissions.receive || targetPermissions.receive,
|
|
|
|
|
+ send: sourcePermissions.send || targetPermissions.send
|
|
|
|
|
+ });
|
|
|
|
|
+ upsert.run(
|
|
|
|
|
+ sourceGrant.mailbox_id,
|
|
|
|
|
+ Number(targetUserId),
|
|
|
|
|
+ boolString(permissions.view),
|
|
|
|
|
+ boolString(permissions.receive),
|
|
|
|
|
+ boolString(permissions.send),
|
|
|
|
|
+ targetGrant?.created_at || sourceGrant.created_at || timestamp,
|
|
|
|
|
+ timestamp
|
|
|
|
|
+ );
|
|
|
|
|
+ moved += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ requireDb().prepare('DELETE FROM inbound_mailbox_grants WHERE user_id = ?').run(Number(sourceUserId));
|
|
|
|
|
+ return moved;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function requireDomainRow(domainId) {
|
|
function requireDomainRow(domainId) {
|
|
|
const domain = requireDb().prepare('SELECT * FROM domains WHERE id = ?').get(Number(domainId));
|
|
const domain = requireDb().prepare('SELECT * FROM domains WHERE id = ?').get(Number(domainId));
|
|
|
if (!domain) throw new Error('域名不存在。');
|
|
if (!domain) throw new Error('域名不存在。');
|
|
@@ -4738,13 +4982,20 @@ function privateDomainRow(row) {
|
|
|
return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
|
|
return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function publicInboundMailbox(row, { includeHash = false, includeSecret = false } = {}) {
|
|
|
|
|
|
|
+function publicInboundMailbox(row, {
|
|
|
|
|
+ includeHash = false,
|
|
|
|
|
+ includeSecret = false,
|
|
|
|
|
+ accessContext = null
|
|
|
|
|
+} = {}) {
|
|
|
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;
|
|
const expiresAt = row.expires_at || null;
|
|
|
|
|
+ const access = inboundMailboxAccessFromRow(row, accessContext);
|
|
|
|
|
+ const hideMessageMetadata = access?.type === 'assigned' && !access.permissions.receive;
|
|
|
return {
|
|
return {
|
|
|
id: row.id,
|
|
id: row.id,
|
|
|
userId: row.user_id,
|
|
userId: row.user_id,
|
|
|
|
|
+ ownerUserId: row.user_id,
|
|
|
domainId: row.domain_id,
|
|
domainId: row.domain_id,
|
|
|
domain: row.domain || '',
|
|
domain: row.domain || '',
|
|
|
address: row.address,
|
|
address: row.address,
|
|
@@ -4759,9 +5010,10 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
|
|
|
status: inboundMailboxStatus(row),
|
|
status: inboundMailboxStatus(row),
|
|
|
expiresAt,
|
|
expiresAt,
|
|
|
temporary: Boolean(expiresAt),
|
|
temporary: Boolean(expiresAt),
|
|
|
- messageCount: Number(row.message_count || 0),
|
|
|
|
|
- unreadCount: Number(row.unread_count || 0),
|
|
|
|
|
- lastMessageAt: row.last_message_at || null,
|
|
|
|
|
|
|
+ messageCount: hideMessageMetadata ? null : Number(row.message_count || 0),
|
|
|
|
|
+ unreadCount: hideMessageMetadata ? null : Number(row.unread_count || 0),
|
|
|
|
|
+ lastMessageAt: hideMessageMetadata ? null : (row.last_message_at || null),
|
|
|
|
|
+ ...(access ? { access } : {}),
|
|
|
...(includeHash ? { passwordHash: row.password_hash } : {}),
|
|
...(includeHash ? { passwordHash: row.password_hash } : {}),
|
|
|
...(includeSecret ? { passwordSecret: row.password_secret } : {}),
|
|
...(includeSecret ? { passwordSecret: row.password_secret } : {}),
|
|
|
createdAt: row.created_at,
|
|
createdAt: row.created_at,
|
|
@@ -4769,6 +5021,27 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function inboundMailboxAccessFromRow(row, context) {
|
|
|
|
|
+ if (!row || !context) return null;
|
|
|
|
|
+ if (Number(row.user_id) === Number(context.userId)) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ type: 'owner',
|
|
|
|
|
+ permissions: { view: true, receive: true, send: true }
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ const permissions = storedInboundMailboxGrantPermissions(row, 'access_');
|
|
|
|
|
+ if (permissions.view || permissions.receive || permissions.send) {
|
|
|
|
|
+ return { type: 'assigned', permissions };
|
|
|
|
|
+ }
|
|
|
|
|
+ if (context.includeAllUsers) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ type: 'admin',
|
|
|
|
|
+ permissions: { view: true, receive: true, send: false }
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function publicInboundMessage(row, { includeBody = false, includeRawBytes = false } = {}) {
|
|
function publicInboundMessage(row, { includeBody = false, includeRawBytes = false } = {}) {
|
|
|
if (!row) return null;
|
|
if (!row) return null;
|
|
|
const rawMessageSize = row.raw_message_size === undefined
|
|
const rawMessageSize = row.raw_message_size === undefined
|
|
@@ -5450,13 +5723,35 @@ function normalizeUserRolePatch(value, fallback) {
|
|
|
throw new Error('用户角色不正确。');
|
|
throw new Error('用户角色不正确。');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function inboundAccessFilter(alias, userId, { includeAllUsers = false, mailboxIds = null } = {}) {
|
|
|
|
|
|
|
+function inboundAccessFilter(alias, userId, {
|
|
|
|
|
+ includeAllUsers = false,
|
|
|
|
|
+ mailboxIds = null,
|
|
|
|
|
+ permission = 'owner'
|
|
|
|
|
+} = {}) {
|
|
|
const clauses = [];
|
|
const clauses = [];
|
|
|
const params = [];
|
|
const params = [];
|
|
|
const mailboxColumn = alias === 'm' ? 'id' : 'mailbox_id';
|
|
const mailboxColumn = alias === 'm' ? 'id' : 'mailbox_id';
|
|
|
|
|
+ const cleanPermission = normalizeInboundMailboxAccessPermission(permission);
|
|
|
if (!includeAllUsers) {
|
|
if (!includeAllUsers) {
|
|
|
- clauses.push(`${alias}.user_id = ?`);
|
|
|
|
|
- params.push(Number(userId));
|
|
|
|
|
|
|
+ if (cleanPermission === 'owner') {
|
|
|
|
|
+ clauses.push(`${alias}.user_id = ?`);
|
|
|
|
|
+ params.push(Number(userId));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const grantPermission = cleanPermission === 'receive'
|
|
|
|
|
+ ? "grant_access.can_receive = 'true'"
|
|
|
|
|
+ : "(grant_access.can_view = 'true' OR grant_access.can_receive = 'true' OR grant_access.can_send = 'true')";
|
|
|
|
|
+ clauses.push(`(
|
|
|
|
|
+ ${alias}.user_id = ?
|
|
|
|
|
+ OR EXISTS (
|
|
|
|
|
+ SELECT 1
|
|
|
|
|
+ FROM inbound_mailbox_grants grant_access
|
|
|
|
|
+ WHERE grant_access.mailbox_id = ${alias}.${mailboxColumn}
|
|
|
|
|
+ AND grant_access.user_id = ?
|
|
|
|
|
+ AND ${grantPermission}
|
|
|
|
|
+ )
|
|
|
|
|
+ )`);
|
|
|
|
|
+ params.push(Number(userId), Number(userId));
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
if (Array.isArray(mailboxIds)) {
|
|
if (Array.isArray(mailboxIds)) {
|
|
|
const ids = [...new Set(mailboxIds
|
|
const ids = [...new Set(mailboxIds
|
|
@@ -5471,10 +5766,67 @@ function inboundAccessFilter(alias, userId, { includeAllUsers = false, mailboxId
|
|
|
}
|
|
}
|
|
|
return {
|
|
return {
|
|
|
clause: clauses.length ? clauses.join(' AND ') : '1 = 1',
|
|
clause: clauses.length ? clauses.join(' AND ') : '1 = 1',
|
|
|
- params
|
|
|
|
|
|
|
+ params,
|
|
|
|
|
+ permission: cleanPermission
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function normalizeInboundMailboxAccessPermission(value) {
|
|
|
|
|
+ const permission = String(value || 'owner').trim().toLowerCase();
|
|
|
|
|
+ if (!['owner', 'view', 'receive'].includes(permission)) {
|
|
|
|
|
+ throw new Error('邮箱访问权限不正确。');
|
|
|
|
|
+ }
|
|
|
|
|
+ return permission;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeInboundMailboxGrantPermissions(value = {}) {
|
|
|
|
|
+ const permissions = Array.isArray(value)
|
|
|
|
|
+ ? {
|
|
|
|
|
+ view: value.includes('view'),
|
|
|
|
|
+ receive: value.includes('receive'),
|
|
|
|
|
+ send: value.includes('send')
|
|
|
|
|
+ }
|
|
|
|
|
+ : value;
|
|
|
|
|
+ if (!permissions || typeof permissions !== 'object') throw new Error('邮箱授权权限格式不正确。');
|
|
|
|
|
+ for (const key of ['view', 'receive', 'send']) {
|
|
|
|
|
+ if (permissions[key] !== undefined && typeof permissions[key] !== 'boolean') {
|
|
|
|
|
+ throw new Error('邮箱授权权限格式不正确。');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const receive = permissions.receive === true;
|
|
|
|
|
+ const send = permissions.send === true;
|
|
|
|
|
+ return {
|
|
|
|
|
+ view: permissions.view === true || receive || send,
|
|
|
|
|
+ receive,
|
|
|
|
|
+ send
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function storedInboundMailboxGrantPermissions(row, prefix = '') {
|
|
|
|
|
+ return {
|
|
|
|
|
+ view: row?.[`${prefix}can_view`] === 'true',
|
|
|
|
|
+ receive: row?.[`${prefix}can_receive`] === 'true',
|
|
|
|
|
+ send: row?.[`${prefix}can_send`] === 'true'
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function hasInboundMailboxGrantPermission(mailboxId, userId, permission) {
|
|
|
|
|
+ const column = {
|
|
|
|
|
+ view: 'can_view',
|
|
|
|
|
+ receive: 'can_receive',
|
|
|
|
|
+ send: 'can_send'
|
|
|
|
|
+ }[String(permission || '').trim().toLowerCase()];
|
|
|
|
|
+ if (!column) throw new Error('邮箱授权权限不正确。');
|
|
|
|
|
+ return Boolean(requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT 1
|
|
|
|
|
+ FROM inbound_mailbox_grants
|
|
|
|
|
+ WHERE mailbox_id = ? AND user_id = ? AND ${column} = 'true'
|
|
|
|
|
+ LIMIT 1
|
|
|
|
|
+ `)
|
|
|
|
|
+ .get(Number(mailboxId), Number(userId)));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function normalizeApiTokenScopes(value) {
|
|
function normalizeApiTokenScopes(value) {
|
|
|
const candidates = value === undefined ? defaultApiTokenScopes : (Array.isArray(value) ? value : [value]);
|
|
const candidates = value === undefined ? defaultApiTokenScopes : (Array.isArray(value) ? value : [value]);
|
|
|
const scopes = [...new Set(candidates.map((item) => String(item || '').trim()).filter(Boolean))];
|
|
const scopes = [...new Set(candidates.map((item) => String(item || '').trim()).filter(Boolean))];
|
|
@@ -5508,8 +5860,21 @@ function normalizeApiTokenMailboxAccess(userId, { mailboxAccess = 'owner', mailb
|
|
|
const params = [...ids];
|
|
const params = [...ids];
|
|
|
const where = [`id IN (${placeholders})`, 'deleted_at IS NULL'];
|
|
const where = [`id IN (${placeholders})`, 'deleted_at IS NULL'];
|
|
|
if (user.role !== 'admin') {
|
|
if (user.role !== 'admin') {
|
|
|
- where.push('user_id = ?');
|
|
|
|
|
- params.push(Number(userId));
|
|
|
|
|
|
|
+ where.push(`(
|
|
|
|
|
+ user_id = ?
|
|
|
|
|
+ OR EXISTS (
|
|
|
|
|
+ SELECT 1
|
|
|
|
|
+ FROM inbound_mailbox_grants grant_access
|
|
|
|
|
+ WHERE grant_access.mailbox_id = inbound_mailboxes.id
|
|
|
|
|
+ AND grant_access.user_id = ?
|
|
|
|
|
+ AND (
|
|
|
|
|
+ grant_access.can_view = 'true'
|
|
|
|
|
+ OR grant_access.can_receive = 'true'
|
|
|
|
|
+ OR grant_access.can_send = 'true'
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ )`);
|
|
|
|
|
+ params.push(Number(userId), Number(userId));
|
|
|
}
|
|
}
|
|
|
const rows = requireDb()
|
|
const rows = requireDb()
|
|
|
.prepare(`SELECT id FROM inbound_mailboxes WHERE ${where.join(' AND ')}`)
|
|
.prepare(`SELECT id FROM inbound_mailboxes WHERE ${where.join(' AND ')}`)
|