Add environment configuration and update email view components
- Created a .cursorignore file to manage local environment files. - Updated .env.example to reflect changes in the public app URL. - Modified the gmail workspace configuration to include the drive-suite path. - Enhanced email view components to support attachment handling and fallback for plain text bodies. - Improved user experience by updating attachment display logic and integrating inline attachment support.
This commit is contained in:
parent
5567e2f0c1
commit
8a02c10ba3
409
.cursor/plans/drive_suite_build_7921c055.plan.md
Normal file
409
.cursor/plans/drive_suite_build_7921c055.plan.md
Normal file
@ -0,0 +1,409 @@
|
||||
---
|
||||
name: Drive Suite Build
|
||||
overview: "Build UltiDrive as a Google Drive–like frontend in `drive-suite`, with all backend/infra in `ulti-backend` (ultid API, Nextcloud, OnlyOffice, Authentik, deploy). Mail stays frontend-only in `gmail-interface-clone`. Production routing: same domain at `/drive`."
|
||||
todos:
|
||||
- id: phase0-provisioning
|
||||
content: Fix drive EnsurePrincipal + extend drive API (quota, shares CRUD, restore, favorite, create blank file)
|
||||
status: completed
|
||||
- id: phase0-onlyoffice
|
||||
content: Deploy OnlyOffice Document Server, enable NC onlyoffice app, add /api/v1/office session+callback endpoints
|
||||
status: completed
|
||||
- id: phase1-scaffold
|
||||
content: Scaffold drive-suite Next.js app (auth, API client, basePath /drive, app shell, URL routing)
|
||||
status: completed
|
||||
- id: phase2-mvp
|
||||
content: Build Google Drive file browser MVP wired to real /api/v1/drive (upload, browse, share, trash, recent, starred)
|
||||
status: completed
|
||||
- id: phase3-editor
|
||||
content: Integrate OnlyOffice editor embed for docx/xlsx/pptx via /drive/edit/{id}
|
||||
status: completed
|
||||
- id: phase4-chrome
|
||||
content: "Google-like editor chrome: OnlyOffice theme first, then custom wrapper using drive-dump specs if needed"
|
||||
status: completed
|
||||
- id: phase5-suite
|
||||
content: "Wire suite integration: nginx /drive route, launcher hrefs, mail attachment picker, Authentik callbacks"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# UltiDrive (Drive Suite) — Build Plan
|
||||
|
||||
## Repository roles
|
||||
|
||||
| Repo | Responsibility |
|
||||
|------|----------------|
|
||||
| **[`ulti-backend`](file:///Users/red/workdev/ulti-backend)** | **All backend infrastructure and server-side code:** custom Go API (`ultid`), Docker Compose, edge nginx, Authentik, Postgres/KeyDB/RustFS, headless Nextcloud, mail stack (IMAP/SMTP/sync), OnlyOffice Document Server, Jitsi, observability, deploy scripts, env templates, Authentik blueprints. This is the single ops + API hub for the suite. |
|
||||
| **[`gmail-interface-clone`](file:///Users/red/workdev/gmail-interface-clone)** | **Frontend only — Ultimail.** Next.js UI for mail (list, compose, settings, contacts). Talks to ulti-backend via `/api/v1` proxy; no server infrastructure lives here. |
|
||||
| **[`drive-suite`](file:///Users/red/workdev/drive-suite)** | **Frontend only — UltiDrive.** Next.js UI for the Google Drive–like file browser and editor shell. Same pattern as mail: API calls go to ulti-backend; deploy wiring (nginx route, compose service) is defined in ulti-backend. |
|
||||
|
||||
Suite frontends are thin clients; every persistent service and integration runs from **ulti-backend**.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
| Layer | Status |
|
||||
|-------|--------|
|
||||
| [`drive-suite/`](file:///Users/red/workdev/drive-suite) | Empty placeholder — greenfield |
|
||||
| [`ulti-backend`](file:///Users/red/workdev/ulti-backend) | Headless NC 30 deployed; `/api/v1/drive` proxy (list, upload, download, move, copy, rename, trash, recent, starred, shares) |
|
||||
| [`gmail-interface-clone`](file:///Users/red/workdev/gmail-interface-clone) | Auth stack, shadcn UI, app shell patterns; UltiDrive tile exists but has no `href` |
|
||||
| [`ultimail/apps/web/src/components/drive/`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive) | Mock UI components (file-browser, share-dialog, upload-zone) — reference only |
|
||||
| [`drive-dump/`](file:///Users/red/workdev/drive-dump) | Google Docs chrome research (titlebar, toolbar CSS specs) — useful for editor skinning, not for file browser |
|
||||
| OnlyOffice | Not deployed; mentioned only in [`project-plan/ultidrive.md`](file:///Users/red/workdev/ulti-backend/project-plan/ultidrive.md) |
|
||||
|
||||
**Critical backend bug to fix first:** drive handlers pass `claims.Sub` as NC user ID, but NC OIDC maps `preferred_username` (email). Contacts already use `EnsurePrincipal(email, sub, name)` — drive must match or provisioning fails.
|
||||
|
||||
---
|
||||
|
||||
## Target architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph frontends [Frontend repos]
|
||||
MailFE["gmail-interface-clone"]
|
||||
DriveFE["drive-suite"]
|
||||
end
|
||||
|
||||
subgraph ultiBackend [ulti-backend deploy and API]
|
||||
Nginx["edge nginx"]
|
||||
Ultid["ultid Go API"]
|
||||
Auth["Authentik"]
|
||||
NC["Nextcloud headless"]
|
||||
OO["OnlyOffice"]
|
||||
MailSvc["mail IMAP SMTP sync"]
|
||||
S3["RustFS S3"]
|
||||
end
|
||||
|
||||
MailFE -->|"/mail"| Nginx
|
||||
DriveFE -->|"/drive"| Nginx
|
||||
Nginx --> Ultid
|
||||
Nginx --> Auth
|
||||
Ultid --> DriveAPI["/api/v1/drive"]
|
||||
Ultid --> OfficeAPI["/api/v1/office"]
|
||||
Ultid --> MailAPI["/api/v1/mail"]
|
||||
Ultid --> WS["/ws"]
|
||||
DriveAPI --> NC
|
||||
OfficeAPI --> NC
|
||||
OfficeAPI --> OO
|
||||
NC --> OO
|
||||
NC --> S3
|
||||
MailAPI --> MailSvc
|
||||
```
|
||||
|
||||
**Principles:**
|
||||
- Browser never talks to Nextcloud or OnlyOffice directly — all via **ultid** in ulti-backend (auth, RBAC, stable API).
|
||||
- Nextcloud UI stays hidden (`/cloud/` admin-only); users only see UltiDrive.
|
||||
- Same Authentik OIDC session; production path `/drive` on the suite domain (shared cookies with `/mail`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Backend hardening ([`ulti-backend`](file:///Users/red/workdev/ulti-backend) only)
|
||||
|
||||
All work in this phase stays in ulti-backend (Go API + `deploy/`). No infrastructure or API logic belongs in drive-suite or gmail-interface-clone.
|
||||
|
||||
Fix and extend the existing drive layer before building UI.
|
||||
|
||||
### 0.1 User provisioning fix
|
||||
In [`internal/api/drive/handlers.go`](file:///Users/red/workdev/ulti-backend/internal/api/drive/handlers.go) and `service.go`, replace raw `claims.Sub` with:
|
||||
|
||||
```go
|
||||
userID, err := nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
|
||||
```
|
||||
|
||||
Mirror the contacts pattern in [`internal/nextcloud/users.go`](file:///Users/red/workdev/ulti-backend/internal/nextcloud/users.go).
|
||||
|
||||
### 0.2 API gaps (priority order)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /drive/quota` | Storage bar in UI |
|
||||
| `GET /drive/shares?path=` | List existing shares |
|
||||
| `PUT /drive/shares/{id}` | Update role / expiry / password |
|
||||
| `DELETE /drive/shares/{id}` | Revoke share |
|
||||
| `POST /drive/trash/restore` | Restore from trash |
|
||||
| `POST /drive/favorite` | Star/unstar |
|
||||
| `POST /drive/files/new` | Create empty doc/sheet/slide (NC template or OO blank) |
|
||||
| `GET /drive/search` | Full-text (v1: name filter; v2: Meilisearch) |
|
||||
|
||||
Extend [`internal/nextcloud/drive.go`](file:///Users/red/workdev/ulti-backend/internal/nextcloud/drive.go) with OCS share list/update/delete and favorite WebDAV properties.
|
||||
|
||||
### 0.3 WebSocket events
|
||||
Add `drive.file_changed`, `drive.share_updated` to ultid WS hub so drive UI can invalidate TanStack Query cache (same pattern as mail events in [`gmail-interface-clone/lib/api/ws.ts`](file:///Users/red/workdev/gmail-interface-clone/lib/api/ws.ts)).
|
||||
|
||||
### 0.4 OnlyOffice + Nextcloud deployment
|
||||
|
||||
New files under [`deploy/onlyoffice/`](file:///Users/red/workdev/ulti-backend/deploy):
|
||||
|
||||
- `docker-compose.onlyoffice.yml` — Document Server container
|
||||
- Update [`deploy/nextcloud/init.sh`](file:///Users/red/workdev/ulti-backend/deploy/nextcloud/init.sh) — `$OCC app:enable onlyoffice`
|
||||
- Update [`deploy/compose-up.sh`](file:///Users/red/workdev/ulti-backend/deploy/compose-up.sh) — conditional include when `ONLYOFFICE_ENABLED=true`
|
||||
- Update [`deploy/nginx/default.conf.template`](file:///Users/red/workdev/ulti-backend/deploy/nginx/default.conf.template):
|
||||
- `/drive/` → drive-suite container
|
||||
- `/office/` → OnlyOffice (internal, JWT-protected; not exposed to users directly)
|
||||
|
||||
Env vars to add to `.env.example`:
|
||||
|
||||
```
|
||||
ONLYOFFICE_ENABLED=true
|
||||
ONLYOFFICE_URL=http://onlyoffice:80
|
||||
ONLYOFFICE_JWT_SECRET=...
|
||||
ONLYOFFICE_PUBLIC_URL=https://{DOMAIN}/office
|
||||
```
|
||||
|
||||
### 0.5 Office API (new package `internal/api/office/`)
|
||||
|
||||
| Endpoint | Role |
|
||||
|----------|------|
|
||||
| `POST /office/session` | Given `{ path, mode: view\|edit }`, return OnlyOffice editor config + signed JWT |
|
||||
| `GET /office/callback` | OnlyOffice save callback → NC WebDAV PUT |
|
||||
| `POST /office/create` | Create blank docx/xlsx/pptx in user folder |
|
||||
|
||||
Flow: drive UI opens `/drive/edit/{fileId}` → backend builds OnlyOffice config pointing at NC file via internal URL → OnlyOffice loads document → saves via callback.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Scaffold [`drive-suite`](file:///Users/red/workdev/drive-suite) (Google Drive shell)
|
||||
|
||||
Frontend-only repo. Copy **UI/auth/client patterns** from [`gmail-interface-clone`](file:///Users/red/workdev/gmail-interface-clone) (the mail frontend), not from ultimail (different router). Match stack: **Next.js 16, React 19, Tailwind 4, shadcn new-york, Zustand 5, TanStack Query 5, pnpm**.
|
||||
|
||||
### 1.1 Repo bootstrap
|
||||
```
|
||||
drive-suite/
|
||||
├── app/
|
||||
│ ├── layout.tsx # QueryProvider + AuthProvider
|
||||
│ ├── page.tsx # redirect → /drive
|
||||
│ ├── api/auth/ # copy OIDC routes from mail
|
||||
│ ├── auth/complete/
|
||||
│ ├── login/
|
||||
│ └── drive/
|
||||
│ ├── layout.tsx # DriveAppShell
|
||||
│ ├── [[...segments]]/page.tsx
|
||||
│ ├── edit/[fileId]/page.tsx # OnlyOffice embed (Phase 3)
|
||||
│ └── settings/[[...section]]/
|
||||
├── components/drive/ # file browser, sidebar, header, share, upload
|
||||
├── lib/
|
||||
│ ├── auth/ # session, pkce, jwt-claims
|
||||
│ ├── api/client.ts # Bearer + 401 refresh
|
||||
│ ├── api/hooks/use-drive-queries.ts
|
||||
│ ├── drive-url.ts # URL = source of truth
|
||||
│ └── stores/ # drive-ui-store, drive-settings-store
|
||||
├── next.config.mjs # basePath: '/drive' in prod; /api/v1 rewrite
|
||||
└── Dockerfile # same multi-stage pattern as mail
|
||||
```
|
||||
|
||||
**`basePath: '/drive'`** in production so assets and routes work behind nginx path routing.
|
||||
|
||||
### 1.2 Auth
|
||||
Copy auth stack from mail; change:
|
||||
- Default `returnTo` → `/drive`
|
||||
- Persist key → `ultidrive-auth`
|
||||
- Keep `ulti_*` cookie names (same origin)
|
||||
|
||||
### 1.3 App shell (Google Drive UX)
|
||||
|
||||
Three-zone layout mirroring [`mail-app-shell.tsx`](file:///Users/red/workdev/gmail-interface-clone/app/mail/mail-app-shell.tsx):
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Header: search, view toggle, account, suite launcher │
|
||||
├──────────┬───────────────────────────────────────────────┤
|
||||
│ Sidebar │ Main: breadcrumb + file grid/list │
|
||||
│ My Drive │ │
|
||||
│ Recent │ (optional detail panel for file preview) │
|
||||
│ Starred │ │
|
||||
│ Trash │ │
|
||||
│ Storage │ │
|
||||
└──────────┴───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Port/adapt from ultimail mock components:
|
||||
- [`file-browser.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/file-browser.tsx) — grid/list, sort, multi-select
|
||||
- [`breadcrumb-nav.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/breadcrumb-nav.tsx)
|
||||
- [`upload-zone.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/upload-zone.tsx) — drag-drop + chunked upload via `X-Upload-*` headers
|
||||
- [`share-dialog.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/share-dialog.tsx)
|
||||
- [`quota-bar.tsx`](file:///Users/red/workdev/ultimail/apps/web/src/components/drive/quota-bar.tsx)
|
||||
|
||||
Add `--drive-*` CSS tokens in `globals.css` parallel to mail's `--mail-*`.
|
||||
|
||||
### 1.4 URL routing
|
||||
|
||||
[`lib/drive-url.ts`](file:///Users/red/workdev/drive-suite/lib/drive-url.ts) + `use-drive-route.ts`:
|
||||
|
||||
| URL segment | View |
|
||||
|-------------|------|
|
||||
| `/drive` | My Drive root |
|
||||
| `/drive/folders/documents/page/2` | Folder + pagination |
|
||||
| `/drive/recent` | Recent files |
|
||||
| `/drive/starred` | Favorites |
|
||||
| `/drive/trash` | Trash |
|
||||
| `/drive/search?q=report` | Search results |
|
||||
| `/drive/file/{id}` | Preview panel |
|
||||
| `/drive/edit/{id}` | Editor (Phase 3) |
|
||||
|
||||
Wire real API from day one — no mock data.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Core Drive features (MVP)
|
||||
|
||||
Ship a usable Google Drive clone before editors.
|
||||
|
||||
| Feature | Backend | Frontend |
|
||||
|---------|---------|----------|
|
||||
| Browse folders | `GET /drive/files/*` | Grid/list + breadcrumb |
|
||||
| Upload (simple + chunked) | `POST /drive/files/*` | Progress, retry |
|
||||
| Create folder | `POST /drive/folders/*` | New folder dialog |
|
||||
| Rename / move / copy | existing endpoints | Context menu + drag-drop move |
|
||||
| Delete / trash / restore | DELETE + trash endpoints | Trash view |
|
||||
| Starred / recent | existing endpoints | Sidebar views |
|
||||
| Share (link + users) | shares CRUD | Share dialog (owner/editor/viewer) |
|
||||
| Download | `GET /drive/download/*` | Context menu |
|
||||
| Preview | download + mime routing | Images, PDF inline; others download |
|
||||
| Storage quota | `GET /drive/quota` | Sidebar bar |
|
||||
| Multi-select bulk ops | batch endpoints or sequential | Toolbar actions |
|
||||
|
||||
**New file affordance (Google-style):**
|
||||
- Dropdown: Blank document / spreadsheet / presentation / folder / upload
|
||||
- Creates file via `POST /office/create` or NC template, then opens editor
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — OnlyOffice editor integration
|
||||
|
||||
### 3.1 Embed pattern
|
||||
[`app/drive/edit/[fileId]/page.tsx`](file:///Users/red/workdev/drive-suite/app/drive/edit/[fileId]/page.tsx):
|
||||
|
||||
1. Fetch editor config from `POST /api/v1/office/session`
|
||||
2. Load `@onlyoffice/document-editor-react` (or iframe with DocsAPI)
|
||||
3. Full-viewport editor; minimal chrome initially
|
||||
|
||||
### 3.2 File type routing
|
||||
|
||||
| MIME / extension | Editor |
|
||||
|------------------|--------|
|
||||
| `.docx`, `.odt` | Document |
|
||||
| `.xlsx`, `.ods` | Spreadsheet |
|
||||
| `.pptx`, `.odp` | Presentation |
|
||||
| Other | Preview or download only |
|
||||
|
||||
### 3.3 Save / co-editing
|
||||
OnlyOffice handles real-time co-editing natively when connected to NC via the OnlyOffice app. Backend callback ensures saves land in NC WebDAV. No custom CRDT needed for v1.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Google-like editor chrome (theme → wrapper → fork)
|
||||
|
||||
Decision gate after Phase 3 ships with stock OnlyOffice UI.
|
||||
|
||||
### Step A — OnlyOffice theme (try first, ~1–2 weeks)
|
||||
- Custom JSON skin: toolbar colors, fonts (Roboto/Google Sans), icon set
|
||||
- CSS overrides via OnlyOffice `customization` config
|
||||
- Hide OnlyOffice branding; match light/dark to suite theme
|
||||
|
||||
### Step B — Custom chrome wrapper (if theme insufficient)
|
||||
Leverage [`drive-dump`](file:///Users/red/workdev/drive-dump) research:
|
||||
- [`docs/CHROME-SPEC.md`](file:///Users/red/workdev/drive-dump/docs/CHROME-SPEC.md) — titlebar, menubar, toolbar DOM/CSS specs
|
||||
- [`web/components/chrome/`](file:///Users/red/workdev/drive-dump/web/components/chrome) — React chrome components
|
||||
- Build suite-owned titlebar (doc title, share, star, account) **above** OnlyOffice iframe
|
||||
- Pass `customization.layout` to hide OnlyOffice's own header/toolbar
|
||||
- Separate chrome per app type: Docs, Sheets, Slides (toolbar sets differ)
|
||||
|
||||
### Step C — Fork (last resort)
|
||||
Only if theme + wrapper cannot reach Google parity on toolbars/menus. Fork OnlyOffice Document Server frontend (AGPL) — high maintenance cost. **Defer until A+B evaluated.**
|
||||
|
||||
Per [`drive-dump/docs/EDITOR-CORE-DECISION.md`](file:///Users/red/workdev/drive-dump/docs/EDITOR-CORE-DECISION.md): do **not** rebuild the editing engine — OnlyOffice remains the core; only chrome is customized.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Suite integration
|
||||
|
||||
### 5.1 Cross-app navigation
|
||||
- [`header-account-actions.tsx`](file:///Users/red/workdev/gmail-interface-clone/components/gmail/header-account-actions.tsx): add `href: "/drive"` to UltiDrive tile
|
||||
- Reciprocal launcher in drive-suite header back to `/mail`, Agenda, etc.
|
||||
- Shared suite favicon/branding via `pnpm run brand:authentik` pattern
|
||||
|
||||
### 5.2 Mail ↔ Drive
|
||||
- **Mail UI** (gmail-interface-clone): compose attachment picker "Insert from UltiDrive"; "Save attachment to Drive" on received mail
|
||||
- **API** (ulti-backend): mail attachment upload → `POST /drive/files/...` in ultid
|
||||
|
||||
### 5.3 Deploy wiring (ulti-backend)
|
||||
|
||||
All routing and compose changes live in **ulti-backend** [`deploy/`](file:///Users/red/workdev/ulti-backend/deploy). The drive-suite repo supplies the Docker image / build context only.
|
||||
|
||||
Update [`deploy/nginx/default.conf.template`](file:///Users/red/workdev/ulti-backend/deploy/nginx/default.conf.template):
|
||||
|
||||
```nginx
|
||||
location /drive/ {
|
||||
proxy_pass http://drive-suite:3000/;
|
||||
# WebSocket + standard headers
|
||||
}
|
||||
```
|
||||
|
||||
Add a `drive-suite` service to ulti-backend compose (port 3000 internal; dev: build from `../drive-suite` or host :3001). Mail frontend is deployed the same way today — gmail-interface-clone is not part of ulti-backend source tree, but its container is orchestrated from ulti-backend deploy.
|
||||
|
||||
Update Authentik blueprints / OIDC redirect URIs in ulti-backend for `/drive/api/auth/callback`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Advanced (post-MVP)
|
||||
|
||||
| Feature | Approach |
|
||||
|---------|----------|
|
||||
| Full-text search | Meilisearch indexer on NC files + `/drive/search` |
|
||||
| Version history | NC versions API wrapper |
|
||||
| Shared drives / team folders | NC `groupfolders` app + API |
|
||||
| Public link sharing page | `/drive/shared/{token}` read-only view |
|
||||
| Desktop mount | rclone sidecar in Tauri (per ultidrive.md) |
|
||||
| Offline uploads | IDB queue pattern from mail offline-queue |
|
||||
| Comments on files | OnlyOffice comments (built-in) or NC comments API |
|
||||
|
||||
---
|
||||
|
||||
## Suggested delivery order
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title UltiDrive delivery phases
|
||||
dateFormat YYYY-MM-DD
|
||||
section ulti backend
|
||||
API and provisioning :a1, 2026-06-02, 14d
|
||||
OnlyOffice deploy :a2, after a1, 7d
|
||||
section drive suite
|
||||
Scaffold :a3, 2026-06-02, 7d
|
||||
Drive MVP :a4, after a3, 21d
|
||||
Editor embed :a5, after a2, 14d
|
||||
Google chrome :a6, after a5, 21d
|
||||
section integration
|
||||
Suite wiring :a7, after a4, 7d
|
||||
```
|
||||
|
||||
Parallel tracks: **a1→a2** (ulti-backend) runs alongside **a3→a4** (drive-suite); **a5** starts after OnlyOffice (**a2**); **a7** (suite wiring) after Drive MVP (**a4**).
|
||||
|
||||
**First shippable milestone (4–5 weeks):** Phase 0 + 1 + 2 + 5 nginx/auth — full Drive file browser on real backend.
|
||||
|
||||
**Second milestone (+3 weeks):** Phase 3 — open and edit docs/sheets/slides in OnlyOffice.
|
||||
|
||||
**Third milestone (+3 weeks):** Phase 4 — Google-like editor chrome.
|
||||
|
||||
---
|
||||
|
||||
## Key files to create/modify
|
||||
|
||||
| Repo | Role | Files |
|
||||
|------|------|-------|
|
||||
| **ulti-backend** | Backend + all infra | `internal/api/drive/*`, `internal/api/office/*`, `deploy/onlyoffice/*`, `deploy/nginx/*`, compose, Authentik blueprints |
|
||||
| **drive-suite** | Drive frontend only | Entire Next.js app; copy auth + API client patterns from gmail-interface-clone |
|
||||
| **gmail-interface-clone** | Mail frontend only | `header-account-actions.tsx`, compose drive picker (UI links to `/drive`) |
|
||||
| **Reference only** | — | `ultimail/.../drive/*`, `drive-dump/docs/CHROME-SPEC.md` |
|
||||
|
||||
---
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| NC user ID mismatch | Fix EnsurePrincipal in Phase 0 before any UI work |
|
||||
| OnlyOffice JWT / NC connector misconfig | Test with single doc open before building chrome |
|
||||
| Chunk upload edge cases | Reuse existing `X-Upload-*` protocol; add integration tests |
|
||||
| Google UI parity scope creep | Theme first; wrapper second; fork only as last resort |
|
||||
| Path-based routing (`/drive`) asset breaks | `basePath` in next.config from day one |
|
||||
12
.cursor/rules/local-env.mdc
Normal file
12
.cursor/rules/local-env.mdc
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
description: Fichiers .env locaux — les agents peuvent les lire et les modifier
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Environnement local (test)
|
||||
|
||||
Les fichiers `.env` et `.env*.local` à la racine de ce dépôt contiennent **uniquement des valeurs de test locales**.
|
||||
|
||||
- Les agents **peuvent et doivent** les lire et les modifier quand la tâche l'exige (URLs, ports, clés de dev, etc.).
|
||||
- Ne pas les committer : ils restent dans `.gitignore`.
|
||||
- Pour documenter les variables, mettre à jour `.env.example`.
|
||||
4
.cursorignore
Normal file
4
.cursorignore
Normal file
@ -0,0 +1,4 @@
|
||||
# Environnement local (test) — accessible aux agents Cursor.
|
||||
# Contre la liste d'ignore par défaut de Cursor ; fichiers toujours dans .gitignore.
|
||||
!.env
|
||||
!.env*.local
|
||||
@ -8,7 +8,8 @@ NEXT_PUBLIC_WS_URL=ws://localhost/ws
|
||||
NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/
|
||||
NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
|
||||
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
# URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail
|
||||
NEXT_PUBLIC_APP_URL=http://localhost
|
||||
|
||||
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
|
||||
OIDC_CLIENT_SECRET=changeme
|
||||
|
||||
@ -934,6 +934,51 @@ html.dark .ultimail-app iframe[title='Sujet du message'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Hovercard destinataire (portail Radix hors .ultimail-app) */
|
||||
html.dark [data-contact-hover-card] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.text-\[\#202124\], .text-\[\#3c4043\], .text-\[\#1f1f1f\]) {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.text-\[\#5f6368\], .text-\[\#444746\]) {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.text-\[\#001d35\]) {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.bg-\[\#d3e3fd\]) {
|
||||
background-color: var(--mail-nav-selected) !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#c4d9fc\]:hover) {
|
||||
background-color: var(--mail-active) !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#f1f3f4\]:hover) {
|
||||
background-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.bg-\[\#f1f3f4\]) {
|
||||
background-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#e8eaed\]:hover) {
|
||||
background-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.text-\[\#1a73e8\]) {
|
||||
color: #8ab4f8 !important;
|
||||
}
|
||||
|
||||
html.dark [data-contact-hover-card] :where(.border-\[\#eceff1\]) {
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* ── Dark : panneau Contacts (formulaires) ── */
|
||||
html.dark :where([data-contacts-panel] .bg-white) {
|
||||
background-color: var(--mail-surface) !important;
|
||||
|
||||
@ -490,7 +490,7 @@ export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
||||
title="Insérer des fichiers avec Google Drive"
|
||||
title="Insérer des fichiers depuis UltiDrive"
|
||||
>
|
||||
<HardDrive className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
|
||||
@ -167,6 +167,7 @@ export function ContactHoverCard({
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
ref={contentRef}
|
||||
data-contact-hover-card
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={8}
|
||||
|
||||
@ -143,6 +143,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||
const [starred, setStarred] = useState(false)
|
||||
const [nameExpanded, setNameExpanded] = useState(false)
|
||||
const [companyExpanded, setCompanyExpanded] = useState(false)
|
||||
const hydratedEditIdRef = useRef<string | null>(null)
|
||||
|
||||
const existingContact =
|
||||
mode === "edit" ? contacts.find((c) => c.id === contactId) : null
|
||||
@ -221,45 +222,52 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||
}, [mode, createDraft, reset, clearCreateDraft])
|
||||
|
||||
useEffect(() => {
|
||||
if (existingContact) {
|
||||
const hasExtendedName = !!(
|
||||
existingContact.namePrefix ||
|
||||
existingContact.middleName ||
|
||||
existingContact.nameSuffix ||
|
||||
existingContact.phoneticFirstName ||
|
||||
existingContact.phoneticLastName
|
||||
)
|
||||
if (hasExtendedName) setNameExpanded(true)
|
||||
if (existingContact.department) setCompanyExpanded(true)
|
||||
|
||||
reset({
|
||||
namePrefix: existingContact.namePrefix ?? "",
|
||||
firstName: existingContact.firstName,
|
||||
middleName: existingContact.middleName ?? "",
|
||||
lastName: existingContact.lastName,
|
||||
nameSuffix: existingContact.nameSuffix ?? "",
|
||||
phoneticFirstName: existingContact.phoneticFirstName ?? "",
|
||||
phoneticLastName: existingContact.phoneticLastName ?? "",
|
||||
company: existingContact.company ?? "",
|
||||
department: existingContact.department ?? "",
|
||||
jobTitle: existingContact.jobTitle ?? "",
|
||||
emails: existingContact.emails.length
|
||||
? existingContact.emails
|
||||
: [{ value: "", label: "Domicile" }],
|
||||
phones: existingContact.phones.length
|
||||
? existingContact.phones
|
||||
: [{ value: "", label: "Mobile" }],
|
||||
addresses: existingContact.addresses ?? [],
|
||||
birthday: existingContact.birthday ?? {
|
||||
day: undefined,
|
||||
month: undefined,
|
||||
year: undefined,
|
||||
},
|
||||
notes: existingContact.notes ?? "",
|
||||
labels: existingContact.labels ?? [],
|
||||
})
|
||||
if (mode !== "edit" || !contactId) {
|
||||
hydratedEditIdRef.current = null
|
||||
return
|
||||
}
|
||||
}, [existingContact, reset])
|
||||
if (hydratedEditIdRef.current === contactId) return
|
||||
const contact = contacts.find((c) => c.id === contactId)
|
||||
if (!contact) return
|
||||
hydratedEditIdRef.current = contactId
|
||||
|
||||
const hasExtendedName = !!(
|
||||
contact.namePrefix ||
|
||||
contact.middleName ||
|
||||
contact.nameSuffix ||
|
||||
contact.phoneticFirstName ||
|
||||
contact.phoneticLastName
|
||||
)
|
||||
if (hasExtendedName) setNameExpanded(true)
|
||||
if (contact.department) setCompanyExpanded(true)
|
||||
|
||||
reset({
|
||||
namePrefix: contact.namePrefix ?? "",
|
||||
firstName: contact.firstName,
|
||||
middleName: contact.middleName ?? "",
|
||||
lastName: contact.lastName,
|
||||
nameSuffix: contact.nameSuffix ?? "",
|
||||
phoneticFirstName: contact.phoneticFirstName ?? "",
|
||||
phoneticLastName: contact.phoneticLastName ?? "",
|
||||
company: contact.company ?? "",
|
||||
department: contact.department ?? "",
|
||||
jobTitle: contact.jobTitle ?? "",
|
||||
emails: contact.emails.length
|
||||
? contact.emails
|
||||
: [{ value: "", label: "Domicile" }],
|
||||
phones: contact.phones.length
|
||||
? contact.phones
|
||||
: [{ value: "", label: "Mobile" }],
|
||||
addresses: contact.addresses ?? [],
|
||||
birthday: contact.birthday ?? {
|
||||
day: undefined,
|
||||
month: undefined,
|
||||
year: undefined,
|
||||
},
|
||||
notes: contact.notes ?? "",
|
||||
labels: contact.labels ?? [],
|
||||
})
|
||||
}, [mode, contactId, contacts, reset])
|
||||
|
||||
const firstName = watch("firstName")
|
||||
const lastName = watch("lastName")
|
||||
@ -775,8 +783,8 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
|
||||
const innerRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (innerRef.current && innerRef.current.value) setFilled(true)
|
||||
})
|
||||
if (innerRef.current?.value) setFilled(true)
|
||||
}, [defaultValue])
|
||||
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
@ -842,8 +850,8 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
|
||||
const innerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (innerRef.current && innerRef.current.value) setFilled(true)
|
||||
})
|
||||
if (innerRef.current?.value) setFilled(true)
|
||||
}, [])
|
||||
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLTextAreaElement | null) => {
|
||||
|
||||
@ -65,7 +65,8 @@ import {
|
||||
} from "@/components/gmail/email-list/email-list-helpers"
|
||||
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
|
||||
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
|
||||
import { attachmentsForEmailList } from "@/lib/attachment-display"
|
||||
import { resolveListRowAttachments } from "@/lib/attachment-display"
|
||||
import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
|
||||
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
|
||||
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
|
||||
import { cleanSenderName } from "@/lib/sender-display"
|
||||
@ -621,6 +622,16 @@ export function useEmailListData({
|
||||
|
||||
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
|
||||
|
||||
const attachmentFetchIds = useMemo(
|
||||
() =>
|
||||
listEmails
|
||||
.filter((e) => e.hasAttachment && !(e.attachments?.length))
|
||||
.map((e) => e.id),
|
||||
[listEmails]
|
||||
)
|
||||
const { byId: fetchedAttachmentsById, stateById: attachmentFetchStateById } =
|
||||
useListMessageAttachments(attachmentFetchIds)
|
||||
|
||||
const listRowExtras = useMemo(() => {
|
||||
const invitationById = new Map<
|
||||
string,
|
||||
@ -638,7 +649,15 @@ export function useEmailListData({
|
||||
|
||||
for (const e of listEmails) {
|
||||
invitationById.set(e.id, resolveParsedCalendarInvitation(e))
|
||||
attachmentsById.set(e.id, attachmentsForEmailList(e))
|
||||
const fetchState = attachmentFetchStateById.get(e.id) ?? "idle"
|
||||
attachmentsById.set(
|
||||
e.id,
|
||||
resolveListRowAttachments(
|
||||
e,
|
||||
fetchedAttachmentsById.get(e.id),
|
||||
fetchState
|
||||
)
|
||||
)
|
||||
if (showCategoryTabIcons) {
|
||||
const tabs = resolveEmailInboxCategoryTabs(
|
||||
e,
|
||||
@ -653,6 +672,8 @@ export function useEmailListData({
|
||||
return { invitationById, attachmentsById, categoryTabsById }
|
||||
}, [
|
||||
listEmails,
|
||||
fetchedAttachmentsById,
|
||||
attachmentFetchStateById,
|
||||
selectedFolder,
|
||||
inboxTab,
|
||||
folderFilterCtx,
|
||||
|
||||
@ -15,6 +15,8 @@ import {
|
||||
senderInitial,
|
||||
} from "@/lib/sender-display"
|
||||
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
||||
import { useMessageAttachments } from "@/lib/api/hooks/use-message-attachments"
|
||||
import { attachmentsForEmailList } from "@/lib/attachment-display"
|
||||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||
import {
|
||||
mailFlagIsRead,
|
||||
@ -65,6 +67,7 @@ import {
|
||||
SpamWhyBanner,
|
||||
ThreadPriorMessage,
|
||||
formatApiMessageBody,
|
||||
plainTextBodyFallback,
|
||||
} from "@/components/gmail/email-view/email-view-messages"
|
||||
|
||||
function apiToLegacyEmail(
|
||||
@ -148,6 +151,10 @@ export function EmailView({
|
||||
),
|
||||
[fullMessage, fullMessagePending, email.snippet]
|
||||
)
|
||||
const plainTextFallback = useMemo(
|
||||
() => plainTextBodyFallback(fullMessage),
|
||||
[fullMessage]
|
||||
)
|
||||
|
||||
const [showFullThread, setShowFullThread] = useState(false)
|
||||
const [mainDetailsOpen, setMainDetailsOpen] = useState(false)
|
||||
@ -169,14 +176,11 @@ export function EmailView({
|
||||
const showFullThreadList = !isSingleMessageView || showFullThread
|
||||
const messagesBefore = showFullThreadList ? threadBefore : []
|
||||
const messagesAfter = showFullThreadList ? threadAfter : []
|
||||
/** Conversation preview: all thread messages expanded (each gets its own remote-content banner). */
|
||||
const expandAllThreadMessages =
|
||||
showFullThreadList && (!isSingleMessageView || showFullThread)
|
||||
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const isThreadMessageExpanded = useCallback(
|
||||
(msgId: string) => expandAllThreadMessages || expandedIds.has(msgId),
|
||||
[expandAllThreadMessages, expandedIds]
|
||||
(msgId: string) => expandedIds.has(msgId),
|
||||
[expandedIds]
|
||||
)
|
||||
const toggleExpanded = (msgId: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
@ -216,11 +220,18 @@ export function EmailView({
|
||||
[email, fullMessage, threadMessages]
|
||||
)
|
||||
|
||||
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
|
||||
if (email.has_attachments)
|
||||
return [{ name: "Pièce jointe", kind: "other" }]
|
||||
return []
|
||||
}, [email.has_attachments])
|
||||
const { data: fetchedAttachments } = useMessageAttachments(
|
||||
email.id,
|
||||
email.has_attachments
|
||||
)
|
||||
const mainMessageAttachments = useMemo(
|
||||
(): EmailAttachment[] =>
|
||||
attachmentsForEmailList({
|
||||
hasAttachment: email.has_attachments,
|
||||
attachments: fetchedAttachments,
|
||||
}),
|
||||
[email.has_attachments, fetchedAttachments]
|
||||
)
|
||||
|
||||
const { composeWindows } = useComposeWindows()
|
||||
const { savedThreadReplyDrafts } = useComposeDrafts()
|
||||
@ -393,6 +404,7 @@ export function EmailView({
|
||||
onDetailsOpenChange={setMainDetailsOpen}
|
||||
collapseQuotedReplies={otherThreadCount > 0}
|
||||
messageId={email.id}
|
||||
plainTextFallback={plainTextFallback}
|
||||
/>
|
||||
|
||||
{messagesAfter.map((msg) => (
|
||||
|
||||
@ -71,16 +71,16 @@ export function EmailViewDetailsPopover({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
className="flex min-w-0 max-w-full items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseEnter={keepOpen}
|
||||
onMouseLeave={() => {
|
||||
if (open) scheduleClose()
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
<span className="min-w-0 truncate">{summary}</span>
|
||||
<ChevronDown
|
||||
className={cn("h-3 w-3 transition-transform", open && "rotate-180")}
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
@ -32,6 +32,15 @@ import {
|
||||
MAIL_TOOLTIP_CONTENT_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import { repairMimeBodies } from "@/lib/mail-mime-body"
|
||||
import { plainTextToDisplayHtml } from "@/lib/mail-plain-text-html"
|
||||
|
||||
export function plainTextBodyFallback(
|
||||
full: { body_text?: string; body_html?: string } | null | undefined
|
||||
): string | undefined {
|
||||
const { bodyText } = repairMimeBodies(full?.body_text, full?.body_html)
|
||||
const t = bodyText?.trim()
|
||||
return t || undefined
|
||||
}
|
||||
|
||||
export function formatApiMessageBody(
|
||||
full: { body_html?: string; body_text?: string } | null | undefined,
|
||||
@ -50,11 +59,7 @@ export function formatApiMessageBody(
|
||||
if (html) return html
|
||||
const text = repaired.bodyText?.trim()
|
||||
if (text) {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
return `<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${escaped}</pre>`
|
||||
return plainTextToDisplayHtml(text)
|
||||
}
|
||||
if (full) {
|
||||
const s = snippet?.trim()
|
||||
@ -119,6 +124,10 @@ export function ThreadPriorMessage({
|
||||
),
|
||||
[fullMessage, message.snippet, isExpanded, isPending]
|
||||
)
|
||||
const plainTextFallback = useMemo(
|
||||
() => plainTextBodyFallback(fullMessage),
|
||||
[fullMessage]
|
||||
)
|
||||
|
||||
const isSpam = messageIsSpam(merged.flags, merged.labels)
|
||||
|
||||
@ -151,6 +160,7 @@ export function ThreadPriorMessage({
|
||||
onDetailsOpenChange={setDetailsOpen}
|
||||
collapseQuotedReplies={collapseQuotedReplies}
|
||||
messageId={message.id}
|
||||
plainTextFallback={plainTextFallback}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -232,6 +242,7 @@ export function ExpandedMessage({
|
||||
onDetailsOpenChange,
|
||||
collapseQuotedReplies = false,
|
||||
messageId,
|
||||
plainTextFallback,
|
||||
}: {
|
||||
sender: string
|
||||
senderEmail: string
|
||||
@ -251,6 +262,7 @@ export function ExpandedMessage({
|
||||
detailsOpen?: boolean
|
||||
onDetailsOpenChange?: (open: boolean) => void
|
||||
collapseQuotedReplies?: boolean
|
||||
plainTextFallback?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@ -285,6 +297,7 @@ export function ExpandedMessage({
|
||||
senderEmail={senderEmail}
|
||||
messageId={messageId}
|
||||
collapseQuotedReplies={collapseQuotedReplies}
|
||||
plainTextFallback={plainTextFallback}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ export function EmailViewMessageToolbar({
|
||||
</ContactHoverCard>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<EmailViewDetailsPopover
|
||||
summary={headerDetails.recipientSummary}
|
||||
details={headerDetails}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { splitQuotedHtml } from "@/lib/mail-quoted-content"
|
||||
import { htmlHasRemoteContent } from "@/lib/mail-remote-content"
|
||||
@ -14,6 +14,7 @@ import {
|
||||
useTrustedSendersStore,
|
||||
} from "@/lib/stores/trusted-senders-store"
|
||||
import { useMessageAttachmentCidMap } from "@/lib/api/hooks/use-message-attachment-cid-map"
|
||||
import { useReindexMessageAttachments } from "@/lib/api/hooks/use-reindex-message-attachments"
|
||||
import { useInlineCidUrls } from "@/lib/hooks/use-inline-cid-urls"
|
||||
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
|
||||
import { RemoteContentBanner } from "@/components/gmail/email-view/remote-content-banner"
|
||||
@ -24,6 +25,7 @@ export function MessageBodyContent({
|
||||
senderEmail,
|
||||
messageId,
|
||||
collapseQuotedReplies = false,
|
||||
plainTextFallback,
|
||||
}: {
|
||||
html: string
|
||||
isSpam: boolean
|
||||
@ -31,6 +33,7 @@ export function MessageBodyContent({
|
||||
messageId: string
|
||||
/** Hide included prior messages when the thread already lists them. */
|
||||
collapseQuotedReplies?: boolean
|
||||
plainTextFallback?: string
|
||||
}) {
|
||||
const [showQuoted, setShowQuoted] = useState(false)
|
||||
const selfEmails = useSelfMailEmails()
|
||||
@ -63,8 +66,28 @@ export function MessageBodyContent({
|
||||
}, [html, collapseQuotedReplies])
|
||||
|
||||
const { data: cidMap } = useMessageAttachmentCidMap(messageId)
|
||||
const {
|
||||
mutate: reindexInlineAttachments,
|
||||
isPending: reindexPending,
|
||||
isSuccess: reindexDone,
|
||||
isError: reindexFailed,
|
||||
} = useReindexMessageAttachments(messageId)
|
||||
const cidUrlMap = useInlineCidUrls(cidMap)
|
||||
|
||||
useEffect(() => {
|
||||
if (!/cid:/i.test(mainHtml)) return
|
||||
if (Object.keys(cidMap ?? {}).length > 0) return
|
||||
if (reindexPending || reindexDone || reindexFailed) return
|
||||
reindexInlineAttachments()
|
||||
}, [
|
||||
mainHtml,
|
||||
cidMap,
|
||||
reindexPending,
|
||||
reindexDone,
|
||||
reindexFailed,
|
||||
reindexInlineAttachments,
|
||||
])
|
||||
|
||||
const hasRemoteContent = useMemo(
|
||||
() =>
|
||||
htmlHasRemoteContent(mainHtml) ||
|
||||
@ -89,13 +112,14 @@ export function MessageBodyContent({
|
||||
restrictPopups: isSpam,
|
||||
senderEmail,
|
||||
cidUrlMap,
|
||||
plainTextFallback,
|
||||
messageId,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{showRemoteBanner ? (
|
||||
<RemoteContentBanner
|
||||
senderEmail={senderEmail}
|
||||
onShowOnce={() => allowMessageRemoteContent(messageId)}
|
||||
onAlwaysShow={() => {
|
||||
trustSender(senderEmail)
|
||||
@ -103,7 +127,7 @@ export function MessageBodyContent({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<SandboxedContent html={mainHtml} {...sandboxProps} />
|
||||
<SandboxedContent html={mainHtml} previewPart="body" {...sandboxProps} />
|
||||
{hasHiddenQuote ? (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
@ -122,7 +146,11 @@ export function MessageBodyContent({
|
||||
) : null}
|
||||
{quotedHtml && showQuoted ? (
|
||||
<div className="mt-2 border-t border-border/60 pt-2">
|
||||
<SandboxedContent html={quotedHtml} {...sandboxProps} />
|
||||
<SandboxedContent
|
||||
html={quotedHtml}
|
||||
previewPart="quoted"
|
||||
{...sandboxProps}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
"use client"
|
||||
|
||||
export function RemoteContentBanner({
|
||||
senderEmail,
|
||||
onShowOnce,
|
||||
onAlwaysShow,
|
||||
}: {
|
||||
senderEmail: string
|
||||
onShowOnce: () => void
|
||||
onAlwaysShow: () => void
|
||||
}) {
|
||||
@ -25,7 +23,7 @@ export function RemoteContentBanner({
|
||||
onClick={onAlwaysShow}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
toujours afficher le contenu distant venant de {senderEmail}
|
||||
toujours afficher le contenu distant de cet expéditeur
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@ -12,14 +13,25 @@ import { useTheme } from "next-themes"
|
||||
import {
|
||||
emailPreviewBaseCss,
|
||||
emailPreviewDarkOverrideCss,
|
||||
emailPreviewDarkRemoteBodyTailCss,
|
||||
emailPreviewDarkTailOverrideCss,
|
||||
emailPreviewLightOverrideCss,
|
||||
emailPreviewWrapperCss,
|
||||
} from "@/lib/email-preview-dark-styles"
|
||||
import {
|
||||
EMAIL_PREVIEW_MIN_IFRAME_HEIGHT,
|
||||
measureEmailPreviewIframeHeight,
|
||||
} from "@/lib/email-preview-iframe-height"
|
||||
import {
|
||||
buildEmailPreviewSrcdoc,
|
||||
prepareEmailHtmlForIframe,
|
||||
injectEmailHtmlIntoDocument,
|
||||
} from "@/lib/mail-html-iframe"
|
||||
import { buildEmailPreviewCsp } from "@/lib/mail-remote-content"
|
||||
import {
|
||||
isEmailPreviewContrastDebugEnabled,
|
||||
logEmailPreviewContrastIssues,
|
||||
repairEmailPreviewContrast,
|
||||
} from "@/lib/email-preview-contrast"
|
||||
|
||||
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
|
||||
display: "block",
|
||||
@ -30,34 +42,33 @@ function documentIsDark(): boolean {
|
||||
return document.documentElement.classList.contains("dark")
|
||||
}
|
||||
|
||||
function measureIframeContentHeight(doc: Document): number {
|
||||
const body = doc.body
|
||||
const root = doc.documentElement
|
||||
if (!body) return 60
|
||||
const heights = [
|
||||
body.scrollHeight,
|
||||
body.offsetHeight,
|
||||
root?.scrollHeight ?? 0,
|
||||
root?.clientHeight ?? 0,
|
||||
]
|
||||
return Math.max(60, ...heights) + 2
|
||||
}
|
||||
|
||||
export function SandboxedContent({
|
||||
html,
|
||||
blockRemoteContent,
|
||||
restrictPopups = false,
|
||||
senderEmail,
|
||||
cidUrlMap,
|
||||
plainTextFallback,
|
||||
messageId,
|
||||
previewPart = "body",
|
||||
}: {
|
||||
html: string
|
||||
blockRemoteContent: boolean
|
||||
restrictPopups?: boolean
|
||||
senderEmail?: string
|
||||
cidUrlMap?: Record<string, string>
|
||||
/** Plain body when HTML is image-only or empty with remote content blocked. */
|
||||
plainTextFallback?: string
|
||||
/** Pour les logs dev [email-preview:low-contrast]. */
|
||||
messageId?: string
|
||||
previewPart?: "body" | "quoted"
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [height, setHeight] = useState(120)
|
||||
const contrastLoggedKeyRef = useRef<string | null>(null)
|
||||
const contrastDelayTimerRef = useRef<number | null>(null)
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null)
|
||||
const contentGenerationRef = useRef(0)
|
||||
const [height, setHeight] = useState(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
|
||||
|
||||
const sandboxValue = restrictPopups
|
||||
? "allow-same-origin"
|
||||
@ -77,12 +88,16 @@ export function SandboxedContent({
|
||||
isDark,
|
||||
senderEmail,
|
||||
cidUrlMap,
|
||||
plainTextFallback,
|
||||
}),
|
||||
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap]
|
||||
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap, plainTextFallback]
|
||||
)
|
||||
|
||||
const themeCss = useMemo(() => {
|
||||
if (!blockRemoteContent) return emailPreviewWrapperCss()
|
||||
if (!blockRemoteContent) {
|
||||
const wrapper = emailPreviewWrapperCss(isDark)
|
||||
return isDark ? `${wrapper}${emailPreviewDarkOverrideCss()}` : wrapper
|
||||
}
|
||||
return `${emailPreviewBaseCss(isDark)}${
|
||||
isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss()
|
||||
}`
|
||||
@ -93,65 +108,152 @@ export function SandboxedContent({
|
||||
[blockRemoteContent]
|
||||
)
|
||||
|
||||
const injectContent = useCallback(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return
|
||||
const srcdoc = useMemo(
|
||||
() =>
|
||||
buildEmailPreviewSrcdoc(parsedEmail, {
|
||||
csp: cspContent,
|
||||
wrapperCss: themeCss,
|
||||
plainTextFallback,
|
||||
bodyTailCss: isDark
|
||||
? blockRemoteContent
|
||||
? emailPreviewDarkTailOverrideCss()
|
||||
: emailPreviewDarkRemoteBodyTailCss()
|
||||
: undefined,
|
||||
}),
|
||||
[
|
||||
parsedEmail,
|
||||
cspContent,
|
||||
themeCss,
|
||||
plainTextFallback,
|
||||
isDark,
|
||||
blockRemoteContent,
|
||||
]
|
||||
)
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
if (!doc) return
|
||||
const iframeKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent ? "remote-blocked" : "remote-allowed"}:${isDark ? "dark" : "light"}`
|
||||
|
||||
injectEmailHtmlIntoDocument(doc, {
|
||||
csp: cspContent,
|
||||
documentBaseHref: parsedEmail.documentBaseHref,
|
||||
resolveBaseHref: parsedEmail.resolveBaseHref,
|
||||
headMarkup: parsedEmail.headMarkup,
|
||||
bodyHtml: parsedEmail.bodyHtml,
|
||||
wrapperCss: themeCss,
|
||||
})
|
||||
const syncHeight = useCallback((generation: number) => {
|
||||
if (generation !== contentGenerationRef.current) return
|
||||
const doc = iframeRef.current?.contentDocument
|
||||
if (!doc?.body) return
|
||||
const next = measureEmailPreviewIframeHeight(doc)
|
||||
setHeight((prev) => (prev === next ? prev : next))
|
||||
}, [])
|
||||
|
||||
const syncHeight = () => {
|
||||
const liveDoc = iframe.contentDocument
|
||||
if (!liveDoc) return
|
||||
const next = measureIframeContentHeight(liveDoc)
|
||||
setHeight((prev) => (prev === next ? prev : next))
|
||||
}
|
||||
const scheduleHeightSync = useCallback(
|
||||
(generation: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
syncHeight(generation)
|
||||
})
|
||||
})
|
||||
},
|
||||
[syncHeight]
|
||||
)
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncHeight)
|
||||
const runContrastPipeline = useCallback(
|
||||
(doc: Document, pass: "initial" | "delayed", generation: number) => {
|
||||
if (!isDark || generation !== contentGenerationRef.current) return
|
||||
|
||||
if (doc.body) {
|
||||
resizeObserver.observe(doc.body)
|
||||
for (const img of doc.images) {
|
||||
if (!img.complete) {
|
||||
img.addEventListener("load", syncHeight, { once: true })
|
||||
img.addEventListener("error", syncHeight, { once: true })
|
||||
const repair = repairEmailPreviewContrast(doc, {
|
||||
isDark: true,
|
||||
repairMode: blockRemoteContent ? "dark-only" : "all",
|
||||
newsletterLightCanvas: false,
|
||||
assumedCanvasRgb: [32, 33, 36],
|
||||
})
|
||||
|
||||
if (repair && (repair.lightSurfaces > 0 || repair.darkSurfaces > 0)) {
|
||||
scheduleHeightSync(generation)
|
||||
if (isEmailPreviewContrastDebugEnabled()) {
|
||||
console.info("[email-preview:contrast-repaired]", {
|
||||
messageId,
|
||||
part: previewPart,
|
||||
pass,
|
||||
blockRemoteContent,
|
||||
...repair,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const link of doc.querySelectorAll('link[rel~="stylesheet"]')) {
|
||||
link.addEventListener("load", syncHeight, { once: true })
|
||||
link.addEventListener("error", syncHeight, { once: true })
|
||||
}
|
||||
syncHeight()
|
||||
requestAnimationFrame(syncHeight)
|
||||
setTimeout(syncHeight, 250)
|
||||
setTimeout(syncHeight, 1000)
|
||||
|
||||
if (!isEmailPreviewContrastDebugEnabled()) return
|
||||
|
||||
const logKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent}:${isDark}:${srcdoc.length}:${pass}`
|
||||
if (contrastLoggedKeyRef.current === logKey) return
|
||||
contrastLoggedKeyRef.current = logKey
|
||||
logEmailPreviewContrastIssues(
|
||||
{
|
||||
messageId,
|
||||
part: previewPart,
|
||||
blockRemoteContent,
|
||||
isDark,
|
||||
senderEmail,
|
||||
},
|
||||
doc,
|
||||
{ newsletterLightCanvas: false, assumedCanvasRgb: [32, 33, 36] }
|
||||
)
|
||||
},
|
||||
[
|
||||
messageId,
|
||||
previewPart,
|
||||
blockRemoteContent,
|
||||
isDark,
|
||||
senderEmail,
|
||||
srcdoc,
|
||||
scheduleHeightSync,
|
||||
]
|
||||
)
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const generation = contentGenerationRef.current
|
||||
const doc = iframeRef.current?.contentDocument
|
||||
|
||||
resizeObserverRef.current?.disconnect()
|
||||
if (doc?.body) {
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
syncHeight(generation)
|
||||
})
|
||||
resizeObserverRef.current.observe(doc.body)
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [parsedEmail, themeCss, cspContent])
|
||||
scheduleHeightSync(generation)
|
||||
if (doc) runContrastPipeline(doc, "initial", generation)
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = injectContent()
|
||||
return () => cleanup?.()
|
||||
}, [injectContent])
|
||||
if (contrastDelayTimerRef.current !== null) {
|
||||
window.clearTimeout(contrastDelayTimerRef.current)
|
||||
}
|
||||
contrastDelayTimerRef.current = window.setTimeout(() => {
|
||||
contrastDelayTimerRef.current = null
|
||||
if (generation !== contentGenerationRef.current) return
|
||||
const lateDoc = iframeRef.current?.contentDocument
|
||||
if (lateDoc) {
|
||||
scheduleHeightSync(generation)
|
||||
runContrastPipeline(lateDoc, "delayed", generation)
|
||||
}
|
||||
}, 1000)
|
||||
}, [scheduleHeightSync, runContrastPipeline, syncHeight])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
contentGenerationRef.current += 1
|
||||
setHeight(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
|
||||
contrastLoggedKeyRef.current = null
|
||||
resizeObserverRef.current?.disconnect()
|
||||
resizeObserverRef.current = null
|
||||
if (contrastDelayTimerRef.current !== null) {
|
||||
window.clearTimeout(contrastDelayTimerRef.current)
|
||||
contrastDelayTimerRef.current = null
|
||||
}
|
||||
}, [srcdoc, messageId])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={blockRemoteContent ? "remote-blocked" : "remote-allowed"}
|
||||
key={iframeKey}
|
||||
ref={iframeRef}
|
||||
sandbox={sandboxValue}
|
||||
title="Contenu du message"
|
||||
className="w-full border-0 bg-transparent"
|
||||
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
|
||||
srcDoc={srcdoc}
|
||||
onLoad={handleIframeLoad}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -35,7 +35,7 @@ const googleApps: FavoriteApp[] = [
|
||||
{ name: "Agenda", icon: "/agenda-mark.svg" },
|
||||
{ name: "Photos", icon: "/photos-mark.svg" },
|
||||
{ name: "Ultimail", icon: "/brand/ultimail-header-icon.png", href: "/mail" },
|
||||
{ name: "UltiDrive", icon: "/ultidrive-mark.svg" },
|
||||
{ name: "UltiDrive", icon: "/ultidrive-mark.svg", href: "/drive" },
|
||||
{ name: "UltiMeet", icon: "/ultimeet-mark.svg" },
|
||||
{ name: "Administration", icon: "/admin-mark.svg" },
|
||||
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
|
||||
|
||||
@ -105,18 +105,22 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
|
||||
try {
|
||||
const result = await resanitizeBodies.mutateAsync()
|
||||
setMaintenanceMessage(
|
||||
`HTML re-sanitisé : ${result.updated} message(s) mis à jour sur ${result.scanned} analysé(s).`
|
||||
`Corps réimportés depuis IMAP : ${result.updated} message(s) mis à jour sur ${result.scanned} analysé(s).`
|
||||
)
|
||||
} catch {
|
||||
setMaintenanceMessage("Échec de la re-sanitisation du HTML.")
|
||||
setMaintenanceMessage("Échec de la réimportation des corps depuis IMAP.")
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync() {
|
||||
async function runSync(force = false) {
|
||||
setMaintenanceMessage(null)
|
||||
try {
|
||||
await syncAccount.mutateAsync()
|
||||
setMaintenanceMessage("Synchronisation IMAP terminée.")
|
||||
await syncAccount.mutateAsync({ force })
|
||||
setMaintenanceMessage(
|
||||
force
|
||||
? "Re-synchronisation complète IMAP terminée."
|
||||
: "Synchronisation IMAP terminée."
|
||||
)
|
||||
} catch {
|
||||
setMaintenanceMessage("Échec de la synchronisation IMAP.")
|
||||
}
|
||||
@ -156,8 +160,8 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
|
||||
onClick={() => void runResanitize()}
|
||||
>
|
||||
{resanitizeBodies.isPending
|
||||
? "Re-sanitisation…"
|
||||
: "Re-sanitiser le HTML"}
|
||||
? "Réimportation IMAP…"
|
||||
: "Réimporter les corps depuis IMAP"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={maintenancePending}
|
||||
@ -165,6 +169,12 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
|
||||
>
|
||||
{syncAccount.isPending ? "Synchronisation…" : "Synchroniser IMAP"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={maintenancePending}
|
||||
onClick={() => void runSync(true)}
|
||||
>
|
||||
Forcer re-sync complet
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
|
||||
39
fixtures/email-preview/README.md
Normal file
39
fixtures/email-preview/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Fixtures aperçu mail (contraste / dark mode)
|
||||
|
||||
HTML minimaux qui reproduisent les conflits courants quand le **contenu distant** est autorisé en **mode sombre**.
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. `pnpm dev` — ouvrir un mail dont le corps ressemble à un fixture (ou coller le HTML en mock).
|
||||
2. Activer « afficher le contenu distant », thème dark.
|
||||
3. Console navigateur :
|
||||
- `[email-preview:contrast-repaired]` — surfaces corrigées (dark + contenu distant **autorisé**)
|
||||
- `[email-preview:low-contrast]` — problèmes restants après correction (dev)
|
||||
|
||||
| Mode | Tail CSS fin de body | Repair runtime |
|
||||
|------|----------------------|----------------|
|
||||
| Dark + distant **bloqué** | Texte clair forcé | `dark-only` |
|
||||
| Dark + distant **autorisé** | Texte clair + exception zones `[data-ultimail-light-surface]` | `light-surface` si fond clair **réel** (pas canvas blanc supposé) |
|
||||
|
||||
Le canvas par défaut pour fond transparent est **sombre** (fond app), pas blanc — évite les faux `lightSurfaces` sur mails type signature Cloudflare.
|
||||
|
||||
Forcer les logs hors `development` :
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_EMAIL_PREVIEW_CONTRAST_DEBUG=1 pnpm dev
|
||||
```
|
||||
|
||||
## Fichiers
|
||||
|
||||
| Fichier | Scénario |
|
||||
|---------|----------|
|
||||
| `external-stylesheet-wins.html` | `<style>` après le wrapper Ultimail : texte blanc sur fond blanc |
|
||||
| `inline-white-on-light.html` | `color:#fff` + `background:#f5f5f5` inline (sans prétraitement distant) |
|
||||
| `dark-newsletter-on-transparent.html` | Newsletter fond clair + texte clair ; `bgcolor` partiellement neutralisé |
|
||||
| `mixed-light-and-dark-sections.html` | Section gradient claire + bloc `#0b1e3d` (bunny.net-like) |
|
||||
|
||||
## Tests unitaires (math contraste)
|
||||
|
||||
```bash
|
||||
pnpm test:email-preview-contrast
|
||||
```
|
||||
18
fixtures/email-preview/dark-newsletter-on-transparent.html
Normal file
18
fixtures/email-preview/dark-newsletter-on-transparent.html
Normal file
@ -0,0 +1,18 @@
|
||||
<table bgcolor="#ffffff" width="100%">
|
||||
<tr>
|
||||
<td bgcolor="#f8f9fa" style="color: #e8eaed; padding: 20px;">
|
||||
<font color="#ffffff">
|
||||
Override Ultimail force #e8eaed mais feuille distante peut gagner sur .title
|
||||
</font>
|
||||
<h2 class="title" style="color: #ffffff; margin: 0;">Titre newsletter</h2>
|
||||
<p style="color: rgba(255, 255, 255, 0.95);">
|
||||
Paragraphe très clair sur fond encore clair après transparent bgcolor.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<style>
|
||||
.title {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
</style>
|
||||
14
fixtures/email-preview/external-stylesheet-wins.html
Normal file
14
fixtures/email-preview/external-stylesheet-wins.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!-- Corps seul : simule headMarkup injecté APRÈS data-ultimail-wrapper -->
|
||||
<style>
|
||||
.promo {
|
||||
color: #ffffff !important;
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
</style>
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="promo" style="padding: 24px;">
|
||||
<p>Offre limitée — texte blanc sur fond blanc (illisible en dark + distant).</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
6
fixtures/email-preview/inline-white-on-light.html
Normal file
6
fixtures/email-preview/inline-white-on-light.html
Normal file
@ -0,0 +1,6 @@
|
||||
<div style="background-color: #f1f3f4; padding: 16px;">
|
||||
<p style="color: #ffffff; font-size: 14px;">
|
||||
Texte blanc explicite sur fond gris clair — typique signature / template inline.
|
||||
</p>
|
||||
<span style="color: rgb(255, 255, 255);">Même problème sur un span imbriqué.</span>
|
||||
</div>
|
||||
23
fixtures/email-preview/mixed-light-and-dark-sections.html
Normal file
23
fixtures/email-preview/mixed-light-and-dark-sections.html
Normal file
@ -0,0 +1,23 @@
|
||||
<!-- bunny.net-like: section claire illisible + bloc bleu foncé lisible -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td
|
||||
style="background: linear-gradient(180deg, #f5f7fa 0%, #eceff5 100%); padding: 32px;"
|
||||
>
|
||||
<h2 style="color: #e8eaed; margin: 0 0 12px;">
|
||||
Introducing Bunny Shield API Guardian
|
||||
</h2>
|
||||
<p style="color: #e8eaed; margin: 0 0 16px;">
|
||||
Most API abuse doesn't look suspicious. It looks valid.
|
||||
</p>
|
||||
<a href="#" style="color: #e8eaed;">Explore More</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#0b1e3d" style="padding: 24px;">
|
||||
<p style="color: #ffffff; margin: 0;">
|
||||
Readable white text on dark blue footer block.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -5,6 +5,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../ulti-backend"
|
||||
},
|
||||
{
|
||||
"path": "../drive-suite"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
|
||||
@ -185,6 +185,28 @@ class ApiClient {
|
||||
return this.request<T>("GET", path, { params })
|
||||
}
|
||||
|
||||
/** GET binary body (inline attachments, exports). */
|
||||
async getBlob(path: string): Promise<Blob> {
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
||||
throw new OfflineError()
|
||||
}
|
||||
const url = this.resolveUrl(path)
|
||||
const headers: Record<string, string> = {}
|
||||
const token = useAuthStore.getState().accessToken
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
const response = await fetch(url.toString(), { method: "GET", headers })
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(
|
||||
response.status,
|
||||
"UNKNOWN",
|
||||
response.statusText
|
||||
)
|
||||
}
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>("POST", path, { body })
|
||||
}
|
||||
|
||||
47
lib/api/hooks/use-list-message-attachments.ts
Normal file
47
lib/api/hooks/use-list-message-attachments.ts
Normal file
@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useQueries } from "@tanstack/react-query"
|
||||
import { useAuthReady } from "../use-auth-ready"
|
||||
import { fetchMessageAttachments } from "./use-message-attachments"
|
||||
import type { EmailAttachment } from "@/lib/email-data"
|
||||
|
||||
export type ListAttachmentFetchState = "idle" | "loading" | "done"
|
||||
|
||||
export function useListMessageAttachments(messageIds: string[]) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const stableIds = useMemo(
|
||||
() => [...new Set(messageIds)].sort(),
|
||||
[messageIds]
|
||||
)
|
||||
|
||||
const queries = useQueries({
|
||||
queries: stableIds.map((id) => ({
|
||||
queryKey: ["message-attachments", id] as const,
|
||||
queryFn: () => fetchMessageAttachments(id),
|
||||
enabled: ready && authenticated,
|
||||
staleTime: 5 * 60_000,
|
||||
})),
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const byId = new Map<string, EmailAttachment[]>()
|
||||
const stateById = new Map<string, ListAttachmentFetchState>()
|
||||
|
||||
stableIds.forEach((id, index) => {
|
||||
const q = queries[index]
|
||||
if (!q) {
|
||||
stateById.set(id, "idle")
|
||||
return
|
||||
}
|
||||
if (q.isPending || q.isFetching) {
|
||||
stateById.set(id, "loading")
|
||||
return
|
||||
}
|
||||
stateById.set(id, "done")
|
||||
if (q.data?.length) byId.set(id, q.data)
|
||||
})
|
||||
|
||||
return { byId, stateById }
|
||||
}, [stableIds, queries])
|
||||
}
|
||||
@ -70,8 +70,10 @@ export function useSyncMailAccount(accountId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.post<{ status: string }>(`/mail/accounts/${accountId}/sync`),
|
||||
mutationFn: (opts?: { force?: boolean }) =>
|
||||
apiClient.post<{ status: string }>(
|
||||
`/mail/accounts/${accountId}/sync${opts?.force ? "?force=true" : ""}`
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
|
||||
@ -3,23 +3,67 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "../client"
|
||||
import { useAuthReady } from "../use-auth-ready"
|
||||
import { normalizeCidKey } from "@/lib/mail-cid"
|
||||
import {
|
||||
type ApiMessageAttachment,
|
||||
} from "../map-message-attachments"
|
||||
|
||||
type CidMapResponse = {
|
||||
cid_map?: Record<string, string>
|
||||
}
|
||||
|
||||
type AttachmentsResponse = {
|
||||
attachments?: ApiMessageAttachment[]
|
||||
}
|
||||
|
||||
function mergeAttachmentIntoCidMap(
|
||||
map: Record<string, string>,
|
||||
att: ApiMessageAttachment
|
||||
): void {
|
||||
if (!att.is_inline || !att.id) return
|
||||
if (att.content_id) {
|
||||
const key = normalizeCidKey(att.content_id)
|
||||
map[key] = att.id
|
||||
map[key.toLowerCase()] = att.id
|
||||
map[`cid:${key}`] = att.id
|
||||
map[`cid:${key.toLowerCase()}`] = att.id
|
||||
}
|
||||
if (att.filename) {
|
||||
const base = att.filename.includes("/")
|
||||
? att.filename.split("/").pop()!
|
||||
: att.filename
|
||||
const key = normalizeCidKey(base)
|
||||
map[key] = att.id
|
||||
map[key.toLowerCase()] = att.id
|
||||
map[`cid:${key}`] = att.id
|
||||
map[`cid:${key.toLowerCase()}`] = att.id
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMessageCidMap(messageId: string): Promise<Record<string, string>> {
|
||||
const [cidRes, listRes] = await Promise.all([
|
||||
apiClient.get<CidMapResponse>(
|
||||
`/mail/messages/${messageId}/attachments/cid-map`
|
||||
),
|
||||
apiClient.get<AttachmentsResponse>(
|
||||
`/mail/messages/${messageId}/attachments`
|
||||
),
|
||||
])
|
||||
|
||||
const map: Record<string, string> = { ...(cidRes?.cid_map ?? {}) }
|
||||
for (const att of listRes?.attachments ?? []) {
|
||||
mergeAttachmentIntoCidMap(map, att)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function useMessageAttachmentCidMap(messageId: string | undefined) {
|
||||
const authReady = useAuthReady()
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["message-cid-map", messageId],
|
||||
enabled: authReady && Boolean(messageId),
|
||||
enabled: ready && authenticated && Boolean(messageId),
|
||||
staleTime: 5 * 60_000,
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<CidMapResponse>(
|
||||
`/mail/messages/${messageId}/attachments/cid-map`
|
||||
)
|
||||
return res?.cid_map ?? {}
|
||||
},
|
||||
queryFn: () => fetchMessageCidMap(messageId!),
|
||||
})
|
||||
}
|
||||
|
||||
36
lib/api/hooks/use-message-attachments.ts
Normal file
36
lib/api/hooks/use-message-attachments.ts
Normal file
@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "../client"
|
||||
import { useAuthReady } from "../use-auth-ready"
|
||||
import {
|
||||
mapApiAttachmentsToEmail,
|
||||
type ApiMessageAttachment,
|
||||
} from "../map-message-attachments"
|
||||
import type { EmailAttachment } from "@/lib/email-data"
|
||||
|
||||
type AttachmentsResponse = {
|
||||
attachments?: ApiMessageAttachment[]
|
||||
}
|
||||
|
||||
export function fetchMessageAttachments(
|
||||
messageId: string
|
||||
): Promise<EmailAttachment[]> {
|
||||
return apiClient
|
||||
.get<AttachmentsResponse>(`/mail/messages/${messageId}/attachments`)
|
||||
.then((res) => mapApiAttachmentsToEmail(res.attachments))
|
||||
}
|
||||
|
||||
export function useMessageAttachments(
|
||||
messageId: string | undefined,
|
||||
enabled = true
|
||||
) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["message-attachments", messageId],
|
||||
queryFn: () => fetchMessageAttachments(messageId!),
|
||||
enabled: ready && authenticated && enabled && Boolean(messageId),
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
}
|
||||
21
lib/api/hooks/use-reindex-message-attachments.ts
Normal file
21
lib/api/hooks/use-reindex-message-attachments.ts
Normal file
@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "../client"
|
||||
|
||||
export function useReindexMessageAttachments(messageId: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.post<{ status: string }>(
|
||||
`/mail/messages/${messageId}/attachments/reindex`
|
||||
),
|
||||
onSuccess: () => {
|
||||
if (!messageId) return
|
||||
queryClient.invalidateQueries({ queryKey: ["message-cid-map", messageId] })
|
||||
queryClient.invalidateQueries({ queryKey: ["message-attachments", messageId] })
|
||||
queryClient.invalidateQueries({ queryKey: ["message", messageId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
33
lib/api/map-message-attachments.ts
Normal file
33
lib/api/map-message-attachments.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { EmailAttachment } from "@/lib/email-data"
|
||||
import { resolveAttachmentKind } from "@/lib/attachment-display"
|
||||
|
||||
export interface ApiMessageAttachment {
|
||||
id: string
|
||||
filename: string
|
||||
content_type: string
|
||||
size: number
|
||||
is_inline?: boolean
|
||||
content_id?: string
|
||||
}
|
||||
|
||||
export function mapApiAttachmentsToEmail(
|
||||
list: ApiMessageAttachment[] | undefined
|
||||
): EmailAttachment[] {
|
||||
if (!list?.length) return []
|
||||
return list
|
||||
.filter((a) => !a.is_inline)
|
||||
.map((a) => ({
|
||||
name: a.filename || "Pièce jointe",
|
||||
kind: resolveAttachmentKind(a.filename, kindFromContentType(a.content_type)),
|
||||
sizeBytes: a.size > 0 ? a.size : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function kindFromContentType(
|
||||
contentType: string
|
||||
): EmailAttachment["kind"] | undefined {
|
||||
const ct = contentType.toLowerCase()
|
||||
if (ct.includes("pdf")) return "pdf"
|
||||
if (ct.startsWith("image/")) return "image"
|
||||
return undefined
|
||||
}
|
||||
@ -18,6 +18,24 @@ export function attachmentsForEmailList(
|
||||
return []
|
||||
}
|
||||
|
||||
/** Liste : noms réels si fetch OK ; pas de chip générique pendant le chargement. */
|
||||
export function resolveListRowAttachments(
|
||||
email: Pick<Email, "attachments" | "hasAttachment" | "hasInvitation">,
|
||||
fetched: EmailAttachment[] | undefined,
|
||||
fetchState: "idle" | "loading" | "done"
|
||||
): EmailAttachment[] {
|
||||
if (email.attachments?.length) {
|
||||
return attachmentsForEmailList(email)
|
||||
}
|
||||
if (fetched?.length) {
|
||||
return attachmentsForEmailList({ ...email, attachments: fetched })
|
||||
}
|
||||
if (email.hasAttachment && fetchState === "loading") {
|
||||
return []
|
||||
}
|
||||
return attachmentsForEmailList(email)
|
||||
}
|
||||
|
||||
/** Fichiers « riches » (cartes type Gmail) : PDF, images, vidéos, bureautique. */
|
||||
const RICH_PREVIEW_EXT =
|
||||
/\.(mp4|mpe?g|webm|mov|avi|mkv|m4v|wmv|flv|xls|xlsx|xlsm|ods|numbers|ppt|pptx|key|odp|doc|docx|odt|rtf)$/i
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
useContacts,
|
||||
useDefaultContactBookId,
|
||||
@ -10,6 +11,9 @@ export function useContactsList(bookId?: string) {
|
||||
const defaultBookId = useDefaultContactBookId()
|
||||
const resolvedBookId = bookId ?? defaultBookId
|
||||
const { data: apiContacts, ...rest } = useContacts(resolvedBookId)
|
||||
const contacts = apiContacts?.map(apiContactToFullContact) ?? []
|
||||
const contacts = useMemo(
|
||||
() => apiContacts?.map(apiContactToFullContact) ?? [],
|
||||
[apiContacts]
|
||||
)
|
||||
return { contacts, bookId: resolvedBookId, ...rest }
|
||||
}
|
||||
|
||||
78
lib/email-preview-contrast.test.ts
Normal file
78
lib/email-preview-contrast.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, it } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
classifyContrastRepairKind,
|
||||
contrastRatio,
|
||||
isLikelyLightPaintedBackground,
|
||||
parseCssColorToRgb,
|
||||
relativeLuminance,
|
||||
} from "./email-preview-contrast.ts"
|
||||
|
||||
describe("email-preview-contrast math", () => {
|
||||
it("parseCssColorToRgb handles hex and rgb", () => {
|
||||
assert.deepEqual(parseCssColorToRgb("#ffffff"), [255, 255, 255])
|
||||
assert.deepEqual(parseCssColorToRgb("rgb(32, 33, 36)"), [32, 33, 36])
|
||||
assert.equal(parseCssColorToRgb("transparent"), null)
|
||||
assert.equal(parseCssColorToRgb("rgba(255,255,255,0)"), null)
|
||||
})
|
||||
|
||||
it("contrastRatio is low for similar colors", () => {
|
||||
const white: [number, number, number] = [255, 255, 255]
|
||||
const nearWhite: [number, number, number] = [250, 250, 250]
|
||||
assert.ok(contrastRatio(white, nearWhite) < 1.2)
|
||||
assert.ok(contrastRatio(white, [0, 0, 0]) > 10)
|
||||
})
|
||||
|
||||
it("relativeLuminance orders black below white", () => {
|
||||
assert.ok(relativeLuminance([0, 0, 0]) < relativeLuminance([255, 255, 255]))
|
||||
})
|
||||
|
||||
it("classifyContrastRepairKind detects light-on-light", () => {
|
||||
assert.equal(
|
||||
classifyContrastRepairKind([232, 234, 237], [255, 255, 255]),
|
||||
"light-surface"
|
||||
)
|
||||
assert.equal(
|
||||
classifyContrastRepairKind([232, 234, 237], [236, 239, 245]),
|
||||
"light-surface"
|
||||
)
|
||||
})
|
||||
|
||||
it("classifyContrastRepairKind ignores readable pairs", () => {
|
||||
assert.equal(classifyContrastRepairKind([32, 33, 36], [255, 255, 255]), null)
|
||||
assert.equal(
|
||||
classifyContrastRepairKind([255, 255, 255], [11, 30, 61]),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it("classifyContrastRepairKind detects dark gray on dark canvas", () => {
|
||||
assert.equal(
|
||||
classifyContrastRepairKind([51, 51, 51], [32, 33, 36]),
|
||||
"dark-surface"
|
||||
)
|
||||
})
|
||||
|
||||
it("classifyContrastRepairKind keeps light-on-light even if dark canvas looked fine", () => {
|
||||
assert.equal(
|
||||
classifyContrastRepairKind([232, 234, 237], [255, 255, 255]),
|
||||
"light-surface"
|
||||
)
|
||||
assert.ok(
|
||||
contrastRatio([232, 234, 237], [32, 33, 36]) >= 3,
|
||||
"misleading high ratio vs dark assumed canvas"
|
||||
)
|
||||
})
|
||||
|
||||
it("isLikelyLightPaintedBackground detects newsletter gradients", () => {
|
||||
assert.ok(
|
||||
isLikelyLightPaintedBackground(
|
||||
"linear-gradient(180deg, #f5f7fa 0%, #eceff5 100%)"
|
||||
)
|
||||
)
|
||||
assert.equal(
|
||||
isLikelyLightPaintedBackground("linear-gradient(180deg, #0b1e3d, #000)"),
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
531
lib/email-preview-contrast.ts
Normal file
531
lib/email-preview-contrast.ts
Normal file
@ -0,0 +1,531 @@
|
||||
/**
|
||||
* Détection et correction de contraste dans les iframes d’aperçu mail.
|
||||
*/
|
||||
|
||||
const REPAIR_LIGHT_TEXT = "#202124"
|
||||
const REPAIR_LIGHT_LINK = "#1a73e8"
|
||||
const REPAIR_DARK_TEXT = "#e8eaed"
|
||||
const REPAIR_DARK_LINK = "#8ab4f8"
|
||||
|
||||
const CONTRAST_REPAIR_TEXT_TAGS =
|
||||
"div,p,span,td,th,li,h1,h2,h3,h4,h5,h6,font,label,strong,b,em,i,u,center,table"
|
||||
|
||||
function emailPreviewContrastRepairCss(): string {
|
||||
const lightChildren = CONTRAST_REPAIR_TEXT_TAGS.split(",")
|
||||
.map((t) => `[data-ultimail-light-surface] ${t}`)
|
||||
.join(", ")
|
||||
const darkChildren = CONTRAST_REPAIR_TEXT_TAGS.split(",")
|
||||
.map((t) => `[data-ultimail-dark-surface] ${t}`)
|
||||
.join(", ")
|
||||
|
||||
return `
|
||||
${lightChildren},
|
||||
[data-ultimail-light-surface] {
|
||||
color: ${REPAIR_LIGHT_TEXT} !important;
|
||||
}
|
||||
[data-ultimail-light-surface] a,
|
||||
[data-ultimail-light-surface] a * {
|
||||
color: ${REPAIR_LIGHT_LINK} !important;
|
||||
}
|
||||
|
||||
${darkChildren},
|
||||
[data-ultimail-dark-surface] {
|
||||
color: ${REPAIR_DARK_TEXT} !important;
|
||||
}
|
||||
[data-ultimail-dark-surface] a,
|
||||
[data-ultimail-dark-surface] a * {
|
||||
color: ${REPAIR_DARK_LINK} !important;
|
||||
}
|
||||
|
||||
[data-ultimail-light-surface] blockquote {
|
||||
color: #5f6368 !important;
|
||||
border-left-color: #dadce0 !important;
|
||||
}
|
||||
[data-ultimail-dark-surface] blockquote {
|
||||
color: #9aa0a6 !important;
|
||||
border-left-color: #5f6368 !important;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const EMAIL_PREVIEW_MIN_CONTRAST_RATIO = 3
|
||||
|
||||
export type Rgb = readonly [number, number, number]
|
||||
|
||||
export type EmailPreviewContrastIssue = {
|
||||
tag: string
|
||||
contrastRatio: number
|
||||
color: string
|
||||
backgroundColor: string
|
||||
effectiveBackground: string
|
||||
sample: string
|
||||
selectorHint: string
|
||||
}
|
||||
|
||||
export type EmailPreviewContrastReport = {
|
||||
issueCount: number
|
||||
issues: EmailPreviewContrastIssue[]
|
||||
hasExternalStyles: boolean
|
||||
scannedElements: number
|
||||
}
|
||||
|
||||
export type DetectEmailPreviewContrastOptions = {
|
||||
minRatio?: number
|
||||
maxIssues?: number
|
||||
maxScan?: number
|
||||
/** Fallback when la chaîne de fonds est transparente (aperçu dark : blanc newsletter). */
|
||||
assumedCanvasRgb?: Rgb
|
||||
/** En dark mode, défaut fond clair pour les zones transparentes. */
|
||||
newsletterLightCanvas?: boolean
|
||||
textSelectors?: string
|
||||
}
|
||||
|
||||
type BackgroundSample = {
|
||||
rgb: Rgb
|
||||
css: string
|
||||
source: Element
|
||||
/** Fond déduit (transparent) — ne pas en déduire light-surface. */
|
||||
isAssumedCanvas?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_TEXT_SELECTORS =
|
||||
"p,span,td,th,div,li,a,h1,h2,h3,h4,h5,h6,font,label,strong,b,em"
|
||||
|
||||
const NAMED_RGB: Record<string, Rgb> = {
|
||||
white: [255, 255, 255],
|
||||
black: [0, 0, 0],
|
||||
transparent: [0, 0, 0],
|
||||
}
|
||||
|
||||
export function parseCssColorToRgb(color: string): Rgb | null {
|
||||
const trimmed = color.trim().toLowerCase()
|
||||
if (!trimmed || trimmed === "transparent") return null
|
||||
|
||||
const named = NAMED_RGB[trimmed]
|
||||
if (named) return named
|
||||
|
||||
const hex = trimmed.match(/^#([0-9a-f]{3,8})$/)
|
||||
if (hex) {
|
||||
let h = hex[1]
|
||||
if (h.length === 3) h = h.split("").map((c) => c + c).join("")
|
||||
if (h.length === 6) {
|
||||
return [
|
||||
parseInt(h.slice(0, 2), 16),
|
||||
parseInt(h.slice(2, 4), 16),
|
||||
parseInt(h.slice(4, 6), 16),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const rgb = trimmed.match(
|
||||
/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/
|
||||
)
|
||||
if (rgb) {
|
||||
const alpha = rgb[4] !== undefined ? Number(rgb[4]) : 1
|
||||
if (alpha < 0.08) return null
|
||||
return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function relativeLuminance([r, g, b]: Rgb): number {
|
||||
const f = (x: number) => {
|
||||
const s = x / 255
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
|
||||
}
|
||||
|
||||
export function contrastRatio(fg: Rgb, bg: Rgb): number {
|
||||
const L1 = relativeLuminance(fg)
|
||||
const L2 = relativeLuminance(bg)
|
||||
const lighter = Math.max(L1, L2)
|
||||
const darker = Math.min(L1, L2)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
function selectorHint(el: Element): string {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const id = el.id ? `#${el.id}` : ""
|
||||
const cls =
|
||||
typeof el.className === "string" && el.className.trim()
|
||||
? `.${el.className.trim().split(/\s+/).slice(0, 2).join(".")}`
|
||||
: ""
|
||||
return `${tag}${id}${cls}`
|
||||
}
|
||||
|
||||
function parseHtmlBgcolorAttribute(el: Element): Rgb | null {
|
||||
const raw = el.getAttribute("bgcolor")?.trim()
|
||||
if (!raw) return null
|
||||
const normalized = raw.startsWith("#") ? raw : `#${raw}`
|
||||
return parseCssColorToRgb(normalized) ?? parseCssColorToRgb(raw)
|
||||
}
|
||||
|
||||
/** Gradients / fonds peints sans background-color opaque (newsletters type bunny.net). */
|
||||
export function isLikelyLightPaintedBackground(backgroundImage: string): boolean {
|
||||
if (!backgroundImage || backgroundImage === "none") return false
|
||||
const s = backgroundImage.toLowerCase()
|
||||
if (s.includes("url(")) return false
|
||||
if (!s.includes("gradient")) return false
|
||||
if (
|
||||
s.includes("#000") ||
|
||||
s.includes("rgb(0,") ||
|
||||
s.includes("rgb(0 ") ||
|
||||
s.includes(", 0,") ||
|
||||
s.includes("black")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
s.includes("#fff") ||
|
||||
s.includes("white") ||
|
||||
s.includes("rgb(255") ||
|
||||
/#[ef][0-9a-f]{5}/i.test(s) ||
|
||||
s.includes("#f") ||
|
||||
s.includes("rgb(24") ||
|
||||
s.includes("rgb(23") ||
|
||||
s.includes("rgb(22") ||
|
||||
s.includes("rgb(236") ||
|
||||
s.includes("rgb(239") ||
|
||||
s.includes("rgb(245")
|
||||
)
|
||||
}
|
||||
|
||||
function backgroundRgbFromElement(
|
||||
el: Element,
|
||||
view: Window
|
||||
): { rgb: Rgb; css: string } | null {
|
||||
const cs = view.getComputedStyle(el)
|
||||
const solid = parseCssColorToRgb(cs.backgroundColor)
|
||||
if (solid) return { rgb: solid, css: cs.backgroundColor }
|
||||
|
||||
const bgcolor = parseHtmlBgcolorAttribute(el)
|
||||
if (bgcolor) {
|
||||
return { rgb: bgcolor, css: el.getAttribute("bgcolor") ?? "" }
|
||||
}
|
||||
|
||||
if (isLikelyLightPaintedBackground(cs.backgroundImage)) {
|
||||
return { rgb: [245, 247, 250], css: cs.backgroundImage }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(n, min), max)
|
||||
}
|
||||
|
||||
/** Fond réellement visible sous le texte (évite de confondre avec un ancêtre bleu foncé). */
|
||||
function visualBackgroundAtTextElement(
|
||||
el: Element,
|
||||
doc: Document,
|
||||
view: Window
|
||||
): BackgroundSample | null {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width < 1 || rect.height < 1) return null
|
||||
|
||||
const x = clamp(
|
||||
rect.left + rect.width / 2,
|
||||
0,
|
||||
Math.max(0, view.innerWidth - 1)
|
||||
)
|
||||
const y = clamp(
|
||||
rect.top + Math.min(14, rect.height * 0.2),
|
||||
0,
|
||||
Math.max(0, view.innerHeight - 1)
|
||||
)
|
||||
|
||||
let node = doc.elementFromPoint(x, y)
|
||||
if (!node || node === doc.documentElement) return null
|
||||
|
||||
while (node && node !== doc.body) {
|
||||
const bg = backgroundRgbFromElement(node, view)
|
||||
if (bg) return { ...bg, source: node }
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function ancestryBackgroundAtElement(
|
||||
el: Element,
|
||||
doc: Document,
|
||||
view: Window
|
||||
): BackgroundSample | null {
|
||||
let node: Element | null = el
|
||||
while (node && node !== doc.documentElement) {
|
||||
const bg = backgroundRgbFromElement(node, view)
|
||||
if (bg) return { ...bg, source: node }
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
const bodyBg = backgroundRgbFromElement(doc.body, view)
|
||||
if (bodyBg) return { ...bodyBg, source: doc.body }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function defaultAssumedCanvasRgb(options: DetectEmailPreviewContrastOptions): Rgb {
|
||||
if (options.newsletterLightCanvas === true) {
|
||||
return [255, 255, 255]
|
||||
}
|
||||
return options.assumedCanvasRgb ?? [32, 33, 36]
|
||||
}
|
||||
|
||||
function effectiveBackgroundForText(
|
||||
el: Element,
|
||||
doc: Document,
|
||||
view: Window,
|
||||
options: DetectEmailPreviewContrastOptions
|
||||
): BackgroundSample {
|
||||
const visual = visualBackgroundAtTextElement(el, doc, view)
|
||||
if (visual) return visual
|
||||
|
||||
const ancestry = ancestryBackgroundAtElement(el, doc, view)
|
||||
if (ancestry) return ancestry
|
||||
|
||||
const rgb = defaultAssumedCanvasRgb(options)
|
||||
return {
|
||||
rgb,
|
||||
css: "(assumed-canvas)",
|
||||
source: doc.body,
|
||||
isAssumedCanvas: true,
|
||||
}
|
||||
}
|
||||
|
||||
export type ContrastRepairKind = "light-surface" | "dark-surface"
|
||||
|
||||
export function classifyContrastRepairKind(
|
||||
fg: Rgb,
|
||||
bg: Rgb,
|
||||
minRatio = EMAIL_PREVIEW_MIN_CONTRAST_RATIO
|
||||
): ContrastRepairKind | null {
|
||||
const fgL = relativeLuminance(fg)
|
||||
const bgL = relativeLuminance(bg)
|
||||
const ratio = contrastRatio(fg, bg)
|
||||
|
||||
if (bgL > 0.8 && fgL > 0.55) return "light-surface"
|
||||
if (bgL < 0.22 && fgL < 0.48) return "dark-surface"
|
||||
|
||||
if (ratio < minRatio) {
|
||||
if (bgL > 0.72 && fgL > 0.5) return "light-surface"
|
||||
if (bgL < 0.28 && fgL < 0.52) return "dark-surface"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const CONTRAST_REPAIR_STYLE_ID = "ultimail-contrast-repair"
|
||||
const MAX_MARKED_SURFACES = 48
|
||||
|
||||
function ensureContrastRepairStylesheet(doc: Document): void {
|
||||
if (doc.getElementById(CONTRAST_REPAIR_STYLE_ID)) return
|
||||
const style = doc.createElement("style")
|
||||
style.id = CONTRAST_REPAIR_STYLE_ID
|
||||
style.setAttribute("data-ultimail-contrast-repair", "true")
|
||||
style.textContent = emailPreviewContrastRepairCss()
|
||||
doc.head.appendChild(style)
|
||||
}
|
||||
|
||||
export type RepairEmailPreviewContrastResult = {
|
||||
lightSurfaces: number
|
||||
darkSurfaces: number
|
||||
}
|
||||
|
||||
export type EmailPreviewContrastRepairMode = "all" | "dark-only" | "light-only"
|
||||
|
||||
export type RepairEmailPreviewContrastOptions = DetectEmailPreviewContrastOptions & {
|
||||
isDark?: boolean
|
||||
repairMode?: EmailPreviewContrastRepairMode
|
||||
}
|
||||
|
||||
export function repairEmailPreviewContrast(
|
||||
doc: Document,
|
||||
options: RepairEmailPreviewContrastOptions = {}
|
||||
): RepairEmailPreviewContrastResult | null {
|
||||
if (options.isDark === false) return null
|
||||
|
||||
const view = doc.defaultView
|
||||
const body = doc.body
|
||||
if (!view || !body) return null
|
||||
|
||||
const minRatio = options.minRatio ?? EMAIL_PREVIEW_MIN_CONTRAST_RATIO
|
||||
const maxScan = options.maxScan ?? 800
|
||||
const repairMode = options.repairMode ?? "all"
|
||||
const detectOptions: DetectEmailPreviewContrastOptions = {
|
||||
...options,
|
||||
newsletterLightCanvas: options.newsletterLightCanvas ?? false,
|
||||
assumedCanvasRgb: options.assumedCanvasRgb ?? [32, 33, 36],
|
||||
}
|
||||
const selectors = options.textSelectors ?? DEFAULT_TEXT_SELECTORS
|
||||
|
||||
const lightSurfaces = new Set<Element>()
|
||||
const darkSurfaces = new Set<Element>()
|
||||
let scannedElements = 0
|
||||
|
||||
for (const el of body.querySelectorAll(selectors)) {
|
||||
if (scannedElements >= maxScan) break
|
||||
scannedElements += 1
|
||||
|
||||
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim()
|
||||
if (text.length < 2) continue
|
||||
|
||||
const cs = view.getComputedStyle(el)
|
||||
const fg = parseCssColorToRgb(cs.color)
|
||||
if (!fg) continue
|
||||
|
||||
const bg = effectiveBackgroundForText(el, doc, view, detectOptions)
|
||||
|
||||
const kind = classifyContrastRepairKind(fg, bg.rgb, minRatio)
|
||||
if (!kind) continue
|
||||
if (bg.isAssumedCanvas && kind === "light-surface") continue
|
||||
if (repairMode === "dark-only" && kind === "light-surface") continue
|
||||
if (repairMode === "light-only" && kind === "dark-surface") continue
|
||||
|
||||
const bucket = kind === "light-surface" ? lightSurfaces : darkSurfaces
|
||||
if (bucket.size >= MAX_MARKED_SURFACES) continue
|
||||
bucket.add(el)
|
||||
}
|
||||
|
||||
if (lightSurfaces.size === 0 && darkSurfaces.size === 0) {
|
||||
return { lightSurfaces: 0, darkSurfaces: 0 }
|
||||
}
|
||||
|
||||
for (const el of lightSurfaces) {
|
||||
el.setAttribute("data-ultimail-light-surface", "true")
|
||||
el.removeAttribute("data-ultimail-dark-surface")
|
||||
}
|
||||
for (const el of darkSurfaces) {
|
||||
el.setAttribute("data-ultimail-dark-surface", "true")
|
||||
el.removeAttribute("data-ultimail-light-surface")
|
||||
}
|
||||
|
||||
ensureContrastRepairStylesheet(doc)
|
||||
|
||||
return {
|
||||
lightSurfaces: lightSurfaces.size,
|
||||
darkSurfaces: darkSurfaces.size,
|
||||
}
|
||||
}
|
||||
|
||||
export function emailPreviewHasExternalStyles(doc: Document): boolean {
|
||||
return Boolean(
|
||||
doc.querySelector('link[rel~="stylesheet" i]') ||
|
||||
doc.querySelector("style:not([data-ultimail-wrapper])")
|
||||
)
|
||||
}
|
||||
|
||||
export function detectEmailPreviewContrastIssues(
|
||||
doc: Document,
|
||||
options: DetectEmailPreviewContrastOptions = {}
|
||||
): EmailPreviewContrastReport {
|
||||
const view = doc.defaultView
|
||||
const body = doc.body
|
||||
if (!view || !body) {
|
||||
return {
|
||||
issueCount: 0,
|
||||
issues: [],
|
||||
hasExternalStyles: false,
|
||||
scannedElements: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const minRatio = options.minRatio ?? EMAIL_PREVIEW_MIN_CONTRAST_RATIO
|
||||
const maxIssues = options.maxIssues ?? 20
|
||||
const maxScan = options.maxScan ?? 800
|
||||
const detectOptions: DetectEmailPreviewContrastOptions = {
|
||||
...options,
|
||||
newsletterLightCanvas: options.newsletterLightCanvas ?? false,
|
||||
assumedCanvasRgb: options.assumedCanvasRgb ?? [32, 33, 36],
|
||||
}
|
||||
const selectors = options.textSelectors ?? DEFAULT_TEXT_SELECTORS
|
||||
|
||||
const issues: EmailPreviewContrastIssue[] = []
|
||||
let scannedElements = 0
|
||||
|
||||
for (const el of body.querySelectorAll(selectors)) {
|
||||
if (scannedElements >= maxScan || issues.length >= maxIssues) break
|
||||
scannedElements += 1
|
||||
|
||||
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim()
|
||||
if (text.length < 2) continue
|
||||
|
||||
const cs = view.getComputedStyle(el)
|
||||
const fg = parseCssColorToRgb(cs.color)
|
||||
if (!fg) continue
|
||||
|
||||
const bg = effectiveBackgroundForText(el, doc, view, detectOptions)
|
||||
const ratio = contrastRatio(fg, bg.rgb)
|
||||
const kind = classifyContrastRepairKind(fg, bg.rgb, minRatio)
|
||||
if (bg.isAssumedCanvas && kind === "light-surface") continue
|
||||
if (ratio >= minRatio && kind !== "light-surface") continue
|
||||
|
||||
issues.push({
|
||||
tag: el.tagName,
|
||||
contrastRatio: Math.round(ratio * 100) / 100,
|
||||
color: cs.color,
|
||||
backgroundColor: cs.backgroundColor,
|
||||
effectiveBackground: bg.css,
|
||||
sample: text.slice(0, 80),
|
||||
selectorHint: selectorHint(el),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
issueCount: issues.length,
|
||||
hasExternalStyles: emailPreviewHasExternalStyles(doc),
|
||||
issues,
|
||||
scannedElements,
|
||||
}
|
||||
}
|
||||
|
||||
export type EmailPreviewContrastLogContext = {
|
||||
messageId?: string
|
||||
part?: "body" | "quoted"
|
||||
blockRemoteContent: boolean
|
||||
isDark: boolean
|
||||
senderEmail?: string
|
||||
}
|
||||
|
||||
const DEV_CONTRAST_LOG_PREFIX = "[email-preview:low-contrast]"
|
||||
|
||||
export function isEmailPreviewContrastDebugEnabled(): boolean {
|
||||
if (process.env.NODE_ENV === "development") return true
|
||||
return process.env.NEXT_PUBLIC_EMAIL_PREVIEW_CONTRAST_DEBUG === "1"
|
||||
}
|
||||
|
||||
export function logEmailPreviewContrastIssues(
|
||||
context: EmailPreviewContrastLogContext,
|
||||
doc: Document,
|
||||
options?: DetectEmailPreviewContrastOptions
|
||||
): EmailPreviewContrastReport | null {
|
||||
if (!isEmailPreviewContrastDebugEnabled()) return null
|
||||
|
||||
const report = detectEmailPreviewContrastIssues(doc, {
|
||||
...options,
|
||||
newsletterLightCanvas: context.isDark,
|
||||
assumedCanvasRgb:
|
||||
options?.assumedCanvasRgb ?? (context.isDark ? [255, 255, 255] : [255, 255, 255]),
|
||||
})
|
||||
|
||||
if (report.issueCount === 0) return report
|
||||
|
||||
console.warn(DEV_CONTRAST_LOG_PREFIX, {
|
||||
messageId: context.messageId,
|
||||
part: context.part ?? "body",
|
||||
blockRemoteContent: context.blockRemoteContent,
|
||||
isDark: context.isDark,
|
||||
senderEmail: context.senderEmail,
|
||||
hasExternalStyles: report.hasExternalStyles,
|
||||
scannedElements: report.scannedElements,
|
||||
issueCount: report.issueCount,
|
||||
issues: report.issues,
|
||||
hint:
|
||||
report.hasExternalStyles && !context.blockRemoteContent
|
||||
? "Styles expéditeur après data-ultimail-wrapper — voir fixtures/email-preview/"
|
||||
: "Texte clair Ultimail sur fond clair expéditeur — repair runtime data-ultimail-light-surface",
|
||||
})
|
||||
|
||||
return report
|
||||
}
|
||||
@ -70,8 +70,12 @@ export function emailPreviewSubjectCss(isDark: boolean): string {
|
||||
}
|
||||
|
||||
/** CSS minimal quand le mail conserve sa mise en forme d'origine (contenu distant autorisé). */
|
||||
export function emailPreviewWrapperCss(): string {
|
||||
export function emailPreviewWrapperCss(isDark = false): string {
|
||||
return `
|
||||
html {
|
||||
color-scheme: ${isDark ? "dark" : "light"};
|
||||
background: transparent !important;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@ -137,6 +141,49 @@ export function emailPreviewDarkOverrideCss(): string {
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* En fin de <body> (après les <style> expéditeur) quand le contenu distant est bloqué.
|
||||
* Gagne la cascade sur p { color: #333 !important } dans le corps du mail.
|
||||
*/
|
||||
export function emailPreviewDarkTailOverrideCss(): string {
|
||||
return `
|
||||
body p, body span, body div, body td, body th, body li, body font,
|
||||
body h1, body h2, body h3, body h4, body h5, body h6,
|
||||
body label, body strong, body b, body em, body i, body u,
|
||||
body center, body blockquote, body pre {
|
||||
color: ${DARK_TEXT} !important;
|
||||
}
|
||||
body a, body a * {
|
||||
color: ${DARK_LINK} !important;
|
||||
}
|
||||
[color], font[color] {
|
||||
color: ${DARK_TEXT} !important;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
const LIGHT_SURFACE_TEXT_TAGS =
|
||||
"p,span,div,td,th,li,h1,h2,h3,h4,h5,h6,font,label,strong,b,em,i,u,center,blockquote,pre"
|
||||
|
||||
/** Fin de body + contenu distant : texte clair par défaut, zones claires marquées au runtime. */
|
||||
export function emailPreviewDarkRemoteBodyTailCss(): string {
|
||||
const lightSurfaceChildren = LIGHT_SURFACE_TEXT_TAGS.split(",")
|
||||
.map((t) => `[data-ultimail-light-surface] ${t}`)
|
||||
.join(", ")
|
||||
|
||||
return `
|
||||
${emailPreviewDarkTailOverrideCss()}
|
||||
${lightSurfaceChildren},
|
||||
[data-ultimail-light-surface] {
|
||||
color: ${LIGHT_TEXT} !important;
|
||||
}
|
||||
[data-ultimail-light-surface] a,
|
||||
[data-ultimail-light-surface] a * {
|
||||
color: ${LIGHT_LINK} !important;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
/** Adoucit les fonds très sombres en mode clair (e-mails « dark »). */
|
||||
export function emailPreviewLightOverrideCss(): string {
|
||||
return `
|
||||
@ -197,9 +244,54 @@ function rewriteInlineStyles(html: string, isDark: boolean): string {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeAttrColorValue(raw: string): string {
|
||||
const v = raw.trim()
|
||||
if (/^#?[0-9a-f]{3,8}$/i.test(v)) {
|
||||
return v.startsWith("#") ? v : `#${v}`
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
function isDarkColorAttrValue(raw: string): boolean {
|
||||
const normalized = normalizeAttrColorValue(raw).toLowerCase()
|
||||
if (normalized === "black") return true
|
||||
const hex = normalized.match(/^#([0-9a-f]{3,8})$/i)
|
||||
if (hex) {
|
||||
let h = hex[1]
|
||||
if (h.length === 3) h = h.split("").map((c) => c + c).join("")
|
||||
if (h.length >= 6) {
|
||||
const r = parseInt(h.slice(0, 2), 16)
|
||||
const g = parseInt(h.slice(2, 4), 16)
|
||||
const b = parseInt(h.slice(4, 6), 16)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 120
|
||||
}
|
||||
}
|
||||
const rgb = normalized.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
|
||||
if (rgb) {
|
||||
const r = Number(rgb[1])
|
||||
const g = Number(rgb[2])
|
||||
const b = Number(rgb[3])
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 120
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function rewriteHtmlColorAttributes(html: string, isDark: boolean): string {
|
||||
if (!isDark) return html
|
||||
return html.replace(
|
||||
/\s(color)=(["'])([^"']*)\2/gi,
|
||||
(match, attr: string, quote: string, value: string) => {
|
||||
if (attr.toLowerCase() !== "color") return match
|
||||
if (!isDarkColorAttrValue(value)) return match
|
||||
return ` color=${quote}${DARK_TEXT}${quote}`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function preprocessEmailHtmlForTheme(html: string, isDark: boolean): string {
|
||||
let next = stripHiddenEmailHtml(html)
|
||||
next = rewriteInlineStyles(next, isDark)
|
||||
next = rewriteHtmlColorAttributes(next, isDark)
|
||||
if (isDark) {
|
||||
next = next.replace(LIGHT_BG_STYLE, "background:transparent")
|
||||
next = next.replace(/\sbgcolor=(["'])(?:#?(?:fff(?:fff)?|ffffff|white)|#f[0-9a-f]{5})\1/gi, "")
|
||||
|
||||
40
lib/email-preview-iframe-height.ts
Normal file
40
lib/email-preview-iframe-height.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/** Hauteur utile du corps d’un iframe d’aperçu mail (évite le scrollHeight fantôme). */
|
||||
|
||||
export const EMAIL_PREVIEW_MIN_IFRAME_HEIGHT = 120
|
||||
|
||||
const CONTENT_MEASURE_SELECTORS =
|
||||
"p,div,span,td,th,li,h1,h2,h3,h4,h5,h6,table,img,blockquote,pre,hr,a,section,article,footer,header"
|
||||
|
||||
export function measureEmailPreviewIframeHeight(doc: Document): number {
|
||||
const body = doc.body
|
||||
const view = doc.defaultView
|
||||
if (!body || !view) return EMAIL_PREVIEW_MIN_IFRAME_HEIGHT
|
||||
|
||||
const scrollEstimate = Math.max(
|
||||
body.scrollHeight,
|
||||
body.offsetHeight,
|
||||
doc.documentElement?.scrollHeight ?? 0,
|
||||
doc.documentElement?.offsetHeight ?? 0
|
||||
)
|
||||
|
||||
const bodyTop = body.getBoundingClientRect().top
|
||||
let contentBottom = bodyTop
|
||||
|
||||
for (const el of body.querySelectorAll(CONTENT_MEASURE_SELECTORS)) {
|
||||
const cs = view.getComputedStyle(el)
|
||||
if (cs.display === "none" || cs.visibility === "hidden") continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height < 1 && rect.width < 1) continue
|
||||
contentBottom = Math.max(contentBottom, rect.bottom)
|
||||
}
|
||||
|
||||
const hasVisibleLayout = contentBottom > bodyTop + 8
|
||||
if (hasVisibleLayout) {
|
||||
const boundEstimate = Math.ceil(contentBottom - bodyTop) + 4
|
||||
if (boundEstimate < scrollEstimate * 0.92) {
|
||||
return Math.max(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT, boundEstimate)
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT, scrollEstimate + 2)
|
||||
}
|
||||
@ -1,54 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuthStore } from "@/lib/api/auth-store"
|
||||
|
||||
function attachmentInlineUrl(attachmentId: string): string {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"
|
||||
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base
|
||||
return `${normalizedBase}/mail/attachments/${encodeURIComponent(attachmentId)}/inline`
|
||||
}
|
||||
|
||||
function normalizeCidKey(raw: string): string {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed.toLowerCase().startsWith("cid:")) {
|
||||
return trimmed.slice(4).trim()
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import { registerCidUrlAliases } from "@/lib/mail-cid"
|
||||
|
||||
/** Fetches inline attachment blobs and exposes blob: URLs keyed by cid (with or without cid: prefix). */
|
||||
export function useInlineCidUrls(cidMap: Record<string, string> | undefined) {
|
||||
const accessToken = useAuthStore((s) => s.accessToken)
|
||||
const [urls, setUrls] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !cidMap || Object.keys(cidMap).length === 0) {
|
||||
if (!cidMap || Object.keys(cidMap).length === 0) {
|
||||
setUrls({})
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const objectUrls: string[] = []
|
||||
|
||||
void (async () => {
|
||||
const next: Record<string, string> = {}
|
||||
const blobByAttachmentId = new Map<string, string>()
|
||||
|
||||
for (const [contentId, attachmentId] of Object.entries(cidMap)) {
|
||||
if (!attachmentId) continue
|
||||
try {
|
||||
const res = await fetch(attachmentInlineUrl(attachmentId), {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!res.ok) continue
|
||||
const blob = await res.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
objectUrls.push(blobUrl)
|
||||
const key = normalizeCidKey(contentId)
|
||||
next[key] = blobUrl
|
||||
next[`cid:${key}`] = blobUrl
|
||||
} catch {
|
||||
// skip broken inline parts
|
||||
let blobUrl = blobByAttachmentId.get(attachmentId)
|
||||
if (!blobUrl) {
|
||||
try {
|
||||
const blob = await apiClient.getBlob(
|
||||
`/mail/attachments/${encodeURIComponent(attachmentId)}/inline`
|
||||
)
|
||||
blobUrl = URL.createObjectURL(blob)
|
||||
blobByAttachmentId.set(attachmentId, blobUrl)
|
||||
objectUrls.push(blobUrl)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
registerCidUrlAliases(next, contentId, blobUrl)
|
||||
}
|
||||
if (!cancelled) {
|
||||
setUrls(next)
|
||||
@ -61,7 +48,7 @@ export function useInlineCidUrls(cidMap: Record<string, string> | undefined) {
|
||||
cancelled = true
|
||||
for (const u of objectUrls) URL.revokeObjectURL(u)
|
||||
}
|
||||
}, [accessToken, cidMap])
|
||||
}, [cidMap])
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
57
lib/mail-cid.ts
Normal file
57
lib/mail-cid.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/** Visible placeholder while inline attachment blobs load (not transparent). */
|
||||
export const CID_IMAGE_PLACEHOLDER =
|
||||
"data:image/svg+xml," +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80" viewBox="0 0 120 80">' +
|
||||
'<rect width="120" height="80" fill="#e8eaed"/>' +
|
||||
'<path d="M36 52l14-18 12 14 10-12 22 26H36z" fill="#9aa0a6"/>' +
|
||||
'<circle cx="46" cy="32" r="8" fill="#9aa0a6"/>' +
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
/** Normalize Content-ID / cid: URL references for lookup. */
|
||||
export function normalizeCidKey(raw: string): string {
|
||||
let key = raw.trim()
|
||||
if (key.toLowerCase().startsWith("cid:")) {
|
||||
key = key.slice(4).trim()
|
||||
}
|
||||
key = key.replace(/^<|>$/g, "").trim()
|
||||
return key
|
||||
}
|
||||
|
||||
/** Register blob/API URLs under common cid spellings (case, angle brackets). */
|
||||
export function registerCidUrlAliases(
|
||||
map: Record<string, string>,
|
||||
contentId: string,
|
||||
url: string
|
||||
): void {
|
||||
const key = normalizeCidKey(contentId)
|
||||
if (!key) return
|
||||
map[key] = url
|
||||
map[key.toLowerCase()] = url
|
||||
map[`cid:${key}`] = url
|
||||
map[`cid:${key.toLowerCase()}`] = url
|
||||
}
|
||||
|
||||
export function lookupCidUrl(
|
||||
map: Record<string, string>,
|
||||
cidReference: string
|
||||
): string | undefined {
|
||||
const key = normalizeCidKey(cidReference)
|
||||
if (!key) return undefined
|
||||
return (
|
||||
map[key] ??
|
||||
map[key.toLowerCase()] ??
|
||||
map[`cid:${key}`] ??
|
||||
map[`cid:${key.toLowerCase()}`]
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveCidReference(
|
||||
map: Record<string, string> | undefined,
|
||||
cidReference: string
|
||||
): string {
|
||||
const trimmed = cidReference.trim()
|
||||
if (!trimmed.toLowerCase().startsWith("cid:")) return trimmed
|
||||
return lookupCidUrl(map ?? {}, trimmed) ?? CID_IMAGE_PLACEHOLDER
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
/** Prépare le HTML d'un mail pour affichage dans une iframe sandboxée. */
|
||||
|
||||
import { stripHiddenEmailHtml } from "@/lib/strip-hidden-email-html"
|
||||
import { stripExecutableEmailHtml } from "@/lib/strip-executable-email-html"
|
||||
import { preprocessEmailHtmlForTheme } from "@/lib/email-preview-dark-styles"
|
||||
import { resolveCidReference } from "@/lib/mail-cid"
|
||||
|
||||
const REMOTE_URL = /^https?:\/\//i
|
||||
const PROTOCOL_RELATIVE = /^\/\//
|
||||
@ -246,6 +248,154 @@ function rewriteHeadMarkupUrls(headMarkup: string, baseHref?: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
const REMOTE_CSS_URL =
|
||||
/url\s*\(\s*['"]?(?:https?:\/\/|\/\/|[^'"data:#][^'")]*)/i
|
||||
|
||||
function neutralizeRemoteUrlsInCss(css: string): string {
|
||||
return css.replace(
|
||||
/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/gi,
|
||||
(_match, url: string) => {
|
||||
const trimmed = url.trim()
|
||||
if (
|
||||
trimmed.startsWith("data:") ||
|
||||
trimmed.startsWith("#") ||
|
||||
trimmed.startsWith("cid:")
|
||||
) {
|
||||
return `url(${trimmed})`
|
||||
}
|
||||
if (REMOTE_URL.test(trimmed) || PROTOCOL_RELATIVE.test(trimmed)) {
|
||||
return "url(about:blank)"
|
||||
}
|
||||
return `url(${trimmed})`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Keep inline <style>; drop external stylesheets when remote content is blocked. */
|
||||
export function filterHeadMarkupForBlockedRemote(headMarkup: string): string {
|
||||
if (!headMarkup.trim() || typeof DOMParser === "undefined") {
|
||||
return headMarkup.replace(/<link\b[^>]*>/gi, "")
|
||||
}
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(
|
||||
`<div id="head-root">${headMarkup}</div>`,
|
||||
"text/html"
|
||||
)
|
||||
const root = doc.getElementById("head-root")
|
||||
if (!root) return headMarkup
|
||||
|
||||
for (const link of root.querySelectorAll("link")) {
|
||||
if (isStylesheetLink(link)) link.remove()
|
||||
}
|
||||
for (const style of root.querySelectorAll("style")) {
|
||||
const css = style.textContent ?? ""
|
||||
if (css) style.textContent = neutralizeRemoteUrlsInCss(css)
|
||||
}
|
||||
return root.innerHTML.trim()
|
||||
} catch {
|
||||
return headMarkup.replace(/<link\b[^>]*>/gi, "")
|
||||
}
|
||||
}
|
||||
|
||||
function clearRemoteImageAttrs(img: Element): void {
|
||||
for (const name of [
|
||||
"src",
|
||||
"srcset",
|
||||
"sizes",
|
||||
"background",
|
||||
...LAZY_IMG_SRC_ATTRS,
|
||||
] as const) {
|
||||
img.removeAttribute(name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Newsletter HTML: keep layout/text; block remote images and external CSS. */
|
||||
export function stripRemoteResourcesForPreview(html: string): string {
|
||||
if (!html.trim() || typeof DOMParser === "undefined") return html
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
||||
|
||||
for (const link of doc.querySelectorAll("link")) {
|
||||
if (isStylesheetLink(link)) link.remove()
|
||||
}
|
||||
|
||||
for (const el of doc.querySelectorAll("[style]")) {
|
||||
const style = el.getAttribute("style")
|
||||
if (style && REMOTE_CSS_URL.test(style)) {
|
||||
el.setAttribute("style", neutralizeRemoteUrlsInCss(style))
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of doc.querySelectorAll("[background]")) {
|
||||
const bg = el.getAttribute("background")?.trim() ?? ""
|
||||
if (bg && isRemoteOrRelativeUrl(bg)) el.removeAttribute("background")
|
||||
}
|
||||
|
||||
for (const img of doc.querySelectorAll("img")) {
|
||||
const src = img.getAttribute("src") ?? ""
|
||||
const lazy = firstLazyImageSrc(img)
|
||||
const remote =
|
||||
(src.trim() && isRemoteOrRelativeUrl(src) && !src.startsWith("cid:")) ||
|
||||
Boolean(lazy && isRemoteOrRelativeUrl(lazy))
|
||||
|
||||
if (!remote) continue
|
||||
|
||||
const alt = img.getAttribute("alt")?.trim()
|
||||
clearRemoteImageAttrs(img)
|
||||
img.setAttribute(
|
||||
"style",
|
||||
`${img.getAttribute("style") ?? ""};max-width:100%;height:auto;object-fit:contain;background:#e8eaed;border-radius:4px;`.replace(
|
||||
/^;/,
|
||||
""
|
||||
)
|
||||
)
|
||||
|
||||
if (alt) {
|
||||
const caption = doc.createElement("p")
|
||||
caption.setAttribute(
|
||||
"style",
|
||||
"margin:4px 0 12px;font:13px/1.4 sans-serif;color:#5f6368"
|
||||
)
|
||||
caption.textContent = alt
|
||||
img.insertAdjacentElement("afterend", caption)
|
||||
}
|
||||
}
|
||||
|
||||
return doc.body.innerHTML
|
||||
} catch {
|
||||
return html
|
||||
}
|
||||
}
|
||||
|
||||
export function htmlHasMeaningfulVisibleText(html: string, minChars = 24): boolean {
|
||||
if (!html.trim() || typeof DOMParser === "undefined") {
|
||||
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().length >= minChars
|
||||
}
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
||||
const text = (doc.body?.textContent ?? "").replace(/\s+/g, " ").trim()
|
||||
return text.length >= minChars
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function plainTextFallbackHtml(text: string): string {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return ""
|
||||
const escaped = trimmed
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
return (
|
||||
`<div class="ultimail-plain-fallback" style="margin:0 0 16px;padding:12px 14px;` +
|
||||
`border-radius:8px;background:var(--muted,#f1f3f4);color:inherit">` +
|
||||
`<pre style="white-space:pre-wrap;font:14px/1.5 inherit;margin:0">` +
|
||||
`${escaped}</pre></div>`
|
||||
)
|
||||
}
|
||||
|
||||
function isRemoteOrRelativeUrl(url: string): boolean {
|
||||
const t = url.trim()
|
||||
return (
|
||||
@ -338,96 +488,83 @@ export function activateRemoteResourcesInHtml(
|
||||
}
|
||||
}
|
||||
|
||||
/** Injecte le mail dans le document iframe sans doc.write (préserve les <style>). */
|
||||
export function injectEmailHtmlIntoDocument(
|
||||
doc: Document,
|
||||
/** Full HTML document for iframe srcDoc (scripts stripped; no live DOM injection). */
|
||||
export function buildEmailPreviewSrcdoc(
|
||||
parsed: ParsedEmailHtml,
|
||||
options: {
|
||||
csp: string
|
||||
/** Explicit <base href> from the message, if any. */
|
||||
documentBaseHref?: string
|
||||
/** Inferred origin for relative assets when the message has no <base>. */
|
||||
resolveBaseHref?: string
|
||||
headMarkup: string
|
||||
bodyHtml: string
|
||||
wrapperCss: string
|
||||
plainTextFallback?: string
|
||||
/** CSS injecté en fin de body (après styles expéditeur inline). */
|
||||
bodyTailCss?: string
|
||||
}
|
||||
): void {
|
||||
doc.open()
|
||||
doc.write("<!DOCTYPE html><html><head></head><body></body></html>")
|
||||
doc.close()
|
||||
): string {
|
||||
const baseHref = parsed.documentBaseHref ?? parsed.resolveBaseHref
|
||||
// headMarkup is CSS (<style> blocks) — only strip script-like tags via regex
|
||||
const headMarkup = (parsed.headMarkup ?? "")
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||
.replace(/<script\b[^>]*\/>/gi, "")
|
||||
let bodyHtml = stripExecutableEmailHtml(parsed.bodyHtml)
|
||||
|
||||
const head = doc.head
|
||||
const body = doc.body
|
||||
if (!head || !body) return
|
||||
|
||||
const charset = doc.createElement("meta")
|
||||
charset.setAttribute("charset", "utf-8")
|
||||
head.appendChild(charset)
|
||||
|
||||
const csp = doc.createElement("meta")
|
||||
csp.setAttribute("http-equiv", "Content-Security-Policy")
|
||||
csp.setAttribute("content", options.csp)
|
||||
head.appendChild(csp)
|
||||
|
||||
const baseHref = options.documentBaseHref ?? options.resolveBaseHref
|
||||
if (baseHref) {
|
||||
const base = doc.createElement("base")
|
||||
base.setAttribute("href", baseHref)
|
||||
base.setAttribute("target", "_blank")
|
||||
head.appendChild(base)
|
||||
if (!bodyHtml.trim() && options.plainTextFallback?.trim()) {
|
||||
bodyHtml = plainTextFallbackHtml(options.plainTextFallback)
|
||||
}
|
||||
if (!bodyHtml.trim()) {
|
||||
bodyHtml =
|
||||
'<p style="color:#5f6368;font:14px sans-serif;margin:0">Ce message n\'a pas de contenu affichable.</p>'
|
||||
}
|
||||
|
||||
const wrapper = doc.createElement("style")
|
||||
wrapper.setAttribute("data-ultimail-wrapper", "true")
|
||||
wrapper.textContent = options.wrapperCss
|
||||
head.appendChild(wrapper)
|
||||
const baseTag = baseHref
|
||||
? `<base href="${escapeHtmlAttr(baseHref)}" target="_blank">`
|
||||
: ""
|
||||
|
||||
if (options.headMarkup.trim()) {
|
||||
const tpl = doc.createElement("template")
|
||||
tpl.innerHTML = options.headMarkup
|
||||
head.append(...Array.from(tpl.content.childNodes))
|
||||
}
|
||||
const bodyTailStyle = options.bodyTailCss
|
||||
? `<style data-ultimail-tail="true">${options.bodyTailCss}</style>`
|
||||
: ""
|
||||
|
||||
body.replaceChildren()
|
||||
if (options.bodyHtml.trim()) {
|
||||
const tpl = doc.createElement("template")
|
||||
tpl.innerHTML = options.bodyHtml
|
||||
body.append(...Array.from(tpl.content.childNodes))
|
||||
} else {
|
||||
const empty = doc.createElement("p")
|
||||
empty.textContent = "Ce message n'a pas de contenu affichable."
|
||||
empty.setAttribute(
|
||||
"style",
|
||||
"color:#5f6368;font:14px sans-serif;margin:0;"
|
||||
)
|
||||
body.appendChild(empty)
|
||||
}
|
||||
return (
|
||||
"<!DOCTYPE html><html><head>" +
|
||||
'<meta charset="utf-8">' +
|
||||
`<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttr(options.csp)}">` +
|
||||
baseTag +
|
||||
`<style data-ultimail-wrapper="true">${options.wrapperCss}</style>` +
|
||||
headMarkup +
|
||||
"</head><body>" +
|
||||
bodyHtml +
|
||||
bodyTailStyle +
|
||||
"</body></html>"
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace cid: image references with resolved blob or API URLs. */
|
||||
/** Replace cid: references with blob URLs, or a data placeholder until blobs load. */
|
||||
export function rewriteCidUrlsInHtml(
|
||||
html: string,
|
||||
cidUrlMap: Record<string, string> | undefined
|
||||
): string {
|
||||
if (!html.trim() || !cidUrlMap || Object.keys(cidUrlMap).length === 0) {
|
||||
return html
|
||||
}
|
||||
if (typeof DOMParser === "undefined") return html
|
||||
if (!html.trim() || typeof DOMParser === "undefined") return html
|
||||
if (!html.includes("cid:")) return html
|
||||
|
||||
const resolveCid = (value: string): string => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.toLowerCase().startsWith("cid:")) return trimmed
|
||||
const key = trimmed.slice(4).trim()
|
||||
return cidUrlMap[key] ?? cidUrlMap[trimmed] ?? trimmed
|
||||
}
|
||||
const resolveCid = (value: string): string => resolveCidReference(cidUrlMap, value)
|
||||
|
||||
const CID_ATTRS = [
|
||||
"src",
|
||||
"data-src",
|
||||
"data-original",
|
||||
"data-lazy-src",
|
||||
"background",
|
||||
"poster",
|
||||
] as const
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
||||
for (const img of doc.querySelectorAll("img")) {
|
||||
for (const attr of ["src", "data-src", "data-original", "data-lazy-src"] as const) {
|
||||
for (const img of doc.querySelectorAll("img, video, source, table, td, th, div, span")) {
|
||||
for (const attr of CID_ATTRS) {
|
||||
const value = img.getAttribute(attr)
|
||||
if (value?.toLowerCase().startsWith("cid:")) {
|
||||
img.setAttribute(attr, resolveCid(value))
|
||||
if (value?.toLowerCase().includes("cid:")) {
|
||||
img.setAttribute(
|
||||
attr,
|
||||
value.replace(/cid:[^\s'")]+/gi, (match) => resolveCid(match))
|
||||
)
|
||||
}
|
||||
}
|
||||
const srcset = img.getAttribute("srcset")
|
||||
@ -453,6 +590,17 @@ export function rewriteCidUrlsInHtml(
|
||||
}
|
||||
}
|
||||
|
||||
function maybePrependPlainTextFallback(
|
||||
bodyHtml: string,
|
||||
plainTextFallback?: string
|
||||
): string {
|
||||
const fallback = plainTextFallback?.trim()
|
||||
if (!fallback || htmlHasMeaningfulVisibleText(bodyHtml)) {
|
||||
return bodyHtml
|
||||
}
|
||||
return plainTextFallbackHtml(fallback) + (bodyHtml || "")
|
||||
}
|
||||
|
||||
export function prepareEmailHtmlForIframe(
|
||||
html: string,
|
||||
options: {
|
||||
@ -460,9 +608,14 @@ export function prepareEmailHtmlForIframe(
|
||||
isDark: boolean
|
||||
senderEmail?: string
|
||||
cidUrlMap?: Record<string, string>
|
||||
plainTextFallback?: string
|
||||
}
|
||||
): ParsedEmailHtml {
|
||||
if (!html.trim()) {
|
||||
const fallback = options.plainTextFallback?.trim()
|
||||
if (fallback) {
|
||||
return { headMarkup: "", bodyHtml: plainTextFallbackHtml(fallback) }
|
||||
}
|
||||
return { headMarkup: "", bodyHtml: "" }
|
||||
}
|
||||
|
||||
@ -474,13 +627,21 @@ export function prepareEmailHtmlForIframe(
|
||||
|
||||
if (options.blockRemoteContent) {
|
||||
const parsed = parseEmailHtmlForIframe(html, resolveBaseHref)
|
||||
const bodyHtml = rewriteCidUrlsInHtml(
|
||||
preprocessEmailHtmlForTheme(parsed.bodyHtml || html, options.isDark),
|
||||
let bodyHtml = stripHiddenEmailHtml(parsed.bodyHtml || html)
|
||||
bodyHtml = stripRemoteResourcesForPreview(bodyHtml)
|
||||
bodyHtml = rewriteCidUrlsInHtml(
|
||||
preprocessEmailHtmlForTheme(bodyHtml, options.isDark),
|
||||
options.cidUrlMap
|
||||
)
|
||||
const headMarkup = rewriteCidUrlsInHtml(
|
||||
filterHeadMarkupForBlockedRemote(parsed.headMarkup),
|
||||
options.cidUrlMap
|
||||
)
|
||||
bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback)
|
||||
return {
|
||||
headMarkup: "",
|
||||
headMarkup,
|
||||
bodyHtml,
|
||||
resolveBaseHref,
|
||||
}
|
||||
}
|
||||
|
||||
@ -490,8 +651,12 @@ export function prepareEmailHtmlForIframe(
|
||||
baseHref: resolveBaseHref,
|
||||
})
|
||||
bodyHtml = rewriteCidUrlsInHtml(bodyHtml, options.cidUrlMap)
|
||||
bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback)
|
||||
|
||||
const headMarkup = rewriteHeadMarkupUrls(parsed.headMarkup, resolveBaseHref)
|
||||
const headMarkup = rewriteCidUrlsInHtml(
|
||||
rewriteHeadMarkupUrls(parsed.headMarkup, resolveBaseHref),
|
||||
options.cidUrlMap
|
||||
)
|
||||
|
||||
return {
|
||||
headMarkup,
|
||||
|
||||
@ -16,12 +16,64 @@ function looksLikeRawMime(s: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function decodeBase64Part(encoded: string): string {
|
||||
function charsetFromContentType(contentType: string): string {
|
||||
const match = contentType.match(/charset\s*=\s*"?([^";\s]+)"?/i)
|
||||
return match?.[1]?.trim().toLowerCase() ?? ""
|
||||
}
|
||||
|
||||
function isUtf8Charset(charset: string): boolean {
|
||||
return (
|
||||
charset === "" ||
|
||||
charset === "utf-8" ||
|
||||
charset === "utf8" ||
|
||||
charset === "unicode-1-1-utf-8"
|
||||
)
|
||||
}
|
||||
|
||||
function decodeBytesToUtf8(bytes: Uint8Array, charset = ""): string {
|
||||
if (bytes.length === 0) return ""
|
||||
const normalized = charset.toLowerCase()
|
||||
if (normalized && !isUtf8Charset(normalized)) {
|
||||
try {
|
||||
return new TextDecoder(normalized).decode(bytes)
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
||||
} catch {
|
||||
try {
|
||||
return new TextDecoder("windows-1252").decode(bytes)
|
||||
} catch {
|
||||
try {
|
||||
return new TextDecoder("iso-8859-1").decode(bytes)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function repairLegacyCharsetString(s: string): string {
|
||||
if (!s) return s
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff)
|
||||
)
|
||||
return s
|
||||
} catch {
|
||||
const bytes = Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff)
|
||||
return decodeBytesToUtf8(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Part(encoded: string, charset = ""): string {
|
||||
const clean = encoded.replace(/[\r\n\t ]/g, "")
|
||||
try {
|
||||
if (typeof atob !== "undefined") {
|
||||
const bytes = Uint8Array.from(atob(clean), (c) => c.charCodeAt(0))
|
||||
return new TextDecoder("utf-8").decode(bytes)
|
||||
return decodeBytesToUtf8(bytes, charset)
|
||||
}
|
||||
} catch {
|
||||
return ""
|
||||
@ -60,14 +112,20 @@ function parseEmbeddedMime(raw: string): { text: string; html: string } | null {
|
||||
const headers = trimmed.slice(0, headerEnd)
|
||||
const body = trimmed.slice(headerEnd).replace(/^[\r\n]+/, "")
|
||||
|
||||
const typeMatch = headers.match(/Content-Type:\s*([^\r\n;]+)/i)
|
||||
const mediaType = typeMatch?.[1]?.trim().toLowerCase() ?? ""
|
||||
const typeHeader = headers.match(/Content-Type:\s*([^\r\n]+)/i)?.[1] ?? ""
|
||||
const mediaType = typeHeader.split(";")[0]?.trim().toLowerCase() ?? ""
|
||||
const charset = charsetFromContentType(typeHeader)
|
||||
const encMatch = headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)
|
||||
const encoding = encMatch?.[1]?.trim().toLowerCase() ?? ""
|
||||
|
||||
let decoded = body.trim()
|
||||
if (encoding === "base64") {
|
||||
decoded = decodeBase64Part(decoded)
|
||||
decoded = decodeBase64Part(decoded, charset)
|
||||
} else if (encoding === "quoted-printable" || looksLikeQuotedPrintable(decoded)) {
|
||||
decoded = decodeQuotedPrintableIfNeeded(decoded)
|
||||
} else {
|
||||
const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0) & 0xff)
|
||||
decoded = decodeBytesToUtf8(bytes, charset)
|
||||
}
|
||||
|
||||
if (mediaType === "text/plain" && !text) text = decoded
|
||||
@ -117,7 +175,7 @@ function decodeQuotedPrintableIfNeeded(s: string): string {
|
||||
bytes.push(ch.charCodeAt(0))
|
||||
i += 1
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(new Uint8Array(bytes))
|
||||
return decodeBytesToUtf8(new Uint8Array(bytes))
|
||||
} catch {
|
||||
return s
|
||||
}
|
||||
@ -134,8 +192,8 @@ export function repairMimeBodies(
|
||||
bodyText?: string,
|
||||
bodyHtml?: string
|
||||
): { bodyText?: string; bodyHtml?: string } {
|
||||
let text = bodyText?.trim() ?? ""
|
||||
let html = bodyHtml?.trim() ?? ""
|
||||
let text = repairLegacyCharsetString(bodyText?.trim() ?? "")
|
||||
let html = repairLegacyCharsetString(bodyHtml?.trim() ?? "")
|
||||
|
||||
text = decodeQuotedPrintableIfNeeded(text)
|
||||
html = decodeQuotedPrintableIfNeeded(html)
|
||||
@ -155,16 +213,92 @@ export function repairMimeBodies(
|
||||
}
|
||||
}
|
||||
|
||||
/** List/search preview stored as undecoded base64. */
|
||||
function looksLikeCssSnippet(s: string): boolean {
|
||||
const lower = s.toLowerCase()
|
||||
return (
|
||||
lower.includes(":root") ||
|
||||
lower.includes("color-scheme:") ||
|
||||
lower.includes("@media") ||
|
||||
(s.includes("{") && s.includes("}") && s.split(";").length >= 3) ||
|
||||
/^\s*\/\*/.test(s)
|
||||
)
|
||||
}
|
||||
|
||||
function isMostlySeparatorLine(s: string): boolean {
|
||||
if (s.length < 8) return false
|
||||
const sep = (s.match(/[-_*=·—]/g) ?? []).length
|
||||
return sep / s.length >= 0.6
|
||||
}
|
||||
|
||||
function isSnippetBoilerplate(s: string): boolean {
|
||||
const t = stripHtmlTagsForSnippet(s.trim())
|
||||
if (!t || t.length < 4) return true
|
||||
if (looksLikeCssSnippet(t) || isMostlySeparatorLine(t)) return true
|
||||
if (/<[^>]+>/.test(s)) return true
|
||||
const lower = t.toLowerCase()
|
||||
const phrases = [
|
||||
"afficher dans le navigateur",
|
||||
"view in browser",
|
||||
"si vous ne visualisez pas",
|
||||
"cliquer ici",
|
||||
]
|
||||
if (phrases.some((p) => lower.includes(p)) && t.length < 160) return true
|
||||
const letters = (t.match(/\p{L}|\p{N}/gu) ?? []).length
|
||||
return letters / [...t].length < 0.35
|
||||
}
|
||||
|
||||
function pickBestSnippetLine(lines: string[]): string {
|
||||
let best = ""
|
||||
let bestScore = -1
|
||||
for (const line of lines) {
|
||||
const t = line.trim()
|
||||
if (!t || isSnippetBoilerplate(t)) continue
|
||||
const letters = (t.match(/\p{L}/gu) ?? []).length
|
||||
if (letters < 8) continue
|
||||
let score = letters * 4
|
||||
if (t.length > 40 && t.length < 280) score += 40
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = t
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function stripHtmlTagsForSnippet(s: string): string {
|
||||
const stripped = s
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/&#(\d+);/g, (_, code) =>
|
||||
String.fromCodePoint(Number.parseInt(code, 10))
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
return stripped
|
||||
}
|
||||
|
||||
function polishSnippetPreview(snippet: string): string {
|
||||
const cleaned = stripHtmlTagsForSnippet(snippet)
|
||||
const lines = cleaned.replace(/\r\n/g, "\n").split("\n")
|
||||
const best = pickBestSnippetLine(lines)
|
||||
if (best) return best.length > 200 ? best.slice(0, 200) : best
|
||||
if (isSnippetBoilerplate(cleaned)) return ""
|
||||
return cleaned.length > 200 ? cleaned.slice(0, 200) : cleaned
|
||||
}
|
||||
|
||||
/** List/search preview stored as undecoded base64 or marketing boilerplate. */
|
||||
export function repairSnippet(snippet?: string): string | undefined {
|
||||
if (!snippet?.trim()) return snippet
|
||||
const trimmed = snippet.trim()
|
||||
const qp = decodeQuotedPrintableIfNeeded(trimmed)
|
||||
const decoded = decodeBareBase64IfNeeded(qp)
|
||||
if (decoded !== trimmed) {
|
||||
return stripInvisibleTextRuns(
|
||||
decoded.length > 200 ? decoded.slice(0, 200) : decoded
|
||||
)
|
||||
}
|
||||
return stripInvisibleTextRuns(snippet)
|
||||
const raw = decoded !== trimmed ? decoded : snippet
|
||||
const cleaned = stripInvisibleTextRuns(raw)
|
||||
const polished = polishSnippetPreview(cleaned)
|
||||
return polished || undefined
|
||||
}
|
||||
|
||||
43
lib/mail-plain-text-html.ts
Normal file
43
lib/mail-plain-text-html.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/** Render plain-text-only messages with clickable links and readable line breaks. */
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
function linkifyEscapedText(text: string): string {
|
||||
let out = ""
|
||||
let last = 0
|
||||
for (const match of text.matchAll(URL_RE)) {
|
||||
const index = match.index ?? 0
|
||||
out += text.slice(last, index)
|
||||
const url = match[0]
|
||||
out += `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
|
||||
last = index + url.length
|
||||
}
|
||||
out += text.slice(last)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Insert breaks before common transactional-mail markers when the body is one long line. */
|
||||
function softenPlainTextWall(text: string): string {
|
||||
if (text.includes("\n")) return text
|
||||
return text
|
||||
.replace(/,\s*(Cher(?:e)?\s)/gi, ",\n\n$1")
|
||||
.replace(/,\s*(Le \d{2}\/\d{2}\/\d{4})/g, ",\n\n$1")
|
||||
.replace(/\.\s+(Nous restons)/g, ".\n\n$1")
|
||||
.replace(/\.\s+(L['’]équipe)/g, ".\n\n$1")
|
||||
.replace(/\.\s+(Pour obtenir)/g, ".\n\n$1")
|
||||
.replace(/\.\s+(Vous pouvez)/g, ".\n\n$1")
|
||||
}
|
||||
|
||||
export function plainTextToDisplayHtml(text: string): string {
|
||||
const normalized = softenPlainTextWall(text.trim())
|
||||
const escaped = escapeHtml(normalized)
|
||||
const linked = linkifyEscapedText(escaped)
|
||||
return `<pre style="white-space:pre-wrap;font-family:inherit;margin:0;line-height:1.5;">${linked}</pre>`
|
||||
}
|
||||
@ -3,6 +3,10 @@
|
||||
const WROTE_LINE =
|
||||
/(^|\n)\s*(Le\s.+a\sécrit\s*:|On\s.+wrote:|Am\s.+schrieb|El\s.+escribió|Il\s.+ha\s+scritto|.+a écrit\s*:)/i
|
||||
|
||||
/** Inline reply header after whitespace collapse (no leading newline). */
|
||||
const WROTE_INLINE =
|
||||
/(?:^|[\s.])(Le\s.+?a\s+écrit\s*:|On\s+.+?\bwrote:|Am\s+.+?\bschrieb|El\s+.+?\bescribió|Il\s+.+?\bha\s+scritto|.+?\ba\s+écrit\s*:)/i
|
||||
|
||||
const QUOTE_SELECTOR = [
|
||||
".gmail_quote",
|
||||
".gmail_extra",
|
||||
@ -104,13 +108,37 @@ function splitHtmlRoot(root: Element): { mainHtml: string; quotedHtml: string |
|
||||
return splitAtQuote(root, quoteHit)
|
||||
}
|
||||
|
||||
function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } {
|
||||
/** Restore line-based quotes after preview repair collapsed newlines to spaces. */
|
||||
function normalizeCollapsedPlainQuotes(text: string): string {
|
||||
let s = text.replace(/ > /g, "\n> ")
|
||||
const wroteIdx = findInlineWroteIndex(s)
|
||||
if (wroteIdx > 0) {
|
||||
const before = s.slice(0, wroteIdx).trimEnd()
|
||||
const after = s.slice(wroteIdx).trimStart()
|
||||
if (before && after) s = `${before}\n\n${after}`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function findInlineWroteIndex(text: string): number {
|
||||
const m = WROTE_INLINE.exec(text)
|
||||
if (!m || m.index === undefined) return -1
|
||||
let idx = m.index
|
||||
const lead = m[0]![0]
|
||||
if (lead && /\s/.test(lead)) idx += 1
|
||||
return idx
|
||||
}
|
||||
|
||||
function splitPlainPreBody(html: string): { mainHtml: string; quotedHtml: string | null } {
|
||||
const preMatch = html.match(/^<pre[^>]*>([\s\S]*)<\/pre>$/i)
|
||||
const text = preMatch ? preMatch[1]! : html
|
||||
const decoded = text
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
if (!preMatch) return { mainHtml: html, quotedHtml: null }
|
||||
const text = preMatch[1]!
|
||||
const decoded = normalizeCollapsedPlainQuotes(
|
||||
text
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
)
|
||||
const lines = decoded.split("\n")
|
||||
let splitAt = -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
@ -124,7 +152,21 @@ function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: strin
|
||||
break
|
||||
}
|
||||
}
|
||||
if (splitAt < 0) return { mainHtml: html, quotedHtml: null }
|
||||
if (splitAt < 0) {
|
||||
const inlineIdx = findInlineWroteIndex(decoded)
|
||||
if (inlineIdx < 0) return { mainHtml: html, quotedHtml: null }
|
||||
const main = decoded.slice(0, inlineIdx).trimEnd()
|
||||
const quoted = decoded.slice(inlineIdx).trimStart()
|
||||
if (!quoted) return { mainHtml: html, quotedHtml: null }
|
||||
const escape = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
const wrap = (body: string) =>
|
||||
`<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${body}</pre>`
|
||||
return {
|
||||
mainHtml: wrap(escape(main)),
|
||||
quotedHtml: wrap(escape(quoted)),
|
||||
}
|
||||
}
|
||||
|
||||
const mainLines = lines.slice(0, splitAt)
|
||||
const quotedLines = lines.slice(splitAt)
|
||||
@ -150,13 +192,12 @@ export function splitQuotedHtml(html: string): {
|
||||
const trimmed = html.trim()
|
||||
if (!trimmed) return { mainHtml: html, quotedHtml: null }
|
||||
|
||||
if (typeof DOMParser === "undefined") {
|
||||
return { mainHtml: html, quotedHtml: null }
|
||||
if (/^<pre\b/i.test(trimmed) || /<pre\b/i.test(trimmed)) {
|
||||
return splitPlainPreBody(trimmed)
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("<pre")) {
|
||||
const plain = splitPlainTextBody(trimmed)
|
||||
if (plain.quotedHtml) return plain
|
||||
if (typeof DOMParser === "undefined") {
|
||||
return { mainHtml: html, quotedHtml: null }
|
||||
}
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
@ -169,6 +210,10 @@ export function splitQuotedHtml(html: string): {
|
||||
return splitHtmlRoot(root)
|
||||
}
|
||||
|
||||
export function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } {
|
||||
return splitPlainPreBody(html)
|
||||
}
|
||||
|
||||
export function hasQuotedContent(html: string): boolean {
|
||||
return splitQuotedHtml(html).quotedHtml !== null
|
||||
}
|
||||
|
||||
@ -34,11 +34,13 @@ export function htmlHasRemoteContent(html: string): boolean {
|
||||
}
|
||||
|
||||
export function buildEmailPreviewCsp(blockRemoteContent: boolean): string {
|
||||
const scriptNone = "script-src 'none'"
|
||||
if (blockRemoteContent) {
|
||||
return "default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:;"
|
||||
return `default-src 'none'; ${scriptNone}; style-src 'unsafe-inline'; img-src data: blob:;`
|
||||
}
|
||||
return [
|
||||
"default-src 'none'",
|
||||
scriptNone,
|
||||
"style-src 'unsafe-inline' https: http:",
|
||||
"style-src-elem 'unsafe-inline' https: http:",
|
||||
// url() in style="" (Gmail signatures often use background-image on table cells)
|
||||
|
||||
67
lib/strip-executable-email-html.ts
Normal file
67
lib/strip-executable-email-html.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Remove scripts and other executable markup before displaying mail HTML.
|
||||
* DOM-only (no broad regex) so newsletter markup is not corrupted.
|
||||
*/
|
||||
|
||||
const EXECUTABLE_SELECTOR =
|
||||
"script, iframe, object, embed, frame, frameset, link[rel='import'], link[as='script']"
|
||||
|
||||
const EVENT_HANDLER_ATTR = /^on/i
|
||||
const URI_ATTRS = /^(?:href|src|srcset|data|action|formaction|xlink:href)$/i
|
||||
const JAVASCRIPT_URI = /^javascript:/i
|
||||
|
||||
function stripExecutableFromDocument(doc: Document): void {
|
||||
doc.querySelectorAll(EXECUTABLE_SELECTOR).forEach((el) => el.remove())
|
||||
doc.querySelectorAll("svg script").forEach((el) => el.remove())
|
||||
|
||||
// <noscript> content is visible when scripts are blocked — unwrap it
|
||||
for (const ns of doc.querySelectorAll("noscript")) {
|
||||
const fragment = doc.createDocumentFragment()
|
||||
const tpl = doc.createElement("template")
|
||||
tpl.innerHTML = ns.textContent ?? ""
|
||||
fragment.append(...Array.from(tpl.content.childNodes))
|
||||
ns.replaceWith(fragment)
|
||||
}
|
||||
|
||||
for (const el of doc.querySelectorAll("*")) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
if (EVENT_HANDLER_ATTR.test(attr.name)) {
|
||||
el.removeAttribute(attr.name)
|
||||
continue
|
||||
}
|
||||
if (URI_ATTRS.test(attr.name) && JAVASCRIPT_URI.test(attr.value.trim())) {
|
||||
el.removeAttribute(attr.name)
|
||||
}
|
||||
}
|
||||
const style = el.getAttribute("style")
|
||||
if (style && JAVASCRIPT_URI.test(style)) {
|
||||
el.setAttribute(
|
||||
"style",
|
||||
style.replace(/javascript:[^;'")]+/gi, "about:blank")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const meta of doc.querySelectorAll('meta[http-equiv]')) {
|
||||
if (meta.getAttribute("http-equiv")?.toLowerCase() !== "refresh") continue
|
||||
const content = meta.getAttribute("content") ?? ""
|
||||
if (JAVASCRIPT_URI.test(content)) meta.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip scripts, nested frames, and inline handlers from an HTML fragment. */
|
||||
export function stripExecutableEmailHtml(html: string): string {
|
||||
if (!html || !html.trim()) return html
|
||||
|
||||
if (typeof DOMParser === "undefined") {
|
||||
return html
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
||||
stripExecutableFromDocument(doc)
|
||||
return doc.body?.innerHTML ?? html
|
||||
} catch {
|
||||
return html
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,21 @@
|
||||
/** Remove marketing preheader blocks before display (bluemonday strips display:none). */
|
||||
|
||||
const HIDDEN_STYLE =
|
||||
/display\s*:\s*none|mso-hide\s*:\s*all|max-height\s*:\s*0|opacity\s*:\s*0|font-size\s*:\s*0|visibility\s*:\s*hidden/i
|
||||
/display\s*:\s*none|mso-hide\s*:\s*all|max-height\s*:\s*0|opacity\s*:\s*0|visibility\s*:\s*hidden/i
|
||||
|
||||
const HIDDEN_CLASS = /mcnPreviewText|preheader|preview-text/i
|
||||
|
||||
const INVISIBLE_RUN = /[\u034f\u200b-\u200f\ufeff\u00a0]{4,}/g
|
||||
|
||||
function hasSignificantChildElements(el: Element): boolean {
|
||||
for (const child of el.children) {
|
||||
const tag = child.tagName.toLowerCase()
|
||||
if (tag === "br" || tag === "wbr" || tag === "hr") continue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldStripElement(el: Element): boolean {
|
||||
if (el.hasAttribute("hidden")) return true
|
||||
if (el.getAttribute("aria-hidden") === "true") return true
|
||||
@ -15,10 +24,16 @@ function shouldStripElement(el: Element): boolean {
|
||||
const style = el.getAttribute("style") ?? ""
|
||||
if (!style) return false
|
||||
const compact = style.replace(/\s+/g, "")
|
||||
return (
|
||||
if (
|
||||
HIDDEN_STYLE.test(style) ||
|
||||
(compact.includes("overflow:hidden") && /max-height\s*:\s*0/.test(style))
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (/font-size\s*:\s*0/i.test(style) && !hasSignificantChildElements(el)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function stripHiddenEmailHtml(html: string): string {
|
||||
@ -38,6 +53,13 @@ export function stripHiddenEmailHtml(html: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip invisible padding; keep line breaks (do not collapse plain-text quotes). */
|
||||
export function stripInvisibleTextRuns(text: string): string {
|
||||
return text.replace(INVISIBLE_RUN, " ").replace(/\s+/g, " ").trim()
|
||||
return text
|
||||
.replace(INVISIBLE_RUN, " ")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[^\S\n]+/g, " ").trimEnd())
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"test:email-preview-contrast": "node --experimental-strip-types --test lib/email-preview-contrast.test.ts",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user