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:
R3D347HR4Y 2026-06-04 00:12:43 +02:00
parent 5567e2f0c1
commit 8a02c10ba3
48 changed files with 2547 additions and 289 deletions

View File

@ -0,0 +1,409 @@
---
name: Drive Suite Build
overview: "Build UltiDrive as a Google Drivelike 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 Drivelike 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, ~12 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 (45 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 |

View 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
View 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

View File

@ -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

View File

@ -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;

View File

@ -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>

View File

@ -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}

View File

@ -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) => {

View File

@ -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,

View File

@ -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) => (

View File

@ -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>

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
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>

View File

@ -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}

View File

@ -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>

View File

@ -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>
) )

View File

@ -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}
/> />
) )

View File

@ -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" },

View File

@ -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

View 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
```

View 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>

View 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>

View 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>

View 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>

View File

@ -5,6 +5,9 @@
}, },
{ {
"path": "../ulti-backend" "path": "../ulti-backend"
},
{
"path": "../drive-suite"
} }
], ],
"settings": {} "settings": {}

View File

@ -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 })
} }

View 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])
}

View File

@ -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'] })

View File

@ -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 ?? {}
},
}) })
} }

View 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,
})
}

View 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] })
},
})
}

View 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
}

View File

@ -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

View File

@ -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 }
} }

View 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
)
})
})

View File

@ -0,0 +1,531 @@
/**
* Détection et correction de contraste dans les iframes daperç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
}

View File

@ -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, "")

View File

@ -0,0 +1,40 @@
/** Hauteur utile du corps dun iframe daperç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)
}

View File

@ -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
View 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
}

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
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,

View File

@ -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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/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)
} }

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
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>`
}

View File

@ -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(/&lt;/g, "<") .replace(/&lt;/g, "<")
.replace(/&gt;/g, ">") .replace(/&gt;/g, ">")
.replace(/&amp;/g, "&") .replace(/&amp;/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
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
} }

View File

@ -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)

View 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
}
}

View File

@ -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()
} }

View File

@ -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