diff --git a/package.json b/package.json index 4beb4b8..67c34e3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "marked": "^17.0.5", "next": "16.2.0", "next-auth": "^5.0.0-beta.30", + "pino": "^10.3.1", "react": "19.2.4", "react-dom": "19.2.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ff5e65..85eb458 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: next-auth: specifier: ^5.0.0-beta.30 version: 5.0.0-beta.30(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + pino: + specifier: ^10.3.1 + version: 10.3.1 react: specifier: 19.2.4 version: 19.2.4 @@ -593,6 +596,9 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -934,6 +940,10 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1796,6 +1806,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1838,6 +1852,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1862,6 +1886,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1872,6 +1899,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -1884,6 +1914,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -1928,6 +1962,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1980,10 +2018,17 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -2050,6 +2095,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2599,6 +2648,8 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@pinojs/redact@0.4.0': {} + '@rtsao/scc@1.1.0': {} '@swc/helpers@0.5.15': @@ -2946,6 +2997,8 @@ snapshots: async-function@1.0.0: {} + atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -3944,6 +3997,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-exit-leak-free@2.1.2: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3983,6 +4038,26 @@ snapshots: picomatch@4.0.3: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -4005,6 +4080,8 @@ snapshots: prelude-ls@1.2.1: {} + process-warning@5.0.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4015,6 +4092,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -4024,6 +4103,8 @@ snapshots: react@19.2.4: {} + real-require@0.2.0: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -4088,6 +4169,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -4182,8 +4265,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + stable-hash@0.0.5: {} stop-iteration-iterator@1.1.0: @@ -4262,6 +4351,10 @@ snapshots: tapable@2.3.0: {} + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) diff --git a/src/app/(inbox)/actions.ts b/src/app/(inbox)/actions.ts index 6114822..b6207cf 100644 --- a/src/app/(inbox)/actions.ts +++ b/src/app/(inbox)/actions.ts @@ -2,76 +2,100 @@ import { getSession, getAccountId, listEmails, loadMoreEmailsFiltered, searchEmails, setPin, setKeywordsOnMany, moveEmailsToMailbox } from "@/lib/jmap"; import { parseSearchQuery, buildJmapFilter } from "@/lib/search"; +import { log } from "@/lib/logger"; import { Email } from "@/lib/types"; export async function loadMoreEmails( inboxId: string, position: number ): Promise<{ emails: Email[]; total: number }> { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); - return listEmails(session.apiUrl, accountId, inboxId, 50, position); + const result = await listEmails(session.apiUrl, accountId, inboxId, 50, position); + log.info({ mailbox_id: inboxId, position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); + return result; } export async function loadMoreUnreads( inboxId: string, position: number ): Promise<{ emails: Email[]; total: number }> { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); - return loadMoreEmailsFiltered(session.apiUrl, accountId, inboxId, "unread", position); + const result = await loadMoreEmailsFiltered(session.apiUrl, accountId, inboxId, "unread", position); + log.info({ mailbox_id: inboxId, filter: "unread", position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); + return result; } export async function loadMoreReads( inboxId: string, position: number ): Promise<{ emails: Email[]; total: number }> { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); - return loadMoreEmailsFiltered(session.apiUrl, accountId, inboxId, "read", position); + const result = await loadMoreEmailsFiltered(session.apiUrl, accountId, inboxId, "read", position); + log.info({ mailbox_id: inboxId, filter: "read", position, limit: 50, returned: result.emails.length, total: result.total, duration_ms: Date.now() - t }, "action.load_more"); + return result; } export async function searchEmailsAction(query: string): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); - const filter = buildJmapFilter(parseSearchQuery(query)); - return searchEmails(session.apiUrl, accountId, filter); + const parsed = parseSearchQuery(query); + const filter = buildJmapFilter(parsed); + const results = await searchEmails(session.apiUrl, accountId, filter); + log.info({ query, filter, results: results.length, duration_ms: Date.now() - t }, "action.search"); + return results; } export async function togglePinAction( emailId: string, pin: boolean ): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); await setPin(session.apiUrl, accountId, emailId, pin); + log.info({ email_id: emailId, pin, duration_ms: Date.now() - t }, "action.toggle_pin"); } export async function bulkMarkAsRead(emailIds: string[]): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); await setKeywordsOnMany(session.apiUrl, accountId, emailIds, { "keywords/$seen": true }); + log.info({ email_ids: emailIds, count: emailIds.length, duration_ms: Date.now() - t }, "action.mark_read"); } export async function bulkMarkAsUnread(emailIds: string[]): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); await setKeywordsOnMany(session.apiUrl, accountId, emailIds, { "keywords/$seen": null }); + log.info({ email_ids: emailIds, count: emailIds.length, duration_ms: Date.now() - t }, "action.mark_unread"); } export async function bulkSetPin(emailIds: string[], pin: boolean): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); await setKeywordsOnMany(session.apiUrl, accountId, emailIds, { "keywords/$flagged": pin ? true : null, }); + log.info({ email_ids: emailIds, count: emailIds.length, pin, duration_ms: Date.now() - t }, "action.bulk_pin"); } export async function bulkMoveToMailbox( emails: { id: string; mailboxIds: Record }[], targetMailboxId: string ): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); await moveEmailsToMailbox(session.apiUrl, accountId, emails, targetMailboxId); + log.info({ email_ids: emails.map((e) => e.id), count: emails.length, target_mailbox_id: targetMailboxId, duration_ms: Date.now() - t }, "action.move_emails"); } diff --git a/src/app/(inbox)/email/[id]/actions.ts b/src/app/(inbox)/email/[id]/actions.ts index 5b3ac44..e2325ae 100644 --- a/src/app/(inbox)/email/[id]/actions.ts +++ b/src/app/(inbox)/email/[id]/actions.ts @@ -2,17 +2,22 @@ import { getSession, getAccountId, getIdentities, markAsRead, markAsUnread, sendCalendarReply } from "@/lib/jmap"; import { parseIcs, buildCalendarReply } from "@/lib/ics"; +import { log } from "@/lib/logger"; export async function markEmailAsRead(emailId: string): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); await markAsRead(session.apiUrl, accountId, emailId); + log.info({ email_id: emailId, duration_ms: Date.now() - t }, "action.mark_read"); } export async function markEmailAsUnread(emailId: string): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); await markAsUnread(session.apiUrl, accountId, emailId); + log.info({ email_id: emailId, duration_ms: Date.now() - t }, "action.mark_unread"); } export async function sendCalendarReplyAction( @@ -20,6 +25,7 @@ export async function sendCalendarReplyAction( response: "ACCEPTED" | "DECLINED" | "TENTATIVE", inReplyToMessageId?: string ): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); const identities = await getIdentities(session.apiUrl, accountId); @@ -57,4 +63,14 @@ export async function sendCalendarReplyAction( replyIcs, inReplyToMessageId ); + + log.info({ + response, + event_summary: event.summary, + event_start: event.dtStart, + organizer: to.email, + from: identity.email, + in_reply_to_message_id: inReplyToMessageId, + duration_ms: Date.now() - t, + }, "action.calendar_reply"); } diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index e8be121..f16f35e 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -1,12 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { getSession, getAccountId, getIdentities, getMailboxes, sendEmail } from "@/lib/jmap"; +import { log } from "@/lib/logger"; export async function POST(req: NextRequest) { + const t = Date.now(); try { const body = await req.json(); const { identityId, to, cc, bcc, subject, textBody, htmlBody, inlineImages, inReplyToId } = body; if (!identityId || !to?.length || !subject || !textBody) { + log.warn({ identityId: !!identityId, to_count: to?.length ?? 0, has_subject: !!subject, has_body: !!textBody }, "route.send.bad_request"); return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } @@ -20,6 +23,7 @@ export async function POST(req: NextRequest) { ]); const identity = identities.find((i) => i.id === identityId); if (!identity) { + log.warn({ identity_id: identityId, duration_ms: Date.now() - t }, "route.send.invalid_identity"); return NextResponse.json({ error: "Invalid identity" }, { status: 400 }); } const sentMailboxId = mailboxes.find((m) => m.role === "sent")?.id; @@ -38,10 +42,27 @@ export async function POST(req: NextRequest) { sentMailboxId, }); + log.info({ + from: identity.email, + to, + to_count: to.length, + cc_count: cc?.length ?? 0, + bcc_count: bcc?.length ?? 0, + subject, + text_len: textBody.length, + html_len: htmlBody?.length ?? 0, + inline_image_count: inlineImages?.length ?? 0, + is_reply: !!inReplyToId, + in_reply_to_id: inReplyToId, + email_id: result.emailId, + submission_id: result.submissionId, + duration_ms: Date.now() - t, + }, "route.send"); + return NextResponse.json(result); } catch (e) { const message = e instanceof Error ? e.message : "Unknown error"; - console.error("Send error:", message); + log.error({ err: message, duration_ms: Date.now() - t }, "route.send.error"); return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index f87e82f..5f94e92 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getSession, getAccountId, uploadBlob } from "@/lib/jmap"; +import { log } from "@/lib/logger"; const ALLOWED_TYPES = new Set([ "image/png", @@ -10,14 +11,17 @@ const ALLOWED_TYPES = new Set([ ]); export async function POST(req: NextRequest) { + const t = Date.now(); try { const formData = await req.formData(); const file = formData.get("file"); if (!(file instanceof File)) { + log.warn({}, "route.upload.no_file"); return NextResponse.json({ error: "No file provided" }, { status: 400 }); } if (!ALLOWED_TYPES.has(file.type)) { + log.warn({ file_type: file.type, file_name: file.name }, "route.upload.unsupported_type"); return NextResponse.json({ error: "Unsupported file type" }, { status: 415 }); } @@ -26,10 +30,18 @@ export async function POST(req: NextRequest) { const buffer = await file.arrayBuffer(); const result = await uploadBlob(session.uploadUrl, accountId, buffer, file.type); + log.info({ + file_name: file.name, + file_type: file.type, + file_size_bytes: buffer.byteLength, + blob_id: result.blobId, + duration_ms: Date.now() - t, + }, "route.upload"); + return NextResponse.json({ blobId: result.blobId, type: result.type }); } catch (e) { const message = e instanceof Error ? e.message : "Unknown error"; - console.error("Upload error:", message); + log.error({ err: message, duration_ms: Date.now() - t }, "route.upload.error"); return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/src/app/compose/actions.ts b/src/app/compose/actions.ts index b3dddd3..a0e1a27 100644 --- a/src/app/compose/actions.ts +++ b/src/app/compose/actions.ts @@ -8,6 +8,7 @@ import { deleteDraft, parseAddresses, } from "@/lib/jmap"; +import { log } from "@/lib/logger"; function splitRaw(raw: string) { return raw @@ -30,32 +31,53 @@ export interface DraftSaveInput { export async function saveDraftAction( input: DraftSaveInput ): Promise<{ draftId: string }> { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); const mailboxes = await getMailboxes(session.apiUrl, accountId); const draftsMailbox = mailboxes.find((m) => m.role === "drafts"); if (!draftsMailbox) throw new Error("No drafts mailbox found"); + const toAddrs = parseAddresses(splitRaw(input.to)); + const ccAddrs = parseAddresses(splitRaw(input.cc)); + const bccAddrs = parseAddresses(splitRaw(input.bcc)); + const draftId = await saveDraft( session.apiUrl, accountId, draftsMailbox.id, { from: { name: input.fromName, email: input.fromEmail }, - to: parseAddresses(splitRaw(input.to)), - cc: parseAddresses(splitRaw(input.cc)), - bcc: parseAddresses(splitRaw(input.bcc)), + to: toAddrs, + cc: ccAddrs, + bcc: bccAddrs, subject: input.subject, body: input.body, }, input.draftId ); + log.info({ + is_update: !!input.draftId, + prev_draft_id: input.draftId ?? undefined, + new_draft_id: draftId, + from: input.fromEmail, + to: toAddrs.map((a) => a.email), + to_count: toAddrs.length, + cc_count: ccAddrs.length, + bcc_count: bccAddrs.length, + subject: input.subject, + body_len: input.body.length, + duration_ms: Date.now() - t, + }, "action.save_draft"); + return { draftId }; } export async function deleteDraftAction(draftId: string): Promise { + const t = Date.now(); const session = await getSession(); const accountId = getAccountId(session); await deleteDraft(session.apiUrl, accountId, draftId); + log.info({ draft_id: draftId, duration_ms: Date.now() - t }, "action.delete_draft"); } diff --git a/src/lib/jmap.ts b/src/lib/jmap.ts index 22629f2..c31a8e9 100644 --- a/src/lib/jmap.ts +++ b/src/lib/jmap.ts @@ -1,4 +1,5 @@ import { Email, Identity, JMAPSession, Mailbox } from "./types"; +import { log } from "./logger"; const SESSION_URL = "https://api.fastmail.com/jmap/session"; @@ -29,14 +30,32 @@ export async function jmapCall( apiUrl: string, methodCalls: MethodCall[] ): Promise<{ methodResponses: [string, Record, string][] }> { + const t = Date.now(); + const methods = methodCalls.map(([name]) => name); + // accountId is present in every method call's params object + const accountId = methodCalls[0]?.[1]?.accountId as string | undefined; + const res = await fetch(apiUrl, { method: "POST", headers: { ...authHeader(), "Content-Type": "application/json" }, body: JSON.stringify({ using: JMAP_USING, methodCalls }), cache: "no-store", }); - if (!res.ok) throw new Error(`JMAP call failed: ${res.statusText}`); - return res.json(); + + if (!res.ok) { + log.error( + { methods, method_count: methodCalls.length, account_id: accountId, http_status: res.status, duration_ms: Date.now() - t }, + "jmap.error" + ); + throw new Error(`JMAP call failed: ${res.statusText}`); + } + + const data: { methodResponses: [string, Record, string][] } = await res.json(); + log.info( + { methods, method_count: methodCalls.length, account_id: accountId, response_count: data.methodResponses.length, duration_ms: Date.now() - t }, + "jmap.call" + ); + return data; } // Get the primary mail account ID from a session @@ -488,14 +507,20 @@ export async function downloadBlobAsText( blobId: string, name = "file" ): Promise { + const t = Date.now(); const url = downloadUrl .replace(/\{accountId\}/, accountId) .replace(/\{blobId\}/, blobId) .replace(/\{name\}/, encodeURIComponent(name)) .replace(/\{type\}/, "text%2Fcalendar"); const res = await fetch(url, { headers: authHeader(), cache: "no-store" }); - if (!res.ok) throw new Error(`Blob download failed: ${res.statusText}`); - return res.text(); + if (!res.ok) { + log.error({ blob_id: blobId, http_status: res.status, duration_ms: Date.now() - t }, "jmap.blob_download.error"); + throw new Error(`Blob download failed: ${res.statusText}`); + } + const text = await res.text(); + log.info({ blob_id: blobId, bytes: text.length, duration_ms: Date.now() - t }, "jmap.blob_download"); + return text; } export async function uploadBlob( @@ -504,14 +529,20 @@ export async function uploadBlob( data: ArrayBuffer, contentType: string ): Promise<{ blobId: string; type: string; size: number }> { + const t = Date.now(); const url = uploadUrl.replace("{accountId}", accountId); const res = await fetch(url, { method: "POST", headers: { ...authHeader(), "Content-Type": contentType }, body: data, }); - if (!res.ok) throw new Error(`Blob upload failed: ${res.statusText}`); - return res.json(); + if (!res.ok) { + log.error({ content_type: contentType, bytes: data.byteLength, http_status: res.status, duration_ms: Date.now() - t }, "jmap.blob_upload.error"); + throw new Error(`Blob upload failed: ${res.statusText}`); + } + const result: { blobId: string; type: string; size: number } = await res.json(); + log.info({ blob_id: result.blobId, content_type: contentType, bytes: data.byteLength, duration_ms: Date.now() - t }, "jmap.blob_upload"); + return result; } export interface InlineImage { @@ -745,6 +776,7 @@ export async function searchContacts( accountId: string, query: string ): Promise { + const t = Date.now(); // Contacts use a separate JMAP capability — issue a dedicated request // rather than mixing into the mail-capability batch. const res = await fetch(apiUrl, { @@ -771,7 +803,12 @@ export async function searchContacts( cache: "no-store", }); - if (!res.ok) return []; + const duration_ms = Date.now() - t; + + if (!res.ok) { + log.warn({ query, http_status: res.status, duration_ms }, "jmap.contacts.error"); + return []; + } const data = await res.json(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -784,5 +821,7 @@ export async function searchContacts( if (entry.value) results.push({ name: name || entry.value, email: entry.value }); } } + + log.info({ query, results: results.length, duration_ms }, "jmap.contacts"); return results; } diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..0cf2a53 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,14 @@ +import pino from "pino"; + +/** + * Structured logger. Outputs newline-delimited JSON in all environments. + * Set LOG_LEVEL env var to override (default: "info"). + * + * In development, pipe through `pino-pretty` for human-readable output: + * pnpm dev | pnpm dlx pino-pretty + */ +export const log = pino({ + level: process.env.LOG_LEVEL ?? "info", + base: { service: "email" }, + timestamp: pino.stdTimeFunctions.isoTime, +});