Skip to content
File

Blob: src/worker/services/inbox/domains.ts

typescript176 lines
1import { and, asc, count, eq, inArray } from "drizzle-orm";
2import { nanoid } from "nanoid";
3import { createDb, type Database } from "@/worker/db";
4import { domains, emails, inboxes } from "@/worker/db/schema";
5import { createLogger } from "@/worker/logger";
6import { PublicError } from "@/worker/security";
7import { normalizeDomainName, RESERVED_ADDRESSES, RESERVED_ADDRESS_VALUES } from "./shared";
8 
9const logger = createLogger("inbox-service");
10 
11export async function seedPermanentInboxesForDomains(env: Env, domainNames: string[], db?: Database) {
12 if (domainNames.length === 0) {
13 return;
14 }
15 
16 const database = db ?? createDb(env.DB);
17 
18 const values = domainNames.flatMap((domainName) =>
19 RESERVED_ADDRESSES.map((localPart) => ({
20 id: nanoid(),
21 localPart,
22 domain: domainName,
23 fullAddress: `${localPart}@${domainName}`,
24 isPermanent: true,
25 expiresAt: null,
26 })),
27 );
28 
29 if (values.length > 0) {
30 await database.insert(inboxes).values(values).onConflictDoNothing();
31 }
32}
33 
34export async function listDomainsForAdmin(env: Env, db?: Database) {
35 const database = db ?? createDb(env.DB);
36 const [domainRows, inboxCounts, deletableReservedInboxCounts, emailCounts] = await Promise.all([
37 database.query.domains.findMany({
38 orderBy: [asc(domains.domain)],
39 }),
40 database.select({ domain: inboxes.domain, inboxCount: count() }).from(inboxes).groupBy(inboxes.domain),
41 database
42 .select({ domain: inboxes.domain, inboxCount: count() })
43 .from(inboxes)
44 .where(and(eq(inboxes.isPermanent, true), inArray(inboxes.localPart, RESERVED_ADDRESS_VALUES)))
45 .groupBy(inboxes.domain),
46 database
47 .select({ domain: inboxes.domain, emailCount: count(emails.id) })
48 .from(inboxes)
49 .leftJoin(emails, eq(emails.inboxId, inboxes.id))
50 .groupBy(inboxes.domain),
51 ]);
52 
53 const countByDomain = new Map(inboxCounts.map((row) => [row.domain, row.inboxCount]));
54 const deletableReservedCountByDomain = new Map(
55 deletableReservedInboxCounts.map((row) => [row.domain, row.inboxCount]),
56 );
57 const emailCountByDomain = new Map(emailCounts.map((row) => [row.domain, row.emailCount]));
58 
59 return domainRows.map((domainRow) => {
60 const inboxCount = countByDomain.get(domainRow.domain) ?? 0;
61 const deletableReservedInboxCount = deletableReservedCountByDomain.get(domainRow.domain) ?? 0;
62 const emailCount = emailCountByDomain.get(domainRow.domain) ?? 0;
63 
64 return {
65 domain: domainRow.domain,
66 isActive: domainRow.isActive,
67 createdAt: domainRow.createdAt,
68 inboxCount,
69 canDelete: inboxCount === 0 || (deletableReservedInboxCount === inboxCount && emailCount === 0),
70 };
71 });
72}
73 
74export async function addDomain(env: Env, domainName: string, isActive = true, db?: Database) {
75 const normalizedDomain = normalizeDomainName(domainName);
76 const database = db ?? createDb(env.DB);
77 
78 const existingDomain = await database.query.domains.findFirst({
79 where: eq(domains.domain, normalizedDomain),
80 });
81 
82 if (existingDomain) {
83 throw new PublicError("Domain already exists");
84 }
85 
86 await database.insert(domains).values({
87 id: nanoid(),
88 domain: normalizedDomain,
89 isActive,
90 createdAt: new Date(),
91 });
92 
93 if (isActive) {
94 await seedPermanentInboxesForDomains(env, [normalizedDomain], database);
95 }
96 
97 logger.info("domain_added", "Added domain to pool", {
98 domain: normalizedDomain,
99 isActive,
100 });
101}
102 
103export async function updateDomainStatus(env: Env, domainName: string, isActive: boolean, db?: Database) {
104 const normalizedDomain = normalizeDomainName(domainName);
105 const database = db ?? createDb(env.DB);
106 const existingDomain = await database.query.domains.findFirst({
107 where: eq(domains.domain, normalizedDomain),
108 });
109 
110 if (!existingDomain) {
111 throw new PublicError("Domain not found");
112 }
113 
114 await database.update(domains).set({ isActive }).where(eq(domains.id, existingDomain.id));
115 
116 if (isActive) {
117 await seedPermanentInboxesForDomains(env, [normalizedDomain], database);
118 }
119 
120 logger.info("domain_status_updated", "Updated domain availability", {
121 domain: normalizedDomain,
122 isActive,
123 });
124}
125 
126export async function deleteDomainByName(env: Env, domainName: string, db?: Database) {
127 const normalizedDomain = normalizeDomainName(domainName);
128 const database = db ?? createDb(env.DB);
129 const existingDomain = await database.query.domains.findFirst({
130 where: eq(domains.domain, normalizedDomain),
131 });
132 
133 if (!existingDomain) {
134 throw new PublicError("Domain not found");
135 }
136 
137 const deletableReservedInboxFilter = and(
138 eq(inboxes.domain, normalizedDomain),
139 eq(inboxes.isPermanent, true),
140 inArray(inboxes.localPart, RESERVED_ADDRESS_VALUES),
141 );
142 const [totalInboxRows, deletableReservedInboxRows] = await Promise.all([
143 database.select({ total: count() }).from(inboxes).where(eq(inboxes.domain, normalizedDomain)),
144 database.select({ total: count() }).from(inboxes).where(deletableReservedInboxFilter),
145 ]);
146 const totalInboxCount = totalInboxRows[0]?.total ?? 0;
147 const deletableReservedInboxCount = deletableReservedInboxRows[0]?.total ?? 0;
148 
149 if (deletableReservedInboxCount !== totalInboxCount) {
150 throw new PublicError("Domain still has inboxes. Disable it instead of deleting it.");
151 }
152 
153 if (deletableReservedInboxCount > 0) {
154 const [emailCountRows] = await Promise.all([
155 database
156 .select({ total: count(emails.id) })
157 .from(inboxes)
158 .leftJoin(emails, eq(emails.inboxId, inboxes.id))
159 .where(deletableReservedInboxFilter),
160 ]);
161 const emailCount = emailCountRows[0]?.total ?? 0;
162 
163 if (emailCount > 0) {
164 throw new PublicError("Domain still has inboxes. Disable it instead of deleting it.");
165 }
166 
167 await database.delete(inboxes).where(deletableReservedInboxFilter);
168 }
169 
170 await database.delete(domains).where(eq(domains.id, existingDomain.id));
171 
172 logger.info("domain_deleted", "Deleted domain from pool", {
173 domain: normalizedDomain,
174 });
175}