kennel 0.2.3 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -13
- package/dist/cli/dispatch.js +16 -3
- package/dist/cli/src/cli/dispatch.js +16 -3
- package/dist/cli/src/cli/usage.js +1 -1
- package/dist/cli/src/core/command.js +1 -0
- package/dist/cli/src/core/init.js +5 -5
- package/dist/cli/src/plugins/external.d.ts +28 -2
- package/dist/cli/src/plugins/external.js +77 -23
- package/dist/cli/src/site/server-plugins.d.ts +4 -9
- package/dist/cli/src/site/viewer.js +1 -0
- package/dist/cli/usage.js +1 -1
- package/dist/core/api-client.js +576 -10317
- package/dist/core/command.js +1 -0
- package/dist/core/core-folders.js +2313 -12088
- package/dist/core/init.js +5 -5
- package/dist/core/runtime.js +8237 -20713
- package/dist/package/preview.js +19617 -33352
- package/dist/plugins/external.d.ts +28 -2
- package/dist/plugins/external.js +77 -23
- package/dist/site/server-plugins.d.ts +4 -9
- package/dist/site/server-plugins.js +557 -10452
- package/dist/site/viewer.js +1 -0
- package/kernel/apps/web/package.json +2 -6
- package/kernel/apps/web/scripts/dev.mjs +53 -0
- package/kernel/apps/web/src/app.css +0 -3
- package/kernel/apps/web/src/external-plugins.ts +1 -1
- package/kernel/apps/web/src/folder-filter-icons.tsx +1 -1
- package/kernel/apps/web/src/folder-plugin-view.tsx +1 -1
- package/kernel/apps/web/src/main.tsx +0 -1
- package/kernel/apps/web/src/plugins.ts +1 -31
- package/kernel/apps/web/tsconfig.json +1 -2
- package/kernel/apps/web/vite.config.ts +32 -14
- package/kernel/packages/agents/package.json +1 -1
- package/kernel/packages/agents/src/agent-card.tsx +1 -1
- package/kernel/packages/agents/src/client.tsx +1 -1
- package/kernel/packages/agents/src/manifest.ts +1 -1
- package/kernel/packages/agents/src/server.ts +1 -1
- package/kernel/packages/agents/src/viewer.tsx +1 -1
- package/kernel/packages/api/package.json +1 -5
- package/kernel/packages/api/src/server/acp-live-plugin.ts +1 -1
- package/kernel/packages/api/src/server/api/create-agent-file.ts +2 -2
- package/kernel/packages/api/src/server/api/document-edit.ts +2 -2
- package/kernel/packages/api/src/server/api/open-path.ts +2 -2
- package/kernel/packages/api/src/server/folder-metadata-plugin.ts +17 -42
- package/kernel/packages/api/src/server/index.ts +0 -1
- package/kernel/packages/api/src/server/plugins.ts +52 -19
- package/kernel/packages/api/src/shared/viewer-route.ts +1 -1
- package/kernel/packages/api/test/core.test.ts +14 -26
- package/kernel/packages/markdown/README.md +1 -1
- package/kernel/packages/notes/package.json +1 -1
- package/kernel/packages/notes/src/client.tsx +1 -1
- package/kernel/packages/notes/src/manifest.ts +1 -1
- package/kernel/packages/notes/src/server.ts +1 -1
- package/kernel/packages/plugins/SKILL.md +39 -0
- package/kernel/{plugins/mail → packages/plugins}/package.json +53 -28
- package/kernel/packages/plugins/src/index.ts +52 -0
- package/kernel/packages/plugins/src/page.ts +17 -0
- package/kernel/{plugins/projects → packages/plugins}/tsconfig.json +5 -1
- package/kernel/packages/plugins/tsup.config.ts +25 -0
- package/kernel/packages/ui/src/components/content-issues.tsx +1 -1
- package/kernel/packages/ui/tsconfig.json +1 -1
- package/package.json +2 -3
- package/web/assets/{abnfDiagram-VRR7QNED-Bgfb1nEB.js → abnfDiagram-VRR7QNED-xd_g1uK3.js} +1 -1
- package/web/assets/app-CX20nNIa.js +2 -0
- package/web/assets/{app-options-D-RJiQ-X.js → app-options-CkPJE5ep.js} +1 -1
- package/web/assets/{app-view-header-BLvLq02T.js → app-view-header-CNk5KnA-.js} +1 -1
- package/web/assets/{arc-Dk_KI36B.js → arc-B0u5yBJA.js} +1 -1
- package/web/assets/{architectureDiagram-ZJ3FMSHR-NR_KhRCf.js → architectureDiagram-ZJ3FMSHR-DfxOAo8P.js} +1 -1
- package/web/assets/{ask-tray-TzeTcO6-.js → ask-tray-SscJecDY.js} +1 -1
- package/web/assets/{blockDiagram-677ZJIJ3-CkLkwx9C.js → blockDiagram-677ZJIJ3-Ci3Usxh6.js} +1 -1
- package/web/assets/{c4Diagram-LMCZKHZV-CFCUH8e_.js → c4Diagram-LMCZKHZV-BqgwIVgh.js} +1 -1
- package/web/assets/channel-X7WHyxxT.js +1 -0
- package/web/assets/{chunk-2Q5K7J3B-B_BPLWsW.js → chunk-2Q5K7J3B-CfX69l2U.js} +1 -1
- package/web/assets/{chunk-32BRIVSS-B1VB1hOy.js → chunk-32BRIVSS-CuFlzZkl.js} +1 -1
- package/web/assets/{chunk-5VM5RSS4-KFcYr8Co.js → chunk-5VM5RSS4-DidCMlJa.js} +1 -1
- package/web/assets/{chunk-EX3LRPZG-CpBX3kuK.js → chunk-EX3LRPZG-B5CDm1dS.js} +1 -1
- package/web/assets/{chunk-JWPE2WC7-BPSl40wG.js → chunk-JWPE2WC7-D5fGV_sH.js} +1 -1
- package/web/assets/{chunk-MOJQB5TN-D2RcYmoU.js → chunk-MOJQB5TN-ayhH5Q-H.js} +1 -1
- package/web/assets/{chunk-RYQCIY6F-m7Gyn-t3.js → chunk-RYQCIY6F-C4BfOOZn.js} +1 -1
- package/web/assets/{chunk-V7JOEXUC-TDvdopxS.js → chunk-V7JOEXUC-DpUEIM9V.js} +1 -1
- package/web/assets/{chunk-VR4S4FIN-DZTsG51a.js → chunk-VR4S4FIN-DoQuFFRh.js} +1 -1
- package/web/assets/{chunk-XXDRQBXY-Dp0--RE2.js → chunk-XXDRQBXY-CI9eiLwn.js} +1 -1
- package/web/assets/classDiagram-OUVF2IWQ-BjsLQ3dz.js +1 -0
- package/web/assets/classDiagram-v2-EOCWNBFH-BjsLQ3dz.js +1 -0
- package/web/assets/client-7OWw5kW2.js +6 -0
- package/web/assets/client-DYj_td4Q.js +17 -0
- package/web/assets/{client-j2kQmxsO.js → client-Dk-NBvJR.js} +4 -4
- package/web/assets/command-zkzH4hVz.js +38 -0
- package/web/assets/{copy-context-C0Uqsp-3.js → copy-context-D5oiWrYC.js} +1 -1
- package/web/assets/{cose-bilkent-JH36ORCC-CPu3PI8h.js → cose-bilkent-JH36ORCC-BHRk1mO3.js} +1 -1
- package/web/assets/{cynefin-VYW2F7L2-DWxftYAm.js → cynefin-VYW2F7L2-C1B09TRE.js} +1 -1
- package/web/assets/{cynefinDiagram-TSTJHNR4-DsmN2V-p.js → cynefinDiagram-TSTJHNR4-B27qXQbv.js} +1 -1
- package/web/assets/{dagre-VKFMJZFB-COEbApyY.js → dagre-VKFMJZFB-B1qEi32g.js} +1 -1
- package/web/assets/{diagram-FQU43EPY-0O9WGWKB.js → diagram-FQU43EPY-BMkkgADD.js} +1 -1
- package/web/assets/{diagram-G47NLZAW-CutEdMVh.js → diagram-G47NLZAW-Cb8_g1A5.js} +1 -1
- package/web/assets/{diagram-NH7WQ7WH-C9KZc3B2.js → diagram-NH7WQ7WH-B51wuc5m.js} +1 -1
- package/web/assets/{diagram-OA4YK3LP-D8kkPXbY.js → diagram-OA4YK3LP-xnsecbbI.js} +1 -1
- package/web/assets/{diagram-WEI45ONY-_YqiTaGP.js → diagram-WEI45ONY-DSZdyaAV.js} +1 -1
- package/web/assets/{document-Dgt5eZFj.js → document-Dc2dftTW.js} +3 -3
- package/web/assets/{ebnfDiagram-CCIWWBDH-JO-zQh6g.js → ebnfDiagram-CCIWWBDH-aTHmcUGy.js} +1 -1
- package/web/assets/{entities-C3wz7FCx.js → entities-CCUtpskY.js} +1 -1
- package/web/assets/{erDiagram-Q63AITRT-9UjQwJ4P.js → erDiagram-Q63AITRT-CUUPBcvo.js} +1 -1
- package/web/assets/{file-viewer-DZJ8nYnl.js → file-viewer-DJ3rsmef.js} +1 -1
- package/web/assets/{flowDiagram-23GEKE2U-Ca08tnYM.js → flowDiagram-23GEKE2U-aTQXx2Mh.js} +1 -1
- package/web/assets/{folder-image-BfGgxo7i.js → folder-image-Bnv43tzP.js} +1 -1
- package/web/assets/{folder-landing-qUKBCnG-.js → folder-landing-DCnzYXXY.js} +1 -1
- package/web/assets/{folder-list-BfFjv7rR.js → folder-list-DvVkVjiR.js} +1 -1
- package/web/assets/{folder-metadata-DCw8xzHk.js → folder-metadata-C9hMMzIH.js} +1 -1
- package/web/assets/{folder-mobile-bar-Ubz4babf.js → folder-mobile-bar-VoTL1GS0.js} +1 -1
- package/web/assets/folder-plugin-view-Cvd804ZU.js +6 -0
- package/web/assets/{ganttDiagram-NO4QXBWP-KnHzX5W3.js → ganttDiagram-NO4QXBWP-DiwdUp7u.js} +1 -1
- package/web/assets/{gitGraphDiagram-IHSO6WYX-Cm8woNtK.js → gitGraphDiagram-IHSO6WYX-BL2gG_IS.js} +1 -1
- package/web/assets/{index-CNyy98F8.js → index-CUTtMSyJ.js} +2 -2
- package/web/assets/index-CegvVQ1C.js +1 -0
- package/web/assets/index-Dwhf8F_U.css +1 -0
- package/web/assets/{infoDiagram-FWYZ7A6U-yyqmUtIb.js → infoDiagram-FWYZ7A6U-B3n0zZpt.js} +1 -1
- package/web/assets/{ishikawaDiagram-FXEZZL3T-BfvR_Zy4.js → ishikawaDiagram-FXEZZL3T-D7YytbEv.js} +1 -1
- package/web/assets/{journeyDiagram-5HDEW3XC-BKuw06HM.js → journeyDiagram-5HDEW3XC-BYwqEl3C.js} +1 -1
- package/web/assets/{kanban-definition-HUTT4EX6-n0010rmQ.js → kanban-definition-HUTT4EX6-BQHNJgXZ.js} +1 -1
- package/web/assets/{linear-C0d0lT8O.js → linear-BPiQCLNk.js} +1 -1
- package/web/assets/{mermaid.core-BDVbShyO.js → mermaid.core-CD-KvBjV.js} +4 -4
- package/web/assets/{metadata-KV0dpp4G.js → metadata-COJ7fZul.js} +1 -1
- package/web/assets/{mindmap-definition-LN4V7U3C--_0ks3GF.js → mindmap-definition-LN4V7U3C-C1L_gyoj.js} +1 -1
- package/web/assets/{pegDiagram-2B236MQR-CBoNIpKR.js → pegDiagram-2B236MQR-BDAfItqS.js} +1 -1
- package/web/assets/{pieDiagram-ENE6RG2P-MA5tkYh5.js → pieDiagram-ENE6RG2P-CkZYH3Vz.js} +1 -1
- package/web/assets/{popover-DBD7FF-W.js → popover-0oA2lLUV.js} +2 -2
- package/web/assets/{project-search-dialog-DM1IkSZu.js → project-search-dialog-B2oqMT6h.js} +1 -1
- package/web/assets/{quadrantDiagram-ABIIQ3AL-CWT1ryqF.js → quadrantDiagram-ABIIQ3AL-Dy8Al3YZ.js} +1 -1
- package/web/assets/{railroadDiagram-RFXS5EU6-ZiLB5FGi.js → railroadDiagram-RFXS5EU6-Dd9_yo7B.js} +1 -1
- package/web/assets/{requirementDiagram-TGXJPOKE-CQq9uneq.js → requirementDiagram-TGXJPOKE-DPhIUsbs.js} +1 -1
- package/web/assets/{sankeyDiagram-HTMAVEWB-qDG-0JqI.js → sankeyDiagram-HTMAVEWB-B2QkYtub.js} +1 -1
- package/web/assets/{search-DNrQA5fF.js → search-C1J0-xiB.js} +1 -1
- package/web/assets/select-CtXC_PLC.js +30 -0
- package/web/assets/{selection-popup-DmtTeRs2.js → selection-popup-qhlUFzGq.js} +1 -1
- package/web/assets/{sequenceDiagram-DBY2YBRQ-CiApurLO.js → sequenceDiagram-DBY2YBRQ-Bx7fcZDx.js} +1 -1
- package/web/assets/{sizeCapture-X5ZJPWSS-DZSR5QXm.js → sizeCapture-X5ZJPWSS-CIXIhl_A.js} +1 -1
- package/web/assets/{stateDiagram-2N3HPSRC-CjJJR9_z.js → stateDiagram-2N3HPSRC-DCrdAflB.js} +1 -1
- package/web/assets/stateDiagram-v2-6OUMAXLB-0KCFLYeQ.js +1 -0
- package/web/assets/{swimlanes-5IMT3BWC-CKmo1sg6.js → swimlanes-5IMT3BWC-DA2vc7iZ.js} +2 -2
- package/web/assets/swimlanesDiagram-G3AALYLV-CAw3AV84.js +8 -0
- package/web/assets/{timeline-definition-FHXFAJF6-D-NMwS3y.js → timeline-definition-FHXFAJF6-lKfJiV9V.js} +1 -1
- package/web/assets/{vennDiagram-L72KCM5P-CfL6LBWw.js → vennDiagram-L72KCM5P-DO1q2KzG.js} +1 -1
- package/web/assets/{view-context-gyDl_p2V.js → view-context-DXKM3-l1.js} +4 -4
- package/web/assets/{viewer-capabilities-Bpp8Uvcg.js → viewer-capabilities-Cig_xM9u.js} +1 -1
- package/web/assets/{wardleyDiagram-EHGQE667-DgldMxaj.js → wardleyDiagram-EHGQE667-Dal9LkuZ.js} +1 -1
- package/web/assets/{xychartDiagram-FW5EYKEG-CIoAIFX8.js → xychartDiagram-FW5EYKEG-B3dPLPII.js} +1 -1
- package/web/index.html +2 -2
- package/kernel/packages/api/test/partial-loading.test.ts +0 -43
- package/kernel/packages/folder-plugin/package.json +0 -50
- package/kernel/packages/folder-plugin/src/index.ts +0 -15
- package/kernel/packages/folder-plugin/tsconfig.json +0 -17
- package/kernel/plugins/calendar/SKILL.md +0 -173
- package/kernel/plugins/calendar/eslint.config.js +0 -10
- package/kernel/plugins/calendar/package.json +0 -73
- package/kernel/plugins/calendar/schema.json +0 -137
- package/kernel/plugins/calendar/src/calendar.tsx +0 -328
- package/kernel/plugins/calendar/src/client.tsx +0 -139
- package/kernel/plugins/calendar/src/context.ts +0 -48
- package/kernel/plugins/calendar/src/copy-button.tsx +0 -66
- package/kernel/plugins/calendar/src/event-dialog.tsx +0 -270
- package/kernel/plugins/calendar/src/index.ts +0 -4
- package/kernel/plugins/calendar/src/manifest.ts +0 -7
- package/kernel/plugins/calendar/src/parse.ts +0 -610
- package/kernel/plugins/calendar/src/server.ts +0 -160
- package/kernel/plugins/calendar/src/utils.ts +0 -80
- package/kernel/plugins/calendar/src/views.tsx +0 -418
- package/kernel/plugins/calendar/test/context.test.mjs +0 -60
- package/kernel/plugins/calendar/test/package.test.mjs +0 -43
- package/kernel/plugins/calendar/test/parse.test.mjs +0 -392
- package/kernel/plugins/calendar/test/server.test.mjs +0 -126
- package/kernel/plugins/calendar/tsconfig.json +0 -17
- package/kernel/plugins/mail/SKILL.md +0 -97
- package/kernel/plugins/mail/eslint.config.js +0 -10
- package/kernel/plugins/mail/src/api.ts +0 -359
- package/kernel/plugins/mail/src/bun-ffi.d.ts +0 -14
- package/kernel/plugins/mail/src/client.tsx +0 -14
- package/kernel/plugins/mail/src/flock.ts +0 -137
- package/kernel/plugins/mail/src/folders.ts +0 -37
- package/kernel/plugins/mail/src/history.ts +0 -485
- package/kernel/plugins/mail/src/index.ts +0 -3
- package/kernel/plugins/mail/src/mail-card.tsx +0 -123
- package/kernel/plugins/mail/src/mail-conversation.tsx +0 -153
- package/kernel/plugins/mail/src/mail-detail-page.tsx +0 -467
- package/kernel/plugins/mail/src/mail-hooks.ts +0 -252
- package/kernel/plugins/mail/src/mail-page.tsx +0 -109
- package/kernel/plugins/mail/src/mail-request.ts +0 -78
- package/kernel/plugins/mail/src/mail-send-box.tsx +0 -175
- package/kernel/plugins/mail/src/mailos.ts +0 -723
- package/kernel/plugins/mail/src/manifest.ts +0 -7
- package/kernel/plugins/mail/src/node.ts +0 -171
- package/kernel/plugins/mail/src/parse.ts +0 -139
- package/kernel/plugins/mail/src/server.ts +0 -125
- package/kernel/plugins/mail/src/types.ts +0 -10
- package/kernel/plugins/mail/src/viewer.tsx +0 -147
- package/kernel/plugins/mail/test/flock.test.mjs +0 -156
- package/kernel/plugins/mail/test/folders.test.mjs +0 -138
- package/kernel/plugins/mail/test/history.test.mjs +0 -460
- package/kernel/plugins/mail/test/mailos.test.mjs +0 -752
- package/kernel/plugins/mail/test/node.test.mjs +0 -207
- package/kernel/plugins/mail/test/package.test.mjs +0 -54
- package/kernel/plugins/mail/test/parse.test.mjs +0 -32
- package/kernel/plugins/mail/test/server.test.mjs +0 -116
- package/kernel/plugins/mail/test/viewer.test.mjs +0 -192
- package/kernel/plugins/mail/tsconfig.json +0 -17
- package/kernel/plugins/projects/SKILL.md +0 -73
- package/kernel/plugins/projects/eslint.config.js +0 -10
- package/kernel/plugins/projects/package.json +0 -70
- package/kernel/plugins/projects/src/client.tsx +0 -17
- package/kernel/plugins/projects/src/data/model.ts +0 -129
- package/kernel/plugins/projects/src/index.ts +0 -17
- package/kernel/plugins/projects/src/manifest.ts +0 -7
- package/kernel/plugins/projects/src/project-context.ts +0 -41
- package/kernel/plugins/projects/src/server.ts +0 -276
- package/kernel/plugins/projects/src/ui/project-card.tsx +0 -61
- package/kernel/plugins/projects/src/ui/projects-page.tsx +0 -102
- package/kernel/plugins/projects/test/package.test.mjs +0 -51
- package/kernel/plugins/tasks/SKILL.md +0 -341
- package/kernel/plugins/tasks/eslint.config.js +0 -10
- package/kernel/plugins/tasks/package.json +0 -74
- package/kernel/plugins/tasks/schema.json +0 -939
- package/kernel/plugins/tasks/src/api.ts +0 -212
- package/kernel/plugins/tasks/src/client.tsx +0 -23
- package/kernel/plugins/tasks/src/data/parse.ts +0 -544
- package/kernel/plugins/tasks/src/data/task-id.ts +0 -56
- package/kernel/plugins/tasks/src/data/todo-model.ts +0 -795
- package/kernel/plugins/tasks/src/index.ts +0 -34
- package/kernel/plugins/tasks/src/manifest.ts +0 -7
- package/kernel/plugins/tasks/src/pages/task-detail.tsx +0 -48
- package/kernel/plugins/tasks/src/pages/task-list.tsx +0 -88
- package/kernel/plugins/tasks/src/server.ts +0 -447
- package/kernel/plugins/tasks/src/styles.css +0 -9
- package/kernel/plugins/tasks/src/ui/compact-todo-list.tsx +0 -372
- package/kernel/plugins/tasks/src/ui/error-details.tsx +0 -78
- package/kernel/plugins/tasks/src/ui/task-page.tsx +0 -392
- package/kernel/plugins/tasks/src/ui/todo-agent-filter.tsx +0 -36
- package/kernel/plugins/tasks/src/ui/todo-filter-bar.tsx +0 -66
- package/kernel/plugins/tasks/src/ui/todo-item.tsx +0 -494
- package/kernel/plugins/tasks/src/ui/todo-status-filter.tsx +0 -60
- package/kernel/plugins/tasks/src/ui/todo-tag-filter.tsx +0 -48
- package/kernel/plugins/tasks/src/ui/todo-warnings.tsx +0 -106
- package/kernel/plugins/tasks/test/api.test.mjs +0 -99
- package/kernel/plugins/tasks/test/folder-sources.test.mjs +0 -46
- package/kernel/plugins/tasks/test/package.test.mjs +0 -54
- package/kernel/plugins/tasks/test/parse.test.mjs +0 -225
- package/kernel/plugins/tasks/test/server.test.mjs +0 -386
- package/kernel/plugins/tasks/tsconfig.json +0 -17
- package/web/assets/app-DoGr2Zi7.js +0 -2
- package/web/assets/channel-uMEjV8qc.js +0 -1
- package/web/assets/classDiagram-OUVF2IWQ-fbZocVor.js +0 -1
- package/web/assets/classDiagram-v2-EOCWNBFH-fbZocVor.js +0 -1
- package/web/assets/client-C6Nxdfif.js +0 -2
- package/web/assets/client-CXYfBko5.js +0 -17
- package/web/assets/client-CoKtOq-X.js +0 -7
- package/web/assets/client-DyRQz5RU.js +0 -53
- package/web/assets/client-OY6HUzuD.js +0 -98
- package/web/assets/client-YOYWZIFi.js +0 -34
- package/web/assets/command-BugZBGXE.js +0 -38
- package/web/assets/folder-plugin-view-DsWR1zmQ.js +0 -6
- package/web/assets/index-BaRnbYRw.js +0 -1
- package/web/assets/index-BexmLxIP.css +0 -1
- package/web/assets/index-DDirZMgx.js +0 -1
- package/web/assets/note-card-DBfX6gW1.js +0 -6
- package/web/assets/select-D8cqNgcp.js +0 -30
- package/web/assets/stateDiagram-v2-6OUMAXLB-BpeGSCu9.js +0 -1
- package/web/assets/swimlanesDiagram-G3AALYLV-D9RjLKb9.js +0 -8
- package/web/assets/use-folder-filter-LQyXns5Q.js +0 -1
- /package/kernel/packages/{folder-plugin → plugins}/eslint.config.js +0 -0
- /package/kernel/packages/{folder-plugin → plugins}/src/client.ts +0 -0
- /package/kernel/packages/{folder-plugin → plugins}/src/endpoint.ts +0 -0
- /package/kernel/packages/{folder-plugin → plugins}/src/server.ts +0 -0
- /package/kernel/packages/{folder-plugin → plugins}/src/subpath.ts +0 -0
- /package/kernel/packages/{folder-plugin → plugins}/src/types.ts +0 -0
- /package/kernel/packages/{folder-plugin → plugins}/src/validation.ts +0 -0
- /package/kernel/packages/{folder-plugin → plugins}/test/contract.test.mjs +0 -0
|
@@ -1,485 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto"
|
|
2
|
-
import {
|
|
3
|
-
lstat,
|
|
4
|
-
mkdir,
|
|
5
|
-
readFile,
|
|
6
|
-
realpath,
|
|
7
|
-
rename,
|
|
8
|
-
unlink,
|
|
9
|
-
writeFile,
|
|
10
|
-
} from "node:fs/promises"
|
|
11
|
-
import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
|
|
12
|
-
|
|
13
|
-
import { FlockTimeoutError, withFlock } from "./flock.ts"
|
|
14
|
-
import { parseMailMarkdown } from "./parse.ts"
|
|
15
|
-
|
|
16
|
-
export type DraftIdentity = {
|
|
17
|
-
id: string
|
|
18
|
-
filePath: string
|
|
19
|
-
folder: string | null
|
|
20
|
-
/** sha256 of the draft bytes the client last loaded. */
|
|
21
|
-
revision: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export type DeleteOutcome = "deleted" | "changed" | "missing" | "invalid"
|
|
25
|
-
|
|
26
|
-
export class MailDeleteError extends Error {
|
|
27
|
-
readonly outcome: DeleteOutcome
|
|
28
|
-
|
|
29
|
-
constructor(outcome: DeleteOutcome, message: string) {
|
|
30
|
-
super(message)
|
|
31
|
-
this.name = "MailDeleteError"
|
|
32
|
-
this.outcome = outcome
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* How long delete waits for the MailOS-compatible flock before giving up.
|
|
38
|
-
* MailOS blocks indefinitely; Kennel bounds the wait so an abandoned holder
|
|
39
|
-
* cannot hang a request, and reports the result as a conflict instead.
|
|
40
|
-
*/
|
|
41
|
-
export const deleteLockTimeoutMs = 30_000
|
|
42
|
-
|
|
43
|
-
function validDraftId(id: string) {
|
|
44
|
-
const trimmed = id.trim()
|
|
45
|
-
return (
|
|
46
|
-
Boolean(trimmed) &&
|
|
47
|
-
trimmed !== "." &&
|
|
48
|
-
trimmed !== ".." &&
|
|
49
|
-
!/[\\/]/.test(trimmed)
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function draftRevision(content: string) {
|
|
54
|
-
return createHash("sha256").update(content).digest("hex")
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Walk `childPath` (which must be inside canonical `anchor`) and make sure
|
|
59
|
-
* every existing component is a real directory — never a symlink —
|
|
60
|
-
* creating missing components ourselves. This closes the history/locks
|
|
61
|
-
* directory symlink-escape: a planted `history`, `<id>`, or `.locks` symlink
|
|
62
|
-
* is rejected instead of followed.
|
|
63
|
-
*/
|
|
64
|
-
async function ensureRealDirChain(
|
|
65
|
-
anchor: string,
|
|
66
|
-
childPath: string,
|
|
67
|
-
): Promise<void> {
|
|
68
|
-
const rel = relative(anchor, childPath)
|
|
69
|
-
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
70
|
-
throw new MailDeleteError(
|
|
71
|
-
"invalid",
|
|
72
|
-
"Lock/history path escapes the mail folder",
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
let current = anchor
|
|
76
|
-
for (const segment of rel.split(sep)) {
|
|
77
|
-
if (!segment) continue
|
|
78
|
-
current = resolve(current, segment)
|
|
79
|
-
let info = await lstat(current).catch(() => undefined)
|
|
80
|
-
if (!info) {
|
|
81
|
-
await mkdir(current, { mode: 0o700 }).catch(() => undefined)
|
|
82
|
-
info = await lstat(current).catch(() => undefined)
|
|
83
|
-
if (!info || info.isSymbolicLink() || !info.isDirectory()) {
|
|
84
|
-
throw new MailDeleteError(
|
|
85
|
-
"invalid",
|
|
86
|
-
"Could not create a real directory for the draft lock/history",
|
|
87
|
-
)
|
|
88
|
-
}
|
|
89
|
-
continue
|
|
90
|
-
}
|
|
91
|
-
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
92
|
-
throw new MailDeleteError(
|
|
93
|
-
"invalid",
|
|
94
|
-
"Draft lock/history path must use real directories (no symlinked components)",
|
|
95
|
-
)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Delete a local draft with a byte-exact, verified history snapshot, under
|
|
102
|
-
* the real MailOS advisory lock.
|
|
103
|
-
*
|
|
104
|
-
* Locking — `withFlock` takes the exclusive `flock(2)` on
|
|
105
|
-
* `<mailDir>/.locks/<id>.md.lock`, the exact file MailOS holds
|
|
106
|
-
* (`emailos/internal/core/drafts.go withLocalDraftLock`) while it
|
|
107
|
-
* snapshots/updates/sends a draft. Kennel's delete/archive critical section
|
|
108
|
-
* therefore serializes against the MailOS binary itself, not only against
|
|
109
|
-
* other Kennel processes.
|
|
110
|
-
*
|
|
111
|
-
* Byte-level guarantees (inside the lock):
|
|
112
|
-
* - the draft file's current bytes must still hash to the revision the client
|
|
113
|
-
* loaded, or the delete is refused ("changed") and newer work survives;
|
|
114
|
-
* - the snapshot written to history/<id>/<revision>.md is byte-identical to
|
|
115
|
-
* the draft content (temp sibling + rename + verified re-read/hash);
|
|
116
|
-
* - pre-existing history is verified byte-for-byte first — corrupt existing
|
|
117
|
-
* history is never overwritten or deleted, and the draft is left in place;
|
|
118
|
-
* - the draft is re-read and re-hashed immediately before the unlink, and its
|
|
119
|
-
* realpath re-verified, so the final unlink only ever removes the exact
|
|
120
|
-
* file whose bytes matched the loaded revision at that moment.
|
|
121
|
-
*
|
|
122
|
-
* Exact guarantee (limitation): a non-cooperating writer that does not take
|
|
123
|
-
* the flock and rewrites the file *after* the final re-read but *before* the
|
|
124
|
-
* unlink can still have newer work deleted — the same two-instruction window
|
|
125
|
-
* MailOS itself has between its locked re-read and remove. No filesystem API
|
|
126
|
-
* can make a byte-compare-and-unlink atomic; the flock + closing re-read +
|
|
127
|
-
* realpath re-check reduce this to the unavoidable sub-microsecond window,
|
|
128
|
-
* and cooperative writers (Kennel, MailOS send/archive/snapshot) are fully
|
|
129
|
-
* serialized.
|
|
130
|
-
*/
|
|
131
|
-
export async function deleteLocalDraftVerified(
|
|
132
|
-
mail: DraftIdentity,
|
|
133
|
-
mailDir: string,
|
|
134
|
-
): Promise<"deleted"> {
|
|
135
|
-
if (!validDraftId(mail.id)) {
|
|
136
|
-
throw new MailDeleteError("invalid", "Invalid mail id")
|
|
137
|
-
}
|
|
138
|
-
const mailDirReal = await realpath(resolve(mailDir))
|
|
139
|
-
const target = resolve(mail.filePath)
|
|
140
|
-
const targetDir = await realpath(dirname(target)).catch(() => null)
|
|
141
|
-
if (targetDir !== mailDirReal) {
|
|
142
|
-
throw new MailDeleteError(
|
|
143
|
-
"invalid",
|
|
144
|
-
"Draft path is outside the mail folder",
|
|
145
|
-
)
|
|
146
|
-
}
|
|
147
|
-
// The draft file itself must be the plain expected file: a symlink that
|
|
148
|
-
// realpaths somewhere else is rejected rather than followed.
|
|
149
|
-
const expected = resolve(mailDirReal, `${mail.id.trim()}.md`)
|
|
150
|
-
const targetReal = await realpath(target).catch(() => null)
|
|
151
|
-
if (targetReal === null) {
|
|
152
|
-
throw new MailDeleteError("missing", "Draft no longer exists")
|
|
153
|
-
}
|
|
154
|
-
if (targetReal !== expected) {
|
|
155
|
-
throw new MailDeleteError(
|
|
156
|
-
"invalid",
|
|
157
|
-
"Draft path is not a direct file in the mail folder",
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (!/^[a-f0-9]{64}$/i.test(mail.revision)) {
|
|
162
|
-
throw new MailDeleteError("invalid", "Invalid draft revision")
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const locksDir = resolve(mailDirReal, ".locks")
|
|
166
|
-
await ensureRealDirChain(mailDirReal, locksDir)
|
|
167
|
-
const lockPath = resolve(locksDir, `${mail.id.trim()}.md.lock`)
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
return await withFlock(lockPath, deleteLockTimeoutMs, async () => {
|
|
171
|
-
// Under the lock: the re-read, the hash, the snapshot, the re-verify
|
|
172
|
-
// and the unlink are one critical section.
|
|
173
|
-
const content = await readFile(target, "utf8").catch(() => null)
|
|
174
|
-
if (content === null) {
|
|
175
|
-
throw new MailDeleteError("missing", "Draft no longer exists")
|
|
176
|
-
}
|
|
177
|
-
if (draftRevision(content) !== mail.revision.toLowerCase()) {
|
|
178
|
-
throw new MailDeleteError(
|
|
179
|
-
"changed",
|
|
180
|
-
"Draft changed since it was loaded; refusing to delete newer work",
|
|
181
|
-
)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const historyDir = resolve(mailDirReal, "history", mail.id)
|
|
185
|
-
await ensureRealDirChain(mailDirReal, historyDir)
|
|
186
|
-
const historyPath = resolve(historyDir, `${mail.revision}.md`)
|
|
187
|
-
|
|
188
|
-
const existingLstat = await lstat(historyPath).catch(() => undefined)
|
|
189
|
-
if (existingLstat?.isSymbolicLink()) {
|
|
190
|
-
throw new MailDeleteError(
|
|
191
|
-
"invalid",
|
|
192
|
-
"History snapshot path is a symlink; refusing to touch it",
|
|
193
|
-
)
|
|
194
|
-
}
|
|
195
|
-
const existing = existingLstat ?? undefined
|
|
196
|
-
if (existing?.isFile()) {
|
|
197
|
-
// Never overwrite or trust an existing snapshot: verify its bytes
|
|
198
|
-
// still hash to the revision, exactly like MailOS
|
|
199
|
-
// (readVerifiedLocalDraftRevisionForPath). A corrupt existing history
|
|
200
|
-
// blocks the delete rather than being clobbered.
|
|
201
|
-
const existingContent = await readFile(historyPath, "utf8").catch(
|
|
202
|
-
() => null,
|
|
203
|
-
)
|
|
204
|
-
if (
|
|
205
|
-
existingContent === null ||
|
|
206
|
-
draftRevision(existingContent) !== mail.revision.toLowerCase()
|
|
207
|
-
) {
|
|
208
|
-
throw new MailDeleteError(
|
|
209
|
-
"changed",
|
|
210
|
-
"Existing draft history does not match this revision; refusing to delete",
|
|
211
|
-
)
|
|
212
|
-
}
|
|
213
|
-
} else if (existing) {
|
|
214
|
-
throw new MailDeleteError(
|
|
215
|
-
"invalid",
|
|
216
|
-
"History snapshot path is not a regular file",
|
|
217
|
-
)
|
|
218
|
-
} else {
|
|
219
|
-
// Write to a temp sibling then rename so a crash never leaves a
|
|
220
|
-
// partial snapshot under the revision name. "wx" refuses to follow or
|
|
221
|
-
// overwrite a planted temp symlink.
|
|
222
|
-
const tempPath = resolve(historyDir, `${mail.revision}.md.tmp`)
|
|
223
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
224
|
-
try {
|
|
225
|
-
await writeFile(tempPath, content, { flag: "wx", mode: 0o600 })
|
|
226
|
-
break
|
|
227
|
-
} catch (error) {
|
|
228
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
229
|
-
const tempInfo = await lstat(tempPath).catch(() => undefined)
|
|
230
|
-
if (attempt === 0 && tempInfo?.isFile()) {
|
|
231
|
-
// A stale temp from an interrupted run: remove and write once
|
|
232
|
-
// more. A symlink/directory is never followed.
|
|
233
|
-
await unlink(tempPath)
|
|
234
|
-
continue
|
|
235
|
-
}
|
|
236
|
-
throw new MailDeleteError(
|
|
237
|
-
"invalid",
|
|
238
|
-
"Temp snapshot path is not a plain file; refusing to write through it",
|
|
239
|
-
)
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
const written = await readFile(tempPath, "utf8").catch(() => null)
|
|
243
|
-
if (
|
|
244
|
-
written !== content ||
|
|
245
|
-
draftRevision(written ?? "") !== mail.revision.toLowerCase()
|
|
246
|
-
) {
|
|
247
|
-
throw new MailDeleteError(
|
|
248
|
-
"changed",
|
|
249
|
-
"Draft changed while snapshotting; refusing to delete",
|
|
250
|
-
)
|
|
251
|
-
}
|
|
252
|
-
await rename(tempPath, historyPath)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Re-verify the draft is byte-identical to the snapshot immediately
|
|
256
|
-
// before unlinking, and that it is still the same regular file; an
|
|
257
|
-
// editor or symlink that raced in is left untouched.
|
|
258
|
-
const current = await readFile(target, "utf8").catch(() => null)
|
|
259
|
-
if (current === null) {
|
|
260
|
-
throw new MailDeleteError("missing", "Draft no longer exists")
|
|
261
|
-
}
|
|
262
|
-
if (draftRevision(current) !== mail.revision.toLowerCase()) {
|
|
263
|
-
throw new MailDeleteError(
|
|
264
|
-
"changed",
|
|
265
|
-
"Draft changed since it was loaded; refusing to delete newer work",
|
|
266
|
-
)
|
|
267
|
-
}
|
|
268
|
-
const finalReal = await realpath(target).catch(() => null)
|
|
269
|
-
if (finalReal !== expected) {
|
|
270
|
-
throw new MailDeleteError(
|
|
271
|
-
"invalid",
|
|
272
|
-
"Draft path changed identity before deletion; refusing to unlink",
|
|
273
|
-
)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
await unlink(target)
|
|
277
|
-
return "deleted" as const
|
|
278
|
-
})
|
|
279
|
-
} catch (error) {
|
|
280
|
-
if (error instanceof FlockTimeoutError) {
|
|
281
|
-
throw new MailDeleteError(
|
|
282
|
-
"changed",
|
|
283
|
-
"Draft is locked by another process (MailOS or another delete); try again",
|
|
284
|
-
)
|
|
285
|
-
}
|
|
286
|
-
throw error
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export type AttachmentRemoveOutcome =
|
|
291
|
-
"removed" | "changed" | "missing" | "invalid"
|
|
292
|
-
|
|
293
|
-
export class MailAttachmentError extends Error {
|
|
294
|
-
readonly outcome: AttachmentRemoveOutcome
|
|
295
|
-
|
|
296
|
-
constructor(outcome: AttachmentRemoveOutcome, message: string) {
|
|
297
|
-
super(message)
|
|
298
|
-
this.name = "MailAttachmentError"
|
|
299
|
-
this.outcome = outcome
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Rewrite the frontmatter `file:` block in place: drop the given attachment
|
|
305
|
-
* path and bump `updated_at`. Key order, quoting, and all other lines stay
|
|
306
|
-
* byte-identical, so MailOS reads the file exactly as before with one fewer
|
|
307
|
-
* linked file. Returns null when there is no `file:` key.
|
|
308
|
-
*/
|
|
309
|
-
function rewriteFileFrontmatter(
|
|
310
|
-
frontmatterRaw: string,
|
|
311
|
-
remaining: string[],
|
|
312
|
-
updatedAt: string,
|
|
313
|
-
): string | null {
|
|
314
|
-
const lines = frontmatterRaw.split("\n")
|
|
315
|
-
const fileIndex = lines.findIndex((line) => /^file:/.test(line))
|
|
316
|
-
if (fileIndex < 0) return null
|
|
317
|
-
|
|
318
|
-
// Eat the block-list item lines directly below an empty `file:` key.
|
|
319
|
-
let end = fileIndex + 1
|
|
320
|
-
if ((lines[fileIndex] ?? "").trim() === "file:") {
|
|
321
|
-
while (end < lines.length) {
|
|
322
|
-
if (!/^\s+- /.test(lines[end] ?? "")) break
|
|
323
|
-
end += 1
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const fileBlock = remaining.length
|
|
328
|
-
? ["file:", ...remaining.map((path) => ` - ${JSON.stringify(path)}`)]
|
|
329
|
-
: ["file: []"]
|
|
330
|
-
lines.splice(fileIndex, end - fileIndex, ...fileBlock)
|
|
331
|
-
|
|
332
|
-
const updatedIndex = lines.findIndex((line) => /^updated_at:/.test(line))
|
|
333
|
-
if (updatedIndex >= 0) {
|
|
334
|
-
lines[updatedIndex] = `updated_at: ${JSON.stringify(updatedAt)}`
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return lines.join("\n")
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Remove one attachment path from a local draft's frontmatter `file:` list.
|
|
342
|
-
* Mirrors deleteLocalDraftVerified's safety pattern: the draft must be a real
|
|
343
|
-
* file in the mail folder, the edit runs under the same MailOS-compatible
|
|
344
|
-
* flock with a byte-exact revision guard, the rewritten file is written to a
|
|
345
|
-
* temp sibling and atomically renamed, and `updated_at` is bumped. The
|
|
346
|
-
* attachment file itself is never touched — only the link in the draft.
|
|
347
|
-
* Returns the new draft revision so callers can continue against fresh data.
|
|
348
|
-
*/
|
|
349
|
-
export async function removeDraftAttachmentVerified(
|
|
350
|
-
mail: DraftIdentity,
|
|
351
|
-
mailDir: string,
|
|
352
|
-
attachment: string,
|
|
353
|
-
): Promise<{ revision: string }> {
|
|
354
|
-
if (!validDraftId(mail.id)) {
|
|
355
|
-
throw new MailAttachmentError("invalid", "Invalid mail id")
|
|
356
|
-
}
|
|
357
|
-
const mailDirReal = await realpath(resolve(mailDir))
|
|
358
|
-
const target = resolve(mail.filePath)
|
|
359
|
-
const targetDir = await realpath(dirname(target)).catch(() => null)
|
|
360
|
-
if (targetDir !== mailDirReal) {
|
|
361
|
-
throw new MailAttachmentError(
|
|
362
|
-
"invalid",
|
|
363
|
-
"Draft path is outside the mail folder",
|
|
364
|
-
)
|
|
365
|
-
}
|
|
366
|
-
const expected = resolve(mailDirReal, `${mail.id.trim()}.md`)
|
|
367
|
-
const targetReal = await realpath(target).catch(() => null)
|
|
368
|
-
if (targetReal === null) {
|
|
369
|
-
throw new MailAttachmentError("missing", "Draft no longer exists")
|
|
370
|
-
}
|
|
371
|
-
if (targetReal !== expected) {
|
|
372
|
-
throw new MailAttachmentError(
|
|
373
|
-
"invalid",
|
|
374
|
-
"Draft path is not a direct file in the mail folder",
|
|
375
|
-
)
|
|
376
|
-
}
|
|
377
|
-
if (!/^[a-f0-9]{64}$/i.test(mail.revision)) {
|
|
378
|
-
throw new MailAttachmentError("invalid", "Invalid draft revision")
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const locksDir = resolve(mailDirReal, ".locks")
|
|
382
|
-
await ensureRealDirChain(mailDirReal, locksDir)
|
|
383
|
-
const lockPath = resolve(locksDir, `${mail.id.trim()}.md.lock`)
|
|
384
|
-
|
|
385
|
-
try {
|
|
386
|
-
return await withFlock(lockPath, deleteLockTimeoutMs, async () => {
|
|
387
|
-
const revision = mail.revision.toLowerCase()
|
|
388
|
-
const content = await readFile(target, "utf8").catch(() => null)
|
|
389
|
-
if (content === null) {
|
|
390
|
-
throw new MailAttachmentError("missing", "Draft no longer exists")
|
|
391
|
-
}
|
|
392
|
-
if (draftRevision(content) !== revision) {
|
|
393
|
-
throw new MailAttachmentError(
|
|
394
|
-
"changed",
|
|
395
|
-
"Draft changed since it was loaded; refusing to edit",
|
|
396
|
-
)
|
|
397
|
-
}
|
|
398
|
-
if (!content.startsWith("---\n")) {
|
|
399
|
-
throw new MailAttachmentError("invalid", "Draft has no frontmatter")
|
|
400
|
-
}
|
|
401
|
-
const end = content.indexOf("\n---\n")
|
|
402
|
-
if (end < 0) {
|
|
403
|
-
throw new MailAttachmentError("invalid", "Draft has no frontmatter")
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const parsed = parseMailMarkdown(content)
|
|
407
|
-
if (!parsed.attachments.includes(attachment)) {
|
|
408
|
-
throw new MailAttachmentError(
|
|
409
|
-
"missing",
|
|
410
|
-
"Attachment is not linked to this draft",
|
|
411
|
-
)
|
|
412
|
-
}
|
|
413
|
-
const remaining = parsed.attachments.filter((path) => path !== attachment)
|
|
414
|
-
const frontmatterRaw = content.slice(4, end)
|
|
415
|
-
const newFrontmatter = rewriteFileFrontmatter(
|
|
416
|
-
frontmatterRaw,
|
|
417
|
-
remaining,
|
|
418
|
-
new Date().toISOString(),
|
|
419
|
-
)
|
|
420
|
-
if (newFrontmatter === null) {
|
|
421
|
-
throw new MailAttachmentError(
|
|
422
|
-
"invalid",
|
|
423
|
-
"Draft frontmatter has no file list",
|
|
424
|
-
)
|
|
425
|
-
}
|
|
426
|
-
const newContent = `---\n${newFrontmatter}\n---\n${content.slice(
|
|
427
|
-
end + "\n---\n".length,
|
|
428
|
-
)}`
|
|
429
|
-
|
|
430
|
-
const tempPath = resolve(mailDirReal, `${mail.id.trim()}.md.tmp`)
|
|
431
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
432
|
-
try {
|
|
433
|
-
await writeFile(tempPath, newContent, { flag: "wx", mode: 0o600 })
|
|
434
|
-
break
|
|
435
|
-
} catch (error) {
|
|
436
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
437
|
-
const tempInfo = await lstat(tempPath).catch(() => undefined)
|
|
438
|
-
if (attempt === 0 && tempInfo?.isFile()) {
|
|
439
|
-
await unlink(tempPath)
|
|
440
|
-
continue
|
|
441
|
-
}
|
|
442
|
-
throw new MailAttachmentError(
|
|
443
|
-
"invalid",
|
|
444
|
-
"Temp draft path is not a plain file; refusing to write through it",
|
|
445
|
-
)
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
const written = await readFile(tempPath, "utf8").catch(() => null)
|
|
449
|
-
if (written !== newContent) {
|
|
450
|
-
throw new MailAttachmentError(
|
|
451
|
-
"changed",
|
|
452
|
-
"Draft changed while writing; refusing to save",
|
|
453
|
-
)
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// The draft must still hash to the client's revision immediately
|
|
457
|
-
// before the rename, so a concurrent editor is never overwritten.
|
|
458
|
-
const current = await readFile(target, "utf8").catch(() => null)
|
|
459
|
-
if (current === null) {
|
|
460
|
-
throw new MailAttachmentError("missing", "Draft no longer exists")
|
|
461
|
-
}
|
|
462
|
-
if (draftRevision(current) !== revision) {
|
|
463
|
-
throw new MailAttachmentError(
|
|
464
|
-
"changed",
|
|
465
|
-
"Draft changed since it was loaded; refusing to edit",
|
|
466
|
-
)
|
|
467
|
-
}
|
|
468
|
-
await rename(tempPath, target)
|
|
469
|
-
|
|
470
|
-
return { revision: draftRevision(newContent) }
|
|
471
|
-
})
|
|
472
|
-
} catch (error) {
|
|
473
|
-
if (error instanceof FlockTimeoutError) {
|
|
474
|
-
throw new MailAttachmentError(
|
|
475
|
-
"changed",
|
|
476
|
-
"Draft is locked by another process (MailOS or another edit); try again",
|
|
477
|
-
)
|
|
478
|
-
}
|
|
479
|
-
throw error
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
export function historyDirFor(mail: DraftIdentity, mailDir: string) {
|
|
484
|
-
return resolve(resolve(mailDir), "history", mail.id)
|
|
485
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react"
|
|
2
|
-
import { Folder, Mail } from "lucide-react"
|
|
3
|
-
|
|
4
|
-
import type { MailListItem } from "./viewer.tsx"
|
|
5
|
-
|
|
6
|
-
function relativeTime(value: string | null) {
|
|
7
|
-
if (!value) return "—"
|
|
8
|
-
const parsed = Date.parse(value)
|
|
9
|
-
if (Number.isNaN(parsed)) return value
|
|
10
|
-
const delta = Date.now() - parsed
|
|
11
|
-
const minutes = Math.round(delta / 60_000)
|
|
12
|
-
if (minutes < 1) return "just now"
|
|
13
|
-
if (minutes < 60) return `${minutes}m ago`
|
|
14
|
-
const hours = Math.round(minutes / 60)
|
|
15
|
-
if (hours < 48) return `${hours}h ago`
|
|
16
|
-
const days = Math.round(hours / 24)
|
|
17
|
-
return `${days}d ago`
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function MailCard({
|
|
21
|
-
actions,
|
|
22
|
-
folderLabel,
|
|
23
|
-
href,
|
|
24
|
-
item,
|
|
25
|
-
}: {
|
|
26
|
-
actions?: (item: MailListItem) => ReactNode
|
|
27
|
-
folderLabel?: string | null
|
|
28
|
-
href: string
|
|
29
|
-
item: MailListItem
|
|
30
|
-
}) {
|
|
31
|
-
const recipientGroup = item.to.length
|
|
32
|
-
? { label: "To", items: item.to }
|
|
33
|
-
: item.cc.length
|
|
34
|
-
? { label: "CC", items: item.cc }
|
|
35
|
-
: item.bcc.length
|
|
36
|
-
? { label: "BCC", items: item.bcc }
|
|
37
|
-
: null
|
|
38
|
-
|
|
39
|
-
const recipient = item.to[0] ?? ""
|
|
40
|
-
const address = /<([^<>]+)>/.exec(recipient)?.[1] ?? recipient
|
|
41
|
-
const domain = /^[^\s@<>]+@([a-z0-9.-]+\.[a-z]{2,})$/i
|
|
42
|
-
.exec(address.trim())?.[1]
|
|
43
|
-
?.toLowerCase()
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<li className="relative min-w-0">
|
|
47
|
-
<a
|
|
48
|
-
className="grid grid-cols-[2rem_minmax(0,1fr)] items-start gap-3
|
|
49
|
-
rounded-xl border border-[#e7ebe7] bg-white px-3.5 py-2.5 pr-10
|
|
50
|
-
text-[#2f382f] no-underline shadow-[0_1px_2px_rgb(31_41_55/0.05)]
|
|
51
|
-
hover:bg-[#fafbfa] focus-visible:outline-2
|
|
52
|
-
focus-visible:outline-offset-1 focus-visible:outline-[#56725e]"
|
|
53
|
-
href={href}
|
|
54
|
-
>
|
|
55
|
-
<span
|
|
56
|
-
aria-hidden="true"
|
|
57
|
-
className="relative flex size-8 items-center justify-center rounded-md
|
|
58
|
-
bg-[#f3f5f2] text-[#6c7a6d]"
|
|
59
|
-
>
|
|
60
|
-
<Mail className="size-4" />
|
|
61
|
-
{domain ? (
|
|
62
|
-
<img
|
|
63
|
-
key={domain}
|
|
64
|
-
alt=""
|
|
65
|
-
className="absolute size-6 rounded-sm bg-[#f3f5f2] object-contain"
|
|
66
|
-
height={24}
|
|
67
|
-
loading="lazy"
|
|
68
|
-
onError={(event) => {
|
|
69
|
-
event.currentTarget.style.display = "none"
|
|
70
|
-
}}
|
|
71
|
-
referrerPolicy="no-referrer"
|
|
72
|
-
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`}
|
|
73
|
-
width={24}
|
|
74
|
-
/>
|
|
75
|
-
) : null}
|
|
76
|
-
</span>
|
|
77
|
-
<div className="grid min-w-0 gap-1">
|
|
78
|
-
<span className="line-clamp-2 min-w-0 font-semibold break-words">
|
|
79
|
-
{item.subject ?? "(no subject)"}
|
|
80
|
-
</span>
|
|
81
|
-
|
|
82
|
-
<div className="flex items-start gap-2">
|
|
83
|
-
<span
|
|
84
|
-
className="shrink-0 text-[0.75rem] whitespace-nowrap
|
|
85
|
-
text-[#6c7a6d]"
|
|
86
|
-
>
|
|
87
|
-
{relativeTime(item.updatedAt ?? item.createdAt)}
|
|
88
|
-
</span>
|
|
89
|
-
{recipientGroup ? (
|
|
90
|
-
<span className="min-w-0 truncate text-[0.82rem] text-[#4a554b]">
|
|
91
|
-
{recipientGroup.label}: {recipientGroup.items[0]}
|
|
92
|
-
{recipientGroup.items.length > 1
|
|
93
|
-
? ` +${recipientGroup.items.length - 1}`
|
|
94
|
-
: ""}
|
|
95
|
-
</span>
|
|
96
|
-
) : (
|
|
97
|
-
<span className="text-[0.82rem] text-[#6c7a6d]">
|
|
98
|
-
No recipients
|
|
99
|
-
</span>
|
|
100
|
-
)}
|
|
101
|
-
</div>
|
|
102
|
-
{folderLabel ? (
|
|
103
|
-
<span
|
|
104
|
-
className="flex min-w-0 items-center gap-1 text-xs text-[#6c7a6d]"
|
|
105
|
-
title={item.folder ?? undefined}
|
|
106
|
-
>
|
|
107
|
-
<Folder aria-hidden="true" className="size-3 shrink-0" />
|
|
108
|
-
<span className="truncate">{folderLabel}</span>
|
|
109
|
-
</span>
|
|
110
|
-
) : null}
|
|
111
|
-
{item.excerpt ? (
|
|
112
|
-
<p className="m-0 min-w-0 truncate text-[0.82rem] text-[#6c7a6d]">
|
|
113
|
-
{item.excerpt}
|
|
114
|
-
</p>
|
|
115
|
-
) : null}
|
|
116
|
-
</div>
|
|
117
|
-
</a>
|
|
118
|
-
{actions ? (
|
|
119
|
-
<div className="absolute top-2 right-2">{actions(item)}</div>
|
|
120
|
-
) : null}
|
|
121
|
-
</li>
|
|
122
|
-
)
|
|
123
|
-
}
|