diff --git a/src/data/nouns.ts b/src/data/nouns.ts index e2dfb47..dacd633 100644 --- a/src/data/nouns.ts +++ b/src/data/nouns.ts @@ -2,79 +2,579 @@ * Collection of common nouns for feed ID generation */ export const nouns = [ - 'actor', 'almond', 'amber', 'anchor', 'angel', 'animal', 'answer', 'apple', - 'autumn', 'avenue', 'badge', 'bagel', 'baker', 'ballet', 'bamboo', - 'banana', 'basket', 'beach', 'beard', 'beauty', 'beetle', 'berry', 'bicycle', - 'bird', 'blanket', 'blossom', 'boat', 'bottle', 'bowl', 'breeze', 'bubble', - 'bucket', 'button', 'cabin', 'cactus', 'cafe', 'camera', 'candle', 'candy', - 'canvas', 'canyon', 'captain', 'carpet', 'carrot', 'castle', 'cave', 'cellar', - 'chair', 'chalk', 'cheese', 'cherry', 'chest', 'chicken', 'chimney', - 'circus', 'cliff', 'clock', 'cloud', 'clover', 'coast', 'cobalt', 'cocoa', - 'coffee', 'coin', 'comet', 'compass', 'cookie', 'copper', 'coral', 'corner', - 'cotton', 'cradle', 'craft', 'creek', 'cricket', 'crown', 'crystal', 'cube', - 'cupboard', 'curtain', 'cushion', 'daisy', 'dance', 'date', 'dawn', 'deer', - 'desert', 'dew', 'diamond', 'dinner', 'dish', 'doctor', 'dolphin', - 'donut', 'door', 'dream', 'dress', 'drink', 'drum', 'duck', 'dusk', - 'eagle', 'earth', 'echo', 'emerald', 'engine', 'evening', 'face', 'fairy', - 'fall', 'family', 'fan', 'farm', 'feather', 'fence', 'ferry', 'field', - 'finger', 'fire', 'fish', 'flag', 'flame', 'flash', 'flavor', 'flight', - 'floor', 'flour', 'flower', 'flute', 'fog', 'foil', 'forest', 'fork', - 'fox', 'frame', 'friend', 'frog', 'frost', 'fruit', 'garden', 'garlic', - 'gate', 'gem', 'gift', 'ginger', 'giraffe', 'glacier', 'glass', - 'glitter', 'glove', 'glow', 'goat', 'gold', 'grape', 'grass', 'gravel', - 'gravity', 'guitar', 'gum', 'hair', 'hammer', 'hand', 'harbor', 'harp', - 'hat', 'hawk', 'heart', 'heath', 'heaven', 'helmet', 'herb', 'hill', - 'hippo', 'honey', 'hood', 'horn', 'horse', 'hotel', 'hour', 'house', - 'hunter', 'ice', 'icicle', 'idea', 'ink', 'insect', 'iron', 'island', - 'ivy', 'jacket', 'jade', 'jam', 'jasmine', 'jelly', 'jewel', - 'joke', 'journal', 'journey', 'joy', 'judge', 'jungle', 'kettle', 'key', - 'kid', 'kingdom', 'kitchen', 'kite', 'kitten', 'knight', - 'lab', 'ladder', 'lake', 'lamb', 'lamp', 'land', 'lantern', - 'laptop', 'laugh', 'lava', 'lawn', 'leaf', 'legend', 'lemon', 'letter', - 'library', 'light', 'lily', 'lime', 'lion', 'lip', 'lobby', 'lock', - 'locket', 'lodge', 'lotus', 'love', 'lunch', 'lyric', 'magic', 'magnet', - 'mango', 'maple', 'marble', 'market', 'mask', 'meadow', 'melody', 'melon', - 'memory', 'metal', 'meteor', 'milk', 'mint', 'mirror', 'mist', 'mitten', - 'moon', 'morning', 'moth', 'motor', 'mountain', 'mouse', - 'movie', 'muffin', 'museum', 'music', 'myth', 'napkin', 'nectar', 'needle', - 'nest', 'net', 'nickel', 'night', 'nose', 'note', 'novel', 'number', - 'nurse', 'nutmeg', 'oasis', 'ocean', 'olive', 'onion', 'opera', 'orange', - 'orbit', 'orchard', 'orchid', 'ostrich', 'otter', 'oven', 'owl', 'oxygen', - 'oyster', 'page', 'paint', 'palace', 'palm', 'pan', 'pancake', 'panda', - 'paper', 'parade', 'parcel', 'park', 'parrot', 'party', 'pasta', 'patch', - 'path', 'peach', 'peanut', 'pear', 'pearl', 'pebble', 'pencil', 'penny', - 'people', 'pepper', 'petal', 'phone', 'photo', 'piano', 'pickle', 'picture', - 'pie', 'pillow', 'pine', 'pink', 'pirate', 'pizza', 'planet', - 'plant', 'plum', 'pocket', 'poem', 'poet', 'point', 'pony', 'pool', - 'popcorn', 'porch', 'port', 'potato', 'powder', 'prairie', 'pretzel', 'prism', - 'prose', 'puppet', 'puppy', 'puzzle', 'quail', 'quartz', 'queen', 'quilt', - 'rabbit', 'raccoon', 'radio', 'raft', 'rain', 'rainbow', 'raisin', - 'ranch', 'rapids', 'raven', 'ray', 'record', 'reef', 'ribbon', 'rice', - 'ring', 'river', 'road', 'robin', 'robot', 'rock', 'rocket', 'rodeo', - 'roof', 'room', 'root', 'rope', 'rose', 'ruby', 'rug', 'ruler', 'sage', - 'sail', 'salad', 'salmon', 'salt', 'sand', 'sandal', 'sauce', 'saucer', - 'scale', 'scarf', 'school', 'sea', 'seed', 'shadow', 'shell', 'ship', - 'shirt', 'shoe', 'shop', 'shower', 'shrimp', 'side', 'sign', 'silk', - 'silver', 'singer', 'sink', 'sky', 'sled', 'sleet', 'sleigh', 'slice', - 'slide', 'slipper', 'slope', 'smoke', 'snail', 'snake', 'snow', 'soap', - 'sock', 'soda', 'sofa', 'soil', 'song', 'soup', 'spade', 'spark', 'sparrow', - 'spice', 'spider', 'spoon', 'spot', 'spring', 'sprout', 'square', 'squirrel', - 'stable', 'stage', 'stair', 'stamp', 'star', 'station', 'steam', 'steel', - 'stem', 'stick', 'stone', 'stork', 'storm', 'story', 'stove', 'straw', - 'stream', 'street', 'string', 'studio', 'sugar', 'summer', 'sun', 'sunset', - 'swan', 'sweater', 'sweets', 'sword', 'table', 'tablet', 'tail', 'talent', - 'tangerine', 'tank', 'tea', 'team', 'teapot', 'tear', 'temple', 'tennis', - 'tent', 'theater', 'thistle', 'thought', 'thread', 'thunder', - 'ticket', 'tide', 'tiger', 'tile', 'time', 'toast', 'toffee', 'tomato', - 'tooth', 'top', 'torch', 'tower', 'town', 'toy', 'track', - 'train', 'tree', 'triangle', 'trick', 'truck', 'trumpet', 'tulip', 'tunnel', - 'turkey', 'turtle', 'twig', 'uncle', 'unicorn', 'universe', 'vacuum', 'valley', - 'vanilla', 'vase', 'velvet', 'vessel', 'village', 'vine', 'violin', - 'voice', 'volcano', 'voyage', 'wagon', 'walnut', 'waltz', 'water', - 'wave', 'wax', 'weather', 'web', 'wedding', 'whale', 'wheat', - 'wheel', 'whistle', 'whisper', 'willow', 'wind', 'window', 'wine', 'wing', - 'winter', 'wire', 'wish', 'wizard', 'wood', 'wool', - 'word', 'world', 'wreath', 'wrist', 'writer', 'xylophone', 'yacht', - 'yard', 'yarn', 'year', 'yolk', 'zebra', 'zephyr', 'zinc', 'zipper', - 'zone', 'zoo' -]; \ No newline at end of file + "actor", + "almond", + "amber", + "anchor", + "angel", + "animal", + "answer", + "apple", + "autumn", + "avenue", + "badge", + "bagel", + "baker", + "ballet", + "bamboo", + "banana", + "basket", + "beach", + "beard", + "beauty", + "beetle", + "berry", + "bicycle", + "bird", + "blanket", + "blossom", + "boat", + "bottle", + "bowl", + "breeze", + "bubble", + "bucket", + "button", + "cabin", + "cactus", + "cafe", + "camera", + "candle", + "candy", + "canvas", + "canyon", + "captain", + "carpet", + "carrot", + "castle", + "cave", + "cellar", + "chair", + "chalk", + "cheese", + "cherry", + "chest", + "chicken", + "chimney", + "circus", + "cliff", + "clock", + "cloud", + "clover", + "coast", + "cobalt", + "cocoa", + "coffee", + "coin", + "comet", + "compass", + "cookie", + "copper", + "coral", + "corner", + "cotton", + "cradle", + "craft", + "creek", + "cricket", + "crown", + "crystal", + "cube", + "cupboard", + "curtain", + "cushion", + "daisy", + "dance", + "date", + "dawn", + "deer", + "desert", + "dew", + "diamond", + "dinner", + "dish", + "doctor", + "dolphin", + "donut", + "door", + "dream", + "dress", + "drink", + "drum", + "duck", + "dusk", + "eagle", + "earth", + "echo", + "emerald", + "engine", + "evening", + "face", + "fairy", + "fall", + "family", + "fan", + "farm", + "feather", + "fence", + "ferry", + "field", + "finger", + "fire", + "fish", + "flag", + "flame", + "flash", + "flavor", + "flight", + "floor", + "flour", + "flower", + "flute", + "fog", + "foil", + "forest", + "fork", + "fox", + "frame", + "friend", + "frog", + "frost", + "fruit", + "garden", + "garlic", + "gate", + "gem", + "gift", + "ginger", + "giraffe", + "glacier", + "glass", + "glitter", + "glove", + "glow", + "goat", + "gold", + "grape", + "grass", + "gravel", + "gravity", + "guitar", + "gum", + "hair", + "hammer", + "hand", + "harbor", + "harp", + "hat", + "hawk", + "heart", + "heath", + "heaven", + "helmet", + "herb", + "hill", + "hippo", + "honey", + "hood", + "horn", + "horse", + "hotel", + "hour", + "house", + "hunter", + "ice", + "icicle", + "idea", + "ink", + "insect", + "iron", + "island", + "ivy", + "jacket", + "jade", + "jam", + "jasmine", + "jelly", + "jewel", + "joke", + "journal", + "journey", + "joy", + "judge", + "jungle", + "kettle", + "key", + "kid", + "kingdom", + "kitchen", + "kite", + "kitten", + "knight", + "lab", + "ladder", + "lake", + "lamb", + "lamp", + "land", + "lantern", + "laptop", + "laugh", + "lava", + "lawn", + "leaf", + "legend", + "lemon", + "letter", + "library", + "light", + "lily", + "lime", + "lion", + "lip", + "lobby", + "lock", + "locket", + "lodge", + "lotus", + "love", + "lunch", + "lyric", + "magic", + "magnet", + "mango", + "maple", + "marble", + "market", + "mask", + "meadow", + "melody", + "melon", + "memory", + "metal", + "meteor", + "milk", + "mint", + "mirror", + "mist", + "mitten", + "moon", + "morning", + "moth", + "motor", + "mountain", + "mouse", + "movie", + "muffin", + "museum", + "music", + "myth", + "napkin", + "nectar", + "needle", + "nest", + "net", + "nickel", + "night", + "nose", + "note", + "novel", + "number", + "nurse", + "nutmeg", + "oasis", + "ocean", + "olive", + "onion", + "opera", + "orange", + "orbit", + "orchard", + "orchid", + "ostrich", + "otter", + "oven", + "owl", + "oxygen", + "oyster", + "page", + "paint", + "palace", + "palm", + "pan", + "pancake", + "panda", + "paper", + "parade", + "parcel", + "park", + "parrot", + "party", + "pasta", + "patch", + "path", + "peach", + "peanut", + "pear", + "pearl", + "pebble", + "pencil", + "penny", + "people", + "pepper", + "petal", + "phone", + "photo", + "piano", + "pickle", + "picture", + "pie", + "pillow", + "pine", + "pink", + "pirate", + "pizza", + "planet", + "plant", + "plum", + "pocket", + "poem", + "poet", + "point", + "pony", + "pool", + "popcorn", + "porch", + "port", + "potato", + "powder", + "prairie", + "pretzel", + "prism", + "prose", + "puppet", + "puppy", + "puzzle", + "quail", + "quartz", + "queen", + "quilt", + "rabbit", + "raccoon", + "radio", + "raft", + "rain", + "rainbow", + "raisin", + "ranch", + "rapids", + "raven", + "ray", + "record", + "reef", + "ribbon", + "rice", + "ring", + "river", + "road", + "robin", + "robot", + "rock", + "rocket", + "rodeo", + "roof", + "room", + "root", + "rope", + "rose", + "ruby", + "rug", + "ruler", + "sage", + "sail", + "salad", + "salmon", + "salt", + "sand", + "sandal", + "sauce", + "saucer", + "scale", + "scarf", + "school", + "sea", + "seed", + "shadow", + "shell", + "ship", + "shirt", + "shoe", + "shop", + "shower", + "shrimp", + "side", + "sign", + "silk", + "silver", + "singer", + "sink", + "sky", + "sled", + "sleet", + "sleigh", + "slice", + "slide", + "slipper", + "slope", + "smoke", + "snail", + "snake", + "snow", + "soap", + "sock", + "soda", + "sofa", + "soil", + "song", + "soup", + "spade", + "spark", + "sparrow", + "spice", + "spider", + "spoon", + "spot", + "spring", + "sprout", + "square", + "squirrel", + "stable", + "stage", + "stair", + "stamp", + "star", + "station", + "steam", + "steel", + "stem", + "stick", + "stone", + "stork", + "storm", + "story", + "stove", + "straw", + "stream", + "street", + "string", + "studio", + "sugar", + "summer", + "sun", + "sunset", + "swan", + "sweater", + "sweets", + "sword", + "table", + "tablet", + "tail", + "talent", + "tangerine", + "tank", + "tea", + "team", + "teapot", + "tear", + "temple", + "tennis", + "tent", + "theater", + "thistle", + "thought", + "thread", + "thunder", + "ticket", + "tide", + "tiger", + "tile", + "time", + "toast", + "toffee", + "tomato", + "tooth", + "top", + "torch", + "tower", + "town", + "toy", + "track", + "train", + "tree", + "triangle", + "trick", + "truck", + "trumpet", + "tulip", + "tunnel", + "turkey", + "turtle", + "twig", + "uncle", + "unicorn", + "universe", + "vacuum", + "valley", + "vanilla", + "vase", + "velvet", + "vessel", + "village", + "vine", + "violin", + "voice", + "volcano", + "voyage", + "wagon", + "walnut", + "waltz", + "water", + "wave", + "wax", + "weather", + "web", + "wedding", + "whale", + "wheat", + "wheel", + "whistle", + "whisper", + "willow", + "wind", + "window", + "wine", + "wing", + "winter", + "wire", + "wish", + "wizard", + "wood", + "wool", + "word", + "world", + "wreath", + "wrist", + "writer", + "xylophone", + "yacht", + "yard", + "yarn", + "year", + "yolk", + "zebra", + "zephyr", + "zinc", + "zipper", + "zone", + "zoo", +]; diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 3befc9b..777723d 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -285,13 +285,16 @@ describe("Admin Routes", () => { )) as { feeds: Array<{ id: string; title: string }> } | null; const feedId = feedList?.feeds[0].id as string; - const deleteRes = await request(`/admin/feeds/${feedId}/delete?view=list`, { - method: "POST", - headers: { - Cookie: authCookie, - Accept: "application/json", + const deleteRes = await request( + `/admin/feeds/${feedId}/delete?view=list`, + { + method: "POST", + headers: { + Cookie: authCookie, + Accept: "application/json", + }, }, - }); + ); expect(deleteRes.status).toBe(200); const payload = await deleteRes.json(); @@ -334,8 +337,12 @@ describe("Admin Routes", () => { }); expect(bulkDeleteRes.status).toBe(302); - expect(bulkDeleteRes.headers.get("Location")).toContain("/admin?view=list"); - expect(bulkDeleteRes.headers.get("Location")).toContain("message=bulkDeleted"); + expect(bulkDeleteRes.headers.get("Location")).toContain( + "/admin?view=list", + ); + expect(bulkDeleteRes.headers.get("Location")).toContain( + "message=bulkDeleted", + ); const feedListAfter = (await mockEnv.EMAIL_STORAGE.get( "feeds:list", @@ -386,7 +393,9 @@ describe("Admin Routes", () => { const feedMetadata = (await mockEnv.EMAIL_STORAGE.get( feedMetadataKey, "json", - )) as { emails: Array<{ key: string; subject: string; receivedAt: number }> } | null; + )) as { + emails: Array<{ key: string; subject: string; receivedAt: number }>; + } | null; const updatedMetadata = { emails: [ ...(feedMetadata?.emails || []), @@ -398,13 +407,16 @@ describe("Admin Routes", () => { JSON.stringify(updatedMetadata), ); - const deleteRes = await request(`/admin/emails/${emailKey}/delete?feedId=${feedId}`, { - method: "POST", - headers: { - Cookie: authCookie, - Accept: "application/json", + const deleteRes = await request( + `/admin/emails/${emailKey}/delete?feedId=${feedId}`, + { + method: "POST", + headers: { + Cookie: authCookie, + Accept: "application/json", + }, }, - }); + ); expect(deleteRes.status).toBe(200); const payload = await deleteRes.json(); @@ -417,7 +429,9 @@ describe("Admin Routes", () => { const metadataAfter = (await mockEnv.EMAIL_STORAGE.get( feedMetadataKey, "json", - )) as { emails: Array<{ key: string; subject: string; receivedAt: number }> } | null; + )) as { + emails: Array<{ key: string; subject: string; receivedAt: number }>; + } | null; expect(metadataAfter?.emails.length).toBe(0); }); }); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 02bd7b1..b7f9428 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -285,7 +285,9 @@ app.get("/", async (c) => { layout( "Dashboard", html` -
+

Email to RSS Admin

@@ -346,15 +348,15 @@ app.get("/", async (c) => { ? html`

No feeds were selected.

` : ""} -
-
-

Your Feeds

- ${feedsWithConfig.length} -
-
${viewToggle}
-
+
+
+

Your Feeds

+ ${feedsWithConfig.length} +
+
${viewToggle}
+
${feedsWithConfig.length === 0 ? html`
@@ -362,14 +364,14 @@ app.get("/", async (c) => {
` : view === "table" ? html` -
-
- +
+ +
@@ -430,33 +432,101 @@ app.get("/", async (c) => { onchange="toggleAllFeeds(this.checked)" /> - - -
+
- - -
+
- - -
+
- - -
+
Actions -
+
@@ -470,21 +540,27 @@ app.get("/", async (c) => { const sortFeedId = feed.id.toLowerCase(); const sortEmail = emailAddress.toLowerCase(); const sortRss = rssUrl.toLowerCase(); - const descDisplay = clampText(feed.description || "", 220); - const descHover = clampText(feed.description || "", 1000); + const descDisplay = clampText( + feed.description || "", + 220, + ); + const descHover = clampText( + feed.description || "", + 1000, + ); const searchHaystack = `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); return html` - + { /> - ${titleDisplay} ${feed.description @@ -553,9 +631,7 @@ app.get("/", async (c) => { stroke-linecap="round" stroke-linejoin="round" > - +
@@ -605,9 +681,7 @@ app.get("/", async (c) => { stroke-linecap="round" stroke-linejoin="round" > - +
@@ -667,7 +741,10 @@ app.get("/", async (c) => { const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`; const titleDisplay = clampText(feed.title, 140); const titleHover = clampText(feed.title, 1000); - const descDisplay = clampText(feed.description || "", 240); + const descDisplay = clampText( + feed.description || "", + 240, + ); const descHover = clampText(feed.description || "", 1000); const searchHaystack = `${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase(); @@ -684,7 +761,9 @@ app.get("/", async (c) => { ${feed.description ? html`

- ${descDisplay} + ${descDisplay}

` : ""}
@@ -1490,7 +1569,8 @@ app.post("/feeds/create", async (c) => { const title = formData.get("title")?.toString() || ""; const description = formData.get("description")?.toString(); const language = formData.get("language")?.toString() || "en"; - const view = formData.get("view")?.toString() === "table" ? "table" : "list"; + const view = + formData.get("view")?.toString() === "table" ? "table" : "list"; const allowedSenders = parseAllowedSenders( formData.get("allowed_senders")?.toString() || "", ); @@ -1776,15 +1856,16 @@ async function purgeFeedKeysStep( listComplete: boolean; }> { const prefix = `feed:${feedId}:`; - const limit = Math.min( - 1000, - Math.max(1, Math.floor(options.limit || 100)), - ); + const limit = Math.min(1000, Math.max(1, Math.floor(options.limit || 100))); const cursor = options.cursor || undefined; const listed = await emailStorage.list({ prefix, cursor, limit }); const keys = (listed.keys || []).map((k) => k.name); - const { ok, failed } = await deleteKeysWithConcurrency(emailStorage, keys, 35); + const { ok, failed } = await deleteKeysWithConcurrency( + emailStorage, + keys, + 35, + ); return { deletedKeys: ok, @@ -1816,7 +1897,10 @@ app.post("/feeds/:feedId/delete", async (c) => { } catch (error) { console.error("Error deleting feed:", error); if (wantsJson) { - return c.json({ ok: false, error: "Error deleting feed. Please try again." }, 400); + return c.json( + { ok: false, error: "Error deleting feed. Please try again." }, + 400, + ); } return c.text("Error deleting feed. Please try again.", 400); } @@ -1953,7 +2037,8 @@ app.post("/feeds/bulk-delete", async (c) => { } const formData = await c.req.formData(); - const view = formData.get("view")?.toString() === "table" ? "table" : "list"; + const view = + formData.get("view")?.toString() === "table" ? "table" : "list"; const redirectBase = `/admin?view=${view}`; const rawIds = formData.getAll("feedIds").map((value) => value.toString()); const parsedFeedIds = Array.from(new Set(rawIds.filter(Boolean))); @@ -2135,9 +2220,11 @@ app.get("/feeds/:feedId/emails", async (c) => {
-

- Emails (${feedMetadata.emails.length}) -

+

+ Emails (${feedMetadata.emails.length}) +

${message === "bulkDeleted" ? html`
@@ -2149,11 +2236,11 @@ app.get("/feeds/:feedId/emails", async (c) => { : ""} ${feedMetadata.emails.length > 0 ? html` - +
{ onchange="toggleAllEmails(this.checked)" /> - - -
+
- - -
+
Actions -
+
@@ -2235,7 +2358,10 @@ app.get("/feeds/:feedId/emails", async (c) => { const subjectHover = clampText(email.subject, 1000); const sortSubject = subjectHover.toLowerCase(); const sortReceivedAt = String(email.receivedAt); - const searchHaystack = clampText(email.subject, 320).toLowerCase(); + const searchHaystack = clampText( + email.subject, + 320, + ).toLowerCase(); return html` { } catch (error) { console.error("Error deleting email:", error); if (wantsJson) { - return c.json({ ok: false, error: "Error deleting email. Please try again." }, 400); + return c.json( + { ok: false, error: "Error deleting email. Please try again." }, + 400, + ); } return c.text("Error deleting email. Please try again.", 400); } @@ -3295,7 +3424,9 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => { emailKeys?: unknown; } | null; - const rawEmailKeys = Array.isArray(body?.emailKeys) ? body?.emailKeys : []; + const rawEmailKeys = Array.isArray(body?.emailKeys) + ? body?.emailKeys + : []; const emailKeys = Array.from( new Set(rawEmailKeys.map((value) => String(value)).filter(Boolean)), ); diff --git a/src/routes/rss.ts b/src/routes/rss.ts index b9b195c..2c654eb 100644 --- a/src/routes/rss.ts +++ b/src/routes/rss.ts @@ -1,6 +1,6 @@ -import { Context } from 'hono'; -import { Env, FeedConfig, FeedMetadata, EmailData } from '../types'; -import { generateRssFeed } from '../utils/feed-generator'; +import { Context } from "hono"; +import { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; +import { generateRssFeed } from "../utils/feed-generator"; /** * Generates an RSS feed for a specific feed ID @@ -9,62 +9,71 @@ export async function handle(c: Context): Promise { try { // Type assertion for environment variables const env = c.env as unknown as Env; - + // Extract the feed ID from the route params - const feedId = c.req.param('feedId'); - + const feedId = c.req.param("feedId"); + if (!feedId) { - return new Response('Feed ID is required', { status: 400 }); + return new Response("Feed ID is required", { status: 400 }); } - + // Get the KV namespace const emailStorage = env.EMAIL_STORAGE; - + // Check if the feed exists const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = await emailStorage.get(feedMetadataKey, 'json') as FeedMetadata | null; - + const feedMetadata = (await emailStorage.get( + feedMetadataKey, + "json", + )) as FeedMetadata | null; + if (!feedMetadata) { - return new Response('Feed not found', { status: 404 }); + return new Response("Feed not found", { status: 404 }); } - + // Get feed configuration (title, description, etc.) const feedConfigKey = `feed:${feedId}:config`; - const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null || { + const feedConfig = ((await emailStorage.get( + feedConfigKey, + "json", + )) as FeedConfig | null) || { title: `Newsletter Feed ${feedId}`, - description: 'Converted email newsletter', + description: "Converted email newsletter", site_url: `https://${env.DOMAIN}/rss/${feedId}`, feed_url: `https://${env.DOMAIN}/rss/${feedId}`, - language: 'en', - created_at: Date.now() + language: "en", + created_at: Date.now(), }; - + // Get the emails for this feed (up to the last 20) const emails = feedMetadata.emails.slice(0, 20); const emailsData: EmailData[] = []; - + // Fetch all email content for (const email of emails) { - const emailData = await emailStorage.get(email.key, 'json') as EmailData | null; + const emailData = (await emailStorage.get( + email.key, + "json", + )) as EmailData | null; if (emailData) { emailsData.push(emailData); } } - + // Generate the RSS feed XML const baseUrl = `https://${env.DOMAIN}`; const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl); - + // Return the RSS feed with appropriate content type return new Response(rssXml, { status: 200, headers: { - 'Content-Type': 'application/rss+xml', - 'Cache-Control': 'max-age=1800' // 30 minutes cache - } + "Content-Type": "application/rss+xml", + "Cache-Control": "max-age=1800", // 30 minutes cache + }, }); } catch (error) { - console.error('Error generating RSS feed:', error); - return new Response('Internal Server Error', { status: 500 }); + console.error("Error generating RSS feed:", error); + return new Response("Internal Server Error", { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/scripts/clipboard.ts b/src/scripts/clipboard.ts index 7b093a9..e5edbfe 100644 --- a/src/scripts/clipboard.ts +++ b/src/scripts/clipboard.ts @@ -61,4 +61,4 @@ export const clipboardScripts = ` form.submit(); } } -`; +`; diff --git a/src/scripts/interactions.ts b/src/scripts/interactions.ts index f022f46..6f4595d 100644 --- a/src/scripts/interactions.ts +++ b/src/scripts/interactions.ts @@ -70,4 +70,4 @@ export const initScripts = ` // Run setup when DOM is fully loaded document.addEventListener('DOMContentLoaded', initInteractive); -`; \ No newline at end of file +`; diff --git a/src/styles/components.ts b/src/styles/components.ts index c747eea..2f808f2 100644 --- a/src/styles/components.ts +++ b/src/styles/components.ts @@ -1014,4 +1014,4 @@ export const componentStyles = ` padding: 0; margin: 0; } -`; +`; diff --git a/src/styles/design-system.ts b/src/styles/design-system.ts index 2b2b100..a130b19 100644 --- a/src/styles/design-system.ts +++ b/src/styles/design-system.ts @@ -1,7 +1,7 @@ // This file is kept for backwards compatibility // It re-exports the new modular design system -import { designSystem } from './index'; -import { interactiveScripts, authHelpers } from '../scripts/index'; +import { designSystem } from "./index"; +import { interactiveScripts, authHelpers } from "../scripts/index"; -export { designSystem, interactiveScripts, authHelpers }; \ No newline at end of file +export { designSystem, interactiveScripts, authHelpers }; diff --git a/src/styles/index.ts b/src/styles/index.ts index 811327a..63ab457 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1,10 +1,10 @@ // Main style exports file // Combines all style components and re-exports them for easy imports -import { variables, lightModeTheme, fontImport } from './variables'; -import { layoutStyles } from './layout'; -import { componentStyles } from './components'; -import { utilityStyles } from './utilities'; +import { variables, lightModeTheme, fontImport } from "./variables"; +import { layoutStyles } from "./layout"; +import { componentStyles } from "./components"; +import { utilityStyles } from "./utilities"; // Combine all style components into a single CSS string export const designSystem = ` @@ -17,4 +17,11 @@ export const designSystem = ` `; // Re-export everything for modular usage if needed -export { variables, lightModeTheme, fontImport, layoutStyles, componentStyles, utilityStyles }; \ No newline at end of file +export { + variables, + lightModeTheme, + fontImport, + layoutStyles, + componentStyles, + utilityStyles, +}; diff --git a/src/styles/layout.ts b/src/styles/layout.ts index 41ac074..13a2b4e 100644 --- a/src/styles/layout.ts +++ b/src/styles/layout.ts @@ -177,4 +177,4 @@ export const layoutStyles = ` margin-bottom: var(--spacing-md); color: var(--color-text-primary); } -`; +`; diff --git a/src/types/hono.d.ts b/src/types/hono.d.ts index 745c070..3e87135 100644 --- a/src/types/hono.d.ts +++ b/src/types/hono.d.ts @@ -1,8 +1,8 @@ -import { Env } from './index'; +import { Env } from "./index"; // Extend Hono's types to include our custom environment -declare module 'hono' { +declare module "hono" { interface ContextVariableMap { env: Env; } -} \ No newline at end of file +} diff --git a/src/types/mailparser.d.ts b/src/types/mailparser.d.ts index 474719b..7f2a27f 100644 --- a/src/types/mailparser.d.ts +++ b/src/types/mailparser.d.ts @@ -1,8 +1,11 @@ // Extend mailparser types for Buffer in worker environment -declare module 'buffer-polyfill' { +declare module "buffer-polyfill" { global { var Buffer: { - from(data: string, encoding?: string): { + from( + data: string, + encoding?: string, + ): { toString(encoding?: string): string; }; }; @@ -10,8 +13,8 @@ declare module 'buffer-polyfill' { } // Add missing atob declaration -declare module 'atob-polyfill' { +declare module "atob-polyfill" { global { function atob(data: string): string; } -} \ No newline at end of file +} diff --git a/src/types/rss.d.ts b/src/types/rss.d.ts index a59f6a4..a3ca0fc 100644 --- a/src/types/rss.d.ts +++ b/src/types/rss.d.ts @@ -1,4 +1,4 @@ -declare module 'rss' { +declare module "rss" { interface RSSOptions { title: string; description: string; @@ -48,4 +48,4 @@ declare module 'rss' { } export = RSS; -} \ No newline at end of file +} diff --git a/src/utils/email-parser.ts b/src/utils/email-parser.ts index 03b6a32..e94dcb1 100644 --- a/src/utils/email-parser.ts +++ b/src/utils/email-parser.ts @@ -1,4 +1,4 @@ -import { EmailData } from '../types'; +import { EmailData } from "../types"; /** * Simple email parser specialized for ForwardEmail.net's webhook format @@ -14,59 +14,62 @@ export class EmailParser { const match = emailAddress.match(/^([a-z]+\.[a-z]+\.\d{2})@/i); return match ? match[1] : null; } - + /** * Parse email data from ForwardEmail.net's webhook payload * @param payload ForwardEmail.net webhook payload */ static parseForwardEmailPayload(payload: any): EmailData { if (!payload) { - throw new Error('Missing or invalid webhook payload'); + throw new Error("Missing or invalid webhook payload"); } - + // Extract the "to" address - const toAddress = payload.recipients?.[0] || ''; - + const toAddress = payload.recipients?.[0] || ""; + // Extract the sender information using ForwardEmail's structure - const fromAddress = payload.from?.text || - (payload.from?.value?.[0]?.address ? - `${payload.from.value[0].name || ''} <${payload.from.value[0].address}>` : - 'Unknown Sender'); - + const fromAddress = + payload.from?.text || + (payload.from?.value?.[0]?.address + ? `${payload.from.value[0].name || ""} <${payload.from.value[0].address}>` + : "Unknown Sender"); + // Extract subject - let subject = payload.subject || 'No Subject'; + let subject = payload.subject || "No Subject"; // Decode any encoded words in the subject subject = this.decodeEncodedWords(subject); - + // Get content, preferring HTML over plain text - const content = payload.html || payload.text || ''; - + const content = payload.html || payload.text || ""; + // Create simple email data object return { subject, from: fromAddress, content, receivedAt: payload.date ? new Date(payload.date).getTime() : Date.now(), - headers: this.extractHeaders(payload) + headers: this.extractHeaders(payload), }; } - + /** * Extract headers from ForwardEmail payload */ private static extractHeaders(payload: any): Record { const headers: Record = {}; - + // Extract headers from headerLines if available if (payload.headerLines && Array.isArray(payload.headerLines)) { - payload.headerLines.forEach((h: {key: string; line: string}) => { + payload.headerLines.forEach((h: { key: string; line: string }) => { const key = h.key.toLowerCase(); - const value = h.line.replace(new RegExp(`^${h.key}:\\s*`, 'i'), '').trim(); + const value = h.line + .replace(new RegExp(`^${h.key}:\\s*`, "i"), "") + .trim(); headers[key] = value; }); } // Or from headers string if provided - else if (typeof payload.headers === 'string') { + else if (typeof payload.headers === "string") { payload.headers.split(/\r?\n/).forEach((line: string) => { const match = line.match(/^([^:]+):\s*(.*)$/); if (match) { @@ -74,35 +77,38 @@ export class EmailParser { } }); } - + return headers; } - + /** * Decode RFC 2047 encoded words in headers * @param text Text that may contain encoded words like =?UTF-8?Q?Hello_World?= */ static decodeEncodedWords(text: string): string { - if (!text) return ''; - + if (!text) return ""; + // Simple RFC 2047 encoded-word decoder - return text.replace(/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, (_, charset, encoding, text) => { - if (encoding.toUpperCase() === 'B') { - // Base64 encoding - try { - const decoded = atob(text); - return decoded; - } catch (e) { - return text; + return text.replace( + /=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, + (_, charset, encoding, text) => { + if (encoding.toUpperCase() === "B") { + // Base64 encoding + try { + const decoded = atob(text); + return decoded; + } catch (e) { + return text; + } + } else if (encoding.toUpperCase() === "Q") { + // Quoted-printable encoding + return this.decodeQuotedPrintable(text.replace(/_/g, " ")); } - } else if (encoding.toUpperCase() === 'Q') { - // Quoted-printable encoding - return this.decodeQuotedPrintable(text.replace(/_/g, ' ')); - } - return text; - }); + return text; + }, + ); } - + /** * Decode quoted-printable encoded text * @param text Quoted-printable encoded text @@ -112,4 +118,4 @@ export class EmailParser { return String.fromCharCode(parseInt(hex, 16)); }); } -} \ No newline at end of file +} diff --git a/src/utils/feed-generator.ts b/src/utils/feed-generator.ts index df5b8ba..08b9a6d 100644 --- a/src/utils/feed-generator.ts +++ b/src/utils/feed-generator.ts @@ -1,38 +1,40 @@ -import { Feed } from 'feed'; -import { FeedConfig, EmailData } from '../types'; +import { Feed } from "feed"; +import { FeedConfig, EmailData } from "../types"; /** * Generate an RSS feed from a list of emails */ export function generateRssFeed( - feedConfig: FeedConfig, + feedConfig: FeedConfig, emails: EmailData[], - baseUrl: string + baseUrl: string, ): string { // Create a new feed const feed = new Feed({ title: feedConfig.title, - description: feedConfig.description || '', + description: feedConfig.description || "", id: feedConfig.feed_url, link: feedConfig.site_url, language: feedConfig.language, updated: new Date(), - generator: 'Email-to-RSS', + generator: "Email-to-RSS", copyright: `Copyright © ${new Date().getFullYear()} ${feedConfig.title}`, feedLinks: { - rss: feedConfig.feed_url + rss: feedConfig.feed_url, }, - author: feedConfig.author ? { - name: feedConfig.author, - email: `noreply@${new URL(feedConfig.site_url).hostname}` - } : undefined + author: feedConfig.author + ? { + name: feedConfig.author, + email: `noreply@${new URL(feedConfig.site_url).hostname}`, + } + : undefined, }); // Add each email as a feed item for (const email of emails) { const date = new Date(email.receivedAt); - const uniqueId = `${email.receivedAt}-${Buffer.from(email.subject).toString('base64').substring(0, 10)}`; - + const uniqueId = `${email.receivedAt}-${Buffer.from(email.subject).toString("base64").substring(0, 10)}`; + feed.addItem({ title: email.subject, id: uniqueId, @@ -50,4 +52,4 @@ export function generateRssFeed( // Return the RSS feed as XML return feed.rss2(); -} \ No newline at end of file +} diff --git a/src/utils/id-generator.ts b/src/utils/id-generator.ts index 5e3a5cf..a850c59 100644 --- a/src/utils/id-generator.ts +++ b/src/utils/id-generator.ts @@ -1,4 +1,4 @@ -import { nouns } from '../data/nouns'; +import { nouns } from "../data/nouns"; /** * Generates a random feed ID in the format noun1.noun2.XY @@ -8,10 +8,10 @@ export function generateFeedId(): string { // Select two random nouns const noun1 = nouns[Math.floor(Math.random() * nouns.length)]; const noun2 = nouns[Math.floor(Math.random() * nouns.length)]; - + // Generate a random 2-digit number between 10 and 99 const number = Math.floor(Math.random() * 90) + 10; - + // Combine to create the ID with dots as separators return `${noun1}.${noun2}.${number}`; -} \ No newline at end of file +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 80b2044..c1f15ed 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,4 +1,10 @@ -import { EmailData, FeedConfig, FeedMetadata, FeedList, EmailMetadata } from '../types'; +import { + EmailData, + FeedConfig, + FeedMetadata, + FeedList, + EmailMetadata, +} from "../types"; /** * Store email data in KV @@ -6,22 +12,22 @@ import { EmailData, FeedConfig, FeedMetadata, FeedList, EmailMetadata } from '.. export async function storeEmail( kv: KVNamespace, feedId: string, - emailData: EmailData + emailData: EmailData, ): Promise { // Generate a unique key for this email const timestamp = Date.now(); const key = `feed:${feedId}:email:${timestamp}`; - + // Store the email content await kv.put(key, JSON.stringify(emailData)); - + // Update the feed's metadata (list of emails) await updateFeedMetadata(kv, feedId, { key, subject: emailData.subject, - receivedAt: timestamp + receivedAt: timestamp, }); - + return key; } @@ -31,21 +37,23 @@ export async function storeEmail( async function updateFeedMetadata( kv: KVNamespace, feedId: string, - emailMetadata: EmailMetadata + emailMetadata: EmailMetadata, ): Promise { const feedMetadataKey = `feed:${feedId}:metadata`; - const existingMetadata = await kv.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; - + const existingMetadata = (await kv.get(feedMetadataKey, { + type: "json", + })) as FeedMetadata | null; + const metadata: FeedMetadata = existingMetadata || { emails: [] }; - + // Add new email to the beginning of the list metadata.emails.unshift(emailMetadata); - + // Keep only the last 50 emails in the metadata if (metadata.emails.length > 50) { metadata.emails = metadata.emails.slice(0, 50); } - + // Store updated metadata await kv.put(feedMetadataKey, JSON.stringify(metadata)); } @@ -55,10 +63,12 @@ async function updateFeedMetadata( */ export async function getFeedMetadata( kv: KVNamespace, - feedId: string + feedId: string, ): Promise { const feedMetadataKey = `feed:${feedId}:metadata`; - return await kv.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; + return (await kv.get(feedMetadataKey, { + type: "json", + })) as FeedMetadata | null; } /** @@ -66,10 +76,10 @@ export async function getFeedMetadata( */ export async function getFeedConfig( kv: KVNamespace, - feedId: string + feedId: string, ): Promise { const feedConfigKey = `feed:${feedId}:config`; - return await kv.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; + return (await kv.get(feedConfigKey, { type: "json" })) as FeedConfig | null; } /** @@ -77,9 +87,9 @@ export async function getFeedConfig( */ export async function getEmailData( kv: KVNamespace, - key: string + key: string, ): Promise { - return await kv.get(key, { type: 'json' }) as EmailData | null; + return (await kv.get(key, { type: "json" })) as EmailData | null; } /** @@ -88,18 +98,21 @@ export async function getEmailData( export async function createFeed( kv: KVNamespace, feedId: string, - feedConfig: FeedConfig + feedConfig: FeedConfig, ): Promise { // Store feed configuration const feedConfigKey = `feed:${feedId}:config`; await kv.put(feedConfigKey, JSON.stringify(feedConfig)); - + // Create empty metadata for the feed const feedMetadataKey = `feed:${feedId}:metadata`; - await kv.put(feedMetadataKey, JSON.stringify({ - emails: [] - })); - + await kv.put( + feedMetadataKey, + JSON.stringify({ + emails: [], + }), + ); + // Add feed to the list of all feeds await addFeedToList(kv, feedId, feedConfig.title, feedConfig.description); } @@ -111,19 +124,21 @@ export async function addFeedToList( kv: KVNamespace, feedId: string, title: string, - description?: string + description?: string, ): Promise { - const feedListKey = 'feeds:list'; - const existingList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null; - + const feedListKey = "feeds:list"; + const existingList = (await kv.get(feedListKey, { + type: "json", + })) as FeedList | null; + const feedList: FeedList = existingList || { feeds: [] }; - + feedList.feeds.push({ id: feedId, title, - description + description, }); - + await kv.put(feedListKey, JSON.stringify(feedList)); } @@ -131,8 +146,10 @@ export async function addFeedToList( * Get all feeds */ export async function getAllFeeds(kv: KVNamespace): Promise { - const feedListKey = 'feeds:list'; - const feedList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null; - + const feedListKey = "feeds:list"; + const feedList = (await kv.get(feedListKey, { + type: "json", + })) as FeedList | null; + return feedList || { feeds: [] }; -} +} diff --git a/tsconfig.json b/tsconfig.json index 60334e4..79664b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,4 +15,4 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] -} \ No newline at end of file +}