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_ISSUER=http://localhost/auth/application/o/ulti/
|
||||||
NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
|
NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
|
||||||
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
|
# 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
|
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
|
||||||
OIDC_CLIENT_SECRET=changeme
|
OIDC_CLIENT_SECRET=changeme
|
||||||
|
|||||||
@ -934,6 +934,51 @@ html.dark .ultimail-app iframe[title='Sujet du message'] {
|
|||||||
color-scheme: dark;
|
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) ── */
|
/* ── Dark : panneau Contacts (formulaires) ── */
|
||||||
html.dark :where([data-contacts-panel] .bg-white) {
|
html.dark :where([data-contacts-panel] .bg-white) {
|
||||||
background-color: var(--mail-surface) !important;
|
background-color: var(--mail-surface) !important;
|
||||||
|
|||||||
@ -490,7 +490,7 @@ export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
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]" />
|
<HardDrive className="h-[18px] w-[18px]" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -167,6 +167,7 @@ export function ContactHoverCard({
|
|||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent
|
<HoverCardContent
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
|
data-contact-hover-card
|
||||||
side={side}
|
side={side}
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
|
|||||||
@ -143,6 +143,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
const [starred, setStarred] = useState(false)
|
const [starred, setStarred] = useState(false)
|
||||||
const [nameExpanded, setNameExpanded] = useState(false)
|
const [nameExpanded, setNameExpanded] = useState(false)
|
||||||
const [companyExpanded, setCompanyExpanded] = useState(false)
|
const [companyExpanded, setCompanyExpanded] = useState(false)
|
||||||
|
const hydratedEditIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const existingContact =
|
const existingContact =
|
||||||
mode === "edit" ? contacts.find((c) => c.id === contactId) : null
|
mode === "edit" ? contacts.find((c) => c.id === contactId) : null
|
||||||
@ -221,45 +222,52 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
}, [mode, createDraft, reset, clearCreateDraft])
|
}, [mode, createDraft, reset, clearCreateDraft])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingContact) {
|
if (mode !== "edit" || !contactId) {
|
||||||
|
hydratedEditIdRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hydratedEditIdRef.current === contactId) return
|
||||||
|
const contact = contacts.find((c) => c.id === contactId)
|
||||||
|
if (!contact) return
|
||||||
|
hydratedEditIdRef.current = contactId
|
||||||
|
|
||||||
const hasExtendedName = !!(
|
const hasExtendedName = !!(
|
||||||
existingContact.namePrefix ||
|
contact.namePrefix ||
|
||||||
existingContact.middleName ||
|
contact.middleName ||
|
||||||
existingContact.nameSuffix ||
|
contact.nameSuffix ||
|
||||||
existingContact.phoneticFirstName ||
|
contact.phoneticFirstName ||
|
||||||
existingContact.phoneticLastName
|
contact.phoneticLastName
|
||||||
)
|
)
|
||||||
if (hasExtendedName) setNameExpanded(true)
|
if (hasExtendedName) setNameExpanded(true)
|
||||||
if (existingContact.department) setCompanyExpanded(true)
|
if (contact.department) setCompanyExpanded(true)
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
namePrefix: existingContact.namePrefix ?? "",
|
namePrefix: contact.namePrefix ?? "",
|
||||||
firstName: existingContact.firstName,
|
firstName: contact.firstName,
|
||||||
middleName: existingContact.middleName ?? "",
|
middleName: contact.middleName ?? "",
|
||||||
lastName: existingContact.lastName,
|
lastName: contact.lastName,
|
||||||
nameSuffix: existingContact.nameSuffix ?? "",
|
nameSuffix: contact.nameSuffix ?? "",
|
||||||
phoneticFirstName: existingContact.phoneticFirstName ?? "",
|
phoneticFirstName: contact.phoneticFirstName ?? "",
|
||||||
phoneticLastName: existingContact.phoneticLastName ?? "",
|
phoneticLastName: contact.phoneticLastName ?? "",
|
||||||
company: existingContact.company ?? "",
|
company: contact.company ?? "",
|
||||||
department: existingContact.department ?? "",
|
department: contact.department ?? "",
|
||||||
jobTitle: existingContact.jobTitle ?? "",
|
jobTitle: contact.jobTitle ?? "",
|
||||||
emails: existingContact.emails.length
|
emails: contact.emails.length
|
||||||
? existingContact.emails
|
? contact.emails
|
||||||
: [{ value: "", label: "Domicile" }],
|
: [{ value: "", label: "Domicile" }],
|
||||||
phones: existingContact.phones.length
|
phones: contact.phones.length
|
||||||
? existingContact.phones
|
? contact.phones
|
||||||
: [{ value: "", label: "Mobile" }],
|
: [{ value: "", label: "Mobile" }],
|
||||||
addresses: existingContact.addresses ?? [],
|
addresses: contact.addresses ?? [],
|
||||||
birthday: existingContact.birthday ?? {
|
birthday: contact.birthday ?? {
|
||||||
day: undefined,
|
day: undefined,
|
||||||
month: undefined,
|
month: undefined,
|
||||||
year: undefined,
|
year: undefined,
|
||||||
},
|
},
|
||||||
notes: existingContact.notes ?? "",
|
notes: contact.notes ?? "",
|
||||||
labels: existingContact.labels ?? [],
|
labels: contact.labels ?? [],
|
||||||
})
|
})
|
||||||
}
|
}, [mode, contactId, contacts, reset])
|
||||||
}, [existingContact, reset])
|
|
||||||
|
|
||||||
const firstName = watch("firstName")
|
const firstName = watch("firstName")
|
||||||
const lastName = watch("lastName")
|
const lastName = watch("lastName")
|
||||||
@ -775,8 +783,8 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
|
|||||||
const innerRef = useRef<HTMLInputElement | null>(null)
|
const innerRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (innerRef.current && innerRef.current.value) setFilled(true)
|
if (innerRef.current?.value) setFilled(true)
|
||||||
})
|
}, [defaultValue])
|
||||||
|
|
||||||
const setRefs = useCallback(
|
const setRefs = useCallback(
|
||||||
(node: HTMLInputElement | null) => {
|
(node: HTMLInputElement | null) => {
|
||||||
@ -842,8 +850,8 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
|
|||||||
const innerRef = useRef<HTMLTextAreaElement | null>(null)
|
const innerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (innerRef.current && innerRef.current.value) setFilled(true)
|
if (innerRef.current?.value) setFilled(true)
|
||||||
})
|
}, [])
|
||||||
|
|
||||||
const setRefs = useCallback(
|
const setRefs = useCallback(
|
||||||
(node: HTMLTextAreaElement | null) => {
|
(node: HTMLTextAreaElement | null) => {
|
||||||
|
|||||||
@ -65,7 +65,8 @@ import {
|
|||||||
} from "@/components/gmail/email-list/email-list-helpers"
|
} from "@/components/gmail/email-list/email-list-helpers"
|
||||||
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
|
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
|
||||||
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
|
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 { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
|
||||||
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
|
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
|
||||||
import { cleanSenderName } from "@/lib/sender-display"
|
import { cleanSenderName } from "@/lib/sender-display"
|
||||||
@ -621,6 +622,16 @@ export function useEmailListData({
|
|||||||
|
|
||||||
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
|
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 listRowExtras = useMemo(() => {
|
||||||
const invitationById = new Map<
|
const invitationById = new Map<
|
||||||
string,
|
string,
|
||||||
@ -638,7 +649,15 @@ export function useEmailListData({
|
|||||||
|
|
||||||
for (const e of listEmails) {
|
for (const e of listEmails) {
|
||||||
invitationById.set(e.id, resolveParsedCalendarInvitation(e))
|
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) {
|
if (showCategoryTabIcons) {
|
||||||
const tabs = resolveEmailInboxCategoryTabs(
|
const tabs = resolveEmailInboxCategoryTabs(
|
||||||
e,
|
e,
|
||||||
@ -653,6 +672,8 @@ export function useEmailListData({
|
|||||||
return { invitationById, attachmentsById, categoryTabsById }
|
return { invitationById, attachmentsById, categoryTabsById }
|
||||||
}, [
|
}, [
|
||||||
listEmails,
|
listEmails,
|
||||||
|
fetchedAttachmentsById,
|
||||||
|
attachmentFetchStateById,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
inboxTab,
|
inboxTab,
|
||||||
folderFilterCtx,
|
folderFilterCtx,
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import {
|
|||||||
senderInitial,
|
senderInitial,
|
||||||
} from "@/lib/sender-display"
|
} from "@/lib/sender-display"
|
||||||
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
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 type { Email, EmailAttachment } from "@/lib/email-data"
|
||||||
import {
|
import {
|
||||||
mailFlagIsRead,
|
mailFlagIsRead,
|
||||||
@ -65,6 +67,7 @@ import {
|
|||||||
SpamWhyBanner,
|
SpamWhyBanner,
|
||||||
ThreadPriorMessage,
|
ThreadPriorMessage,
|
||||||
formatApiMessageBody,
|
formatApiMessageBody,
|
||||||
|
plainTextBodyFallback,
|
||||||
} from "@/components/gmail/email-view/email-view-messages"
|
} from "@/components/gmail/email-view/email-view-messages"
|
||||||
|
|
||||||
function apiToLegacyEmail(
|
function apiToLegacyEmail(
|
||||||
@ -148,6 +151,10 @@ export function EmailView({
|
|||||||
),
|
),
|
||||||
[fullMessage, fullMessagePending, email.snippet]
|
[fullMessage, fullMessagePending, email.snippet]
|
||||||
)
|
)
|
||||||
|
const plainTextFallback = useMemo(
|
||||||
|
() => plainTextBodyFallback(fullMessage),
|
||||||
|
[fullMessage]
|
||||||
|
)
|
||||||
|
|
||||||
const [showFullThread, setShowFullThread] = useState(false)
|
const [showFullThread, setShowFullThread] = useState(false)
|
||||||
const [mainDetailsOpen, setMainDetailsOpen] = useState(false)
|
const [mainDetailsOpen, setMainDetailsOpen] = useState(false)
|
||||||
@ -169,14 +176,11 @@ export function EmailView({
|
|||||||
const showFullThreadList = !isSingleMessageView || showFullThread
|
const showFullThreadList = !isSingleMessageView || showFullThread
|
||||||
const messagesBefore = showFullThreadList ? threadBefore : []
|
const messagesBefore = showFullThreadList ? threadBefore : []
|
||||||
const messagesAfter = showFullThreadList ? threadAfter : []
|
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 [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||||
const isThreadMessageExpanded = useCallback(
|
const isThreadMessageExpanded = useCallback(
|
||||||
(msgId: string) => expandAllThreadMessages || expandedIds.has(msgId),
|
(msgId: string) => expandedIds.has(msgId),
|
||||||
[expandAllThreadMessages, expandedIds]
|
[expandedIds]
|
||||||
)
|
)
|
||||||
const toggleExpanded = (msgId: string) => {
|
const toggleExpanded = (msgId: string) => {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
@ -216,11 +220,18 @@ export function EmailView({
|
|||||||
[email, fullMessage, threadMessages]
|
[email, fullMessage, threadMessages]
|
||||||
)
|
)
|
||||||
|
|
||||||
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
|
const { data: fetchedAttachments } = useMessageAttachments(
|
||||||
if (email.has_attachments)
|
email.id,
|
||||||
return [{ name: "Pièce jointe", kind: "other" }]
|
email.has_attachments
|
||||||
return []
|
)
|
||||||
}, [email.has_attachments])
|
const mainMessageAttachments = useMemo(
|
||||||
|
(): EmailAttachment[] =>
|
||||||
|
attachmentsForEmailList({
|
||||||
|
hasAttachment: email.has_attachments,
|
||||||
|
attachments: fetchedAttachments,
|
||||||
|
}),
|
||||||
|
[email.has_attachments, fetchedAttachments]
|
||||||
|
)
|
||||||
|
|
||||||
const { composeWindows } = useComposeWindows()
|
const { composeWindows } = useComposeWindows()
|
||||||
const { savedThreadReplyDrafts } = useComposeDrafts()
|
const { savedThreadReplyDrafts } = useComposeDrafts()
|
||||||
@ -393,6 +404,7 @@ export function EmailView({
|
|||||||
onDetailsOpenChange={setMainDetailsOpen}
|
onDetailsOpenChange={setMainDetailsOpen}
|
||||||
collapseQuotedReplies={otherThreadCount > 0}
|
collapseQuotedReplies={otherThreadCount > 0}
|
||||||
messageId={email.id}
|
messageId={email.id}
|
||||||
|
plainTextFallback={plainTextFallback}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{messagesAfter.map((msg) => (
|
{messagesAfter.map((msg) => (
|
||||||
|
|||||||
@ -71,16 +71,16 @@ export function EmailViewDetailsPopover({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseEnter={keepOpen}
|
onMouseEnter={keepOpen}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (open) scheduleClose()
|
if (open) scheduleClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{summary}
|
<span className="min-w-0 truncate">{summary}</span>
|
||||||
<ChevronDown
|
<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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -32,6 +32,15 @@ import {
|
|||||||
MAIL_TOOLTIP_CONTENT_CLASS,
|
MAIL_TOOLTIP_CONTENT_CLASS,
|
||||||
} from "@/lib/mail-chrome-classes"
|
} from "@/lib/mail-chrome-classes"
|
||||||
import { repairMimeBodies } from "@/lib/mail-mime-body"
|
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(
|
export function formatApiMessageBody(
|
||||||
full: { body_html?: string; body_text?: string } | null | undefined,
|
full: { body_html?: string; body_text?: string } | null | undefined,
|
||||||
@ -50,11 +59,7 @@ export function formatApiMessageBody(
|
|||||||
if (html) return html
|
if (html) return html
|
||||||
const text = repaired.bodyText?.trim()
|
const text = repaired.bodyText?.trim()
|
||||||
if (text) {
|
if (text) {
|
||||||
const escaped = text
|
return plainTextToDisplayHtml(text)
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
return `<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${escaped}</pre>`
|
|
||||||
}
|
}
|
||||||
if (full) {
|
if (full) {
|
||||||
const s = snippet?.trim()
|
const s = snippet?.trim()
|
||||||
@ -119,6 +124,10 @@ export function ThreadPriorMessage({
|
|||||||
),
|
),
|
||||||
[fullMessage, message.snippet, isExpanded, isPending]
|
[fullMessage, message.snippet, isExpanded, isPending]
|
||||||
)
|
)
|
||||||
|
const plainTextFallback = useMemo(
|
||||||
|
() => plainTextBodyFallback(fullMessage),
|
||||||
|
[fullMessage]
|
||||||
|
)
|
||||||
|
|
||||||
const isSpam = messageIsSpam(merged.flags, merged.labels)
|
const isSpam = messageIsSpam(merged.flags, merged.labels)
|
||||||
|
|
||||||
@ -151,6 +160,7 @@ export function ThreadPriorMessage({
|
|||||||
onDetailsOpenChange={setDetailsOpen}
|
onDetailsOpenChange={setDetailsOpen}
|
||||||
collapseQuotedReplies={collapseQuotedReplies}
|
collapseQuotedReplies={collapseQuotedReplies}
|
||||||
messageId={message.id}
|
messageId={message.id}
|
||||||
|
plainTextFallback={plainTextFallback}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -232,6 +242,7 @@ export function ExpandedMessage({
|
|||||||
onDetailsOpenChange,
|
onDetailsOpenChange,
|
||||||
collapseQuotedReplies = false,
|
collapseQuotedReplies = false,
|
||||||
messageId,
|
messageId,
|
||||||
|
plainTextFallback,
|
||||||
}: {
|
}: {
|
||||||
sender: string
|
sender: string
|
||||||
senderEmail: string
|
senderEmail: string
|
||||||
@ -251,6 +262,7 @@ export function ExpandedMessage({
|
|||||||
detailsOpen?: boolean
|
detailsOpen?: boolean
|
||||||
onDetailsOpenChange?: (open: boolean) => void
|
onDetailsOpenChange?: (open: boolean) => void
|
||||||
collapseQuotedReplies?: boolean
|
collapseQuotedReplies?: boolean
|
||||||
|
plainTextFallback?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -285,6 +297,7 @@ export function ExpandedMessage({
|
|||||||
senderEmail={senderEmail}
|
senderEmail={senderEmail}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
collapseQuotedReplies={collapseQuotedReplies}
|
collapseQuotedReplies={collapseQuotedReplies}
|
||||||
|
plainTextFallback={plainTextFallback}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -134,7 +134,7 @@ export function EmailViewMessageToolbar({
|
|||||||
</ContactHoverCard>
|
</ContactHoverCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<EmailViewDetailsPopover
|
<EmailViewDetailsPopover
|
||||||
summary={headerDetails.recipientSummary}
|
summary={headerDetails.recipientSummary}
|
||||||
details={headerDetails}
|
details={headerDetails}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { splitQuotedHtml } from "@/lib/mail-quoted-content"
|
import { splitQuotedHtml } from "@/lib/mail-quoted-content"
|
||||||
import { htmlHasRemoteContent } from "@/lib/mail-remote-content"
|
import { htmlHasRemoteContent } from "@/lib/mail-remote-content"
|
||||||
@ -14,6 +14,7 @@ import {
|
|||||||
useTrustedSendersStore,
|
useTrustedSendersStore,
|
||||||
} from "@/lib/stores/trusted-senders-store"
|
} from "@/lib/stores/trusted-senders-store"
|
||||||
import { useMessageAttachmentCidMap } from "@/lib/api/hooks/use-message-attachment-cid-map"
|
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 { useInlineCidUrls } from "@/lib/hooks/use-inline-cid-urls"
|
||||||
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
|
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
|
||||||
import { RemoteContentBanner } from "@/components/gmail/email-view/remote-content-banner"
|
import { RemoteContentBanner } from "@/components/gmail/email-view/remote-content-banner"
|
||||||
@ -24,6 +25,7 @@ export function MessageBodyContent({
|
|||||||
senderEmail,
|
senderEmail,
|
||||||
messageId,
|
messageId,
|
||||||
collapseQuotedReplies = false,
|
collapseQuotedReplies = false,
|
||||||
|
plainTextFallback,
|
||||||
}: {
|
}: {
|
||||||
html: string
|
html: string
|
||||||
isSpam: boolean
|
isSpam: boolean
|
||||||
@ -31,6 +33,7 @@ export function MessageBodyContent({
|
|||||||
messageId: string
|
messageId: string
|
||||||
/** Hide included prior messages when the thread already lists them. */
|
/** Hide included prior messages when the thread already lists them. */
|
||||||
collapseQuotedReplies?: boolean
|
collapseQuotedReplies?: boolean
|
||||||
|
plainTextFallback?: string
|
||||||
}) {
|
}) {
|
||||||
const [showQuoted, setShowQuoted] = useState(false)
|
const [showQuoted, setShowQuoted] = useState(false)
|
||||||
const selfEmails = useSelfMailEmails()
|
const selfEmails = useSelfMailEmails()
|
||||||
@ -63,8 +66,28 @@ export function MessageBodyContent({
|
|||||||
}, [html, collapseQuotedReplies])
|
}, [html, collapseQuotedReplies])
|
||||||
|
|
||||||
const { data: cidMap } = useMessageAttachmentCidMap(messageId)
|
const { data: cidMap } = useMessageAttachmentCidMap(messageId)
|
||||||
|
const {
|
||||||
|
mutate: reindexInlineAttachments,
|
||||||
|
isPending: reindexPending,
|
||||||
|
isSuccess: reindexDone,
|
||||||
|
isError: reindexFailed,
|
||||||
|
} = useReindexMessageAttachments(messageId)
|
||||||
const cidUrlMap = useInlineCidUrls(cidMap)
|
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(
|
const hasRemoteContent = useMemo(
|
||||||
() =>
|
() =>
|
||||||
htmlHasRemoteContent(mainHtml) ||
|
htmlHasRemoteContent(mainHtml) ||
|
||||||
@ -89,13 +112,14 @@ export function MessageBodyContent({
|
|||||||
restrictPopups: isSpam,
|
restrictPopups: isSpam,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
cidUrlMap,
|
cidUrlMap,
|
||||||
|
plainTextFallback,
|
||||||
|
messageId,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{showRemoteBanner ? (
|
{showRemoteBanner ? (
|
||||||
<RemoteContentBanner
|
<RemoteContentBanner
|
||||||
senderEmail={senderEmail}
|
|
||||||
onShowOnce={() => allowMessageRemoteContent(messageId)}
|
onShowOnce={() => allowMessageRemoteContent(messageId)}
|
||||||
onAlwaysShow={() => {
|
onAlwaysShow={() => {
|
||||||
trustSender(senderEmail)
|
trustSender(senderEmail)
|
||||||
@ -103,7 +127,7 @@ export function MessageBodyContent({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<SandboxedContent html={mainHtml} {...sandboxProps} />
|
<SandboxedContent html={mainHtml} previewPart="body" {...sandboxProps} />
|
||||||
{hasHiddenQuote ? (
|
{hasHiddenQuote ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
@ -122,7 +146,11 @@ export function MessageBodyContent({
|
|||||||
) : null}
|
) : null}
|
||||||
{quotedHtml && showQuoted ? (
|
{quotedHtml && showQuoted ? (
|
||||||
<div className="mt-2 border-t border-border/60 pt-2">
|
<div className="mt-2 border-t border-border/60 pt-2">
|
||||||
<SandboxedContent html={quotedHtml} {...sandboxProps} />
|
<SandboxedContent
|
||||||
|
html={quotedHtml}
|
||||||
|
previewPart="quoted"
|
||||||
|
{...sandboxProps}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
export function RemoteContentBanner({
|
export function RemoteContentBanner({
|
||||||
senderEmail,
|
|
||||||
onShowOnce,
|
onShowOnce,
|
||||||
onAlwaysShow,
|
onAlwaysShow,
|
||||||
}: {
|
}: {
|
||||||
senderEmail: string
|
|
||||||
onShowOnce: () => void
|
onShowOnce: () => void
|
||||||
onAlwaysShow: () => void
|
onAlwaysShow: () => void
|
||||||
}) {
|
}) {
|
||||||
@ -25,7 +23,7 @@ export function RemoteContentBanner({
|
|||||||
onClick={onAlwaysShow}
|
onClick={onAlwaysShow}
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
toujours afficher le contenu distant venant de {senderEmail}
|
toujours afficher le contenu distant de cet expéditeur
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@ -12,14 +13,25 @@ import { useTheme } from "next-themes"
|
|||||||
import {
|
import {
|
||||||
emailPreviewBaseCss,
|
emailPreviewBaseCss,
|
||||||
emailPreviewDarkOverrideCss,
|
emailPreviewDarkOverrideCss,
|
||||||
|
emailPreviewDarkRemoteBodyTailCss,
|
||||||
|
emailPreviewDarkTailOverrideCss,
|
||||||
emailPreviewLightOverrideCss,
|
emailPreviewLightOverrideCss,
|
||||||
emailPreviewWrapperCss,
|
emailPreviewWrapperCss,
|
||||||
} from "@/lib/email-preview-dark-styles"
|
} from "@/lib/email-preview-dark-styles"
|
||||||
import {
|
import {
|
||||||
|
EMAIL_PREVIEW_MIN_IFRAME_HEIGHT,
|
||||||
|
measureEmailPreviewIframeHeight,
|
||||||
|
} from "@/lib/email-preview-iframe-height"
|
||||||
|
import {
|
||||||
|
buildEmailPreviewSrcdoc,
|
||||||
prepareEmailHtmlForIframe,
|
prepareEmailHtmlForIframe,
|
||||||
injectEmailHtmlIntoDocument,
|
|
||||||
} from "@/lib/mail-html-iframe"
|
} from "@/lib/mail-html-iframe"
|
||||||
import { buildEmailPreviewCsp } from "@/lib/mail-remote-content"
|
import { buildEmailPreviewCsp } from "@/lib/mail-remote-content"
|
||||||
|
import {
|
||||||
|
isEmailPreviewContrastDebugEnabled,
|
||||||
|
logEmailPreviewContrastIssues,
|
||||||
|
repairEmailPreviewContrast,
|
||||||
|
} from "@/lib/email-preview-contrast"
|
||||||
|
|
||||||
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
|
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
|
||||||
display: "block",
|
display: "block",
|
||||||
@ -30,34 +42,33 @@ function documentIsDark(): boolean {
|
|||||||
return document.documentElement.classList.contains("dark")
|
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({
|
export function SandboxedContent({
|
||||||
html,
|
html,
|
||||||
blockRemoteContent,
|
blockRemoteContent,
|
||||||
restrictPopups = false,
|
restrictPopups = false,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
cidUrlMap,
|
cidUrlMap,
|
||||||
|
plainTextFallback,
|
||||||
|
messageId,
|
||||||
|
previewPart = "body",
|
||||||
}: {
|
}: {
|
||||||
html: string
|
html: string
|
||||||
blockRemoteContent: boolean
|
blockRemoteContent: boolean
|
||||||
restrictPopups?: boolean
|
restrictPopups?: boolean
|
||||||
senderEmail?: string
|
senderEmail?: string
|
||||||
cidUrlMap?: Record<string, 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 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
|
const sandboxValue = restrictPopups
|
||||||
? "allow-same-origin"
|
? "allow-same-origin"
|
||||||
@ -77,12 +88,16 @@ export function SandboxedContent({
|
|||||||
isDark,
|
isDark,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
cidUrlMap,
|
cidUrlMap,
|
||||||
|
plainTextFallback,
|
||||||
}),
|
}),
|
||||||
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap]
|
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap, plainTextFallback]
|
||||||
)
|
)
|
||||||
|
|
||||||
const themeCss = useMemo(() => {
|
const themeCss = useMemo(() => {
|
||||||
if (!blockRemoteContent) return emailPreviewWrapperCss()
|
if (!blockRemoteContent) {
|
||||||
|
const wrapper = emailPreviewWrapperCss(isDark)
|
||||||
|
return isDark ? `${wrapper}${emailPreviewDarkOverrideCss()}` : wrapper
|
||||||
|
}
|
||||||
return `${emailPreviewBaseCss(isDark)}${
|
return `${emailPreviewBaseCss(isDark)}${
|
||||||
isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss()
|
isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss()
|
||||||
}`
|
}`
|
||||||
@ -93,65 +108,152 @@ export function SandboxedContent({
|
|||||||
[blockRemoteContent]
|
[blockRemoteContent]
|
||||||
)
|
)
|
||||||
|
|
||||||
const injectContent = useCallback(() => {
|
const srcdoc = useMemo(
|
||||||
const iframe = iframeRef.current
|
() =>
|
||||||
if (!iframe) return
|
buildEmailPreviewSrcdoc(parsedEmail, {
|
||||||
|
|
||||||
const doc = iframe.contentDocument
|
|
||||||
if (!doc) return
|
|
||||||
|
|
||||||
injectEmailHtmlIntoDocument(doc, {
|
|
||||||
csp: cspContent,
|
csp: cspContent,
|
||||||
documentBaseHref: parsedEmail.documentBaseHref,
|
|
||||||
resolveBaseHref: parsedEmail.resolveBaseHref,
|
|
||||||
headMarkup: parsedEmail.headMarkup,
|
|
||||||
bodyHtml: parsedEmail.bodyHtml,
|
|
||||||
wrapperCss: themeCss,
|
wrapperCss: themeCss,
|
||||||
|
plainTextFallback,
|
||||||
|
bodyTailCss: isDark
|
||||||
|
? blockRemoteContent
|
||||||
|
? emailPreviewDarkTailOverrideCss()
|
||||||
|
: emailPreviewDarkRemoteBodyTailCss()
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
parsedEmail,
|
||||||
|
cspContent,
|
||||||
|
themeCss,
|
||||||
|
plainTextFallback,
|
||||||
|
isDark,
|
||||||
|
blockRemoteContent,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const iframeKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent ? "remote-blocked" : "remote-allowed"}:${isDark ? "dark" : "light"}`
|
||||||
|
|
||||||
|
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 scheduleHeightSync = useCallback(
|
||||||
|
(generation: number) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
syncHeight(generation)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[syncHeight]
|
||||||
|
)
|
||||||
|
|
||||||
|
const runContrastPipeline = useCallback(
|
||||||
|
(doc: Document, pass: "initial" | "delayed", generation: number) => {
|
||||||
|
if (!isDark || generation !== contentGenerationRef.current) return
|
||||||
|
|
||||||
|
const repair = repairEmailPreviewContrast(doc, {
|
||||||
|
isDark: true,
|
||||||
|
repairMode: blockRemoteContent ? "dark-only" : "all",
|
||||||
|
newsletterLightCanvas: false,
|
||||||
|
assumedCanvasRgb: [32, 33, 36],
|
||||||
})
|
})
|
||||||
|
|
||||||
const syncHeight = () => {
|
if (repair && (repair.lightSurfaces > 0 || repair.darkSurfaces > 0)) {
|
||||||
const liveDoc = iframe.contentDocument
|
scheduleHeightSync(generation)
|
||||||
if (!liveDoc) return
|
if (isEmailPreviewContrastDebugEnabled()) {
|
||||||
const next = measureIframeContentHeight(liveDoc)
|
console.info("[email-preview:contrast-repaired]", {
|
||||||
setHeight((prev) => (prev === next ? prev : next))
|
messageId,
|
||||||
|
part: previewPart,
|
||||||
|
pass,
|
||||||
|
blockRemoteContent,
|
||||||
|
...repair,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(syncHeight)
|
if (!isEmailPreviewContrastDebugEnabled()) return
|
||||||
|
|
||||||
if (doc.body) {
|
const logKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent}:${isDark}:${srcdoc.length}:${pass}`
|
||||||
resizeObserver.observe(doc.body)
|
if (contrastLoggedKeyRef.current === logKey) return
|
||||||
for (const img of doc.images) {
|
contrastLoggedKeyRef.current = logKey
|
||||||
if (!img.complete) {
|
logEmailPreviewContrastIssues(
|
||||||
img.addEventListener("load", syncHeight, { once: true })
|
{
|
||||||
img.addEventListener("error", syncHeight, { once: true })
|
messageId,
|
||||||
}
|
part: previewPart,
|
||||||
}
|
blockRemoteContent,
|
||||||
for (const link of doc.querySelectorAll('link[rel~="stylesheet"]')) {
|
isDark,
|
||||||
link.addEventListener("load", syncHeight, { once: true })
|
senderEmail,
|
||||||
link.addEventListener("error", syncHeight, { once: true })
|
},
|
||||||
}
|
doc,
|
||||||
syncHeight()
|
{ newsletterLightCanvas: false, assumedCanvasRgb: [32, 33, 36] }
|
||||||
requestAnimationFrame(syncHeight)
|
)
|
||||||
setTimeout(syncHeight, 250)
|
},
|
||||||
setTimeout(syncHeight, 1000)
|
[
|
||||||
|
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()
|
scheduleHeightSync(generation)
|
||||||
}, [parsedEmail, themeCss, cspContent])
|
if (doc) runContrastPipeline(doc, "initial", generation)
|
||||||
|
|
||||||
useEffect(() => {
|
if (contrastDelayTimerRef.current !== null) {
|
||||||
const cleanup = injectContent()
|
window.clearTimeout(contrastDelayTimerRef.current)
|
||||||
return () => cleanup?.()
|
}
|
||||||
}, [injectContent])
|
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 (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
key={blockRemoteContent ? "remote-blocked" : "remote-allowed"}
|
key={iframeKey}
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
sandbox={sandboxValue}
|
sandbox={sandboxValue}
|
||||||
title="Contenu du message"
|
title="Contenu du message"
|
||||||
className="w-full border-0 bg-transparent"
|
className="w-full border-0 bg-transparent"
|
||||||
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
|
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
|
||||||
|
srcDoc={srcdoc}
|
||||||
|
onLoad={handleIframeLoad}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -35,7 +35,7 @@ const googleApps: FavoriteApp[] = [
|
|||||||
{ name: "Agenda", icon: "/agenda-mark.svg" },
|
{ name: "Agenda", icon: "/agenda-mark.svg" },
|
||||||
{ name: "Photos", icon: "/photos-mark.svg" },
|
{ name: "Photos", icon: "/photos-mark.svg" },
|
||||||
{ name: "Ultimail", icon: "/brand/ultimail-header-icon.png", href: "/mail" },
|
{ 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: "UltiMeet", icon: "/ultimeet-mark.svg" },
|
||||||
{ name: "Administration", icon: "/admin-mark.svg" },
|
{ name: "Administration", icon: "/admin-mark.svg" },
|
||||||
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
|
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
|
||||||
|
|||||||
@ -105,18 +105,22 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
|
|||||||
try {
|
try {
|
||||||
const result = await resanitizeBodies.mutateAsync()
|
const result = await resanitizeBodies.mutateAsync()
|
||||||
setMaintenanceMessage(
|
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 {
|
} 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)
|
setMaintenanceMessage(null)
|
||||||
try {
|
try {
|
||||||
await syncAccount.mutateAsync()
|
await syncAccount.mutateAsync({ force })
|
||||||
setMaintenanceMessage("Synchronisation IMAP terminée.")
|
setMaintenanceMessage(
|
||||||
|
force
|
||||||
|
? "Re-synchronisation complète IMAP terminée."
|
||||||
|
: "Synchronisation IMAP terminée."
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
setMaintenanceMessage("Échec de la synchronisation IMAP.")
|
setMaintenanceMessage("Échec de la synchronisation IMAP.")
|
||||||
}
|
}
|
||||||
@ -156,8 +160,8 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
|
|||||||
onClick={() => void runResanitize()}
|
onClick={() => void runResanitize()}
|
||||||
>
|
>
|
||||||
{resanitizeBodies.isPending
|
{resanitizeBodies.isPending
|
||||||
? "Re-sanitisation…"
|
? "Réimportation IMAP…"
|
||||||
: "Re-sanitiser le HTML"}
|
: "Réimporter les corps depuis IMAP"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={maintenancePending}
|
disabled={maintenancePending}
|
||||||
@ -165,6 +169,12 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
|
|||||||
>
|
>
|
||||||
{syncAccount.isPending ? "Synchronisation…" : "Synchroniser IMAP"}
|
{syncAccount.isPending ? "Synchronisation…" : "Synchroniser IMAP"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={maintenancePending}
|
||||||
|
onClick={() => void runSync(true)}
|
||||||
|
>
|
||||||
|
Forcer re-sync complet
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button
|
<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": "../ulti-backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../drive-suite"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {}
|
"settings": {}
|
||||||
|
|||||||
@ -185,6 +185,28 @@ class ApiClient {
|
|||||||
return this.request<T>("GET", path, { params })
|
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> {
|
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
return this.request<T>("POST", path, { body })
|
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()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (opts?: { force?: boolean }) =>
|
||||||
apiClient.post<{ status: string }>(`/mail/accounts/${accountId}/sync`),
|
apiClient.post<{ status: string }>(
|
||||||
|
`/mail/accounts/${accountId}/sync${opts?.force ? "?force=true" : ""}`
|
||||||
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||||
|
|||||||
@ -3,23 +3,67 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiClient } from "../client"
|
import { apiClient } from "../client"
|
||||||
import { useAuthReady } from "../use-auth-ready"
|
import { useAuthReady } from "../use-auth-ready"
|
||||||
|
import { normalizeCidKey } from "@/lib/mail-cid"
|
||||||
|
import {
|
||||||
|
type ApiMessageAttachment,
|
||||||
|
} from "../map-message-attachments"
|
||||||
|
|
||||||
type CidMapResponse = {
|
type CidMapResponse = {
|
||||||
cid_map?: Record<string, string>
|
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) {
|
export function useMessageAttachmentCidMap(messageId: string | undefined) {
|
||||||
const authReady = useAuthReady()
|
const { ready, authenticated } = useAuthReady()
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["message-cid-map", messageId],
|
queryKey: ["message-cid-map", messageId],
|
||||||
enabled: authReady && Boolean(messageId),
|
enabled: ready && authenticated && Boolean(messageId),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
queryFn: async () => {
|
queryFn: () => fetchMessageCidMap(messageId!),
|
||||||
const res = await apiClient.get<CidMapResponse>(
|
|
||||||
`/mail/messages/${messageId}/attachments/cid-map`
|
|
||||||
)
|
|
||||||
return res?.cid_map ?? {}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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 []
|
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. */
|
/** Fichiers « riches » (cartes type Gmail) : PDF, images, vidéos, bureautique. */
|
||||||
const RICH_PREVIEW_EXT =
|
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
|
/\.(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'
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
useContacts,
|
useContacts,
|
||||||
useDefaultContactBookId,
|
useDefaultContactBookId,
|
||||||
@ -10,6 +11,9 @@ export function useContactsList(bookId?: string) {
|
|||||||
const defaultBookId = useDefaultContactBookId()
|
const defaultBookId = useDefaultContactBookId()
|
||||||
const resolvedBookId = bookId ?? defaultBookId
|
const resolvedBookId = bookId ?? defaultBookId
|
||||||
const { data: apiContacts, ...rest } = useContacts(resolvedBookId)
|
const { data: apiContacts, ...rest } = useContacts(resolvedBookId)
|
||||||
const contacts = apiContacts?.map(apiContactToFullContact) ?? []
|
const contacts = useMemo(
|
||||||
|
() => apiContacts?.map(apiContactToFullContact) ?? [],
|
||||||
|
[apiContacts]
|
||||||
|
)
|
||||||
return { contacts, bookId: resolvedBookId, ...rest }
|
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é). */
|
/** 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 `
|
return `
|
||||||
|
html {
|
||||||
|
color-scheme: ${isDark ? "dark" : "light"};
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 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 »). */
|
/** Adoucit les fonds très sombres en mode clair (e-mails « dark »). */
|
||||||
export function emailPreviewLightOverrideCss(): string {
|
export function emailPreviewLightOverrideCss(): string {
|
||||||
return `
|
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 {
|
export function preprocessEmailHtmlForTheme(html: string, isDark: boolean): string {
|
||||||
let next = stripHiddenEmailHtml(html)
|
let next = stripHiddenEmailHtml(html)
|
||||||
next = rewriteInlineStyles(next, isDark)
|
next = rewriteInlineStyles(next, isDark)
|
||||||
|
next = rewriteHtmlColorAttributes(next, isDark)
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
next = next.replace(LIGHT_BG_STYLE, "background:transparent")
|
next = next.replace(LIGHT_BG_STYLE, "background:transparent")
|
||||||
next = next.replace(/\sbgcolor=(["'])(?:#?(?:fff(?:fff)?|ffffff|white)|#f[0-9a-f]{5})\1/gi, "")
|
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,55 +1,42 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useAuthStore } from "@/lib/api/auth-store"
|
import { apiClient } from "@/lib/api/client"
|
||||||
|
import { registerCidUrlAliases } from "@/lib/mail-cid"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetches inline attachment blobs and exposes blob: URLs keyed by cid (with or without cid: prefix). */
|
/** Fetches inline attachment blobs and exposes blob: URLs keyed by cid (with or without cid: prefix). */
|
||||||
export function useInlineCidUrls(cidMap: Record<string, string> | undefined) {
|
export function useInlineCidUrls(cidMap: Record<string, string> | undefined) {
|
||||||
const accessToken = useAuthStore((s) => s.accessToken)
|
|
||||||
const [urls, setUrls] = useState<Record<string, string>>({})
|
const [urls, setUrls] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accessToken || !cidMap || Object.keys(cidMap).length === 0) {
|
if (!cidMap || Object.keys(cidMap).length === 0) {
|
||||||
setUrls({})
|
setUrls({})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const objectUrls: string[] = []
|
const objectUrls: string[] = []
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const next: Record<string, string> = {}
|
const next: Record<string, string> = {}
|
||||||
|
const blobByAttachmentId = new Map<string, string>()
|
||||||
|
|
||||||
for (const [contentId, attachmentId] of Object.entries(cidMap)) {
|
for (const [contentId, attachmentId] of Object.entries(cidMap)) {
|
||||||
if (!attachmentId) continue
|
if (!attachmentId) continue
|
||||||
|
let blobUrl = blobByAttachmentId.get(attachmentId)
|
||||||
|
if (!blobUrl) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(attachmentInlineUrl(attachmentId), {
|
const blob = await apiClient.getBlob(
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
`/mail/attachments/${encodeURIComponent(attachmentId)}/inline`
|
||||||
})
|
)
|
||||||
if (!res.ok) continue
|
blobUrl = URL.createObjectURL(blob)
|
||||||
const blob = await res.blob()
|
blobByAttachmentId.set(attachmentId, blobUrl)
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
|
||||||
objectUrls.push(blobUrl)
|
objectUrls.push(blobUrl)
|
||||||
const key = normalizeCidKey(contentId)
|
|
||||||
next[key] = blobUrl
|
|
||||||
next[`cid:${key}`] = blobUrl
|
|
||||||
} catch {
|
} catch {
|
||||||
// skip broken inline parts
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
registerCidUrlAliases(next, contentId, blobUrl)
|
||||||
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setUrls(next)
|
setUrls(next)
|
||||||
} else {
|
} else {
|
||||||
@ -61,7 +48,7 @@ export function useInlineCidUrls(cidMap: Record<string, string> | undefined) {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
for (const u of objectUrls) URL.revokeObjectURL(u)
|
for (const u of objectUrls) URL.revokeObjectURL(u)
|
||||||
}
|
}
|
||||||
}, [accessToken, cidMap])
|
}, [cidMap])
|
||||||
|
|
||||||
return urls
|
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. */
|
/** Prépare le HTML d'un mail pour affichage dans une iframe sandboxée. */
|
||||||
|
|
||||||
import { stripHiddenEmailHtml } from "@/lib/strip-hidden-email-html"
|
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 { preprocessEmailHtmlForTheme } from "@/lib/email-preview-dark-styles"
|
||||||
|
import { resolveCidReference } from "@/lib/mail-cid"
|
||||||
|
|
||||||
const REMOTE_URL = /^https?:\/\//i
|
const REMOTE_URL = /^https?:\/\//i
|
||||||
const PROTOCOL_RELATIVE = /^\/\//
|
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 {
|
function isRemoteOrRelativeUrl(url: string): boolean {
|
||||||
const t = url.trim()
|
const t = url.trim()
|
||||||
return (
|
return (
|
||||||
@ -338,96 +488,83 @@ export function activateRemoteResourcesInHtml(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Injecte le mail dans le document iframe sans doc.write (préserve les <style>). */
|
/** Full HTML document for iframe srcDoc (scripts stripped; no live DOM injection). */
|
||||||
export function injectEmailHtmlIntoDocument(
|
export function buildEmailPreviewSrcdoc(
|
||||||
doc: Document,
|
parsed: ParsedEmailHtml,
|
||||||
options: {
|
options: {
|
||||||
csp: string
|
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
|
wrapperCss: string
|
||||||
|
plainTextFallback?: string
|
||||||
|
/** CSS injecté en fin de body (après styles expéditeur inline). */
|
||||||
|
bodyTailCss?: string
|
||||||
}
|
}
|
||||||
): void {
|
): string {
|
||||||
doc.open()
|
const baseHref = parsed.documentBaseHref ?? parsed.resolveBaseHref
|
||||||
doc.write("<!DOCTYPE html><html><head></head><body></body></html>")
|
// headMarkup is CSS (<style> blocks) — only strip script-like tags via regex
|
||||||
doc.close()
|
const headMarkup = (parsed.headMarkup ?? "")
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||||
|
.replace(/<script\b[^>]*\/>/gi, "")
|
||||||
|
let bodyHtml = stripExecutableEmailHtml(parsed.bodyHtml)
|
||||||
|
|
||||||
const head = doc.head
|
if (!bodyHtml.trim() && options.plainTextFallback?.trim()) {
|
||||||
const body = doc.body
|
bodyHtml = plainTextFallbackHtml(options.plainTextFallback)
|
||||||
if (!head || !body) return
|
}
|
||||||
|
if (!bodyHtml.trim()) {
|
||||||
const charset = doc.createElement("meta")
|
bodyHtml =
|
||||||
charset.setAttribute("charset", "utf-8")
|
'<p style="color:#5f6368;font:14px sans-serif;margin:0">Ce message n\'a pas de contenu affichable.</p>'
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapper = doc.createElement("style")
|
const baseTag = baseHref
|
||||||
wrapper.setAttribute("data-ultimail-wrapper", "true")
|
? `<base href="${escapeHtmlAttr(baseHref)}" target="_blank">`
|
||||||
wrapper.textContent = options.wrapperCss
|
: ""
|
||||||
head.appendChild(wrapper)
|
|
||||||
|
|
||||||
if (options.headMarkup.trim()) {
|
const bodyTailStyle = options.bodyTailCss
|
||||||
const tpl = doc.createElement("template")
|
? `<style data-ultimail-tail="true">${options.bodyTailCss}</style>`
|
||||||
tpl.innerHTML = options.headMarkup
|
: ""
|
||||||
head.append(...Array.from(tpl.content.childNodes))
|
|
||||||
}
|
|
||||||
|
|
||||||
body.replaceChildren()
|
return (
|
||||||
if (options.bodyHtml.trim()) {
|
"<!DOCTYPE html><html><head>" +
|
||||||
const tpl = doc.createElement("template")
|
'<meta charset="utf-8">' +
|
||||||
tpl.innerHTML = options.bodyHtml
|
`<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttr(options.csp)}">` +
|
||||||
body.append(...Array.from(tpl.content.childNodes))
|
baseTag +
|
||||||
} else {
|
`<style data-ultimail-wrapper="true">${options.wrapperCss}</style>` +
|
||||||
const empty = doc.createElement("p")
|
headMarkup +
|
||||||
empty.textContent = "Ce message n'a pas de contenu affichable."
|
"</head><body>" +
|
||||||
empty.setAttribute(
|
bodyHtml +
|
||||||
"style",
|
bodyTailStyle +
|
||||||
"color:#5f6368;font:14px sans-serif;margin:0;"
|
"</body></html>"
|
||||||
)
|
)
|
||||||
body.appendChild(empty)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
export function rewriteCidUrlsInHtml(
|
||||||
html: string,
|
html: string,
|
||||||
cidUrlMap: Record<string, string> | undefined
|
cidUrlMap: Record<string, string> | undefined
|
||||||
): string {
|
): string {
|
||||||
if (!html.trim() || !cidUrlMap || Object.keys(cidUrlMap).length === 0) {
|
if (!html.trim() || typeof DOMParser === "undefined") return html
|
||||||
return html
|
if (!html.includes("cid:")) return html
|
||||||
}
|
|
||||||
if (typeof DOMParser === "undefined") return html
|
|
||||||
|
|
||||||
const resolveCid = (value: string): string => {
|
const resolveCid = (value: string): string => resolveCidReference(cidUrlMap, value)
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed.toLowerCase().startsWith("cid:")) return trimmed
|
const CID_ATTRS = [
|
||||||
const key = trimmed.slice(4).trim()
|
"src",
|
||||||
return cidUrlMap[key] ?? cidUrlMap[trimmed] ?? trimmed
|
"data-src",
|
||||||
}
|
"data-original",
|
||||||
|
"data-lazy-src",
|
||||||
|
"background",
|
||||||
|
"poster",
|
||||||
|
] as const
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html")
|
const doc = new DOMParser().parseFromString(html, "text/html")
|
||||||
for (const img of doc.querySelectorAll("img")) {
|
for (const img of doc.querySelectorAll("img, video, source, table, td, th, div, span")) {
|
||||||
for (const attr of ["src", "data-src", "data-original", "data-lazy-src"] as const) {
|
for (const attr of CID_ATTRS) {
|
||||||
const value = img.getAttribute(attr)
|
const value = img.getAttribute(attr)
|
||||||
if (value?.toLowerCase().startsWith("cid:")) {
|
if (value?.toLowerCase().includes("cid:")) {
|
||||||
img.setAttribute(attr, resolveCid(value))
|
img.setAttribute(
|
||||||
|
attr,
|
||||||
|
value.replace(/cid:[^\s'")]+/gi, (match) => resolveCid(match))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const srcset = img.getAttribute("srcset")
|
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(
|
export function prepareEmailHtmlForIframe(
|
||||||
html: string,
|
html: string,
|
||||||
options: {
|
options: {
|
||||||
@ -460,9 +608,14 @@ export function prepareEmailHtmlForIframe(
|
|||||||
isDark: boolean
|
isDark: boolean
|
||||||
senderEmail?: string
|
senderEmail?: string
|
||||||
cidUrlMap?: Record<string, string>
|
cidUrlMap?: Record<string, string>
|
||||||
|
plainTextFallback?: string
|
||||||
}
|
}
|
||||||
): ParsedEmailHtml {
|
): ParsedEmailHtml {
|
||||||
if (!html.trim()) {
|
if (!html.trim()) {
|
||||||
|
const fallback = options.plainTextFallback?.trim()
|
||||||
|
if (fallback) {
|
||||||
|
return { headMarkup: "", bodyHtml: plainTextFallbackHtml(fallback) }
|
||||||
|
}
|
||||||
return { headMarkup: "", bodyHtml: "" }
|
return { headMarkup: "", bodyHtml: "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -474,13 +627,21 @@ export function prepareEmailHtmlForIframe(
|
|||||||
|
|
||||||
if (options.blockRemoteContent) {
|
if (options.blockRemoteContent) {
|
||||||
const parsed = parseEmailHtmlForIframe(html, resolveBaseHref)
|
const parsed = parseEmailHtmlForIframe(html, resolveBaseHref)
|
||||||
const bodyHtml = rewriteCidUrlsInHtml(
|
let bodyHtml = stripHiddenEmailHtml(parsed.bodyHtml || html)
|
||||||
preprocessEmailHtmlForTheme(parsed.bodyHtml || html, options.isDark),
|
bodyHtml = stripRemoteResourcesForPreview(bodyHtml)
|
||||||
|
bodyHtml = rewriteCidUrlsInHtml(
|
||||||
|
preprocessEmailHtmlForTheme(bodyHtml, options.isDark),
|
||||||
options.cidUrlMap
|
options.cidUrlMap
|
||||||
)
|
)
|
||||||
|
const headMarkup = rewriteCidUrlsInHtml(
|
||||||
|
filterHeadMarkupForBlockedRemote(parsed.headMarkup),
|
||||||
|
options.cidUrlMap
|
||||||
|
)
|
||||||
|
bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback)
|
||||||
return {
|
return {
|
||||||
headMarkup: "",
|
headMarkup,
|
||||||
bodyHtml,
|
bodyHtml,
|
||||||
|
resolveBaseHref,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -490,8 +651,12 @@ export function prepareEmailHtmlForIframe(
|
|||||||
baseHref: resolveBaseHref,
|
baseHref: resolveBaseHref,
|
||||||
})
|
})
|
||||||
bodyHtml = rewriteCidUrlsInHtml(bodyHtml, options.cidUrlMap)
|
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 {
|
return {
|
||||||
headMarkup,
|
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, "")
|
const clean = encoded.replace(/[\r\n\t ]/g, "")
|
||||||
try {
|
try {
|
||||||
if (typeof atob !== "undefined") {
|
if (typeof atob !== "undefined") {
|
||||||
const bytes = Uint8Array.from(atob(clean), (c) => c.charCodeAt(0))
|
const bytes = Uint8Array.from(atob(clean), (c) => c.charCodeAt(0))
|
||||||
return new TextDecoder("utf-8").decode(bytes)
|
return decodeBytesToUtf8(bytes, charset)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return ""
|
return ""
|
||||||
@ -60,14 +112,20 @@ function parseEmbeddedMime(raw: string): { text: string; html: string } | null {
|
|||||||
const headers = trimmed.slice(0, headerEnd)
|
const headers = trimmed.slice(0, headerEnd)
|
||||||
const body = trimmed.slice(headerEnd).replace(/^[\r\n]+/, "")
|
const body = trimmed.slice(headerEnd).replace(/^[\r\n]+/, "")
|
||||||
|
|
||||||
const typeMatch = headers.match(/Content-Type:\s*([^\r\n;]+)/i)
|
const typeHeader = headers.match(/Content-Type:\s*([^\r\n]+)/i)?.[1] ?? ""
|
||||||
const mediaType = typeMatch?.[1]?.trim().toLowerCase() ?? ""
|
const mediaType = typeHeader.split(";")[0]?.trim().toLowerCase() ?? ""
|
||||||
|
const charset = charsetFromContentType(typeHeader)
|
||||||
const encMatch = headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)
|
const encMatch = headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)
|
||||||
const encoding = encMatch?.[1]?.trim().toLowerCase() ?? ""
|
const encoding = encMatch?.[1]?.trim().toLowerCase() ?? ""
|
||||||
|
|
||||||
let decoded = body.trim()
|
let decoded = body.trim()
|
||||||
if (encoding === "base64") {
|
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
|
if (mediaType === "text/plain" && !text) text = decoded
|
||||||
@ -117,7 +175,7 @@ function decodeQuotedPrintableIfNeeded(s: string): string {
|
|||||||
bytes.push(ch.charCodeAt(0))
|
bytes.push(ch.charCodeAt(0))
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
return new TextDecoder("utf-8").decode(new Uint8Array(bytes))
|
return decodeBytesToUtf8(new Uint8Array(bytes))
|
||||||
} catch {
|
} catch {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@ -134,8 +192,8 @@ export function repairMimeBodies(
|
|||||||
bodyText?: string,
|
bodyText?: string,
|
||||||
bodyHtml?: string
|
bodyHtml?: string
|
||||||
): { bodyText?: string; bodyHtml?: string } {
|
): { bodyText?: string; bodyHtml?: string } {
|
||||||
let text = bodyText?.trim() ?? ""
|
let text = repairLegacyCharsetString(bodyText?.trim() ?? "")
|
||||||
let html = bodyHtml?.trim() ?? ""
|
let html = repairLegacyCharsetString(bodyHtml?.trim() ?? "")
|
||||||
|
|
||||||
text = decodeQuotedPrintableIfNeeded(text)
|
text = decodeQuotedPrintableIfNeeded(text)
|
||||||
html = decodeQuotedPrintableIfNeeded(html)
|
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 {
|
export function repairSnippet(snippet?: string): string | undefined {
|
||||||
if (!snippet?.trim()) return snippet
|
if (!snippet?.trim()) return snippet
|
||||||
const trimmed = snippet.trim()
|
const trimmed = snippet.trim()
|
||||||
const qp = decodeQuotedPrintableIfNeeded(trimmed)
|
const qp = decodeQuotedPrintableIfNeeded(trimmed)
|
||||||
const decoded = decodeBareBase64IfNeeded(qp)
|
const decoded = decodeBareBase64IfNeeded(qp)
|
||||||
if (decoded !== trimmed) {
|
const raw = decoded !== trimmed ? decoded : snippet
|
||||||
return stripInvisibleTextRuns(
|
const cleaned = stripInvisibleTextRuns(raw)
|
||||||
decoded.length > 200 ? decoded.slice(0, 200) : decoded
|
const polished = polishSnippetPreview(cleaned)
|
||||||
)
|
return polished || undefined
|
||||||
}
|
|
||||||
return stripInvisibleTextRuns(snippet)
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 =
|
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
|
/(^|\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 = [
|
const QUOTE_SELECTOR = [
|
||||||
".gmail_quote",
|
".gmail_quote",
|
||||||
".gmail_extra",
|
".gmail_extra",
|
||||||
@ -104,13 +108,37 @@ function splitHtmlRoot(root: Element): { mainHtml: string; quotedHtml: string |
|
|||||||
return splitAtQuote(root, quoteHit)
|
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 preMatch = html.match(/^<pre[^>]*>([\s\S]*)<\/pre>$/i)
|
||||||
const text = preMatch ? preMatch[1]! : html
|
if (!preMatch) return { mainHtml: html, quotedHtml: null }
|
||||||
const decoded = text
|
const text = preMatch[1]!
|
||||||
|
const decoded = normalizeCollapsedPlainQuotes(
|
||||||
|
text
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
)
|
||||||
const lines = decoded.split("\n")
|
const lines = decoded.split("\n")
|
||||||
let splitAt = -1
|
let splitAt = -1
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
@ -124,7 +152,21 @@ function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: strin
|
|||||||
break
|
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 mainLines = lines.slice(0, splitAt)
|
||||||
const quotedLines = lines.slice(splitAt)
|
const quotedLines = lines.slice(splitAt)
|
||||||
@ -150,13 +192,12 @@ export function splitQuotedHtml(html: string): {
|
|||||||
const trimmed = html.trim()
|
const trimmed = html.trim()
|
||||||
if (!trimmed) return { mainHtml: html, quotedHtml: null }
|
if (!trimmed) return { mainHtml: html, quotedHtml: null }
|
||||||
|
|
||||||
if (typeof DOMParser === "undefined") {
|
if (/^<pre\b/i.test(trimmed) || /<pre\b/i.test(trimmed)) {
|
||||||
return { mainHtml: html, quotedHtml: null }
|
return splitPlainPreBody(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.startsWith("<pre")) {
|
if (typeof DOMParser === "undefined") {
|
||||||
const plain = splitPlainTextBody(trimmed)
|
return { mainHtml: html, quotedHtml: null }
|
||||||
if (plain.quotedHtml) return plain
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = new DOMParser().parseFromString(
|
const doc = new DOMParser().parseFromString(
|
||||||
@ -169,6 +210,10 @@ export function splitQuotedHtml(html: string): {
|
|||||||
return splitHtmlRoot(root)
|
return splitHtmlRoot(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } {
|
||||||
|
return splitPlainPreBody(html)
|
||||||
|
}
|
||||||
|
|
||||||
export function hasQuotedContent(html: string): boolean {
|
export function hasQuotedContent(html: string): boolean {
|
||||||
return splitQuotedHtml(html).quotedHtml !== null
|
return splitQuotedHtml(html).quotedHtml !== null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,11 +34,13 @@ export function htmlHasRemoteContent(html: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildEmailPreviewCsp(blockRemoteContent: boolean): string {
|
export function buildEmailPreviewCsp(blockRemoteContent: boolean): string {
|
||||||
|
const scriptNone = "script-src 'none'"
|
||||||
if (blockRemoteContent) {
|
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 [
|
return [
|
||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
|
scriptNone,
|
||||||
"style-src 'unsafe-inline' https: http:",
|
"style-src 'unsafe-inline' https: http:",
|
||||||
"style-src-elem 'unsafe-inline' https: http:",
|
"style-src-elem 'unsafe-inline' https: http:",
|
||||||
// url() in style="" (Gmail signatures often use background-image on table cells)
|
// 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). */
|
/** Remove marketing preheader blocks before display (bluemonday strips display:none). */
|
||||||
|
|
||||||
const HIDDEN_STYLE =
|
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 HIDDEN_CLASS = /mcnPreviewText|preheader|preview-text/i
|
||||||
|
|
||||||
const INVISIBLE_RUN = /[\u034f\u200b-\u200f\ufeff\u00a0]{4,}/g
|
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 {
|
function shouldStripElement(el: Element): boolean {
|
||||||
if (el.hasAttribute("hidden")) return true
|
if (el.hasAttribute("hidden")) return true
|
||||||
if (el.getAttribute("aria-hidden") === "true") return true
|
if (el.getAttribute("aria-hidden") === "true") return true
|
||||||
@ -15,10 +24,16 @@ function shouldStripElement(el: Element): boolean {
|
|||||||
const style = el.getAttribute("style") ?? ""
|
const style = el.getAttribute("style") ?? ""
|
||||||
if (!style) return false
|
if (!style) return false
|
||||||
const compact = style.replace(/\s+/g, "")
|
const compact = style.replace(/\s+/g, "")
|
||||||
return (
|
if (
|
||||||
HIDDEN_STYLE.test(style) ||
|
HIDDEN_STYLE.test(style) ||
|
||||||
(compact.includes("overflow:hidden") && /max-height\s*:\s*0/.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 {
|
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 {
|
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",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"test:email-preview-contrast": "node --experimental-strip-types --test lib/email-preview-contrast.test.ts",
|
||||||
"e2e": "playwright test",
|
"e2e": "playwright test",
|
||||||
"e2e:ui": "playwright test --ui",
|
"e2e:ui": "playwright test --ui",
|
||||||
"e2e:headed": "playwright test --headed",
|
"e2e:headed": "playwright test --headed",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user