diff --git a/scripts/fetch-fixtures.ts b/scripts/fetch-fixtures.ts index b377025..f023509 100644 --- a/scripts/fetch-fixtures.ts +++ b/scripts/fetch-fixtures.ts @@ -11,13 +11,53 @@ import fs from "node:fs"; import path from "node:path"; +function parseEnvValue(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + + const quote = trimmed[0]; + if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) { + return trimmed + .slice(1, -1) + .replace(/\\n/g, "\n") + .replace(/\\"/g, '"') + .replace(/\\'/g, "'"); + } + + const commentStart = trimmed.search(/\s#/); + return commentStart === -1 ? trimmed : trimmed.slice(0, commentStart).trim(); +} + +function loadEnvFile(filePath: string) { + for (const line of fs.readFileSync(filePath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + + process.env[match[1]] = parseEnvValue(match[2]); + } +} + +async function parseJsonResponse(res: Response, label: string): Promise { + const text = await res.text(); + + if (!res.ok) { + throw new Error(`${label} failed (${res.status}): ${text.slice(0, 200)}`); + } + + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`${label} returned non-JSON: ${text.slice(0, 200)}`); + } +} + // Load .env.local manually const envPath = path.resolve(process.cwd(), ".env.local"); if (fs.existsSync(envPath)) { - for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { - const m = line.match(/^([^#=]+)=(.*)$/); - if (m) process.env[m[1].trim()] = m[2].trim(); - } + loadEnvFile(envPath); } const TOKEN = process.env.FASTMAIL_API_TOKEN; @@ -43,14 +83,16 @@ async function jmap(apiUrl: string, methodCalls: unknown[]) { }, body: JSON.stringify({ using: JMAP_USING, methodCalls }), }); - if (!res.ok) throw new Error(`JMAP error: ${res.statusText}`); - return res.json() as Promise<{ methodResponses: [string, Record, string][] }>; + return parseJsonResponse<{ methodResponses: [string, Record, string][] }>( + res, + "JMAP request" + ); } async function main() { const session = await fetch(SESSION_URL, { headers: { Authorization: `Bearer ${TOKEN}` }, - }).then((r) => r.json()) as Record; + }).then((r) => parseJsonResponse>(r, "JMAP session")); const apiUrl = session.apiUrl as string; const accountId = (session.primaryAccounts as Record)[ diff --git a/src/app/(inbox)/attachment/spreadsheet/page.tsx b/src/app/(inbox)/attachment/spreadsheet/page.tsx new file mode 100644 index 0000000..44622e2 --- /dev/null +++ b/src/app/(inbox)/attachment/spreadsheet/page.tsx @@ -0,0 +1,47 @@ +import MobileBackButton from "@/components/MobileBackButton"; +import SpreadsheetViewer from "@/components/SpreadsheetViewer"; +import { notFound } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +interface Props { + searchParams: Promise<{ + blobId?: string; + name?: string; + type?: string; + size?: string; + }>; +} + +export default async function SpreadsheetAttachmentPage({ searchParams }: Props) { + const { blobId, name, type, size } = await searchParams; + + if (!blobId) return notFound(); + + const attachmentName = name ?? "spreadsheet.xlsx"; + const attachmentType = + type ?? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + const attachmentSize = Number(size ?? "0"); + const encodedName = encodeURIComponent(attachmentName); + const encodedType = encodeURIComponent(attachmentType); + const encodedBlobId = encodeURIComponent(blobId); + const downloadHref = `/api/download?blobId=${encodedBlobId}&name=${encodedName}&type=${encodedType}`; + const previewHref = `${downloadHref}&inline=true`; + + return ( +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/AttachmentList.tsx b/src/components/AttachmentList.tsx index b589264..c977a1b 100644 --- a/src/components/AttachmentList.tsx +++ b/src/components/AttachmentList.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, useEffect } from "react"; +import SpreadsheetViewer from "@/components/SpreadsheetViewer"; +import { isSpreadsheetAttachment } from "@/lib/spreadsheet"; import { EmailBodyPart } from "@/lib/types"; function formatSize(bytes: number): string { @@ -15,8 +17,8 @@ function fileIcon(type: string): string { if (type.startsWith("video/")) return "🎬"; if (type.startsWith("audio/")) return "🎵"; if (type.includes("zip") || type.includes("tar") || type.includes("gzip")) return "🗜"; - if (type.includes("word") || type.includes("document")) return "📝"; if (type.includes("spreadsheet") || type.includes("excel")) return "📊"; + if (type.includes("word") || type.includes("document")) return "📝"; return "📎"; } @@ -102,6 +104,7 @@ interface Props { export default function AttachmentList({ attachments }: Props) { const [previewPdf, setPreviewPdf] = useState(null); + const [previewSpreadsheet, setPreviewSpreadsheet] = useState(null); const visible = attachments.filter((a) => a.type !== "text/calendar" && a.blobId); if (visible.length === 0) return null; @@ -116,9 +119,11 @@ export default function AttachmentList({ attachments }: Props) { {visible.map((a) => { const name = a.name ?? "attachment"; const isPdf = a.type === "application/pdf"; + const isSpreadsheet = isSpreadsheetAttachment(a); return isPdf ? ( + ) : isSpreadsheet ? ( + ) : ( setPreviewPdf(null)} /> )} + {previewSpreadsheet && ( + setPreviewSpreadsheet(null)} + /> + )} ); } diff --git a/src/components/EmailBody.tsx b/src/components/EmailBody.tsx index 07a9d86..e497ee8 100644 --- a/src/components/EmailBody.tsx +++ b/src/components/EmailBody.tsx @@ -66,7 +66,7 @@ export default function EmailBody({ body, type, stripQuotes }: Props) { : prepareTextBody(body, { stripQuotes }); return ( -
+