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_CLIENT_ID=ulti-backend
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
NEXT_PUBLIC_APP_URL=http://localhost:3000
# URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail
NEXT_PUBLIC_APP_URL=http://localhost
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
OIDC_CLIENT_SECRET=changeme

View File

@ -934,6 +934,51 @@ html.dark .ultimail-app iframe[title='Sujet du message'] {
color-scheme: dark;
}
/* Hovercard destinataire (portail Radix hors .ultimail-app) */
html.dark [data-contact-hover-card] {
color-scheme: dark;
}
html.dark [data-contact-hover-card] :where(.text-\[\#202124\], .text-\[\#3c4043\], .text-\[\#1f1f1f\]) {
color: var(--foreground) !important;
}
html.dark [data-contact-hover-card] :where(.text-\[\#5f6368\], .text-\[\#444746\]) {
color: var(--muted-foreground) !important;
}
html.dark [data-contact-hover-card] :where(.text-\[\#001d35\]) {
color: var(--foreground) !important;
}
html.dark [data-contact-hover-card] :where(.bg-\[\#d3e3fd\]) {
background-color: var(--mail-nav-selected) !important;
}
html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#c4d9fc\]:hover) {
background-color: var(--mail-active) !important;
}
html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#f1f3f4\]:hover) {
background-color: var(--accent) !important;
}
html.dark [data-contact-hover-card] :where(.bg-\[\#f1f3f4\]) {
background-color: var(--accent) !important;
}
html.dark [data-contact-hover-card] :where(.hover\:bg-\[\#e8eaed\]:hover) {
background-color: var(--accent) !important;
}
html.dark [data-contact-hover-card] :where(.text-\[\#1a73e8\]) {
color: #8ab4f8 !important;
}
html.dark [data-contact-hover-card] :where(.border-\[\#eceff1\]) {
border-color: var(--border) !important;
}
/* ── Dark : panneau Contacts (formulaires) ── */
html.dark :where([data-contacts-panel] .bg-white) {
background-color: var(--mail-surface) !important;

View File

@ -490,7 +490,7 @@ export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer des fichiers avec Google Drive"
title="Insérer des fichiers depuis UltiDrive"
>
<HardDrive className="h-[18px] w-[18px]" />
</button>

View File

@ -167,6 +167,7 @@ export function ContactHoverCard({
</HoverCardTrigger>
<HoverCardContent
ref={contentRef}
data-contact-hover-card
side={side}
align={align}
sideOffset={8}

View File

@ -143,6 +143,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
const [starred, setStarred] = useState(false)
const [nameExpanded, setNameExpanded] = useState(false)
const [companyExpanded, setCompanyExpanded] = useState(false)
const hydratedEditIdRef = useRef<string | null>(null)
const existingContact =
mode === "edit" ? contacts.find((c) => c.id === contactId) : null
@ -221,45 +222,52 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
}, [mode, createDraft, reset, clearCreateDraft])
useEffect(() => {
if (existingContact) {
const hasExtendedName = !!(
existingContact.namePrefix ||
existingContact.middleName ||
existingContact.nameSuffix ||
existingContact.phoneticFirstName ||
existingContact.phoneticLastName
)
if (hasExtendedName) setNameExpanded(true)
if (existingContact.department) setCompanyExpanded(true)
reset({
namePrefix: existingContact.namePrefix ?? "",
firstName: existingContact.firstName,
middleName: existingContact.middleName ?? "",
lastName: existingContact.lastName,
nameSuffix: existingContact.nameSuffix ?? "",
phoneticFirstName: existingContact.phoneticFirstName ?? "",
phoneticLastName: existingContact.phoneticLastName ?? "",
company: existingContact.company ?? "",
department: existingContact.department ?? "",
jobTitle: existingContact.jobTitle ?? "",
emails: existingContact.emails.length
? existingContact.emails
: [{ value: "", label: "Domicile" }],
phones: existingContact.phones.length
? existingContact.phones
: [{ value: "", label: "Mobile" }],
addresses: existingContact.addresses ?? [],
birthday: existingContact.birthday ?? {
day: undefined,
month: undefined,
year: undefined,
},
notes: existingContact.notes ?? "",
labels: existingContact.labels ?? [],
})
if (mode !== "edit" || !contactId) {
hydratedEditIdRef.current = null
return
}
}, [existingContact, reset])
if (hydratedEditIdRef.current === contactId) return
const contact = contacts.find((c) => c.id === contactId)
if (!contact) return
hydratedEditIdRef.current = contactId
const hasExtendedName = !!(
contact.namePrefix ||
contact.middleName ||
contact.nameSuffix ||
contact.phoneticFirstName ||
contact.phoneticLastName
)
if (hasExtendedName) setNameExpanded(true)
if (contact.department) setCompanyExpanded(true)
reset({
namePrefix: contact.namePrefix ?? "",
firstName: contact.firstName,
middleName: contact.middleName ?? "",
lastName: contact.lastName,
nameSuffix: contact.nameSuffix ?? "",
phoneticFirstName: contact.phoneticFirstName ?? "",
phoneticLastName: contact.phoneticLastName ?? "",
company: contact.company ?? "",
department: contact.department ?? "",
jobTitle: contact.jobTitle ?? "",
emails: contact.emails.length
? contact.emails
: [{ value: "", label: "Domicile" }],
phones: contact.phones.length
? contact.phones
: [{ value: "", label: "Mobile" }],
addresses: contact.addresses ?? [],
birthday: contact.birthday ?? {
day: undefined,
month: undefined,
year: undefined,
},
notes: contact.notes ?? "",
labels: contact.labels ?? [],
})
}, [mode, contactId, contacts, reset])
const firstName = watch("firstName")
const lastName = watch("lastName")
@ -775,8 +783,8 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
const innerRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (innerRef.current && innerRef.current.value) setFilled(true)
})
if (innerRef.current?.value) setFilled(true)
}, [defaultValue])
const setRefs = useCallback(
(node: HTMLInputElement | null) => {
@ -842,8 +850,8 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
const innerRef = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => {
if (innerRef.current && innerRef.current.value) setFilled(true)
})
if (innerRef.current?.value) setFilled(true)
}, [])
const setRefs = useCallback(
(node: HTMLTextAreaElement | null) => {

View File

@ -65,7 +65,8 @@ import {
} from "@/components/gmail/email-list/email-list-helpers"
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { attachmentsForEmailList } from "@/lib/attachment-display"
import { resolveListRowAttachments } from "@/lib/attachment-display"
import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
import { cleanSenderName } from "@/lib/sender-display"
@ -621,6 +622,16 @@ export function useEmailListData({
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
const attachmentFetchIds = useMemo(
() =>
listEmails
.filter((e) => e.hasAttachment && !(e.attachments?.length))
.map((e) => e.id),
[listEmails]
)
const { byId: fetchedAttachmentsById, stateById: attachmentFetchStateById } =
useListMessageAttachments(attachmentFetchIds)
const listRowExtras = useMemo(() => {
const invitationById = new Map<
string,
@ -638,7 +649,15 @@ export function useEmailListData({
for (const e of listEmails) {
invitationById.set(e.id, resolveParsedCalendarInvitation(e))
attachmentsById.set(e.id, attachmentsForEmailList(e))
const fetchState = attachmentFetchStateById.get(e.id) ?? "idle"
attachmentsById.set(
e.id,
resolveListRowAttachments(
e,
fetchedAttachmentsById.get(e.id),
fetchState
)
)
if (showCategoryTabIcons) {
const tabs = resolveEmailInboxCategoryTabs(
e,
@ -653,6 +672,8 @@ export function useEmailListData({
return { invitationById, attachmentsById, categoryTabsById }
}, [
listEmails,
fetchedAttachmentsById,
attachmentFetchStateById,
selectedFolder,
inboxTab,
folderFilterCtx,

View File

@ -15,6 +15,8 @@ import {
senderInitial,
} from "@/lib/sender-display"
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
import { useMessageAttachments } from "@/lib/api/hooks/use-message-attachments"
import { attachmentsForEmailList } from "@/lib/attachment-display"
import type { Email, EmailAttachment } from "@/lib/email-data"
import {
mailFlagIsRead,
@ -65,6 +67,7 @@ import {
SpamWhyBanner,
ThreadPriorMessage,
formatApiMessageBody,
plainTextBodyFallback,
} from "@/components/gmail/email-view/email-view-messages"
function apiToLegacyEmail(
@ -148,6 +151,10 @@ export function EmailView({
),
[fullMessage, fullMessagePending, email.snippet]
)
const plainTextFallback = useMemo(
() => plainTextBodyFallback(fullMessage),
[fullMessage]
)
const [showFullThread, setShowFullThread] = useState(false)
const [mainDetailsOpen, setMainDetailsOpen] = useState(false)
@ -169,14 +176,11 @@ export function EmailView({
const showFullThreadList = !isSingleMessageView || showFullThread
const messagesBefore = showFullThreadList ? threadBefore : []
const messagesAfter = showFullThreadList ? threadAfter : []
/** Conversation preview: all thread messages expanded (each gets its own remote-content banner). */
const expandAllThreadMessages =
showFullThreadList && (!isSingleMessageView || showFullThread)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const isThreadMessageExpanded = useCallback(
(msgId: string) => expandAllThreadMessages || expandedIds.has(msgId),
[expandAllThreadMessages, expandedIds]
(msgId: string) => expandedIds.has(msgId),
[expandedIds]
)
const toggleExpanded = (msgId: string) => {
setExpandedIds((prev) => {
@ -216,11 +220,18 @@ export function EmailView({
[email, fullMessage, threadMessages]
)
const mainMessageAttachments = useMemo((): EmailAttachment[] => {
if (email.has_attachments)
return [{ name: "Pièce jointe", kind: "other" }]
return []
}, [email.has_attachments])
const { data: fetchedAttachments } = useMessageAttachments(
email.id,
email.has_attachments
)
const mainMessageAttachments = useMemo(
(): EmailAttachment[] =>
attachmentsForEmailList({
hasAttachment: email.has_attachments,
attachments: fetchedAttachments,
}),
[email.has_attachments, fetchedAttachments]
)
const { composeWindows } = useComposeWindows()
const { savedThreadReplyDrafts } = useComposeDrafts()
@ -393,6 +404,7 @@ export function EmailView({
onDetailsOpenChange={setMainDetailsOpen}
collapseQuotedReplies={otherThreadCount > 0}
messageId={email.id}
plainTextFallback={plainTextFallback}
/>
{messagesAfter.map((msg) => (

View File

@ -71,16 +71,16 @@ export function EmailViewDetailsPopover({
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
className="flex min-w-0 max-w-full items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
onMouseEnter={keepOpen}
onMouseLeave={() => {
if (open) scheduleClose()
}}
>
{summary}
<span className="min-w-0 truncate">{summary}</span>
<ChevronDown
className={cn("h-3 w-3 transition-transform", open && "rotate-180")}
className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-180")}
aria-hidden
/>
</button>

View File

@ -32,6 +32,15 @@ import {
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes"
import { repairMimeBodies } from "@/lib/mail-mime-body"
import { plainTextToDisplayHtml } from "@/lib/mail-plain-text-html"
export function plainTextBodyFallback(
full: { body_text?: string; body_html?: string } | null | undefined
): string | undefined {
const { bodyText } = repairMimeBodies(full?.body_text, full?.body_html)
const t = bodyText?.trim()
return t || undefined
}
export function formatApiMessageBody(
full: { body_html?: string; body_text?: string } | null | undefined,
@ -50,11 +59,7 @@ export function formatApiMessageBody(
if (html) return html
const text = repaired.bodyText?.trim()
if (text) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
return `<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${escaped}</pre>`
return plainTextToDisplayHtml(text)
}
if (full) {
const s = snippet?.trim()
@ -119,6 +124,10 @@ export function ThreadPriorMessage({
),
[fullMessage, message.snippet, isExpanded, isPending]
)
const plainTextFallback = useMemo(
() => plainTextBodyFallback(fullMessage),
[fullMessage]
)
const isSpam = messageIsSpam(merged.flags, merged.labels)
@ -151,6 +160,7 @@ export function ThreadPriorMessage({
onDetailsOpenChange={setDetailsOpen}
collapseQuotedReplies={collapseQuotedReplies}
messageId={message.id}
plainTextFallback={plainTextFallback}
/>
)
}
@ -232,6 +242,7 @@ export function ExpandedMessage({
onDetailsOpenChange,
collapseQuotedReplies = false,
messageId,
plainTextFallback,
}: {
sender: string
senderEmail: string
@ -251,6 +262,7 @@ export function ExpandedMessage({
detailsOpen?: boolean
onDetailsOpenChange?: (open: boolean) => void
collapseQuotedReplies?: boolean
plainTextFallback?: string
}) {
return (
<div>
@ -285,6 +297,7 @@ export function ExpandedMessage({
senderEmail={senderEmail}
messageId={messageId}
collapseQuotedReplies={collapseQuotedReplies}
plainTextFallback={plainTextFallback}
/>
</div>

View File

@ -134,7 +134,7 @@ export function EmailViewMessageToolbar({
</ContactHoverCard>
</div>
<div className="flex items-center gap-1">
<div className="flex min-w-0 items-center gap-1">
<EmailViewDetailsPopover
summary={headerDetails.recipientSummary}
details={headerDetails}

View File

@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { cn } from "@/lib/utils"
import { splitQuotedHtml } from "@/lib/mail-quoted-content"
import { htmlHasRemoteContent } from "@/lib/mail-remote-content"
@ -14,6 +14,7 @@ import {
useTrustedSendersStore,
} from "@/lib/stores/trusted-senders-store"
import { useMessageAttachmentCidMap } from "@/lib/api/hooks/use-message-attachment-cid-map"
import { useReindexMessageAttachments } from "@/lib/api/hooks/use-reindex-message-attachments"
import { useInlineCidUrls } from "@/lib/hooks/use-inline-cid-urls"
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
import { RemoteContentBanner } from "@/components/gmail/email-view/remote-content-banner"
@ -24,6 +25,7 @@ export function MessageBodyContent({
senderEmail,
messageId,
collapseQuotedReplies = false,
plainTextFallback,
}: {
html: string
isSpam: boolean
@ -31,6 +33,7 @@ export function MessageBodyContent({
messageId: string
/** Hide included prior messages when the thread already lists them. */
collapseQuotedReplies?: boolean
plainTextFallback?: string
}) {
const [showQuoted, setShowQuoted] = useState(false)
const selfEmails = useSelfMailEmails()
@ -63,8 +66,28 @@ export function MessageBodyContent({
}, [html, collapseQuotedReplies])
const { data: cidMap } = useMessageAttachmentCidMap(messageId)
const {
mutate: reindexInlineAttachments,
isPending: reindexPending,
isSuccess: reindexDone,
isError: reindexFailed,
} = useReindexMessageAttachments(messageId)
const cidUrlMap = useInlineCidUrls(cidMap)
useEffect(() => {
if (!/cid:/i.test(mainHtml)) return
if (Object.keys(cidMap ?? {}).length > 0) return
if (reindexPending || reindexDone || reindexFailed) return
reindexInlineAttachments()
}, [
mainHtml,
cidMap,
reindexPending,
reindexDone,
reindexFailed,
reindexInlineAttachments,
])
const hasRemoteContent = useMemo(
() =>
htmlHasRemoteContent(mainHtml) ||
@ -89,13 +112,14 @@ export function MessageBodyContent({
restrictPopups: isSpam,
senderEmail,
cidUrlMap,
plainTextFallback,
messageId,
}
return (
<div className="min-w-0">
{showRemoteBanner ? (
<RemoteContentBanner
senderEmail={senderEmail}
onShowOnce={() => allowMessageRemoteContent(messageId)}
onAlwaysShow={() => {
trustSender(senderEmail)
@ -103,7 +127,7 @@ export function MessageBodyContent({
}}
/>
) : null}
<SandboxedContent html={mainHtml} {...sandboxProps} />
<SandboxedContent html={mainHtml} previewPart="body" {...sandboxProps} />
{hasHiddenQuote ? (
<div className="mt-2">
<button
@ -122,7 +146,11 @@ export function MessageBodyContent({
) : null}
{quotedHtml && showQuoted ? (
<div className="mt-2 border-t border-border/60 pt-2">
<SandboxedContent html={quotedHtml} {...sandboxProps} />
<SandboxedContent
html={quotedHtml}
previewPart="quoted"
{...sandboxProps}
/>
</div>
) : null}
</div>

View File

@ -1,11 +1,9 @@
"use client"
export function RemoteContentBanner({
senderEmail,
onShowOnce,
onAlwaysShow,
}: {
senderEmail: string
onShowOnce: () => void
onAlwaysShow: () => void
}) {
@ -25,7 +23,7 @@ export function RemoteContentBanner({
onClick={onAlwaysShow}
className="text-primary hover:underline"
>
toujours afficher le contenu distant venant de {senderEmail}
toujours afficher le contenu distant de cet expéditeur
</button>
</p>
)

View File

@ -3,6 +3,7 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -12,14 +13,25 @@ import { useTheme } from "next-themes"
import {
emailPreviewBaseCss,
emailPreviewDarkOverrideCss,
emailPreviewDarkRemoteBodyTailCss,
emailPreviewDarkTailOverrideCss,
emailPreviewLightOverrideCss,
emailPreviewWrapperCss,
} from "@/lib/email-preview-dark-styles"
import {
EMAIL_PREVIEW_MIN_IFRAME_HEIGHT,
measureEmailPreviewIframeHeight,
} from "@/lib/email-preview-iframe-height"
import {
buildEmailPreviewSrcdoc,
prepareEmailHtmlForIframe,
injectEmailHtmlIntoDocument,
} from "@/lib/mail-html-iframe"
import { buildEmailPreviewCsp } from "@/lib/mail-remote-content"
import {
isEmailPreviewContrastDebugEnabled,
logEmailPreviewContrastIssues,
repairEmailPreviewContrast,
} from "@/lib/email-preview-contrast"
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
display: "block",
@ -30,34 +42,33 @@ function documentIsDark(): boolean {
return document.documentElement.classList.contains("dark")
}
function measureIframeContentHeight(doc: Document): number {
const body = doc.body
const root = doc.documentElement
if (!body) return 60
const heights = [
body.scrollHeight,
body.offsetHeight,
root?.scrollHeight ?? 0,
root?.clientHeight ?? 0,
]
return Math.max(60, ...heights) + 2
}
export function SandboxedContent({
html,
blockRemoteContent,
restrictPopups = false,
senderEmail,
cidUrlMap,
plainTextFallback,
messageId,
previewPart = "body",
}: {
html: string
blockRemoteContent: boolean
restrictPopups?: boolean
senderEmail?: string
cidUrlMap?: Record<string, string>
/** Plain body when HTML is image-only or empty with remote content blocked. */
plainTextFallback?: string
/** Pour les logs dev [email-preview:low-contrast]. */
messageId?: string
previewPart?: "body" | "quoted"
}) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [height, setHeight] = useState(120)
const contrastLoggedKeyRef = useRef<string | null>(null)
const contrastDelayTimerRef = useRef<number | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const contentGenerationRef = useRef(0)
const [height, setHeight] = useState(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
const sandboxValue = restrictPopups
? "allow-same-origin"
@ -77,12 +88,16 @@ export function SandboxedContent({
isDark,
senderEmail,
cidUrlMap,
plainTextFallback,
}),
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap]
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap, plainTextFallback]
)
const themeCss = useMemo(() => {
if (!blockRemoteContent) return emailPreviewWrapperCss()
if (!blockRemoteContent) {
const wrapper = emailPreviewWrapperCss(isDark)
return isDark ? `${wrapper}${emailPreviewDarkOverrideCss()}` : wrapper
}
return `${emailPreviewBaseCss(isDark)}${
isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss()
}`
@ -93,65 +108,152 @@ export function SandboxedContent({
[blockRemoteContent]
)
const injectContent = useCallback(() => {
const iframe = iframeRef.current
if (!iframe) return
const srcdoc = useMemo(
() =>
buildEmailPreviewSrcdoc(parsedEmail, {
csp: cspContent,
wrapperCss: themeCss,
plainTextFallback,
bodyTailCss: isDark
? blockRemoteContent
? emailPreviewDarkTailOverrideCss()
: emailPreviewDarkRemoteBodyTailCss()
: undefined,
}),
[
parsedEmail,
cspContent,
themeCss,
plainTextFallback,
isDark,
blockRemoteContent,
]
)
const doc = iframe.contentDocument
if (!doc) return
const iframeKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent ? "remote-blocked" : "remote-allowed"}:${isDark ? "dark" : "light"}`
injectEmailHtmlIntoDocument(doc, {
csp: cspContent,
documentBaseHref: parsedEmail.documentBaseHref,
resolveBaseHref: parsedEmail.resolveBaseHref,
headMarkup: parsedEmail.headMarkup,
bodyHtml: parsedEmail.bodyHtml,
wrapperCss: themeCss,
})
const syncHeight = useCallback((generation: number) => {
if (generation !== contentGenerationRef.current) return
const doc = iframeRef.current?.contentDocument
if (!doc?.body) return
const next = measureEmailPreviewIframeHeight(doc)
setHeight((prev) => (prev === next ? prev : next))
}, [])
const syncHeight = () => {
const liveDoc = iframe.contentDocument
if (!liveDoc) return
const next = measureIframeContentHeight(liveDoc)
setHeight((prev) => (prev === next ? prev : next))
}
const scheduleHeightSync = useCallback(
(generation: number) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
syncHeight(generation)
})
})
},
[syncHeight]
)
const resizeObserver = new ResizeObserver(syncHeight)
const runContrastPipeline = useCallback(
(doc: Document, pass: "initial" | "delayed", generation: number) => {
if (!isDark || generation !== contentGenerationRef.current) return
if (doc.body) {
resizeObserver.observe(doc.body)
for (const img of doc.images) {
if (!img.complete) {
img.addEventListener("load", syncHeight, { once: true })
img.addEventListener("error", syncHeight, { once: true })
const repair = repairEmailPreviewContrast(doc, {
isDark: true,
repairMode: blockRemoteContent ? "dark-only" : "all",
newsletterLightCanvas: false,
assumedCanvasRgb: [32, 33, 36],
})
if (repair && (repair.lightSurfaces > 0 || repair.darkSurfaces > 0)) {
scheduleHeightSync(generation)
if (isEmailPreviewContrastDebugEnabled()) {
console.info("[email-preview:contrast-repaired]", {
messageId,
part: previewPart,
pass,
blockRemoteContent,
...repair,
})
}
}
for (const link of doc.querySelectorAll('link[rel~="stylesheet"]')) {
link.addEventListener("load", syncHeight, { once: true })
link.addEventListener("error", syncHeight, { once: true })
}
syncHeight()
requestAnimationFrame(syncHeight)
setTimeout(syncHeight, 250)
setTimeout(syncHeight, 1000)
if (!isEmailPreviewContrastDebugEnabled()) return
const logKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent}:${isDark}:${srcdoc.length}:${pass}`
if (contrastLoggedKeyRef.current === logKey) return
contrastLoggedKeyRef.current = logKey
logEmailPreviewContrastIssues(
{
messageId,
part: previewPart,
blockRemoteContent,
isDark,
senderEmail,
},
doc,
{ newsletterLightCanvas: false, assumedCanvasRgb: [32, 33, 36] }
)
},
[
messageId,
previewPart,
blockRemoteContent,
isDark,
senderEmail,
srcdoc,
scheduleHeightSync,
]
)
const handleIframeLoad = useCallback(() => {
const generation = contentGenerationRef.current
const doc = iframeRef.current?.contentDocument
resizeObserverRef.current?.disconnect()
if (doc?.body) {
resizeObserverRef.current = new ResizeObserver(() => {
syncHeight(generation)
})
resizeObserverRef.current.observe(doc.body)
}
return () => resizeObserver.disconnect()
}, [parsedEmail, themeCss, cspContent])
scheduleHeightSync(generation)
if (doc) runContrastPipeline(doc, "initial", generation)
useEffect(() => {
const cleanup = injectContent()
return () => cleanup?.()
}, [injectContent])
if (contrastDelayTimerRef.current !== null) {
window.clearTimeout(contrastDelayTimerRef.current)
}
contrastDelayTimerRef.current = window.setTimeout(() => {
contrastDelayTimerRef.current = null
if (generation !== contentGenerationRef.current) return
const lateDoc = iframeRef.current?.contentDocument
if (lateDoc) {
scheduleHeightSync(generation)
runContrastPipeline(lateDoc, "delayed", generation)
}
}, 1000)
}, [scheduleHeightSync, runContrastPipeline, syncHeight])
useLayoutEffect(() => {
contentGenerationRef.current += 1
setHeight(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
contrastLoggedKeyRef.current = null
resizeObserverRef.current?.disconnect()
resizeObserverRef.current = null
if (contrastDelayTimerRef.current !== null) {
window.clearTimeout(contrastDelayTimerRef.current)
contrastDelayTimerRef.current = null
}
}, [srcdoc, messageId])
return (
<iframe
key={blockRemoteContent ? "remote-blocked" : "remote-allowed"}
key={iframeKey}
ref={iframeRef}
sandbox={sandboxValue}
title="Contenu du message"
className="w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
srcDoc={srcdoc}
onLoad={handleIframeLoad}
tabIndex={-1}
/>
)

View File

@ -35,7 +35,7 @@ const googleApps: FavoriteApp[] = [
{ name: "Agenda", icon: "/agenda-mark.svg" },
{ name: "Photos", icon: "/photos-mark.svg" },
{ name: "Ultimail", icon: "/brand/ultimail-header-icon.png", href: "/mail" },
{ name: "UltiDrive", icon: "/ultidrive-mark.svg" },
{ name: "UltiDrive", icon: "/ultidrive-mark.svg", href: "/drive" },
{ name: "UltiMeet", icon: "/ultimeet-mark.svg" },
{ name: "Administration", icon: "/admin-mark.svg" },
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },

View File

@ -105,18 +105,22 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
try {
const result = await resanitizeBodies.mutateAsync()
setMaintenanceMessage(
`HTML re-sanitisé : ${result.updated} message(s) mis à jour sur ${result.scanned} analysé(s).`
`Corps réimportés depuis IMAP : ${result.updated} message(s) mis à jour sur ${result.scanned} analysé(s).`
)
} catch {
setMaintenanceMessage("Échec de la re-sanitisation du HTML.")
setMaintenanceMessage("Échec de la réimportation des corps depuis IMAP.")
}
}
async function runSync() {
async function runSync(force = false) {
setMaintenanceMessage(null)
try {
await syncAccount.mutateAsync()
setMaintenanceMessage("Synchronisation IMAP terminée.")
await syncAccount.mutateAsync({ force })
setMaintenanceMessage(
force
? "Re-synchronisation complète IMAP terminée."
: "Synchronisation IMAP terminée."
)
} catch {
setMaintenanceMessage("Échec de la synchronisation IMAP.")
}
@ -156,8 +160,8 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
onClick={() => void runResanitize()}
>
{resanitizeBodies.isPending
? "Re-sanitisation…"
: "Re-sanitiser le HTML"}
? "Réimportation IMAP…"
: "Réimporter les corps depuis IMAP"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={maintenancePending}
@ -165,6 +169,12 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
>
{syncAccount.isPending ? "Synchronisation…" : "Synchroniser IMAP"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={maintenancePending}
onClick={() => void runSync(true)}
>
Forcer re-sync complet
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button

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": "../drive-suite"
}
],
"settings": {}

View File

@ -185,6 +185,28 @@ class ApiClient {
return this.request<T>("GET", path, { params })
}
/** GET binary body (inline attachments, exports). */
async getBlob(path: string): Promise<Blob> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError()
}
const url = this.resolveUrl(path)
const headers: Record<string, string> = {}
const token = useAuthStore.getState().accessToken
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const response = await fetch(url.toString(), { method: "GET", headers })
if (!response.ok) {
throw new ApiRequestError(
response.status,
"UNKNOWN",
response.statusText
)
}
return response.blob()
}
async post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("POST", path, { body })
}

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()
return useMutation({
mutationFn: () =>
apiClient.post<{ status: string }>(`/mail/accounts/${accountId}/sync`),
mutationFn: (opts?: { force?: boolean }) =>
apiClient.post<{ status: string }>(
`/mail/accounts/${accountId}/sync${opts?.force ? "?force=true" : ""}`
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] })
queryClient.invalidateQueries({ queryKey: ['messages'] })

View File

@ -3,23 +3,67 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "../client"
import { useAuthReady } from "../use-auth-ready"
import { normalizeCidKey } from "@/lib/mail-cid"
import {
type ApiMessageAttachment,
} from "../map-message-attachments"
type CidMapResponse = {
cid_map?: Record<string, string>
}
type AttachmentsResponse = {
attachments?: ApiMessageAttachment[]
}
function mergeAttachmentIntoCidMap(
map: Record<string, string>,
att: ApiMessageAttachment
): void {
if (!att.is_inline || !att.id) return
if (att.content_id) {
const key = normalizeCidKey(att.content_id)
map[key] = att.id
map[key.toLowerCase()] = att.id
map[`cid:${key}`] = att.id
map[`cid:${key.toLowerCase()}`] = att.id
}
if (att.filename) {
const base = att.filename.includes("/")
? att.filename.split("/").pop()!
: att.filename
const key = normalizeCidKey(base)
map[key] = att.id
map[key.toLowerCase()] = att.id
map[`cid:${key}`] = att.id
map[`cid:${key.toLowerCase()}`] = att.id
}
}
async function fetchMessageCidMap(messageId: string): Promise<Record<string, string>> {
const [cidRes, listRes] = await Promise.all([
apiClient.get<CidMapResponse>(
`/mail/messages/${messageId}/attachments/cid-map`
),
apiClient.get<AttachmentsResponse>(
`/mail/messages/${messageId}/attachments`
),
])
const map: Record<string, string> = { ...(cidRes?.cid_map ?? {}) }
for (const att of listRes?.attachments ?? []) {
mergeAttachmentIntoCidMap(map, att)
}
return map
}
export function useMessageAttachmentCidMap(messageId: string | undefined) {
const authReady = useAuthReady()
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["message-cid-map", messageId],
enabled: authReady && Boolean(messageId),
enabled: ready && authenticated && Boolean(messageId),
staleTime: 5 * 60_000,
queryFn: async () => {
const res = await apiClient.get<CidMapResponse>(
`/mail/messages/${messageId}/attachments/cid-map`
)
return res?.cid_map ?? {}
},
queryFn: () => fetchMessageCidMap(messageId!),
})
}

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 []
}
/** Liste : noms réels si fetch OK ; pas de chip générique pendant le chargement. */
export function resolveListRowAttachments(
email: Pick<Email, "attachments" | "hasAttachment" | "hasInvitation">,
fetched: EmailAttachment[] | undefined,
fetchState: "idle" | "loading" | "done"
): EmailAttachment[] {
if (email.attachments?.length) {
return attachmentsForEmailList(email)
}
if (fetched?.length) {
return attachmentsForEmailList({ ...email, attachments: fetched })
}
if (email.hasAttachment && fetchState === "loading") {
return []
}
return attachmentsForEmailList(email)
}
/** Fichiers « riches » (cartes type Gmail) : PDF, images, vidéos, bureautique. */
const RICH_PREVIEW_EXT =
/\.(mp4|mpe?g|webm|mov|avi|mkv|m4v|wmv|flv|xls|xlsx|xlsm|ods|numbers|ppt|pptx|key|odp|doc|docx|odt|rtf)$/i

View File

@ -1,5 +1,6 @@
'use client'
import { useMemo } from 'react'
import {
useContacts,
useDefaultContactBookId,
@ -10,6 +11,9 @@ export function useContactsList(bookId?: string) {
const defaultBookId = useDefaultContactBookId()
const resolvedBookId = bookId ?? defaultBookId
const { data: apiContacts, ...rest } = useContacts(resolvedBookId)
const contacts = apiContacts?.map(apiContactToFullContact) ?? []
const contacts = useMemo(
() => apiContacts?.map(apiContactToFullContact) ?? [],
[apiContacts]
)
return { contacts, bookId: resolvedBookId, ...rest }
}

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é). */
export function emailPreviewWrapperCss(): string {
export function emailPreviewWrapperCss(isDark = false): string {
return `
html {
color-scheme: ${isDark ? "dark" : "light"};
background: transparent !important;
}
html, body {
margin: 0;
padding: 0;
@ -137,6 +141,49 @@ export function emailPreviewDarkOverrideCss(): string {
`
}
/**
* En fin de <body> (après les <style> expéditeur) quand le contenu distant est bloqué.
* Gagne la cascade sur p { color: #333 !important } dans le corps du mail.
*/
export function emailPreviewDarkTailOverrideCss(): string {
return `
body p, body span, body div, body td, body th, body li, body font,
body h1, body h2, body h3, body h4, body h5, body h6,
body label, body strong, body b, body em, body i, body u,
body center, body blockquote, body pre {
color: ${DARK_TEXT} !important;
}
body a, body a * {
color: ${DARK_LINK} !important;
}
[color], font[color] {
color: ${DARK_TEXT} !important;
}
`
}
const LIGHT_SURFACE_TEXT_TAGS =
"p,span,div,td,th,li,h1,h2,h3,h4,h5,h6,font,label,strong,b,em,i,u,center,blockquote,pre"
/** Fin de body + contenu distant : texte clair par défaut, zones claires marquées au runtime. */
export function emailPreviewDarkRemoteBodyTailCss(): string {
const lightSurfaceChildren = LIGHT_SURFACE_TEXT_TAGS.split(",")
.map((t) => `[data-ultimail-light-surface] ${t}`)
.join(", ")
return `
${emailPreviewDarkTailOverrideCss()}
${lightSurfaceChildren},
[data-ultimail-light-surface] {
color: ${LIGHT_TEXT} !important;
}
[data-ultimail-light-surface] a,
[data-ultimail-light-surface] a * {
color: ${LIGHT_LINK} !important;
}
`
}
/** Adoucit les fonds très sombres en mode clair (e-mails « dark »). */
export function emailPreviewLightOverrideCss(): string {
return `
@ -197,9 +244,54 @@ function rewriteInlineStyles(html: string, isDark: boolean): string {
)
}
function normalizeAttrColorValue(raw: string): string {
const v = raw.trim()
if (/^#?[0-9a-f]{3,8}$/i.test(v)) {
return v.startsWith("#") ? v : `#${v}`
}
return v
}
function isDarkColorAttrValue(raw: string): boolean {
const normalized = normalizeAttrColorValue(raw).toLowerCase()
if (normalized === "black") return true
const hex = normalized.match(/^#([0-9a-f]{3,8})$/i)
if (hex) {
let h = hex[1]
if (h.length === 3) h = h.split("").map((c) => c + c).join("")
if (h.length >= 6) {
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 120
}
}
const rgb = normalized.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
if (rgb) {
const r = Number(rgb[1])
const g = Number(rgb[2])
const b = Number(rgb[3])
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 120
}
return false
}
function rewriteHtmlColorAttributes(html: string, isDark: boolean): string {
if (!isDark) return html
return html.replace(
/\s(color)=(["'])([^"']*)\2/gi,
(match, attr: string, quote: string, value: string) => {
if (attr.toLowerCase() !== "color") return match
if (!isDarkColorAttrValue(value)) return match
return ` color=${quote}${DARK_TEXT}${quote}`
}
)
}
export function preprocessEmailHtmlForTheme(html: string, isDark: boolean): string {
let next = stripHiddenEmailHtml(html)
next = rewriteInlineStyles(next, isDark)
next = rewriteHtmlColorAttributes(next, isDark)
if (isDark) {
next = next.replace(LIGHT_BG_STYLE, "background:transparent")
next = next.replace(/\sbgcolor=(["'])(?:#?(?:fff(?:fff)?|ffffff|white)|#f[0-9a-f]{5})\1/gi, "")

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,54 +1,41 @@
"use client"
import { useEffect, useState } from "react"
import { useAuthStore } from "@/lib/api/auth-store"
function attachmentInlineUrl(attachmentId: string): string {
const base = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base
return `${normalizedBase}/mail/attachments/${encodeURIComponent(attachmentId)}/inline`
}
function normalizeCidKey(raw: string): string {
const trimmed = raw.trim()
if (trimmed.toLowerCase().startsWith("cid:")) {
return trimmed.slice(4).trim()
}
return trimmed
}
import { apiClient } from "@/lib/api/client"
import { registerCidUrlAliases } from "@/lib/mail-cid"
/** Fetches inline attachment blobs and exposes blob: URLs keyed by cid (with or without cid: prefix). */
export function useInlineCidUrls(cidMap: Record<string, string> | undefined) {
const accessToken = useAuthStore((s) => s.accessToken)
const [urls, setUrls] = useState<Record<string, string>>({})
useEffect(() => {
if (!accessToken || !cidMap || Object.keys(cidMap).length === 0) {
if (!cidMap || Object.keys(cidMap).length === 0) {
setUrls({})
return
}
let cancelled = false
const objectUrls: string[] = []
void (async () => {
const next: Record<string, string> = {}
const blobByAttachmentId = new Map<string, string>()
for (const [contentId, attachmentId] of Object.entries(cidMap)) {
if (!attachmentId) continue
try {
const res = await fetch(attachmentInlineUrl(attachmentId), {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) continue
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
objectUrls.push(blobUrl)
const key = normalizeCidKey(contentId)
next[key] = blobUrl
next[`cid:${key}`] = blobUrl
} catch {
// skip broken inline parts
let blobUrl = blobByAttachmentId.get(attachmentId)
if (!blobUrl) {
try {
const blob = await apiClient.getBlob(
`/mail/attachments/${encodeURIComponent(attachmentId)}/inline`
)
blobUrl = URL.createObjectURL(blob)
blobByAttachmentId.set(attachmentId, blobUrl)
objectUrls.push(blobUrl)
} catch {
continue
}
}
registerCidUrlAliases(next, contentId, blobUrl)
}
if (!cancelled) {
setUrls(next)
@ -61,7 +48,7 @@ export function useInlineCidUrls(cidMap: Record<string, string> | undefined) {
cancelled = true
for (const u of objectUrls) URL.revokeObjectURL(u)
}
}, [accessToken, cidMap])
}, [cidMap])
return urls
}

57
lib/mail-cid.ts Normal file
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. */
import { stripHiddenEmailHtml } from "@/lib/strip-hidden-email-html"
import { stripExecutableEmailHtml } from "@/lib/strip-executable-email-html"
import { preprocessEmailHtmlForTheme } from "@/lib/email-preview-dark-styles"
import { resolveCidReference } from "@/lib/mail-cid"
const REMOTE_URL = /^https?:\/\//i
const PROTOCOL_RELATIVE = /^\/\//
@ -246,6 +248,154 @@ function rewriteHeadMarkupUrls(headMarkup: string, baseHref?: string): string {
)
}
const REMOTE_CSS_URL =
/url\s*\(\s*['"]?(?:https?:\/\/|\/\/|[^'"data:#][^'")]*)/i
function neutralizeRemoteUrlsInCss(css: string): string {
return css.replace(
/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/gi,
(_match, url: string) => {
const trimmed = url.trim()
if (
trimmed.startsWith("data:") ||
trimmed.startsWith("#") ||
trimmed.startsWith("cid:")
) {
return `url(${trimmed})`
}
if (REMOTE_URL.test(trimmed) || PROTOCOL_RELATIVE.test(trimmed)) {
return "url(about:blank)"
}
return `url(${trimmed})`
}
)
}
/** Keep inline <style>; drop external stylesheets when remote content is blocked. */
export function filterHeadMarkupForBlockedRemote(headMarkup: string): string {
if (!headMarkup.trim() || typeof DOMParser === "undefined") {
return headMarkup.replace(/<link\b[^>]*>/gi, "")
}
try {
const doc = new DOMParser().parseFromString(
`<div id="head-root">${headMarkup}</div>`,
"text/html"
)
const root = doc.getElementById("head-root")
if (!root) return headMarkup
for (const link of root.querySelectorAll("link")) {
if (isStylesheetLink(link)) link.remove()
}
for (const style of root.querySelectorAll("style")) {
const css = style.textContent ?? ""
if (css) style.textContent = neutralizeRemoteUrlsInCss(css)
}
return root.innerHTML.trim()
} catch {
return headMarkup.replace(/<link\b[^>]*>/gi, "")
}
}
function clearRemoteImageAttrs(img: Element): void {
for (const name of [
"src",
"srcset",
"sizes",
"background",
...LAZY_IMG_SRC_ATTRS,
] as const) {
img.removeAttribute(name)
}
}
/** Newsletter HTML: keep layout/text; block remote images and external CSS. */
export function stripRemoteResourcesForPreview(html: string): string {
if (!html.trim() || typeof DOMParser === "undefined") return html
try {
const doc = new DOMParser().parseFromString(html, "text/html")
for (const link of doc.querySelectorAll("link")) {
if (isStylesheetLink(link)) link.remove()
}
for (const el of doc.querySelectorAll("[style]")) {
const style = el.getAttribute("style")
if (style && REMOTE_CSS_URL.test(style)) {
el.setAttribute("style", neutralizeRemoteUrlsInCss(style))
}
}
for (const el of doc.querySelectorAll("[background]")) {
const bg = el.getAttribute("background")?.trim() ?? ""
if (bg && isRemoteOrRelativeUrl(bg)) el.removeAttribute("background")
}
for (const img of doc.querySelectorAll("img")) {
const src = img.getAttribute("src") ?? ""
const lazy = firstLazyImageSrc(img)
const remote =
(src.trim() && isRemoteOrRelativeUrl(src) && !src.startsWith("cid:")) ||
Boolean(lazy && isRemoteOrRelativeUrl(lazy))
if (!remote) continue
const alt = img.getAttribute("alt")?.trim()
clearRemoteImageAttrs(img)
img.setAttribute(
"style",
`${img.getAttribute("style") ?? ""};max-width:100%;height:auto;object-fit:contain;background:#e8eaed;border-radius:4px;`.replace(
/^;/,
""
)
)
if (alt) {
const caption = doc.createElement("p")
caption.setAttribute(
"style",
"margin:4px 0 12px;font:13px/1.4 sans-serif;color:#5f6368"
)
caption.textContent = alt
img.insertAdjacentElement("afterend", caption)
}
}
return doc.body.innerHTML
} catch {
return html
}
}
export function htmlHasMeaningfulVisibleText(html: string, minChars = 24): boolean {
if (!html.trim() || typeof DOMParser === "undefined") {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().length >= minChars
}
try {
const doc = new DOMParser().parseFromString(html, "text/html")
const text = (doc.body?.textContent ?? "").replace(/\s+/g, " ").trim()
return text.length >= minChars
} catch {
return false
}
}
export function plainTextFallbackHtml(text: string): string {
const trimmed = text.trim()
if (!trimmed) return ""
const escaped = trimmed
.replace(/&/g, "&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 {
const t = url.trim()
return (
@ -338,96 +488,83 @@ export function activateRemoteResourcesInHtml(
}
}
/** Injecte le mail dans le document iframe sans doc.write (préserve les <style>). */
export function injectEmailHtmlIntoDocument(
doc: Document,
/** Full HTML document for iframe srcDoc (scripts stripped; no live DOM injection). */
export function buildEmailPreviewSrcdoc(
parsed: ParsedEmailHtml,
options: {
csp: string
/** Explicit <base href> from the message, if any. */
documentBaseHref?: string
/** Inferred origin for relative assets when the message has no <base>. */
resolveBaseHref?: string
headMarkup: string
bodyHtml: string
wrapperCss: string
plainTextFallback?: string
/** CSS injecté en fin de body (après styles expéditeur inline). */
bodyTailCss?: string
}
): void {
doc.open()
doc.write("<!DOCTYPE html><html><head></head><body></body></html>")
doc.close()
): string {
const baseHref = parsed.documentBaseHref ?? parsed.resolveBaseHref
// headMarkup is CSS (<style> blocks) — only strip script-like tags via regex
const headMarkup = (parsed.headMarkup ?? "")
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/<script\b[^>]*\/>/gi, "")
let bodyHtml = stripExecutableEmailHtml(parsed.bodyHtml)
const head = doc.head
const body = doc.body
if (!head || !body) return
const charset = doc.createElement("meta")
charset.setAttribute("charset", "utf-8")
head.appendChild(charset)
const csp = doc.createElement("meta")
csp.setAttribute("http-equiv", "Content-Security-Policy")
csp.setAttribute("content", options.csp)
head.appendChild(csp)
const baseHref = options.documentBaseHref ?? options.resolveBaseHref
if (baseHref) {
const base = doc.createElement("base")
base.setAttribute("href", baseHref)
base.setAttribute("target", "_blank")
head.appendChild(base)
if (!bodyHtml.trim() && options.plainTextFallback?.trim()) {
bodyHtml = plainTextFallbackHtml(options.plainTextFallback)
}
if (!bodyHtml.trim()) {
bodyHtml =
'<p style="color:#5f6368;font:14px sans-serif;margin:0">Ce message n\'a pas de contenu affichable.</p>'
}
const wrapper = doc.createElement("style")
wrapper.setAttribute("data-ultimail-wrapper", "true")
wrapper.textContent = options.wrapperCss
head.appendChild(wrapper)
const baseTag = baseHref
? `<base href="${escapeHtmlAttr(baseHref)}" target="_blank">`
: ""
if (options.headMarkup.trim()) {
const tpl = doc.createElement("template")
tpl.innerHTML = options.headMarkup
head.append(...Array.from(tpl.content.childNodes))
}
const bodyTailStyle = options.bodyTailCss
? `<style data-ultimail-tail="true">${options.bodyTailCss}</style>`
: ""
body.replaceChildren()
if (options.bodyHtml.trim()) {
const tpl = doc.createElement("template")
tpl.innerHTML = options.bodyHtml
body.append(...Array.from(tpl.content.childNodes))
} else {
const empty = doc.createElement("p")
empty.textContent = "Ce message n'a pas de contenu affichable."
empty.setAttribute(
"style",
"color:#5f6368;font:14px sans-serif;margin:0;"
)
body.appendChild(empty)
}
return (
"<!DOCTYPE html><html><head>" +
'<meta charset="utf-8">' +
`<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttr(options.csp)}">` +
baseTag +
`<style data-ultimail-wrapper="true">${options.wrapperCss}</style>` +
headMarkup +
"</head><body>" +
bodyHtml +
bodyTailStyle +
"</body></html>"
)
}
/** Replace cid: image references with resolved blob or API URLs. */
/** Replace cid: references with blob URLs, or a data placeholder until blobs load. */
export function rewriteCidUrlsInHtml(
html: string,
cidUrlMap: Record<string, string> | undefined
): string {
if (!html.trim() || !cidUrlMap || Object.keys(cidUrlMap).length === 0) {
return html
}
if (typeof DOMParser === "undefined") return html
if (!html.trim() || typeof DOMParser === "undefined") return html
if (!html.includes("cid:")) return html
const resolveCid = (value: string): string => {
const trimmed = value.trim()
if (!trimmed.toLowerCase().startsWith("cid:")) return trimmed
const key = trimmed.slice(4).trim()
return cidUrlMap[key] ?? cidUrlMap[trimmed] ?? trimmed
}
const resolveCid = (value: string): string => resolveCidReference(cidUrlMap, value)
const CID_ATTRS = [
"src",
"data-src",
"data-original",
"data-lazy-src",
"background",
"poster",
] as const
try {
const doc = new DOMParser().parseFromString(html, "text/html")
for (const img of doc.querySelectorAll("img")) {
for (const attr of ["src", "data-src", "data-original", "data-lazy-src"] as const) {
for (const img of doc.querySelectorAll("img, video, source, table, td, th, div, span")) {
for (const attr of CID_ATTRS) {
const value = img.getAttribute(attr)
if (value?.toLowerCase().startsWith("cid:")) {
img.setAttribute(attr, resolveCid(value))
if (value?.toLowerCase().includes("cid:")) {
img.setAttribute(
attr,
value.replace(/cid:[^\s'")]+/gi, (match) => resolveCid(match))
)
}
}
const srcset = img.getAttribute("srcset")
@ -453,6 +590,17 @@ export function rewriteCidUrlsInHtml(
}
}
function maybePrependPlainTextFallback(
bodyHtml: string,
plainTextFallback?: string
): string {
const fallback = plainTextFallback?.trim()
if (!fallback || htmlHasMeaningfulVisibleText(bodyHtml)) {
return bodyHtml
}
return plainTextFallbackHtml(fallback) + (bodyHtml || "")
}
export function prepareEmailHtmlForIframe(
html: string,
options: {
@ -460,9 +608,14 @@ export function prepareEmailHtmlForIframe(
isDark: boolean
senderEmail?: string
cidUrlMap?: Record<string, string>
plainTextFallback?: string
}
): ParsedEmailHtml {
if (!html.trim()) {
const fallback = options.plainTextFallback?.trim()
if (fallback) {
return { headMarkup: "", bodyHtml: plainTextFallbackHtml(fallback) }
}
return { headMarkup: "", bodyHtml: "" }
}
@ -474,13 +627,21 @@ export function prepareEmailHtmlForIframe(
if (options.blockRemoteContent) {
const parsed = parseEmailHtmlForIframe(html, resolveBaseHref)
const bodyHtml = rewriteCidUrlsInHtml(
preprocessEmailHtmlForTheme(parsed.bodyHtml || html, options.isDark),
let bodyHtml = stripHiddenEmailHtml(parsed.bodyHtml || html)
bodyHtml = stripRemoteResourcesForPreview(bodyHtml)
bodyHtml = rewriteCidUrlsInHtml(
preprocessEmailHtmlForTheme(bodyHtml, options.isDark),
options.cidUrlMap
)
const headMarkup = rewriteCidUrlsInHtml(
filterHeadMarkupForBlockedRemote(parsed.headMarkup),
options.cidUrlMap
)
bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback)
return {
headMarkup: "",
headMarkup,
bodyHtml,
resolveBaseHref,
}
}
@ -490,8 +651,12 @@ export function prepareEmailHtmlForIframe(
baseHref: resolveBaseHref,
})
bodyHtml = rewriteCidUrlsInHtml(bodyHtml, options.cidUrlMap)
bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback)
const headMarkup = rewriteHeadMarkupUrls(parsed.headMarkup, resolveBaseHref)
const headMarkup = rewriteCidUrlsInHtml(
rewriteHeadMarkupUrls(parsed.headMarkup, resolveBaseHref),
options.cidUrlMap
)
return {
headMarkup,

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, "")
try {
if (typeof atob !== "undefined") {
const bytes = Uint8Array.from(atob(clean), (c) => c.charCodeAt(0))
return new TextDecoder("utf-8").decode(bytes)
return decodeBytesToUtf8(bytes, charset)
}
} catch {
return ""
@ -60,14 +112,20 @@ function parseEmbeddedMime(raw: string): { text: string; html: string } | null {
const headers = trimmed.slice(0, headerEnd)
const body = trimmed.slice(headerEnd).replace(/^[\r\n]+/, "")
const typeMatch = headers.match(/Content-Type:\s*([^\r\n;]+)/i)
const mediaType = typeMatch?.[1]?.trim().toLowerCase() ?? ""
const typeHeader = headers.match(/Content-Type:\s*([^\r\n]+)/i)?.[1] ?? ""
const mediaType = typeHeader.split(";")[0]?.trim().toLowerCase() ?? ""
const charset = charsetFromContentType(typeHeader)
const encMatch = headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)
const encoding = encMatch?.[1]?.trim().toLowerCase() ?? ""
let decoded = body.trim()
if (encoding === "base64") {
decoded = decodeBase64Part(decoded)
decoded = decodeBase64Part(decoded, charset)
} else if (encoding === "quoted-printable" || looksLikeQuotedPrintable(decoded)) {
decoded = decodeQuotedPrintableIfNeeded(decoded)
} else {
const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0) & 0xff)
decoded = decodeBytesToUtf8(bytes, charset)
}
if (mediaType === "text/plain" && !text) text = decoded
@ -117,7 +175,7 @@ function decodeQuotedPrintableIfNeeded(s: string): string {
bytes.push(ch.charCodeAt(0))
i += 1
}
return new TextDecoder("utf-8").decode(new Uint8Array(bytes))
return decodeBytesToUtf8(new Uint8Array(bytes))
} catch {
return s
}
@ -134,8 +192,8 @@ export function repairMimeBodies(
bodyText?: string,
bodyHtml?: string
): { bodyText?: string; bodyHtml?: string } {
let text = bodyText?.trim() ?? ""
let html = bodyHtml?.trim() ?? ""
let text = repairLegacyCharsetString(bodyText?.trim() ?? "")
let html = repairLegacyCharsetString(bodyHtml?.trim() ?? "")
text = decodeQuotedPrintableIfNeeded(text)
html = decodeQuotedPrintableIfNeeded(html)
@ -155,16 +213,92 @@ export function repairMimeBodies(
}
}
/** List/search preview stored as undecoded base64. */
function looksLikeCssSnippet(s: string): boolean {
const lower = s.toLowerCase()
return (
lower.includes(":root") ||
lower.includes("color-scheme:") ||
lower.includes("@media") ||
(s.includes("{") && s.includes("}") && s.split(";").length >= 3) ||
/^\s*\/\*/.test(s)
)
}
function isMostlySeparatorLine(s: string): boolean {
if (s.length < 8) return false
const sep = (s.match(/[-_*=·—]/g) ?? []).length
return sep / s.length >= 0.6
}
function isSnippetBoilerplate(s: string): boolean {
const t = stripHtmlTagsForSnippet(s.trim())
if (!t || t.length < 4) return true
if (looksLikeCssSnippet(t) || isMostlySeparatorLine(t)) return true
if (/<[^>]+>/.test(s)) return true
const lower = t.toLowerCase()
const phrases = [
"afficher dans le navigateur",
"view in browser",
"si vous ne visualisez pas",
"cliquer ici",
]
if (phrases.some((p) => lower.includes(p)) && t.length < 160) return true
const letters = (t.match(/\p{L}|\p{N}/gu) ?? []).length
return letters / [...t].length < 0.35
}
function pickBestSnippetLine(lines: string[]): string {
let best = ""
let bestScore = -1
for (const line of lines) {
const t = line.trim()
if (!t || isSnippetBoilerplate(t)) continue
const letters = (t.match(/\p{L}/gu) ?? []).length
if (letters < 8) continue
let score = letters * 4
if (t.length > 40 && t.length < 280) score += 40
if (score > bestScore) {
bestScore = score
best = t
}
}
return best
}
function stripHtmlTagsForSnippet(s: string): string {
const stripped = s
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&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 {
if (!snippet?.trim()) return snippet
const trimmed = snippet.trim()
const qp = decodeQuotedPrintableIfNeeded(trimmed)
const decoded = decodeBareBase64IfNeeded(qp)
if (decoded !== trimmed) {
return stripInvisibleTextRuns(
decoded.length > 200 ? decoded.slice(0, 200) : decoded
)
}
return stripInvisibleTextRuns(snippet)
const raw = decoded !== trimmed ? decoded : snippet
const cleaned = stripInvisibleTextRuns(raw)
const polished = polishSnippetPreview(cleaned)
return polished || undefined
}

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 =
/(^|\n)\s*(Le\s.+a\sécrit\s*:|On\s.+wrote:|Am\s.+schrieb|El\s.+escribió|Il\s.+ha\s+scritto|.+a écrit\s*:)/i
/** Inline reply header after whitespace collapse (no leading newline). */
const WROTE_INLINE =
/(?:^|[\s.])(Le\s.+?a\s+écrit\s*:|On\s+.+?\bwrote:|Am\s+.+?\bschrieb|El\s+.+?\bescribió|Il\s+.+?\bha\s+scritto|.+?\ba\s+écrit\s*:)/i
const QUOTE_SELECTOR = [
".gmail_quote",
".gmail_extra",
@ -104,13 +108,37 @@ function splitHtmlRoot(root: Element): { mainHtml: string; quotedHtml: string |
return splitAtQuote(root, quoteHit)
}
function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } {
/** Restore line-based quotes after preview repair collapsed newlines to spaces. */
function normalizeCollapsedPlainQuotes(text: string): string {
let s = text.replace(/ > /g, "\n> ")
const wroteIdx = findInlineWroteIndex(s)
if (wroteIdx > 0) {
const before = s.slice(0, wroteIdx).trimEnd()
const after = s.slice(wroteIdx).trimStart()
if (before && after) s = `${before}\n\n${after}`
}
return s
}
function findInlineWroteIndex(text: string): number {
const m = WROTE_INLINE.exec(text)
if (!m || m.index === undefined) return -1
let idx = m.index
const lead = m[0]![0]
if (lead && /\s/.test(lead)) idx += 1
return idx
}
function splitPlainPreBody(html: string): { mainHtml: string; quotedHtml: string | null } {
const preMatch = html.match(/^<pre[^>]*>([\s\S]*)<\/pre>$/i)
const text = preMatch ? preMatch[1]! : html
const decoded = text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
if (!preMatch) return { mainHtml: html, quotedHtml: null }
const text = preMatch[1]!
const decoded = normalizeCollapsedPlainQuotes(
text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
)
const lines = decoded.split("\n")
let splitAt = -1
for (let i = 0; i < lines.length; i++) {
@ -124,7 +152,21 @@ function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: strin
break
}
}
if (splitAt < 0) return { mainHtml: html, quotedHtml: null }
if (splitAt < 0) {
const inlineIdx = findInlineWroteIndex(decoded)
if (inlineIdx < 0) return { mainHtml: html, quotedHtml: null }
const main = decoded.slice(0, inlineIdx).trimEnd()
const quoted = decoded.slice(inlineIdx).trimStart()
if (!quoted) return { mainHtml: html, quotedHtml: null }
const escape = (s: string) =>
s.replace(/&/g, "&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 quotedLines = lines.slice(splitAt)
@ -150,13 +192,12 @@ export function splitQuotedHtml(html: string): {
const trimmed = html.trim()
if (!trimmed) return { mainHtml: html, quotedHtml: null }
if (typeof DOMParser === "undefined") {
return { mainHtml: html, quotedHtml: null }
if (/^<pre\b/i.test(trimmed) || /<pre\b/i.test(trimmed)) {
return splitPlainPreBody(trimmed)
}
if (trimmed.startsWith("<pre")) {
const plain = splitPlainTextBody(trimmed)
if (plain.quotedHtml) return plain
if (typeof DOMParser === "undefined") {
return { mainHtml: html, quotedHtml: null }
}
const doc = new DOMParser().parseFromString(
@ -169,6 +210,10 @@ export function splitQuotedHtml(html: string): {
return splitHtmlRoot(root)
}
export function splitPlainTextBody(html: string): { mainHtml: string; quotedHtml: string | null } {
return splitPlainPreBody(html)
}
export function hasQuotedContent(html: string): boolean {
return splitQuotedHtml(html).quotedHtml !== null
}

View File

@ -34,11 +34,13 @@ export function htmlHasRemoteContent(html: string): boolean {
}
export function buildEmailPreviewCsp(blockRemoteContent: boolean): string {
const scriptNone = "script-src 'none'"
if (blockRemoteContent) {
return "default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:;"
return `default-src 'none'; ${scriptNone}; style-src 'unsafe-inline'; img-src data: blob:;`
}
return [
"default-src 'none'",
scriptNone,
"style-src 'unsafe-inline' https: http:",
"style-src-elem 'unsafe-inline' https: http:",
// url() in style="" (Gmail signatures often use background-image on table cells)

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). */
const HIDDEN_STYLE =
/display\s*:\s*none|mso-hide\s*:\s*all|max-height\s*:\s*0|opacity\s*:\s*0|font-size\s*:\s*0|visibility\s*:\s*hidden/i
/display\s*:\s*none|mso-hide\s*:\s*all|max-height\s*:\s*0|opacity\s*:\s*0|visibility\s*:\s*hidden/i
const HIDDEN_CLASS = /mcnPreviewText|preheader|preview-text/i
const INVISIBLE_RUN = /[\u034f\u200b-\u200f\ufeff\u00a0]{4,}/g
function hasSignificantChildElements(el: Element): boolean {
for (const child of el.children) {
const tag = child.tagName.toLowerCase()
if (tag === "br" || tag === "wbr" || tag === "hr") continue
return true
}
return false
}
function shouldStripElement(el: Element): boolean {
if (el.hasAttribute("hidden")) return true
if (el.getAttribute("aria-hidden") === "true") return true
@ -15,10 +24,16 @@ function shouldStripElement(el: Element): boolean {
const style = el.getAttribute("style") ?? ""
if (!style) return false
const compact = style.replace(/\s+/g, "")
return (
if (
HIDDEN_STYLE.test(style) ||
(compact.includes("overflow:hidden") && /max-height\s*:\s*0/.test(style))
)
) {
return true
}
if (/font-size\s*:\s*0/i.test(style) && !hasSignificantChildElements(el)) {
return true
}
return false
}
export function stripHiddenEmailHtml(html: string): string {
@ -38,6 +53,13 @@ export function stripHiddenEmailHtml(html: string): string {
}
}
/** Strip invisible padding; keep line breaks (do not collapse plain-text quotes). */
export function stripInvisibleTextRuns(text: string): string {
return text.replace(INVISIBLE_RUN, " ").replace(/\s+/g, " ").trim()
return text
.replace(INVISIBLE_RUN, " ")
.split("\n")
.map((line) => line.replace(/[^\S\n]+/g, " ").trimEnd())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim()
}

View File

@ -9,6 +9,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint .",
"test:email-preview-contrast": "node --experimental-strip-types --test lib/email-preview-contrast.test.ts",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",

File diff suppressed because one or more lines are too long