Skip to content

Commit 68b8982

Browse files
committed
pref: optimization around the codebase
1 parent d6a56f5 commit 68b8982

7 files changed

Lines changed: 206 additions & 73 deletions

File tree

src/config/constants.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Application constants
3+
*/
4+
5+
// HTML processing constants
6+
export const HTML_PROCESSING = {
7+
WORDWRAP_LENGTH: 130,
8+
MAX_CONVERSION_SIZE: 900 * 1024, // 900KB limit for HTML to text conversion
9+
} as const;
10+
11+
// KV operation constants
12+
export const KV_LIMITS = {
13+
MAX_SENDER_KEYS: 1000,
14+
BATCH_SIZE: 50,
15+
LIST_BATCH_SIZE: 100,
16+
} as const;
17+
18+
// Cache constants
19+
export const CACHE = {
20+
DOMAINS_TTL: 3600, // 1 hour
21+
} as const;

src/database/d1.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { D1Database } from "@cloudflare/workers-types";
21
import type { Email, EmailSummary } from "@/schemas/emails/schema";
32

43
/**

src/database/kv.ts

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { KV_LIMITS } from "@/config/constants";
12
import { getDomain } from "@/utils/mail";
23

34
/**
@@ -10,47 +11,71 @@ export async function updateSenderStats(kv: KVNamespace, senderAddress: string)
1011
const currentCountStr = await kv.get(senderKey);
1112
const newCount = (currentCountStr ? parseInt(currentCountStr, 10) : 0) + 1;
1213

13-
kv.put(senderKey, newCount.toString()).catch((error) => {
14-
throw new Error(`KV put failed for ${senderKey}: ${error}`);
15-
});
14+
await kv.put(senderKey, newCount.toString());
1615

17-
return newCount;
16+
return { success: true, count: newCount, error: undefined };
1817
} catch (error) {
19-
throw new Error(`Failed to get/update KV for ${senderKey}: ${error}`);
18+
const errorMessage = error instanceof Error ? error.message : String(error);
19+
return {
20+
success: false,
21+
count: 0,
22+
error: new Error(`Failed to update KV for ${senderKey}: ${errorMessage}`),
23+
};
2024
}
2125
}
2226

2327
/**
2428
* Get top senders from KV
2529
*/
2630
export async function getTopSenders(kv: KVNamespace, limit = 10) {
27-
const allKeys = [];
28-
let cursor: string | undefined;
31+
try {
32+
const allKeys = [];
33+
let cursor: string | undefined;
34+
const maxKeys = KV_LIMITS.MAX_SENDER_KEYS; // Prevent excessive memory usage
35+
36+
// Collect all sender keys with pagination
37+
while (allKeys.length < maxKeys) {
38+
const page: KVNamespaceListResult<unknown, string> = await kv.list({
39+
prefix: "sender_count:",
40+
cursor,
41+
limit: Math.min(KV_LIMITS.LIST_BATCH_SIZE, maxKeys - allKeys.length), // Batch size optimization
42+
});
2943

30-
while (true) {
31-
const page = await kv.list({
32-
prefix: "sender_count:",
33-
cursor,
34-
});
44+
allKeys.push(...page.keys);
3545

36-
allKeys.push(...page.keys);
46+
if (page.list_complete) {
47+
break;
48+
}
3749

38-
if (page.list_complete) {
39-
break;
50+
cursor = page.cursor;
4051
}
4152

42-
cursor = page.cursor;
43-
}
53+
const batchSize = KV_LIMITS.BATCH_SIZE; // Cloudflare KV concurrent request limit consideration
54+
const allSenders = [];
55+
56+
for (let i = 0; i < allKeys.length; i += batchSize) {
57+
const batch = allKeys.slice(i, i + batchSize);
58+
const batchResults = await Promise.all(
59+
batch.map(async ({ name }) => {
60+
try {
61+
const count = await kv.get(name);
62+
return {
63+
name: name.replace("sender_count:", ""),
64+
count: parseInt(count || "0", 10),
65+
};
66+
} catch (_error) {
67+
return null;
68+
}
69+
}),
70+
);
4471

45-
const allSenders = await Promise.all(
46-
allKeys.map(async ({ name }) => {
47-
const count = await kv.get(name);
48-
return {
49-
name: name.replace("sender_count:", ""),
50-
count: parseInt(count || "0", 10),
51-
};
52-
}),
53-
);
54-
55-
return allSenders.sort((a, b) => b.count - a.count).slice(0, limit);
72+
// Filter out failed entries
73+
allSenders.push(...batchResults.filter((sender) => sender !== null));
74+
}
75+
76+
return allSenders.sort((a, b) => b.count - a.count).slice(0, limit);
77+
} catch (error) {
78+
console.error("Failed to get top senders:", error);
79+
return [];
80+
}
5681
}

src/handlers/emailHandler.ts

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { createId } from "@paralleldrive/cuid2";
22
import PostalMime from "postal-mime";
3+
34
import * as db from "@/database/d1";
45
import { updateSenderStats } from "@/database/kv";
56
import { emailSchema } from "@/schemas/emails/schema";
67
import { now } from "@/utils/helpers";
78
import { processEmailContent } from "@/utils/mail";
9+
import { PerformanceTimer } from "@/utils/performance";
810

911
/**
1012
* Cloudflare email router handler - optimized version
@@ -14,29 +16,44 @@ export async function handleEmail(
1416
env: CloudflareBindings,
1517
ctx: ExecutionContext,
1618
) {
17-
const emailId = createId();
18-
const email = await PostalMime.parse(message.raw);
19-
20-
// Process email content
21-
const { htmlContent, textContent } = processEmailContent(email.html ?? null, email.text ?? null);
22-
23-
const emailData = emailSchema.parse({
24-
id: emailId,
25-
from_address: message.from,
26-
to_address: message.to,
27-
subject: email.subject || null,
28-
received_at: now(),
29-
html_content: htmlContent,
30-
text_content: textContent,
31-
});
32-
33-
// Update sender stats
34-
ctx.waitUntil(updateSenderStats(env.KV, message.from));
35-
36-
// Insert email
37-
const { success, error } = await db.insertEmail(env.D1, emailData);
38-
39-
if (!success) {
40-
throw new Error(`Failed to insert email: ${error}`);
19+
try {
20+
const timer = new PerformanceTimer("email-processing");
21+
const emailId = createId();
22+
const email = await PostalMime.parse(message.raw);
23+
24+
// Process email content
25+
const { htmlContent, textContent } = processEmailContent(
26+
email.html ?? null,
27+
email.text ?? null,
28+
);
29+
30+
const emailData = emailSchema.parse({
31+
id: emailId,
32+
from_address: message.from,
33+
to_address: message.to,
34+
subject: email.subject || null,
35+
received_at: now(),
36+
html_content: htmlContent,
37+
text_content: textContent,
38+
});
39+
40+
// Update sender stats
41+
ctx.waitUntil(
42+
updateSenderStats(env.KV, message.from).catch((error) => {
43+
console.error("Failed to update sender stats:", error);
44+
}),
45+
);
46+
47+
// Insert email
48+
const { success, error } = await db.insertEmail(env.D1, emailData);
49+
50+
if (!success) {
51+
throw new Error(`Failed to insert email: ${error}`);
52+
}
53+
54+
timer.end(); // Log processing time
55+
} catch (error) {
56+
console.error("Failed to process email:", error);
57+
throw error;
4158
}
4259
}

src/routes/emailRoutes.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { OpenAPIHono } from "@hono/zod-openapi";
2+
import { CACHE } from "@/config/constants";
23
import { DOMAINS_SET } from "@/config/domains";
34
import * as db from "@/database/d1";
45
import validateDomain from "@/middlewares/validateDomain";
@@ -20,7 +21,7 @@ emailRoutes.use("/emails/count/:emailAddress", validateDomain);
2021

2122
// --- Routes ---
2223
// GET /emails/{emailAddress}
23-
// @ts-ignore - Ignoring OpenAPI type mismatch for utility functions
24+
// @ts-ignore - OpenAPI route handler type mismatch with error response status codes
2425
emailRoutes.openapi(getEmailsRoute, async (c) => {
2526
const { emailAddress } = c.req.valid("param");
2627
const { limit, offset } = c.req.valid("query");
@@ -32,7 +33,7 @@ emailRoutes.openapi(getEmailsRoute, async (c) => {
3233
});
3334

3435
// GET /emails/count/{emailAddress}
35-
// @ts-ignore - Ignoring OpenAPI type mismatch for utility functions
36+
// @ts-ignore - OpenAPI route handler type mismatch with error response status codes
3637
emailRoutes.openapi(getEmailsCountRoute, async (c) => {
3738
const { emailAddress } = c.req.valid("param");
3839

@@ -43,7 +44,7 @@ emailRoutes.openapi(getEmailsCountRoute, async (c) => {
4344
});
4445

4546
// DELETE /emails/{emailAddress}
46-
// @ts-ignore - Ignoring OpenAPI type mismatch for utility functions
47+
// @ts-ignore - OpenAPI route handler type mismatch with error response status codes
4748
emailRoutes.openapi(deleteEmailsRoute, async (c) => {
4849
const { emailAddress } = c.req.valid("param");
4950

@@ -56,7 +57,7 @@ emailRoutes.openapi(deleteEmailsRoute, async (c) => {
5657
});
5758

5859
// GET /inbox/{emailId}
59-
// @ts-ignore - Ignoring OpenAPI type mismatch for utility functions
60+
// @ts-ignore - OpenAPI route handler type mismatch with error response status codes
6061
emailRoutes.openapi(getEmailRoute, async (c) => {
6162
const { emailId } = c.req.valid("param");
6263
const { result, error } = await db.getEmailById(c.env.D1, emailId);
@@ -67,7 +68,7 @@ emailRoutes.openapi(getEmailRoute, async (c) => {
6768
});
6869

6970
// DELETE /inbox/{emailId}
70-
// @ts-ignore - Ignoring OpenAPI type mismatch for utility functions
71+
// @ts-ignore - OpenAPI route handler type mismatch with error response status codes
7172
emailRoutes.openapi(deleteEmailRoute, async (c) => {
7273
const { emailId } = c.req.valid("param");
7374
const { meta, error } = await db.deleteEmailById(c.env.D1, emailId);
@@ -79,7 +80,10 @@ emailRoutes.openapi(deleteEmailRoute, async (c) => {
7980

8081
// GET /domains
8182
emailRoutes.openapi(getDomainsRoute, async (c) => {
82-
c.header("Cache-Control", "public, max-age=3600");
83+
// Set cache headers for better performance
84+
c.header("Cache-Control", `public, max-age=${CACHE.DOMAINS_TTL}`);
85+
c.header("ETag", `"domains-${DOMAINS_SET.size}"`);
86+
8387
return c.json(OK(Array.from(DOMAINS_SET)));
8488
});
8589

src/utils/mail.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { convert } from "html-to-text";
2+
import { HTML_PROCESSING } from "@/config/constants";
23

34
/**
45
* Safely get the domain from an email address
@@ -9,14 +10,45 @@ export function getDomain(email: string): string {
910
}
1011

1112
/**
12-
* Convert HTML to plain text
13+
* Sanitize HTML content by removing potentially dangerous elements
14+
*/
15+
function sanitizeHtml(html: string): string {
16+
// Basic HTML sanitization - remove script tags and event handlers
17+
return html
18+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
19+
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, "")
20+
.replace(/javascript:/gi, "")
21+
.replace(/<iframe\b[^>]*>/gi, "")
22+
.replace(/<object\b[^>]*>/gi, "")
23+
.replace(/<embed\b[^>]*>/gi, "");
24+
}
25+
26+
/**
27+
* Convert HTML to plain text with size limits and error handling
1328
*/
1429
function htmlToText(html: string): string | null {
15-
const text = convert(html, {
16-
wordwrap: 130,
17-
});
30+
try {
31+
// Check size limit before processing
32+
if (Buffer.byteLength(html, "utf8") > HTML_PROCESSING.MAX_CONVERSION_SIZE) {
33+
console.warn("HTML content too large for conversion, truncating");
34+
html = html.substring(0, HTML_PROCESSING.MAX_CONVERSION_SIZE);
35+
}
1836

19-
return text.trim() === "" ? null : text;
37+
const text = convert(html, {
38+
wordwrap: HTML_PROCESSING.WORDWRAP_LENGTH,
39+
selectors: [
40+
// Remove potentially dangerous content
41+
{ selector: "script", format: "skip" },
42+
{ selector: "style", format: "skip" },
43+
{ selector: "iframe", format: "skip" },
44+
],
45+
});
46+
47+
return text.trim() === "" ? null : text;
48+
} catch (error) {
49+
console.error("Failed to convert HTML to text:", error);
50+
return null;
51+
}
2052
}
2153

2254
/**
@@ -31,7 +63,7 @@ function textToHtmlTemplate(text: string): string | null {
3163
}
3264

3365
/**
34-
* Process email content
66+
* Process email content with sanitization and size validation
3567
*/
3668
export function processEmailContent(
3769
html: string | null,
@@ -40,18 +72,21 @@ export function processEmailContent(
4072
htmlContent: string | null;
4173
textContent: string | null;
4274
} {
43-
// Both exist - return as-is
44-
if (html && text) {
45-
return { htmlContent: html, textContent: text };
75+
// Sanitize HTML content if present
76+
const sanitizedHtml = html ? sanitizeHtml(html) : null;
77+
78+
// Both exist - return sanitized HTML and original text
79+
if (sanitizedHtml && text) {
80+
return { htmlContent: sanitizedHtml, textContent: text };
4681
}
4782

48-
// Only HTML exists - generate text
49-
if (html && !text) {
50-
return { htmlContent: html, textContent: htmlToText(html) };
83+
// Only HTML exists - generate text from sanitized HTML
84+
if (sanitizedHtml && !text) {
85+
return { htmlContent: sanitizedHtml, textContent: htmlToText(sanitizedHtml) };
5186
}
5287

53-
// Only text exists - generate HTML
54-
if (!html && text) {
88+
// Only text exists - generate HTML template
89+
if (!sanitizedHtml && text) {
5590
return { htmlContent: textToHtmlTemplate(text), textContent: text };
5691
}
5792

0 commit comments

Comments
 (0)