From 5567e2f0c10b9949fe1b2d4379a86b9836a67747 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Mon, 25 May 2026 13:52:40 +0200 Subject: [PATCH] huhuhuhu --- .env.example | 14 + app/api/auth/callback/route.ts | 128 ++++ app/api/auth/login/route.ts | 77 +++ app/api/auth/logout/route.ts | 22 + app/api/auth/session/route.ts | 60 ++ app/auth/complete/page.tsx | 69 +++ app/globals.css | 30 + app/layout.tsx | 7 +- app/login/layout.tsx | 9 + app/login/page.tsx | 72 +++ app/mail/mail-app-shell.tsx | 46 +- app/mail/settings/[[...section]]/page.tsx | 10 + app/mail/settings/layout.tsx | 9 + app/mail/settings/page.tsx | 10 - components/auth/auth-provider.tsx | 166 +++++ components/auth/login-chrome.tsx | 70 +++ components/first-launch-splash.tsx | 2 +- .../gmail/account-switcher-dropdown.tsx | 113 ++-- components/gmail/compose-identities-sync.tsx | 85 +++ .../gmail/compose/compose-bottom-toolbar.tsx | 5 +- .../gmail/compose/compose-emoji-picker.tsx | 2 +- .../gmail/compose/compose-recipients.tsx | 34 +- components/gmail/compose/compose-shared.ts | 19 +- .../gmail/compose/use-compose-window.ts | 21 +- components/gmail/contact-hover-card.tsx | 2 +- .../contacts-page/bulk-create-dialog.tsx | 4 +- .../contacts-page/contact-create-page.tsx | 11 +- .../gmail/contacts-page/import-dialog.tsx | 4 +- .../contacts-page/merge-duplicates-view.tsx | 6 +- .../gmail/contacts/contact-detail-view.tsx | 6 +- .../gmail/contacts/contact-form-view.tsx | 18 +- .../gmail/contacts/contacts-list-view.tsx | 2 +- .../gmail/contacts/contacts-panel-logo.tsx | 20 +- .../gmail/email-list/email-list-body.tsx | 5 +- .../gmail/email-list/email-list-helpers.ts | 2 +- .../gmail/email-list/email-list-layout.tsx | 8 + .../gmail/email-list/email-list-row.tsx | 31 +- .../gmail/email-list/email-list-toolbar.tsx | 66 +- .../email-list/hooks/use-email-list-data.ts | 163 +++-- .../hooks/use-email-list-reading.ts | 87 ++- components/gmail/email-view.tsx | 236 +++++--- .../email-view/email-view-details-popover.tsx | 154 +++++ .../gmail/email-view/email-view-messages.tsx | 173 +++++- .../gmail/email-view/email-view-toolbar.tsx | 90 +-- .../gmail/email-view/message-body-content.tsx | 130 ++++ .../email-view/remote-content-banner.tsx | 32 + .../gmail/email-view/sandboxed-content.tsx | 133 ++-- .../email-view/unsubscribe-action-button.tsx | 70 +++ components/gmail/header-account-actions.tsx | 26 +- components/gmail/mail-nav-sync.tsx | 59 ++ .../gmail/mail-notifications-bridge.tsx | 78 +++ components/gmail/mail-settings-fields.tsx | 312 ++++++++++ components/gmail/mail-settings-sync.tsx | 107 ++++ components/gmail/mail-signatures-sync.tsx | 23 + components/gmail/nav/nav-color-dot.tsx | 27 + .../gmail/nav/nav-color-picker-trigger.tsx | 65 ++ components/gmail/nav/nav-color-picker.tsx | 68 +++ .../gmail/nav/nav-visibility-fields.tsx | 93 +++ .../quick-settings/quick-settings-panel.tsx | 200 +----- .../quick-settings/settings-preview-icons.tsx | 118 ++++ .../quick-settings/theme-settings-dialog.tsx | 48 +- .../gmail/settings/add-mail-account-form.tsx | 573 ++++++++++++++++++ .../automation/automation-rules-panel.tsx | 194 ++++++ .../automation/automation-suggest-input.tsx | 116 ++++ .../automation/rule-simulator-panel.tsx | 103 ++++ .../automation/rule-workflow-editor.tsx | 330 ++++++++++ .../settings/automation/webhooks-panel.tsx | 84 +++ .../automation/workflow-node-inspector.tsx | 398 ++++++++++++ .../settings/automation/workflow-nodes.tsx | 291 +++++++++ .../automation/workflow-triggers-panel.tsx | 214 +++++++ .../gmail/settings/edit-mail-account-form.tsx | 335 ++++++++++ .../gmail/settings/mail-settings-layout.tsx | 106 ++++ .../settings/mail-settings-section-view.tsx | 39 ++ .../gmail/settings/nav-item-settings-card.tsx | 443 ++++++++++++++ .../gmail/settings/proton-bridge-wizard.tsx | 112 ++++ .../sections/accounts-settings-section.tsx | 377 ++++++++++++ .../sections/automation-settings-section.tsx | 45 ++ .../sections/display-settings-section.tsx | 21 + .../labels-folders-settings-section.tsx | 370 +++++++++++ .../notifications-settings-section.tsx | 80 +++ .../sections/signatures-settings-section.tsx | 378 ++++++++++++ .../gmail/settings/settings-coming-soon.tsx | 20 + .../settings/settings-section-header.tsx | 16 + .../gmail/settings/settings-sync-banner.tsx | 48 ++ .../sidebar/sidebar-folder-row-expanded.tsx | 34 +- .../gmail/sidebar/sidebar-label-item-row.tsx | 34 +- .../sidebar/sidebar-nav-options-sheet.tsx | 34 +- components/theme-provider.tsx | 21 +- components/ui/tooltip.tsx | 6 +- components/ultimail-logo.tsx | 37 +- hooks/use-mail-route.ts | 16 +- hooks/use-mail-split-view.ts | 21 +- hooks/use-match-media.ts | 28 + hooks/use-persist-hydrated.ts | 2 +- hooks/use-xs.ts | 14 +- lib/api/auth-store.ts | 30 +- lib/api/client.ts | 60 +- lib/api/hooks/use-contact-mutations.ts | 27 +- lib/api/hooks/use-contact-queries.ts | 48 +- lib/api/hooks/use-folder-label-queries.ts | 41 +- lib/api/hooks/use-identity-mutations.ts | 50 ++ lib/api/hooks/use-imap-folders.ts | 58 ++ lib/api/hooks/use-list-unsubscribe-mailto.ts | 13 + lib/api/hooks/use-mail-account-discover.ts | 12 + lib/api/hooks/use-mail-account-mutations.ts | 81 +++ lib/api/hooks/use-mail-account-test.ts | 30 + lib/api/hooks/use-mail-account.ts | 16 + lib/api/hooks/use-mail-automation-queries.ts | 149 +++++ lib/api/hooks/use-mail-mutations.ts | 16 +- lib/api/hooks/use-mail-oauth-providers.ts | 18 + lib/api/hooks/use-mail-oauth.ts | 27 + lib/api/hooks/use-mail-queries.ts | 83 ++- lib/api/hooks/use-mail-settings.ts | 31 + lib/api/hooks/use-mail-signatures.ts | 60 ++ .../hooks/use-message-attachment-cid-map.ts | 25 + lib/api/hooks/use-unified-folder-queries.ts | 95 +++ lib/api/types.ts | 189 +++++- lib/api/use-auth-ready.ts | 24 + lib/api/ws.ts | 49 +- lib/auth/jwt-claims.ts | 47 ++ lib/auth/login-url.ts | 18 + lib/auth/oidc-config.ts | 122 ++++ lib/auth/pkce.ts | 26 + lib/auth/session.ts | 102 ++++ lib/compose-context.tsx | 39 +- lib/compose/identity-map.ts | 57 ++ lib/compose/resolve-compose-identity.ts | 8 + lib/contacts-chrome-classes.ts | 3 - lib/contacts/use-contacts-list.ts | 11 +- lib/email-preview-dark-styles.ts | 29 +- lib/hooks/use-chrome-identity.ts | 33 + lib/hooks/use-inline-cid-urls.ts | 67 ++ lib/hooks/use-self-mail-emails.ts | 24 + lib/hooks/use-sidebar-nav-api.ts | 97 +++ lib/mail-automation/condition-helpers.ts | 101 +++ lib/mail-automation/defaults.ts | 100 +++ lib/mail-automation/node-definitions.ts | 96 +++ lib/mail-automation/types.ts | 190 ++++++ .../use-automation-suggestions.ts | 124 ++++ lib/mail-automation/workflow-flow.ts | 120 ++++ lib/mail-flags.ts | 61 ++ lib/mail-folder-filter.ts | 26 + lib/mail-html-iframe.ts | 502 +++++++++++++++ lib/mail-list-page-size.ts | 21 + lib/mail-message-header-details.ts | 89 +++ lib/mail-message-participants.ts | 215 +++++++ lib/mail-mime-body.ts | 170 ++++++ lib/mail-quoted-content.ts | 174 ++++++ lib/mail-remote-content.ts | 51 ++ lib/mail-settings/imap-folder-tree.ts | 98 +++ lib/mail-settings/manual-account-discover.ts | 27 + lib/mail-settings/map-api-settings.ts | 76 +++ lib/mail-settings/resolve-open-email.ts | 5 +- lib/mail-settings/settings-nav.ts | 95 +++ lib/mail-settings/unified-folder-tree.ts | 37 ++ lib/mail-thread/index.ts | 18 + lib/mail-unsubscribe.ts | 179 ++++++ lib/nav-color.ts | 11 + lib/nav-reorder-plan.ts | 40 ++ lib/notifications/desktop-notifications.ts | 85 +++ lib/sidebar-folder-tree-utils.ts | 18 + lib/sidebar-nav-context.tsx | 192 +++++- lib/sidebar-nav-server-id.ts | 4 + lib/stores/account-store.ts | 7 +- lib/stores/compose-identities-store.ts | 37 ++ lib/stores/mail-settings-store.ts | 59 ++ lib/stores/mail-signatures-store.ts | 33 + lib/stores/mail-ui-store.ts | 13 +- lib/stores/nav-store.ts | 18 + lib/stores/trusted-senders-store.ts | 63 ++ lib/strip-hidden-email-html.ts | 43 ++ lib/thread-compose-preset.ts | 25 +- middleware.ts | 18 + next-env.d.ts | 2 +- next.config.mjs | 15 +- package.json | 10 +- pnpm-lock.yaml | 137 +++++ public/agenda-mark.svg | 10 +- public/brand/ultimail-mark.jpg | Bin 4147 -> 8654 bytes public/brand/ultimail-mark.png | Bin 5408 -> 37698 bytes public/brand/ultimail-wordmark-horizontal.jpg | Bin 17055 -> 39331 bytes public/brand/ultimail-wordmark-horizontal.png | Bin 21982 -> 258079 bytes public/brand/ultimail-wordmark-horizontal.svg | 40 -- public/brand/ultimail-wordmark-stacked.jpg | Bin 19792 -> 32226 bytes public/brand/ultimail-wordmark-stacked.png | Bin 26492 -> 353765 bytes public/brand/ultimail-wordmark-stacked.svg | 82 --- public/ultimail-mark.svg | 11 - scripts/emit-authentik-brand.mjs | 173 ++++++ scripts/rasterize-ultimail-brand.mjs | 131 +++- scripts/vectorize-ultimail-brand.mjs | 5 +- tsconfig.tsbuildinfo | 2 +- 191 files changed, 13443 insertions(+), 1028 deletions(-) create mode 100644 .env.example create mode 100644 app/api/auth/callback/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/session/route.ts create mode 100644 app/auth/complete/page.tsx create mode 100644 app/login/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/mail/settings/[[...section]]/page.tsx create mode 100644 app/mail/settings/layout.tsx delete mode 100644 app/mail/settings/page.tsx create mode 100644 components/auth/auth-provider.tsx create mode 100644 components/auth/login-chrome.tsx create mode 100644 components/gmail/compose-identities-sync.tsx create mode 100644 components/gmail/email-view/email-view-details-popover.tsx create mode 100644 components/gmail/email-view/message-body-content.tsx create mode 100644 components/gmail/email-view/remote-content-banner.tsx create mode 100644 components/gmail/email-view/unsubscribe-action-button.tsx create mode 100644 components/gmail/mail-nav-sync.tsx create mode 100644 components/gmail/mail-notifications-bridge.tsx create mode 100644 components/gmail/mail-settings-fields.tsx create mode 100644 components/gmail/mail-settings-sync.tsx create mode 100644 components/gmail/mail-signatures-sync.tsx create mode 100644 components/gmail/nav/nav-color-dot.tsx create mode 100644 components/gmail/nav/nav-color-picker-trigger.tsx create mode 100644 components/gmail/nav/nav-color-picker.tsx create mode 100644 components/gmail/nav/nav-visibility-fields.tsx create mode 100644 components/gmail/settings/add-mail-account-form.tsx create mode 100644 components/gmail/settings/automation/automation-rules-panel.tsx create mode 100644 components/gmail/settings/automation/automation-suggest-input.tsx create mode 100644 components/gmail/settings/automation/rule-simulator-panel.tsx create mode 100644 components/gmail/settings/automation/rule-workflow-editor.tsx create mode 100644 components/gmail/settings/automation/webhooks-panel.tsx create mode 100644 components/gmail/settings/automation/workflow-node-inspector.tsx create mode 100644 components/gmail/settings/automation/workflow-nodes.tsx create mode 100644 components/gmail/settings/automation/workflow-triggers-panel.tsx create mode 100644 components/gmail/settings/edit-mail-account-form.tsx create mode 100644 components/gmail/settings/mail-settings-layout.tsx create mode 100644 components/gmail/settings/mail-settings-section-view.tsx create mode 100644 components/gmail/settings/nav-item-settings-card.tsx create mode 100644 components/gmail/settings/proton-bridge-wizard.tsx create mode 100644 components/gmail/settings/sections/accounts-settings-section.tsx create mode 100644 components/gmail/settings/sections/automation-settings-section.tsx create mode 100644 components/gmail/settings/sections/display-settings-section.tsx create mode 100644 components/gmail/settings/sections/labels-folders-settings-section.tsx create mode 100644 components/gmail/settings/sections/notifications-settings-section.tsx create mode 100644 components/gmail/settings/sections/signatures-settings-section.tsx create mode 100644 components/gmail/settings/settings-coming-soon.tsx create mode 100644 components/gmail/settings/settings-section-header.tsx create mode 100644 components/gmail/settings/settings-sync-banner.tsx create mode 100644 hooks/use-match-media.ts create mode 100644 lib/api/hooks/use-identity-mutations.ts create mode 100644 lib/api/hooks/use-imap-folders.ts create mode 100644 lib/api/hooks/use-list-unsubscribe-mailto.ts create mode 100644 lib/api/hooks/use-mail-account-discover.ts create mode 100644 lib/api/hooks/use-mail-account-mutations.ts create mode 100644 lib/api/hooks/use-mail-account-test.ts create mode 100644 lib/api/hooks/use-mail-account.ts create mode 100644 lib/api/hooks/use-mail-automation-queries.ts create mode 100644 lib/api/hooks/use-mail-oauth-providers.ts create mode 100644 lib/api/hooks/use-mail-oauth.ts create mode 100644 lib/api/hooks/use-mail-settings.ts create mode 100644 lib/api/hooks/use-mail-signatures.ts create mode 100644 lib/api/hooks/use-message-attachment-cid-map.ts create mode 100644 lib/api/hooks/use-unified-folder-queries.ts create mode 100644 lib/api/use-auth-ready.ts create mode 100644 lib/auth/jwt-claims.ts create mode 100644 lib/auth/login-url.ts create mode 100644 lib/auth/oidc-config.ts create mode 100644 lib/auth/pkce.ts create mode 100644 lib/auth/session.ts create mode 100644 lib/compose/identity-map.ts create mode 100644 lib/compose/resolve-compose-identity.ts create mode 100644 lib/hooks/use-chrome-identity.ts create mode 100644 lib/hooks/use-inline-cid-urls.ts create mode 100644 lib/hooks/use-self-mail-emails.ts create mode 100644 lib/hooks/use-sidebar-nav-api.ts create mode 100644 lib/mail-automation/condition-helpers.ts create mode 100644 lib/mail-automation/defaults.ts create mode 100644 lib/mail-automation/node-definitions.ts create mode 100644 lib/mail-automation/types.ts create mode 100644 lib/mail-automation/use-automation-suggestions.ts create mode 100644 lib/mail-automation/workflow-flow.ts create mode 100644 lib/mail-flags.ts create mode 100644 lib/mail-html-iframe.ts create mode 100644 lib/mail-list-page-size.ts create mode 100644 lib/mail-message-header-details.ts create mode 100644 lib/mail-message-participants.ts create mode 100644 lib/mail-mime-body.ts create mode 100644 lib/mail-quoted-content.ts create mode 100644 lib/mail-remote-content.ts create mode 100644 lib/mail-settings/imap-folder-tree.ts create mode 100644 lib/mail-settings/manual-account-discover.ts create mode 100644 lib/mail-settings/map-api-settings.ts create mode 100644 lib/mail-settings/settings-nav.ts create mode 100644 lib/mail-settings/unified-folder-tree.ts create mode 100644 lib/mail-unsubscribe.ts create mode 100644 lib/nav-color.ts create mode 100644 lib/nav-reorder-plan.ts create mode 100644 lib/notifications/desktop-notifications.ts create mode 100644 lib/sidebar-nav-server-id.ts create mode 100644 lib/stores/compose-identities-store.ts create mode 100644 lib/stores/mail-signatures-store.ts create mode 100644 lib/stores/trusted-senders-store.ts create mode 100644 lib/strip-hidden-email-html.ts create mode 100644 middleware.ts delete mode 100644 public/brand/ultimail-wordmark-horizontal.svg delete mode 100644 public/brand/ultimail-wordmark-stacked.svg delete mode 100644 public/ultimail-mark.svg create mode 100644 scripts/emit-authentik-brand.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6da992a --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# API backend — URL relative : Next.js proxy vers nginx (:80), pas de CORS en dev +NEXT_PUBLIC_API_URL=/api/v1 +NEXT_PUBLIC_WS_URL=ws://localhost/ws +# Cible du proxy Next (optionnel, défaut 127.0.0.1:80) +# ULTI_PROXY_ORIGIN=http://127.0.0.1 + +# OIDC Authentik (blueprints deploy/authentik dans ulti-backend) +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 + +# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint +OIDC_CLIENT_SECRET=changeme diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts new file mode 100644 index 0000000..4c6b9e9 --- /dev/null +++ b/app/api/auth/callback/route.ts @@ -0,0 +1,128 @@ +import { cookies } from "next/headers" +import { NextResponse } from "next/server" +import { resolveServerOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config" +import { platformUserFromToken } from "@/lib/auth/jwt-claims" +import { + applySessionCookies, + type TokenResponse, +} from "@/lib/auth/session" + +const PKCE_COOKIE = "ulti_pkce_verifier" +const STATE_COOKIE = "ulti_oauth_state" +const INTENT_COOKIE = "ulti_auth_intent" +const PREVIOUS_SUB_COOKIE = "ulti_auth_previous_sub" + +export async function GET(request: Request) { + const url = new URL(request.url) + const appOrigin = getAppOrigin() + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const oauthError = url.searchParams.get("error") + + if (oauthError) { + const desc = url.searchParams.get("error_description") ?? oauthError + return NextResponse.redirect( + new URL(`/login?error=${encodeURIComponent(desc)}`, appOrigin) + ) + } + + if (!code || !state) { + return NextResponse.redirect( + new URL("/login?error=missing_code", appOrigin) + ) + } + + const jar = await cookies() + const expectedState = jar.get(STATE_COOKIE)?.value + const verifier = jar.get(PKCE_COOKIE)?.value + const returnTo = jar.get("ulti_auth_return")?.value ?? "/mail/inbox" + const authIntent = jar.get(INTENT_COOKIE)?.value + const previousSub = jar.get(PREVIOUS_SUB_COOKIE)?.value + + if (!expectedState || state !== expectedState || !verifier) { + return NextResponse.redirect( + new URL( + `/login?error=${encodeURIComponent( + !expectedState || !verifier + ? "invalid_state:missing_oauth_cookies" + : "invalid_state:state_mismatch" + )}`, + appOrigin + ) + ) + } + + let cfg + try { + cfg = await resolveServerOidcConfig() + } catch (err) { + const message = + err instanceof Error ? err.message : "oidc_discovery_failed" + return NextResponse.redirect( + new URL(`/login?error=${encodeURIComponent(message)}`, appOrigin) + ) + } + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + code, + redirect_uri: cfg.redirectUri, + code_verifier: verifier, + }) + + let tokens: TokenResponse + try { + const res = await fetch(cfg.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }) + if (!res.ok) { + const text = await res.text() + return NextResponse.redirect( + new URL( + `/login?error=${encodeURIComponent(`token_exchange_failed:${text.slice(0, 120)}`)}`, + appOrigin + ) + ) + } + tokens = (await res.json()) as TokenResponse + } catch (err) { + const message = err instanceof Error ? err.message : "token_exchange_failed" + return NextResponse.redirect( + new URL(`/login?error=${encodeURIComponent(message)}`, appOrigin) + ) + } + + if (!tokens.id_token) { + return NextResponse.redirect( + new URL("/login?error=no_id_token", appOrigin) + ) + } + + const bearer = tokens.id_token + + const newUser = platformUserFromToken(bearer) + const completeUrl = new URL("/auth/complete", appOrigin) + completeUrl.searchParams.set("returnTo", returnTo) + if ( + authIntent === "add_account" && + previousSub && + newUser?.sub === previousSub + ) { + completeUrl.searchParams.set("accountNotice", "same") + } + + const response = NextResponse.redirect(completeUrl) + + response.cookies.delete(PKCE_COOKIE) + response.cookies.delete(STATE_COOKIE) + response.cookies.delete("ulti_auth_return") + response.cookies.delete(INTENT_COOKIE) + response.cookies.delete(PREVIOUS_SUB_COOKIE) + + applySessionCookies(response, tokens, bearer) + + return response +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..b81293f --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,77 @@ +import { cookies } from "next/headers" +import { NextResponse } from "next/server" +import { createPkcePair, randomString } from "@/lib/auth/pkce" +import { platformUserFromToken } from "@/lib/auth/jwt-claims" +import { resolveOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config" + +const PKCE_COOKIE = "ulti_pkce_verifier" +const STATE_COOKIE = "ulti_oauth_state" +const INTENT_COOKIE = "ulti_auth_intent" +const PREVIOUS_SUB_COOKIE = "ulti_auth_previous_sub" +const COOKIE_MAX_AGE = 600 + +function oauthCookieOptions() { + return { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + maxAge: COOKIE_MAX_AGE, + secure: process.env.NODE_ENV === "production", + } +} + +export async function GET(request: Request) { + let cfg + try { + cfg = await resolveOidcConfig() + } catch (err) { + const message = + err instanceof Error ? err.message : "oidc_discovery_failed" + return NextResponse.redirect( + new URL( + `/login?error=${encodeURIComponent(message)}`, + getAppOrigin() + ) + ) + } + const { verifier, challenge } = await createPkcePair() + const state = randomString(16) + const requestUrl = new URL(request.url) + const returnTo = requestUrl.searchParams.get("returnTo") ?? "/mail/inbox" + const intent = requestUrl.searchParams.get("intent") + const prompt = + requestUrl.searchParams.get("prompt") ?? + (intent === "add_account" ? "login select_account" : "select_account") + + const jar = await cookies() + const existingUser = platformUserFromToken( + jar.get("ulti_access_token")?.value ?? "" + ) + + const params = new URLSearchParams({ + client_id: cfg.clientId, + redirect_uri: cfg.redirectUri, + response_type: "code", + scope: "openid profile email offline_access", + state, + code_challenge: challenge, + code_challenge_method: "S256", + prompt, + }) + + const response = NextResponse.redirect( + `${cfg.authorizationEndpoint}?${params.toString()}` + ) + const cookieOpts = oauthCookieOptions() + response.cookies.set(PKCE_COOKIE, verifier, cookieOpts) + response.cookies.set(STATE_COOKIE, state, cookieOpts) + response.cookies.set("ulti_auth_return", returnTo, cookieOpts) + if (intent === "add_account") { + response.cookies.set(INTENT_COOKIE, "add_account", cookieOpts) + if (existingUser?.sub) { + response.cookies.set(PREVIOUS_SUB_COOKIE, existingUser.sub, cookieOpts) + } + } + + return response +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..4488333 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,22 @@ +import { cookies } from "next/headers" +import { NextResponse } from "next/server" + +const SESSION_COOKIES = [ + "ulti_session", + "ulti_access_token", + "ulti_refresh_token", + "ulti_expires_at", + "ulti_pkce_verifier", + "ulti_oauth_state", + "ulti_auth_return", + "ulti_auth_intent", + "ulti_auth_previous_sub", +] as const + +export async function POST() { + const response = NextResponse.json({ ok: true }) + for (const name of SESSION_COOKIES) { + response.cookies.delete(name) + } + return response +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..309fd1f --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,60 @@ +import { cookies } from "next/headers" +import { NextResponse } from "next/server" +import { platformUserFromToken } from "@/lib/auth/jwt-claims" +import { resolveServerOidcConfig } from "@/lib/auth/oidc-config" +import { + SESSION_COOKIE_NAMES, + applySessionCookies, + computeExpiresAt, + exchangeRefreshToken, + isAccessTokenValid, + resolveBearerToken, +} from "@/lib/auth/session" + +export async function GET() { + const jar = await cookies() + const accessToken = jar.get(SESSION_COOKIE_NAMES.accessToken)?.value + const refreshToken = jar.get(SESSION_COOKIE_NAMES.refreshToken)?.value + const expiresAtRaw = jar.get(SESSION_COOKIE_NAMES.expiresAt)?.value + + if (!accessToken && !refreshToken) { + return NextResponse.json({ authenticated: false }) + } + + if (isAccessTokenValid(accessToken, expiresAtRaw)) { + const expiresAt = Number(expiresAtRaw) + const user = platformUserFromToken(accessToken!) + return NextResponse.json({ + authenticated: true, + accessToken, + refreshToken: refreshToken ?? null, + expiresAt, + user, + }) + } + + if (!refreshToken) { + return NextResponse.json({ authenticated: false, expired: true }) + } + + try { + const cfg = await resolveServerOidcConfig() + const tokens = await exchangeRefreshToken(refreshToken, cfg) + const bearer = resolveBearerToken(tokens) + const expiresAt = computeExpiresAt(tokens.expires_in ?? 3600) + const user = platformUserFromToken(bearer) + + const response = NextResponse.json({ + authenticated: true, + accessToken: bearer, + refreshToken: tokens.refresh_token ?? refreshToken, + expiresAt, + user, + refreshed: true, + }) + applySessionCookies(response, tokens, bearer) + return response + } catch { + return NextResponse.json({ authenticated: false, expired: true }) + } +} diff --git a/app/auth/complete/page.tsx b/app/auth/complete/page.tsx new file mode 100644 index 0000000..5a15fae --- /dev/null +++ b/app/auth/complete/page.tsx @@ -0,0 +1,69 @@ +"use client" + +import { useEffect, Suspense } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { useAuthStore } from "@/lib/api/auth-store" +import type { PlatformUser } from "@/lib/auth/jwt-claims" + +function AuthCompleteInner() { + const router = useRouter() + const searchParams = useSearchParams() + const login = useAuthStore((s) => s.login) + const returnTo = searchParams.get("returnTo") ?? "/mail/inbox" + const accountNotice = searchParams.get("accountNotice") + + useEffect(() => { + let cancelled = false + + async function finish() { + try { + const res = await fetch("/api/auth/session", { credentials: "include" }) + const data = (await res.json()) as { + authenticated?: boolean + accessToken?: string + refreshToken?: string | null + expiresAt?: number + user?: PlatformUser | null + } + if ( + data.authenticated && + data.accessToken && + data.expiresAt && + !cancelled + ) { + login( + data.accessToken, + data.refreshToken ?? "", + data.expiresAt, + data.user ?? null + ) + if (accountNotice === "same") { + sessionStorage.setItem("ulti_account_notice", "same") + } + router.replace(returnTo.startsWith("/") ? returnTo : "/mail/inbox") + return + } + } catch { + // fall through + } + if (!cancelled) { + router.replace("/login?error=session_failed") + } + } + + void finish() + return () => { + cancelled = true + } + }, [accountNotice, login, returnTo, router]) + + return null +} + +export default function AuthCompletePage() { + return ( + + + + ) +} diff --git a/app/globals.css b/app/globals.css index 621266b..c4f51f4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -564,6 +564,36 @@ html[data-splash-seen='1'] .app-first-launch-splash { } } +/* ── Login : fond Aurore fixe (sm+), pas le fond mail utilisateur ── */ +html:has(.ultimail-login)::before { + opacity: 0 !important; +} + +html:has(.ultimail-login) body { + background-color: transparent !important; +} + +@media (min-width: 640px) { + .ultimail-login-card-frame { + padding: 3px; + border-radius: var(--radius-xl); + background: conic-gradient( + from 145deg, + #1a73e8, + #34a853, + #fbbc04, + #ea4335, + #1a73e8 + ); + box-shadow: 0 16px 40px rgb(0 0 0 / 14%); + } + + .ultimail-login-card-frame > [data-slot='card'] { + border-width: 0; + border-radius: calc(var(--radius-xl) - 3px); + } +} + /* ── Mail : fond décoratif plein écran (derrière toute l’UI) ── */ html { background-color: var(--mail-bg-fallback, var(--app-canvas)); diff --git a/app/layout.tsx b/app/layout.tsx index 76902a3..5147535 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,8 @@ import './globals.css' import { ThemeInitScript } from '@/components/theme-init-script' import { FirstLaunchSplash } from '@/components/first-launch-splash' import { QueryProvider } from '@/lib/api/query-provider' +import { AuthProvider } from '@/components/auth/auth-provider' +import { MailToaster } from '@/components/gmail/mail-toaster' const _geist = Geist({ subsets: ["latin"] }); const _geistMono = Geist_Mono({ subsets: ["latin"] }); @@ -34,8 +36,11 @@ export default function RootLayout({ - {children} + + {children} + + {process.env.NODE_ENV === 'production' && } diff --git a/app/login/layout.tsx b/app/login/layout.tsx new file mode 100644 index 0000000..b487835 --- /dev/null +++ b/app/login/layout.tsx @@ -0,0 +1,9 @@ +import { LoginChrome } from "@/components/auth/login-chrome" + +export default function LoginLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..98a3ca8 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,72 @@ +"use client" + +import { useSearchParams } from "next/navigation" +import { Suspense } from "react" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, +} from "@/components/ui/card" +import { UltiMailLogo } from "@/components/ultimail-logo" +import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config" +import { cn } from "@/lib/utils" + +const LOGIN_CARD_CLASS = cn( + "w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none", + "sm:gap-5 sm:bg-card sm:px-8 sm:py-8 sm:text-card-foreground sm:shadow-none" +) + +function LoginContent() { + const searchParams = useSearchParams() + const error = searchParams.get("error") + const returnTo = searchParams.get("returnTo") ?? "/mail/inbox" + const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}` + const signupHref = getAuthentikEnrollmentUrl() + + return ( +
+
+ + + + + Connecte-toi avec ton compte Ulti (Authentik) pour accéder à la + messagerie. + + {error ? ( +

+ {decodeURIComponent(error)} +

+ ) : null} +
+ + + + + + +

+ Pas encore de compte ?{" "} + + Créer un compte + +

+
+
+
+
+ ) +} + +export default function LoginPage() { + return ( + + + + ) +} diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index 099c8ef..49b3643 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -14,8 +14,7 @@ import { useMailRoute } from "@/hooks/use-mail-route" import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar" import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay" import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome" -import { MailToaster } from "@/components/gmail/mail-toaster" -import { useRouter } from "next/navigation" +import { useRouter, usePathname } from "next/navigation" import { Sidebar } from "@/components/gmail/sidebar" import { Header } from "@/components/gmail/header" import { EmailList } from "@/components/gmail/email-list" @@ -35,6 +34,19 @@ import { cn } from "@/lib/utils" import { ThemeProvider } from "@/components/theme-provider" import { MailThemeApplier } from "@/components/gmail/mail-theme-applier" import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root" +import { MailSettingsSync } from "@/components/gmail/mail-settings-sync" +import { MailNavSync } from "@/components/gmail/mail-nav-sync" +import { ComposeIdentitiesSync } from "@/components/gmail/compose-identities-sync" +import { MailSignaturesSync } from "@/components/gmail/mail-signatures-sync" +import { MailNotificationsBridge } from "@/components/gmail/mail-notifications-bridge" +import { useWebSocket } from "@/lib/api/ws" +import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" + +const MAIL_SETTINGS_PATH = "/mail/settings" + +function isMailSettingsPath(pathname: string | null): boolean { + return pathname === MAIL_SETTINGS_PATH || pathname?.startsWith(`${MAIL_SETTINGS_PATH}/`) === true +} function MailAppInner() { const router = useRouter() @@ -71,6 +83,7 @@ function MailAppInner() { const handleSelectFolder = useCallback( (id: string) => { + useMailUiStore.getState().requestSuppressSplitAutoOpen() navigateRoute({ folderId: id, inboxTab: DEFAULT_INBOX_TAB, @@ -85,14 +98,15 @@ function MailAppInner() { return ( + onRouteFolderIdChange={(nextFolderId) => { + useMailUiStore.getState().requestSuppressSplitAutoOpen() navigateRoute({ folderId: nextFolderId, inboxTab: DEFAULT_INBOX_TAB, page: 1, mailId: null, }) - } + }} >
{!splitView ? ( @@ -192,10 +206,20 @@ function MailAppInner() { } export function MailAppShell({ - children: _routeOutlet, + children: routeOutlet, }: { children: React.ReactNode }) { + const pathname = usePathname() + const showSettingsPage = isMailSettingsPath(pathname) + useWebSocket() + + useEffect(() => { + if (showSettingsPage) { + useMailSettingsStore.getState().setQuickSettingsOpen(false) + } + }, [showSettingsPage]) + useEffect(() => { const blockPinch = (event: Event) => event.preventDefault() document.addEventListener("gesturestart", blockPinch, { passive: false }) @@ -221,13 +245,21 @@ export function MailAppShell({
} > - + {showSettingsPage ? ( + {routeOutlet} + ) : ( + + )} + + + + + - diff --git a/app/mail/settings/[[...section]]/page.tsx b/app/mail/settings/[[...section]]/page.tsx new file mode 100644 index 0000000..dfaa91b --- /dev/null +++ b/app/mail/settings/[[...section]]/page.tsx @@ -0,0 +1,10 @@ +import { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view" + +export default async function MailSettingsSectionPage({ + params, +}: { + params: Promise<{ section?: string[] }> +}) { + const { section } = await params + return +} diff --git a/app/mail/settings/layout.tsx b/app/mail/settings/layout.tsx new file mode 100644 index 0000000..ecb2d50 --- /dev/null +++ b/app/mail/settings/layout.tsx @@ -0,0 +1,9 @@ +import { MailSettingsLayout } from "@/components/gmail/settings/mail-settings-layout" + +export default function MailSettingsRootLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/mail/settings/page.tsx b/app/mail/settings/page.tsx deleted file mode 100644 index b45002b..0000000 --- a/app/mail/settings/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export default function MailSettingsPage() { - return ( -
-

Paramètres

-

- Page en cours de construction. -

-
- ) -} diff --git a/components/auth/auth-provider.tsx b/components/auth/auth-provider.tsx new file mode 100644 index 0000000..c1fcfcc --- /dev/null +++ b/components/auth/auth-provider.tsx @@ -0,0 +1,166 @@ +"use client" + +import { useCallback, useEffect, useState, type ReactNode } from "react" +import { usePathname, useRouter } from "next/navigation" +import { useAuthStore } from "@/lib/api/auth-store" +import { isOidcConfigured } from "@/lib/auth/oidc-config" +import type { PlatformUser } from "@/lib/auth/jwt-claims" + +const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"] +const REFRESH_LEAD_MS = 5 * 60 * 1000 +const REFRESH_CHECK_MS = 60 * 1000 + +function isPublicPath(pathname: string) { + return PUBLIC_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(prefix) + ) +} + +type SessionPayload = { + authenticated?: boolean + accessToken?: string + refreshToken?: string | null + expiresAt?: number + user?: PlatformUser | null +} + +async function fetchSession(): Promise { + try { + const res = await fetch("/api/auth/session", { credentials: "include" }) + if (!res.ok) return null + return (await res.json()) as SessionPayload + } catch { + return null + } +} + +function canTrustPersistedAuth() { + return useAuthStore.persist.hasHydrated() && useAuthStore.getState().isAuthenticated() +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const pathname = usePathname() + const router = useRouter() + const login = useAuthStore((s) => s.login) + const logout = useAuthStore((s) => s.logout) + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + const [ready, setReady] = useState( + () => !isOidcConfigured() || canTrustPersistedAuth() + ) + + const applySession = useCallback( + (data: SessionPayload) => { + if (data.authenticated && data.accessToken && data.expiresAt) { + login( + data.accessToken, + data.refreshToken ?? "", + data.expiresAt, + data.user ?? null + ) + return true + } + return false + }, + [login] + ) + + const syncSession = useCallback(async () => { + const data = await fetchSession() + if (data && applySession(data)) return true + logout() + return false + }, [applySession, logout]) + + useEffect(() => { + let cancelled = false + + async function bootstrap() { + if (!isOidcConfigured()) { + setReady(true) + return + } + + if (canTrustPersistedAuth()) { + setReady(true) + } + + const data = await fetchSession() + if (cancelled) return + + if (data && applySession(data)) { + setReady(true) + return + } + + if (data?.authenticated === false || !canTrustPersistedAuth()) { + logout() + } + setReady(true) + } + + if (!useAuthStore.persist.hasHydrated()) { + const unsubHydrate = useAuthStore.persist.onFinishHydration(() => { + if (useAuthStore.getState().isAuthenticated()) { + setReady(true) + } + }) + void bootstrap() + return () => { + cancelled = true + unsubHydrate() + } + } + + void bootstrap() + return () => { + cancelled = true + } + }, [applySession, logout]) + + useEffect(() => { + if (!ready || !isOidcConfigured()) return + + const interval = setInterval(() => { + const { accessToken, expiresAt } = useAuthStore.getState() + if (!accessToken || !expiresAt) return + if (Date.now() >= expiresAt - REFRESH_LEAD_MS) { + void syncSession() + } + }, REFRESH_CHECK_MS) + + return () => clearInterval(interval) + }, [ready, syncSession]) + + useEffect(() => { + if (!ready || !isOidcConfigured()) return + if (isPublicPath(pathname)) return + if (isAuthenticated()) return + + let cancelled = false + void syncSession().then((ok) => { + if (cancelled || ok) return + const returnTo = encodeURIComponent(pathname) + router.replace(`/login?returnTo=${returnTo}`) + }) + + return () => { + cancelled = true + } + }, [ready, pathname, isAuthenticated, router, syncSession]) + + return <>{children} +} + +export function useAuthLogout() { + const logout = useAuthStore((s) => s.logout) + const router = useRouter() + + return async () => { + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }) + logout() + if (typeof window !== "undefined") { + localStorage.removeItem("ultimail-auth") + } + router.replace("/login") + } +} diff --git a/components/auth/login-chrome.tsx b/components/auth/login-chrome.tsx new file mode 100644 index 0000000..3bc625c --- /dev/null +++ b/components/auth/login-chrome.tsx @@ -0,0 +1,70 @@ +"use client" + +import { useEffect } from "react" +import { mailBackgroundStyle } from "@/lib/mail-settings/constants" + +type HtmlBgState = { + mailBackground?: string + layer: string + fallback: string +} + +function readHtmlBgState(): HtmlBgState { + const html = document.documentElement + return { + mailBackground: html.dataset.mailBackground, + layer: html.style.getPropertyValue("--mail-bg-layer"), + fallback: html.style.getPropertyValue("--mail-bg-fallback"), + } +} + +function applyHtmlBgState(state: HtmlBgState) { + const html = document.documentElement + if (state.mailBackground) { + html.dataset.mailBackground = state.mailBackground + } else { + delete html.dataset.mailBackground + } + if (state.layer) { + html.style.setProperty("--mail-bg-layer", state.layer) + } else { + html.style.removeProperty("--mail-bg-layer") + } + if (state.fallback) { + html.style.setProperty("--mail-bg-fallback", state.fallback) + } else { + html.style.removeProperty("--mail-bg-fallback") + } +} + +function clearHtmlBg() { + const html = document.documentElement + delete html.dataset.mailBackground + html.style.removeProperty("--mail-bg-layer") + html.style.removeProperty("--mail-bg-fallback") +} + +/** Login shell: fixed Aurore bg (sm+), no user mail background, canvas on xs. */ +export function LoginChrome({ children }: { children: React.ReactNode }) { + useEffect(() => { + const saved = readHtmlBgState() + clearHtmlBg() + return () => applyHtmlBgState(saved) + }, []) + + const aurora = mailBackgroundStyle("gradient-aurora") + + return ( +
+
+ {children} +
+ ) +} diff --git a/components/first-launch-splash.tsx b/components/first-launch-splash.tsx index 8c4817c..fdf3a5b 100644 --- a/components/first-launch-splash.tsx +++ b/components/first-launch-splash.tsx @@ -5,7 +5,7 @@ import { UltiMailLogo } from "@/components/ultimail-logo" import { cn } from "@/lib/utils" const SPLASH_SEEN_KEY = "ultimail-splash-seen-v1" -const SPLASH_VISIBLE_MS = 1450 +const SPLASH_VISIBLE_MS = 1750 const SPLASH_EXIT_MS = 500 export function FirstLaunchSplash({ diff --git a/components/gmail/account-switcher-dropdown.tsx b/components/gmail/account-switcher-dropdown.tsx index 1993d68..b6e5082 100644 --- a/components/gmail/account-switcher-dropdown.tsx +++ b/components/gmail/account-switcher-dropdown.tsx @@ -1,14 +1,16 @@ "use client" import { useEffect, useRef, type RefObject } from "react" +import { usePathname } from "next/navigation" import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react" import { AccountAvatar } from "@/components/gmail/account-avatar" import { Button } from "@/components/ui/button" import type { ApiMailAccount } from "@/lib/api/types" import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries" +import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity" +import { buildOidcLoginUrl } from "@/lib/auth/login-url" import { useAccountStore, - useActiveAccount, useSignOutAll, } from "@/lib/stores/account-store" @@ -48,8 +50,8 @@ export function AccountSwitcherDropdown({ containerRef, }: AccountSwitcherDropdownProps) { const panelRef = useRef(null) - const activeAccount = useActiveAccount() - const activeAccountId = useAccountStore((s) => s.activeAccountId) + const pathname = usePathname() + const identity = useChromeIdentity() const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded) const setActiveAccountId = useAccountStore((s) => s.setActiveAccountId) const toggleOtherAccountsExpanded = useAccountStore( @@ -58,9 +60,14 @@ export function AccountSwitcherDropdown({ const signOutAll = useSignOutAll() const { data: accounts } = useMailAccounts() - const otherAccounts = (accounts ?? []).filter((a) => a.id !== activeAccountId) + const mailAccounts = accounts ?? [] + const hasMultipleMailAccounts = mailAccounts.length > 1 - const firstName = activeAccount?.name.split(" ")[0] ?? "" + const firstName = identity?.firstName ?? "" + const addAccountHref = buildOidcLoginUrl({ + returnTo: pathname || "/mail/inbox", + intent: "add_account", + }) useEffect(() => { if (!open) return @@ -83,13 +90,18 @@ export function AccountSwitcherDropdown({ } }, [open, onOpenChange, containerRef]) - if (!open || !activeAccount) return null + if (!open || !identity) return null const handleSelectAccount = (id: string) => { setActiveAccountId(id) onOpenChange(false) } + const handleSignOut = () => { + void signOutAll() + onOpenChange(false) + } + return (

- {activeAccount.email} + {identity.email}

- - {otherAccountsExpanded && ( -
- {otherAccounts.map((account) => ( - handleSelectAccount(account.id)} - /> - ))} -
- )} - -
+ {hasMultipleMailAccounts ? ( +
+ + {otherAccountsExpanded && ( +
+ {mailAccounts.map((account) => ( + handleSelectAccount(account.id)} + /> + ))} +
+ )} +
+ ) : null} + +
+
+ onOpenChange(false)} className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm text-foreground transition-colors hover:bg-accent" > Ajouter un compte - +
diff --git a/components/gmail/compose-identities-sync.tsx b/components/gmail/compose-identities-sync.tsx new file mode 100644 index 0000000..9594f51 --- /dev/null +++ b/components/gmail/compose-identities-sync.tsx @@ -0,0 +1,85 @@ +"use client" + +import { useEffect, useMemo } from "react" +import { useQueries } from "@tanstack/react-query" +import { useAuthReady } from "@/lib/api/use-auth-ready" +import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries" +import { apiClient } from "@/lib/api/client" +import type { ApiIdentity } from "@/lib/api/types" +import { useMailSignatures } from "@/lib/api/hooks/use-mail-signatures" +import { apiIdentityToCompose } from "@/lib/compose/identity-map" +import type { Identity } from "@/lib/compose-context" +import { useComposeIdentitiesStore } from "@/lib/stores/compose-identities-store" + +async function fetchIdentities(accountId: string) { + const res = await apiClient.get( + `/mail/accounts/${accountId}/identities` + ) + return Array.isArray(res) ? res : (res.identities ?? []) +} + +/** Hydrate compose From identities from server for all mail accounts. */ +export function ComposeIdentitiesSync() { + const { ready, authenticated } = useAuthReady() + const { data: accounts = [], isSuccess: accountsReady } = useMailAccounts() + const { data: signatures = [], isSuccess: signaturesReady } = useMailSignatures() + + const signaturesById = useMemo( + () => new Map(signatures.map((s) => [s.id, s])), + [signatures] + ) + + const identityQueries = useQueries({ + queries: accounts.map((account) => ({ + queryKey: ["identities", account.id], + queryFn: () => fetchIdentities(account.id), + enabled: ready && authenticated && !!account.id, + staleTime: 5 * 60_000, + })), + }) + + const mergedKey = identityQueries.map((q) => q.dataUpdatedAt).join("|") + const merged = useMemo(() => { + if (!ready || !authenticated || !accountsReady || !signaturesReady) return [] as Identity[] + if (accounts.length === 0) return [] as Identity[] + if (identityQueries.some((q) => q.isPending && q.fetchStatus !== "idle")) { + return null + } + return identityQueries.flatMap((q) => + (q.data ?? []).map((id) => apiIdentityToCompose(id, signaturesById)) + ) + }, [ + ready, + authenticated, + accountsReady, + signaturesReady, + accounts.length, + mergedKey, + identityQueries, + signaturesById, + ]) + + useEffect(() => { + if (!ready || !authenticated) { + useComposeIdentitiesStore.getState().clear() + return + } + if (merged === null) return + useComposeIdentitiesStore.getState().hydrateFromApi(merged) + }, [ready, authenticated, merged]) + + return null +} + +export function useComposeIdentities(accountId?: string | null) { + const identities = useComposeIdentitiesStore((s) => s.identities) + const hydrated = useComposeIdentitiesStore((s) => s.hydrated) + const scoped = accountId + ? identities.filter((i) => i.accountId === accountId) + : identities + const list = scoped.length > 0 ? scoped : identities + const defaultIdentity = + list.find((i) => i.isDefault) ?? list[0] ?? null + + return { identities: list, defaultIdentity, hydrated } +} diff --git a/components/gmail/compose/compose-bottom-toolbar.tsx b/components/gmail/compose/compose-bottom-toolbar.tsx index 9cf5fd9..ad06ee8 100644 --- a/components/gmail/compose/compose-bottom-toolbar.tsx +++ b/components/gmail/compose/compose-bottom-toolbar.tsx @@ -18,7 +18,6 @@ import { } from "lucide-react" import { type ComposeState, - SIGNATURES, useComposeActions, } from "@/lib/compose-context" import { cn, getNextLocalWallClockDate } from "@/lib/utils" @@ -45,6 +44,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover" +import { useMailSignaturesStore } from "@/lib/stores/mail-signatures-store" import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared" import { ComposeEmojiButton } from "./compose-emoji-picker" @@ -236,6 +236,7 @@ export function ComposeSignatureButton({ editor: Editor | null compose: ComposeState }) { + const signatures = useMailSignaturesStore((s) => s.signatures) const { updateCompose } = useComposeActions() const replaceSignature = useCallback( @@ -299,7 +300,7 @@ export function ComposeSignatureButton({ Aucune signature - {SIGNATURES.map((sig) => ( + {signatures.map((sig) => ( replaceSignature(sig.id)} diff --git a/components/gmail/compose/compose-emoji-picker.tsx b/components/gmail/compose/compose-emoji-picker.tsx index f9fc12c..3d57f15 100644 --- a/components/gmail/compose/compose-emoji-picker.tsx +++ b/components/gmail/compose/compose-emoji-picker.tsx @@ -24,7 +24,7 @@ const LazyPicker = lazy(() => import("@emoji-mart/react")) function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) { const { resolvedTheme } = useTheme() return ( - Chargement…
}> + }> ) => void - handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void + handleIdentityChange: (identity: Identity) => void clearFocusToMount: () => void subjectInputRef: RefObject onRecipientsActivate: () => void @@ -277,6 +278,7 @@ export function ComposeRecipientFields({ compose, isInline, showFromField, + identities = [], updateCompose, handleIdentityChange, clearFocusToMount, @@ -307,17 +309,25 @@ export function ComposeRecipientFields({ - {DEFAULT_IDENTITIES.map((id) => ( - handleIdentityChange(id)} - > -
- {id.name} - {id.email} -
+ {identities.length === 0 ? ( + + + Aucune identité d'envoi — ajoutez un compte mail dans les réglages. + - ))} + ) : ( + identities.map((id) => ( + handleIdentityChange(id)} + > +
+ {id.name} + {id.email} +
+
+ )) + )}
diff --git a/components/gmail/compose/compose-shared.ts b/components/gmail/compose/compose-shared.ts index b270026..d6127b6 100644 --- a/components/gmail/compose/compose-shared.ts +++ b/components/gmail/compose/compose-shared.ts @@ -1,5 +1,5 @@ import { Node as TipTapNode, mergeAttributes } from "@tiptap/core" -import { SIGNATURES } from "@/lib/compose-context" +import { getSignatureHtmlById } from "@/lib/stores/mail-signatures-store" /** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */ export const COMPOSE_PORTAL_Z = "z-[100]" @@ -28,9 +28,18 @@ export function stripSignature(html: string) { return html.replace(SIG_REGEX, "") } -export function insertSignatureHtml(html: string, sigId: string | null) { - const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null +/** Accepts signature library id or raw HTML. */ +export function insertSignatureHtml(html: string, sigIdOrHtml: string | null) { const clean = stripSignature(html) - if (!sig) return clean - return clean + `

--

${sig.html}
` + if (!sigIdOrHtml) return clean + + const fromLibrary = getSignatureHtmlById(sigIdOrHtml) + const sigHtml = fromLibrary ?? (sigIdOrHtml.trimStart().startsWith("<") ? sigIdOrHtml : null) + if (!sigHtml?.trim()) return clean + return `${clean}

--

${sigHtml}
` +} + +export function resolveSignatureContent(sigIdOrHtml: string | null): string | null { + if (!sigIdOrHtml) return null + return getSignatureHtmlById(sigIdOrHtml) ?? (sigIdOrHtml.trimStart().startsWith("<") ? sigIdOrHtml : null) } diff --git a/components/gmail/compose/use-compose-window.ts b/components/gmail/compose/use-compose-window.ts index 4d1397b..98a9c3e 100644 --- a/components/gmail/compose/use-compose-window.ts +++ b/components/gmail/compose/use-compose-window.ts @@ -24,9 +24,11 @@ import { import { type ComposeState, cloneComposeForPendingSend, - DEFAULT_IDENTITIES, + type Identity, useComposeActions, } from "@/lib/compose-context" +import { useActiveAccount } from "@/lib/stores/account-store" +import { useComposeIdentities } from "@/components/gmail/compose-identities-sync" import { useScheduledMail } from "@/lib/scheduled-mail-context" import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail" import type { Email } from "@/lib/email-data" @@ -55,6 +57,8 @@ export function useComposeWindow( } = useComposeActions() const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } = useScheduledMail() + const activeAccount = useActiveAccount() + const { identities: composeIdentities } = useComposeIdentities(activeAccount?.id) const isInline = compose.placement === "inline" const isEditingScheduled = compose.editingScheduledId != null const [showFormatting, setShowFormatting] = useState(false) @@ -131,12 +135,18 @@ export function useComposeWindow( bodyWithoutSig !== "" const handleIdentityChange = useCallback( - (identity: (typeof DEFAULT_IDENTITIES)[number]) => { + (identity: Identity) => { + const sigSource = + identity.signatureHtml ?? + (identity.defaultSignatureId ? identity.defaultSignatureId : null) if (compose.autoInsertSignature && editor) { - const sigId = identity.defaultSignatureId - const newHtml = insertSignatureHtml(editor.getHTML(), sigId) + const newHtml = insertSignatureHtml(editor.getHTML(), sigSource) editor.commands.setContent(newHtml) - updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId }) + updateCompose(compose.id, { + from: identity, + bodyHtml: newHtml, + signatureId: identity.defaultSignatureId, + }) } else { updateCompose(compose.id, { from: identity }) } @@ -497,6 +507,7 @@ export function useComposeWindow( compose, isInline, showFromField, + identities: composeIdentities, updateCompose, handleIdentityChange, clearFocusToMount, diff --git a/components/gmail/contact-hover-card.tsx b/components/gmail/contact-hover-card.tsx index 11e0b50..f76a943 100644 --- a/components/gmail/contact-hover-card.tsx +++ b/components/gmail/contact-hover-card.tsx @@ -149,7 +149,7 @@ export function ContactHoverCard({ role="presentation" tabIndex={0} className={cn( - "inline min-w-0 max-w-full cursor-default text-inherit outline-none focus-visible:ring-2 focus-visible:ring-[#1a73e8]/30 focus-visible:ring-offset-1 rounded-sm", + "inline-block min-w-0 max-w-full cursor-default text-inherit align-middle outline-none focus-visible:ring-2 focus-visible:ring-[#1a73e8]/30 focus-visible:ring-offset-1 rounded-sm", longPress.ackClassName, className )} diff --git a/components/gmail/contacts-page/bulk-create-dialog.tsx b/components/gmail/contacts-page/bulk-create-dialog.tsx index d73ee22..9dd763c 100644 --- a/components/gmail/contacts-page/bulk-create-dialog.tsx +++ b/components/gmail/contacts-page/bulk-create-dialog.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button" import { parseBulkContactText } from "@/lib/contacts/import-parsers" import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations" import { fullContactToApiContact } from "@/lib/api/adapters" +import { useContactsList } from "@/lib/contacts/use-contacts-list" import type { FullContact } from "@/lib/contacts/types" import { CONTACTS_MUTED_TEXT, @@ -28,6 +29,7 @@ interface BulkCreateDialogProps { export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) { const [input, setInput] = useState("") const createContactMutation = useCreateContact() + const { bookId } = useContactsList() function handleCreate() { const parsed = parseBulkContactText(input) @@ -45,7 +47,7 @@ export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreat phones: partial.phones ?? [], } createContactMutation.mutate({ - bookId: "default", + bookId, contact: fullContactToApiContact(fullContact), }) } diff --git a/components/gmail/contacts-page/contact-create-page.tsx b/components/gmail/contacts-page/contact-create-page.tsx index b476f3c..b4a742f 100644 --- a/components/gmail/contacts-page/contact-create-page.tsx +++ b/components/gmail/contacts-page/contact-create-page.tsx @@ -115,7 +115,7 @@ interface ContactCreatePageProps { } export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactCreatePageProps) { - const { contacts } = useContactsList() + const { contacts, bookId } = useContactsList() const createContactMutation = useCreateContact() const updateContactMutation = useUpdateContact() const labelRows = useNavStore((s) => s.labelRows) @@ -225,10 +225,13 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC updatedAt: Date.now(), } createContactMutation.mutate( - { bookId: "default", contact: fullContactToApiContact(fullContact) }, - { onSuccess: (created) => onSaved(created?.uid ?? tempId) }, + { bookId, contact: fullContactToApiContact(fullContact) }, + { + onSuccess: (created) => { + onSaved(created?.uid ?? tempId) + }, + }, ) - onSaved(tempId) } else if (contactId) { const fullContact: FullContact = { id: contactId, diff --git a/components/gmail/contacts-page/import-dialog.tsx b/components/gmail/contacts-page/import-dialog.tsx index 55bdd5b..7a1d743 100644 --- a/components/gmail/contacts-page/import-dialog.tsx +++ b/components/gmail/contacts-page/import-dialog.tsx @@ -12,6 +12,7 @@ import { Info } from "lucide-react" import { parseContactFile } from "@/lib/contacts/import-parsers" import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations" import { fullContactToApiContact } from "@/lib/api/adapters" +import { useContactsList } from "@/lib/contacts/use-contacts-list" import type { FullContact } from "@/lib/contacts/types" import { CONTACTS_HEADING_TEXT, @@ -30,6 +31,7 @@ interface ImportDialogProps { export function ImportDialog({ open, onOpenChange }: ImportDialogProps) { const fileRef = useRef(null) const createContactMutation = useCreateContact() + const { bookId } = useContactsList() const [pendingFile, setPendingFile] = useState(null) const [previewCount, setPreviewCount] = useState(0) const [error, setError] = useState(null) @@ -94,7 +96,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) { phones: partial.phones ?? [], } createContactMutation.mutate({ - bookId: "default", + bookId, contact: fullContactToApiContact(fullContact), }) } diff --git a/components/gmail/contacts-page/merge-duplicates-view.tsx b/components/gmail/contacts-page/merge-duplicates-view.tsx index cad2f30..4e32217 100644 --- a/components/gmail/contacts-page/merge-duplicates-view.tsx +++ b/components/gmail/contacts-page/merge-duplicates-view.tsx @@ -33,7 +33,7 @@ const REASON_LABELS: Record = { export function MergeDuplicatesView() { const [subView, setSubView] = useState("merge") - const { contacts } = useContactsList() + const { contacts, bookId } = useContactsList() const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs) const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair) const mergeDuplicatesMutation = useMergeDuplicates() @@ -46,7 +46,7 @@ export function MergeDuplicatesView() { const [mergingAll, setMergingAll] = useState(false) function handleMerge(_suggestion: MergeSuggestion) { - mergeDuplicatesMutation.mutate({ bookId: "default" }) + mergeDuplicatesMutation.mutate({ bookId }) } function handleIgnore(suggestion: MergeSuggestion) { @@ -56,7 +56,7 @@ export function MergeDuplicatesView() { function handleMergeAll() { setMergingAll(true) mergeDuplicatesMutation.mutate( - { bookId: "default" }, + { bookId }, { onSettled: () => setMergingAll(false) }, ) } diff --git a/components/gmail/contacts/contact-detail-view.tsx b/components/gmail/contacts/contact-detail-view.tsx index 4921286..25fcd67 100644 --- a/components/gmail/contacts/contact-detail-view.tsx +++ b/components/gmail/contacts/contact-detail-view.tsx @@ -27,7 +27,7 @@ import { CONTACTS_HEADING_TEXT, CONTACTS_MUTED_TEXT, CONTACTS_PANEL_DIVIDER_CLASS, - CONTACTS_PANEL_HEADER_COMPACT_CLASS, + CONTACTS_PANEL_HEADER_CLASS, CONTACTS_PANEL_ICON_BTN_CLASS, CONTACTS_PANEL_MUTED_ICON_CLASS, CONTACTS_PANEL_PRIMARY_ACTION_CLASS, @@ -105,8 +105,8 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) { return (
{/* Header */} -
- +
+
) } diff --git a/components/gmail/email-list/email-list-body.tsx b/components/gmail/email-list/email-list-body.tsx index 21235a4..085d3e1 100644 --- a/components/gmail/email-list/email-list-body.tsx +++ b/components/gmail/email-list/email-list-body.tsx @@ -2,7 +2,6 @@ import { ChevronLeft, ChevronUp, ChevronDown, RefreshCw } from "lucide-react" import { Button } from "@/components/ui/button" -import { TooltipProvider } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator" import { mailNavVisitKey } from "@/lib/mail-folder-display" @@ -222,8 +221,7 @@ export function EmailListBody({ /> ) : ( - - <> + <> {selectedFolder === "scheduled" && } {displayListEmails.length === 0 ? ( selectedFolder === "scheduled" ? ( @@ -251,7 +249,6 @@ export function EmailListBody({
)} - )}
diff --git a/components/gmail/email-list/email-list-helpers.ts b/components/gmail/email-list/email-list-helpers.ts index c1c58b8..e9c94b2 100644 --- a/components/gmail/email-list/email-list-helpers.ts +++ b/components/gmail/email-list/email-list-helpers.ts @@ -18,7 +18,7 @@ import { MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS, } from "@/lib/mail-chrome-classes" -export const LIST_PAGE_SIZE = 50 +export { LIST_PAGE_SIZE } from "@/lib/mail-list-page-size" export { PULL_HOLD_HEIGHT, diff --git a/components/gmail/email-list/email-list-layout.tsx b/components/gmail/email-list/email-list-layout.tsx index fb01f05..c14f6ae 100644 --- a/components/gmail/email-list/email-list-layout.tsx +++ b/components/gmail/email-list/email-list-layout.tsx @@ -8,6 +8,7 @@ import { EmailListToolbar } from "@/components/gmail/email-list/email-list-toolb import { EmailListBody } from "@/components/gmail/email-list/email-list-body" import { EmailListEmailViewPane } from "@/components/gmail/email-list/email-list-email-view-pane" import { EmailListEmpty } from "@/components/gmail/email-list/email-list-empty" +import { TooltipProvider } from "@/components/ui/tooltip" import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers" import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels" @@ -102,6 +103,11 @@ export function EmailListLayout({ openMobileXsLabelSheet, listPage: data.listPage, totalPages: data.totalPages, + paginationTotal: data.paginationTotal, + listPageSize: data.listPageSize, + paginationRangeStart: data.paginationRangeStart, + paginationRangeEnd: data.paginationRangeEnd, + onListPageSizeChange: data.handleListPageSizeChange, openMailIndex: reading.openMailIndex, goListPrevPage: reading.goListPrevPage, goListNextPage: reading.goListNextPage, @@ -133,6 +139,7 @@ export function EmailListLayout({ } return ( +
{!isViewMode && touchNav && ( @@ -206,6 +213,7 @@ export function EmailListLayout({ ) : null}
+ ) } diff --git a/components/gmail/email-list/email-list-row.tsx b/components/gmail/email-list/email-list-row.tsx index 8755eda..8e4ee50 100644 --- a/components/gmail/email-list/email-list-row.tsx +++ b/components/gmail/email-list/email-list-row.tsx @@ -688,28 +688,33 @@ function EmailListRowInner(props: EmailListRowProps) {
{isScheduled ? ( À : {email.scheduledToName ?? email.sender} ) : ( - - + + {showDraftBadge && ( Brouillon )} @@ -718,7 +723,7 @@ function EmailListRowInner(props: EmailListRowProps) { )} {threadMessageCount > 1 && ( - + {threadMessageCount} )} @@ -753,13 +758,15 @@ function EmailListRowInner(props: EmailListRowProps) { /> {email.subject} - {email.preview} + + {email.preview} +
{showAttachmentPills && ( diff --git a/components/gmail/email-list/email-list-toolbar.tsx b/components/gmail/email-list/email-list-toolbar.tsx index 51ab086..8998ec0 100644 --- a/components/gmail/email-list/email-list-toolbar.tsx +++ b/components/gmail/email-list/email-list-toolbar.tsx @@ -43,7 +43,6 @@ import { import { Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs" @@ -72,7 +71,10 @@ import { inboxTabBadgeDotClass, REFRESH_SPIN_CLASS, } from "@/components/gmail/email-list/email-list-helpers" -import { LIST_PAGE_SIZE } from "@/components/gmail/email-list/email-list-helpers" +import { + LIST_PAGE_SIZE_OPTIONS, + type ListPageSize, +} from "@/lib/mail-list-page-size" export type EmailListToolbarProps = { isViewMode: boolean @@ -123,6 +125,11 @@ export type EmailListToolbarProps = { openMobileXsLabelSheet: () => void listPage: number totalPages: number + paginationTotal?: number + listPageSize: number + paginationRangeStart: number + paginationRangeEnd: number + onListPageSizeChange: (size: ListPageSize) => void openMailIndex: number goListPrevPage: () => void goListNextPage: () => void @@ -205,6 +212,11 @@ export function EmailListToolbar(props: EmailListToolbarProps) { openMobileXsLabelSheet, listPage, totalPages, + paginationTotal, + listPageSize, + paginationRangeStart, + paginationRangeEnd, + onListPageSizeChange, openMailIndex, goListPrevPage, goListNextPage, @@ -240,7 +252,7 @@ export function EmailListToolbar(props: EmailListToolbarProps) { const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS const openMailToolbar = (showBack: boolean) => ( - + <> {showBack ? ( @@ -475,10 +487,15 @@ export function EmailListToolbar(props: EmailListToolbarProps) { )}
- + ) -const mailPaginationControls = (mode: "list" | "view") => ( +const mailPaginationControls = (mode: "list" | "view") => { + const totalCount = + paginationTotal ?? + (mode === "view" ? displayListEmails.length : paginationRangeEnd) + + return (
( {openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {displayListEmails.length} ) : ( - - {(listPage - 1) * LIST_PAGE_SIZE + 1}– - {Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "} - {displayListEmails.length} - {totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null} + + {paginationRangeStart} à{" "} + + + + + + {LIST_PAGE_SIZE_OPTIONS.map((size) => ( + onListPageSizeChange(size)} + className={cn(size === listPageSize && "font-medium")} + > + {size} par page + + ))} + + {" "} + sur {totalCount} )} @@ -552,12 +589,13 @@ const mailPaginationControls = (mode: "list" | "view") => (
-) + ) +} if (variant === "reading-pane") { return (
- {openMailToolbar(false)} + {openMailToolbar(true)}
{mailPaginationControls("view")}
@@ -783,7 +821,7 @@ const mailPaginationControls = (mode: "list" | "view") => ( {showBulkToolbar ? ( - + <>
@@ -983,7 +1021,7 @@ const mailPaginationControls = (mode: "list" | "view") => (
- + ) : ( <>
) : null} - {hasConversation && - conversation.map((msg) => { - const isExpanded = expandedIds.has(msg.id) - - if (isExpanded) { - return ( -
- toggleExpanded(msg.id)} - onPrintConversation={handlePrint} - /> -
- ) - } - - return ( -
- toggleExpanded(msg.id)} - /> -
- ) - })} + {messagesBefore.map((msg) => ( +
+ toggleExpanded(msg.id)} + onPrintConversation={handlePrint} + onReply={onToolbarReply} + onForward={onToolbarForward} + selfEmails={selfEmails} + selfDisplayName={selfDisplayName} + collapseQuotedReplies={otherThreadCount > 0} + /> +
+ ))} 0} + messageId={email.id} /> + {messagesAfter.map((msg) => ( +
+ toggleExpanded(msg.id)} + onPrintConversation={handlePrint} + onReply={onToolbarReply} + onForward={onToolbarForward} + selfEmails={selfEmails} + selfDisplayName={selfDisplayName} + collapseQuotedReplies={otherThreadCount > 0} + /> +
+ ))} + {showReplyForwardBar ? (
- ) } diff --git a/components/gmail/email-view/email-view-details-popover.tsx b/components/gmail/email-view/email-view-details-popover.tsx new file mode 100644 index 0000000..fe594d8 --- /dev/null +++ b/components/gmail/email-view/email-view-details-popover.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useEffect, useRef } from "react" +import { ChevronDown, Lock } from "lucide-react" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { MailDateText } from "@/components/gmail/mail-date-text" +import type { MessageHeaderDetails } from "@/lib/mail-message-header-details" +import { UnsubscribeActionButton } from "@/components/gmail/email-view/unsubscribe-action-button" +import { cn } from "@/lib/utils" + +function DetailRow({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( + <> +
{label}
+
{children}
+ + ) +} + +export function EmailViewDetailsPopover({ + summary, + details, + open, + onOpenChange, + isSpam, + messageId, +}: { + summary: string + details: MessageHeaderDetails + open: boolean + onOpenChange: (open: boolean) => void + isSpam?: boolean + messageId: string +}) { + const leaveTimerRef = useRef | null>(null) + + const clearLeaveTimer = () => { + if (leaveTimerRef.current) { + clearTimeout(leaveTimerRef.current) + leaveTimerRef.current = null + } + } + + const scheduleClose = () => { + clearLeaveTimer() + leaveTimerRef.current = setTimeout(() => onOpenChange(false), 150) + } + + const keepOpen = () => { + clearLeaveTimer() + } + + useEffect(() => () => clearLeaveTimer(), []) + + useEffect(() => { + if (!open) clearLeaveTimer() + }, [open]) + + return ( + + + + + e.stopPropagation()} + onPointerDownOutside={() => onOpenChange(false)} + onInteractOutside={() => onOpenChange(false)} + onEscapeKeyDown={() => onOpenChange(false)} + onMouseEnter={keepOpen} + onMouseLeave={scheduleClose} + > +
+ {details.fromLine} + {details.replyToLine ? ( + {details.replyToLine} + ) : null} + {details.toLine} + + + + {details.subject} + {details.mailedBy ? ( + {details.mailedBy} + ) : null} + {details.signedBy ? ( + {details.signedBy} + ) : null} + {details.unsubscribe ? ( + <> +
se désabonner :
+
+ +
+ + ) : null} +
sécurité :
+
+ {details.dkimPass === true ? ( +

Signature DKIM conforme

+ ) : details.dkimPass === false ? ( +

+ Signature DKIM non conforme +

+ ) : null} + {details.tls ? ( +

+ + Chiffrement standard (TLS) +

+ ) : null} +

+ Chiffrement PGP : non disponible pour l'instant +

+ {isSpam ? ( +

+ Ce message est marqué comme spam +

+ ) : null} +
+
+
+
+ ) +} diff --git a/components/gmail/email-view/email-view-messages.tsx b/components/gmail/email-view/email-view-messages.tsx index a99de3e..6b99d59 100644 --- a/components/gmail/email-view/email-view-messages.tsx +++ b/components/gmail/email-view/email-view-messages.tsx @@ -1,39 +1,174 @@ "use client" +import { useMemo, useState } from "react" import { Star, Info } from "lucide-react" +import { useMessage } from "@/lib/api/hooks/use-mail-queries" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" +import { mailFlagIsStarred, messageIsSpam } from "@/lib/mail-flags" import { avatarColor, cleanSenderName, senderInitial, } from "@/lib/sender-display" import { MailDateText } from "@/components/gmail/mail-date-text" -import type { ApiMessageFull } from "@/lib/api/types" +import type { ApiMessageFull, Recipient } from "@/lib/api/types" +import { resolveMessageFrom } from "@/lib/mail-message-participants" +import { + buildMessageHeaderDetails, + type MessageHeaderDetails, +} from "@/lib/mail-message-header-details" import type { EmailAttachment } from "@/lib/email-data" import { ContactHoverCard } from "@/components/gmail/contact-hover-card" import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar" -import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content" +import { MessageBodyContent } from "@/components/gmail/email-view/message-body-content" import { MessageAttachmentsSection } from "@/components/gmail/email-view/message-attachments" import { MAIL_MESSAGE_HOVER_CLASS, MAIL_TOOLTIP_CONTENT_CLASS, } from "@/lib/mail-chrome-classes" +import { repairMimeBodies } from "@/lib/mail-mime-body" + +export function formatApiMessageBody( + full: { body_html?: string; body_text?: string } | null | undefined, + snippet: string | undefined, + loading: boolean +): string { + const snippetHtml = snippet?.trim() + ? `

${snippet.trim()}

` + : "" + + if (loading) { + return snippetHtml + } + const repaired = repairMimeBodies(full?.body_text, full?.body_html) + const html = repaired.bodyHtml?.trim() + if (html) return html + const text = repaired.bodyText?.trim() + if (text) { + const escaped = text + .replace(/&/g, "&") + .replace(//g, ">") + return `
${escaped}
` + } + if (full) { + const s = snippet?.trim() + if (s) { + return `

${s}

` + } + return `

Ce message n’a pas de contenu.

` + } + const s = snippet?.trim() + return s + ? `

${s}

` + : "" +} + + +/** Prior message in a thread: loads full body on expand via GET /mail/messages/:id. */ +export function ThreadPriorMessage({ + message, + isExpanded, + onToggle, + onPrintConversation, + onReply, + onForward, + selfEmails, + selfDisplayName, + collapseQuotedReplies = false, +}: { + message: ApiMessageFull + isExpanded: boolean + onToggle: () => void + onPrintConversation?: () => void + onReply?: () => void + onForward?: () => void + selfEmails: string[] + selfDisplayName?: string + collapseQuotedReplies?: boolean +}) { + const [detailsOpen, setDetailsOpen] = useState(false) + const loadFull = isExpanded || detailsOpen + const { data: fullMessage, isPending } = useMessage(loadFull ? message.id : null) + const merged = fullMessage ?? message + const resolved = useMemo( + () => + resolveMessageFrom(merged.from, { selfEmails, selfDisplayName }), + [merged.from, selfEmails, selfDisplayName] + ) + const headerDetails = useMemo( + () => + buildMessageHeaderDetails(merged, { + selfEmails, + selfDisplayName, + subject: message.subject, + }), + [merged, selfEmails, selfDisplayName, message.subject] + ) + const body = useMemo( + () => + formatApiMessageBody( + fullMessage, + message.snippet, + isExpanded && isPending && !fullMessage + ), + [fullMessage, message.snippet, isExpanded, isPending] + ) + + const isSpam = messageIsSpam(merged.flags, merged.labels) + + if (!isExpanded) { + return ( + + ) + } + + return ( + + ) +} export function CollapsedMessage({ message, + senderName: senderNameProp, + senderEmail: senderEmailProp, onClick, }: { message: ApiMessageFull + senderName?: string + senderEmail?: string onClick: () => void }) { - const senderName = message.from[0]?.name ?? "" - const senderAddr = message.from[0]?.address ?? "" - const name = cleanSenderName(senderName) + const senderName = senderNameProp ?? message.from[0]?.name ?? "" + const senderAddr = senderEmailProp ?? message.from[0]?.address ?? "" + const name = cleanSenderName(senderName || senderAddr) const color = avatarColor(name) return ( @@ -81,6 +216,7 @@ export function CollapsedMessage({ export function ExpandedMessage({ sender, senderEmail, + headerDetails, dateIso, body, isSpam, @@ -90,9 +226,17 @@ export function ExpandedMessage({ onToggleStar, onCollapse, onPrintConversation, + onReply, + onForward, + detailsOpen, + onDetailsOpenChange, + collapseQuotedReplies = false, + messageId, }: { sender: string senderEmail: string + headerDetails: MessageHeaderDetails + messageId: string dateIso: string body: string isSpam: boolean @@ -102,12 +246,18 @@ export function ExpandedMessage({ onToggleStar?: () => void onCollapse?: () => void onPrintConversation?: () => void + onReply?: () => void + onForward?: () => void + detailsOpen?: boolean + onDetailsOpenChange?: (open: boolean) => void + collapseQuotedReplies?: boolean }) { return (
- +
{attachments.length > 0 && ( diff --git a/components/gmail/email-view/email-view-toolbar.tsx b/components/gmail/email-view/email-view-toolbar.tsx index 52d0b79..967b376 100644 --- a/components/gmail/email-view/email-view-toolbar.tsx +++ b/components/gmail/email-view/email-view-toolbar.tsx @@ -38,6 +38,8 @@ import { cn } from "@/lib/utils" import { avatarColor, cleanSenderName, senderInitial } from "@/lib/sender-display" import { MailDateText } from "@/components/gmail/mail-date-text" import { ContactHoverCard } from "@/components/gmail/contact-hover-card" +import { EmailViewDetailsPopover } from "@/components/gmail/email-view/email-view-details-popover" +import type { MessageHeaderDetails } from "@/lib/mail-message-header-details" import { MAIL_ICON_BTN, MAIL_MENU_SURFACE_WIDE_CLASS, @@ -51,6 +53,7 @@ const MENU_ICON_CLASS = "size-[18px] shrink-0 text-muted-foreground" export interface EmailViewMessageToolbarProps { sender: string senderEmail: string + headerDetails: MessageHeaderDetails dateIso: string isSpam: boolean isLast: boolean @@ -58,11 +61,17 @@ export interface EmailViewMessageToolbarProps { onToggleStar?: () => void onCollapse?: () => void onPrintConversation?: () => void + onReply?: () => void + onForward?: () => void + detailsOpen?: boolean + onDetailsOpenChange?: (open: boolean) => void + messageId: string } export function EmailViewMessageToolbar({ sender, senderEmail, + headerDetails, dateIso, isSpam, isLast, @@ -70,15 +79,29 @@ export function EmailViewMessageToolbar({ onToggleStar, onCollapse, onPrintConversation, + onReply, + onForward, + detailsOpen, + onDetailsOpenChange, + messageId, }: EmailViewMessageToolbarProps) { - const [showDetails, setShowDetails] = useState(false) const name = cleanSenderName(sender) + const [internalDetailsOpen, setInternalDetailsOpen] = useState(false) + const detailsIsOpen = detailsOpen ?? internalDetailsOpen + const setDetailsIsOpen = onDetailsOpenChange ?? setInternalDetailsOpen return ( <>
{ + setDetailsIsOpen(false) + onCollapse?.() + } + : undefined + } > {isSpam ? (
{name} - <{senderEmail}> + {senderEmail ? ( + <{senderEmail}> + ) : null}
- +
- - {showDetails && ( -
-

- de : {name} <{senderEmail}> -

-

- à : moi -

-

- date :{" "} - -

- {isSpam && ( -

- sécurité : ce message est marqué comme spam — les images et appels externes - sont bloqués -

- )} -
- )}
@@ -189,7 +188,10 @@ export function EmailViewMessageToolbar({ size="icon" className={cn("h-8 w-8", MAIL_ICON_BTN)} aria-label="Répondre" - onClick={(e) => e.stopPropagation()} + onClick={(e) => { + e.stopPropagation() + onReply?.() + }} > @@ -219,11 +221,19 @@ export function EmailViewMessageToolbar({ sideOffset={4} className={MESSAGE_MORE_MENU_CLASS} > - + { + onReply?.() + }} + > Répondre - + { + onForward?.() + }} + > Transférer diff --git a/components/gmail/email-view/message-body-content.tsx b/components/gmail/email-view/message-body-content.tsx new file mode 100644 index 0000000..acd37dd --- /dev/null +++ b/components/gmail/email-view/message-body-content.tsx @@ -0,0 +1,130 @@ +"use client" + +import { useMemo, useState } from "react" +import { cn } from "@/lib/utils" +import { splitQuotedHtml } from "@/lib/mail-quoted-content" +import { htmlHasRemoteContent } from "@/lib/mail-remote-content" +import { findContactByEmail } from "@/lib/contacts/find-contact" +import { useContactsList } from "@/lib/contacts/use-contacts-list" +import { useSelfMailEmails } from "@/lib/hooks/use-self-mail-emails" +import { normalizeMailAddress } from "@/lib/mail-message-participants" +import { + isMessageRemoteContentAllowed, + isTrustedSenderEmail, + useTrustedSendersStore, +} from "@/lib/stores/trusted-senders-store" +import { useMessageAttachmentCidMap } from "@/lib/api/hooks/use-message-attachment-cid-map" +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" + +export function MessageBodyContent({ + html, + isSpam, + senderEmail, + messageId, + collapseQuotedReplies = false, +}: { + html: string + isSpam: boolean + senderEmail: string + messageId: string + /** Hide included prior messages when the thread already lists them. */ + collapseQuotedReplies?: boolean +}) { + const [showQuoted, setShowQuoted] = useState(false) + const selfEmails = useSelfMailEmails() + const { contacts } = useContactsList() + const trustedSenderEmails = useTrustedSendersStore((s) => s.trustedSenderEmails) + const allowedMessageIds = useTrustedSendersStore((s) => s.allowedMessageIds) + const trustSender = useTrustedSendersStore((s) => s.trustSender) + const allowMessageRemoteContent = useTrustedSendersStore( + (s) => s.allowMessageRemoteContent + ) + + const isFromSelf = useMemo(() => { + const norm = normalizeMailAddress(senderEmail) + if (!norm) return false + return selfEmails.some((s) => normalizeMailAddress(s) === norm) + }, [senderEmail, selfEmails]) + + const isContact = Boolean(findContactByEmail(contacts, senderEmail)) + const isTrusted = isTrustedSenderEmail(trustedSenderEmails, senderEmail) + const isMessageAllowed = isMessageRemoteContentAllowed( + allowedMessageIds, + messageId + ) + + const { mainHtml, quotedHtml } = useMemo(() => { + if (!collapseQuotedReplies) { + return { mainHtml: html, quotedHtml: null as string | null } + } + return splitQuotedHtml(html) + }, [html, collapseQuotedReplies]) + + const { data: cidMap } = useMessageAttachmentCidMap(messageId) + const cidUrlMap = useInlineCidUrls(cidMap) + + const hasRemoteContent = useMemo( + () => + htmlHasRemoteContent(mainHtml) || + (quotedHtml ? htmlHasRemoteContent(quotedHtml) : false), + [mainHtml, quotedHtml] + ) + + const remoteContentAllowed = + isFromSelf || isContact || isTrusted || isMessageAllowed + + const blockRemoteContent = isFromSelf + ? false + : isSpam || (hasRemoteContent && !remoteContentAllowed) + + const showRemoteBanner = + !isFromSelf && !isSpam && hasRemoteContent && !remoteContentAllowed + + const hasHiddenQuote = Boolean(quotedHtml) && !showQuoted + + const sandboxProps = { + blockRemoteContent, + restrictPopups: isSpam, + senderEmail, + cidUrlMap, + } + + return ( +
+ {showRemoteBanner ? ( + allowMessageRemoteContent(messageId)} + onAlwaysShow={() => { + trustSender(senderEmail) + allowMessageRemoteContent(messageId) + }} + /> + ) : null} + + {hasHiddenQuote ? ( +
+ +
+ ) : null} + {quotedHtml && showQuoted ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/components/gmail/email-view/remote-content-banner.tsx b/components/gmail/email-view/remote-content-banner.tsx new file mode 100644 index 0000000..f7640fd --- /dev/null +++ b/components/gmail/email-view/remote-content-banner.tsx @@ -0,0 +1,32 @@ +"use client" + +export function RemoteContentBanner({ + senderEmail, + onShowOnce, + onAlwaysShow, +}: { + senderEmail: string + onShowOnce: () => void + onAlwaysShow: () => void +}) { + return ( +

+ Le contenu distant a été masqué :{" "} + + {" — "} + +

+ ) +} diff --git a/components/gmail/email-view/sandboxed-content.tsx b/components/gmail/email-view/sandboxed-content.tsx index 85a04e3..0375ed7 100644 --- a/components/gmail/email-view/sandboxed-content.tsx +++ b/components/gmail/email-view/sandboxed-content.tsx @@ -1,13 +1,25 @@ "use client" -import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react" +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react" import { useTheme } from "next-themes" import { emailPreviewBaseCss, emailPreviewDarkOverrideCss, emailPreviewLightOverrideCss, - preprocessEmailHtmlForTheme, + emailPreviewWrapperCss, } from "@/lib/email-preview-dark-styles" +import { + prepareEmailHtmlForIframe, + injectEmailHtmlIntoDocument, +} from "@/lib/mail-html-iframe" +import { buildEmailPreviewCsp } from "@/lib/mail-remote-content" const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = { display: "block", @@ -18,21 +30,68 @@ 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, - isSpam, + blockRemoteContent, + restrictPopups = false, + senderEmail, + cidUrlMap, }: { html: string - isSpam: boolean + blockRemoteContent: boolean + restrictPopups?: boolean + senderEmail?: string + cidUrlMap?: Record }) { const iframeRef = useRef(null) const [height, setHeight] = useState(120) - const sandboxValue = isSpam + const sandboxValue = restrictPopups ? "allow-same-origin" : "allow-same-origin allow-popups" const { resolvedTheme } = useTheme() + const isDark = + resolvedTheme === "dark" || + ((resolvedTheme === "system" || resolvedTheme === undefined) && + typeof document !== "undefined" && + documentIsDark()) + + const parsedEmail = useMemo( + () => + prepareEmailHtmlForIframe(html, { + blockRemoteContent, + isDark, + senderEmail, + cidUrlMap, + }), + [html, blockRemoteContent, isDark, senderEmail, cidUrlMap] + ) + + const themeCss = useMemo(() => { + if (!blockRemoteContent) return emailPreviewWrapperCss() + return `${emailPreviewBaseCss(isDark)}${ + isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss() + }` + }, [blockRemoteContent, isDark]) + + const cspContent = useMemo( + () => buildEmailPreviewCsp(blockRemoteContent), + [blockRemoteContent] + ) const injectContent = useCallback(() => { const iframe = iframeRef.current @@ -41,45 +100,44 @@ export function SandboxedContent({ const doc = iframe.contentDocument if (!doc) return - const cspMeta = isSpam - ? `` - : `` - - const isDark = documentIsDark() - const processedHtml = preprocessEmailHtmlForTheme(html, isDark) - const themeOverrides = isDark - ? emailPreviewDarkOverrideCss() - : emailPreviewLightOverrideCss() - - doc.open() - doc.write(` - - - - ${cspMeta} - - -${processedHtml} -`) - doc.close() - - const resizeObserver = new ResizeObserver(() => { - const body = iframe.contentDocument?.body - if (body) { - setHeight(Math.max(60, body.scrollHeight + 2)) - } + injectEmailHtmlIntoDocument(doc, { + csp: cspContent, + documentBaseHref: parsedEmail.documentBaseHref, + resolveBaseHref: parsedEmail.resolveBaseHref, + headMarkup: parsedEmail.headMarkup, + bodyHtml: parsedEmail.bodyHtml, + wrapperCss: themeCss, }) + const syncHeight = () => { + const liveDoc = iframe.contentDocument + if (!liveDoc) return + const next = measureIframeContentHeight(liveDoc) + setHeight((prev) => (prev === next ? prev : next)) + } + + const resizeObserver = new ResizeObserver(syncHeight) + if (doc.body) { resizeObserver.observe(doc.body) - setHeight(Math.max(60, doc.body.scrollHeight + 2)) + for (const img of doc.images) { + if (!img.complete) { + img.addEventListener("load", syncHeight, { once: true }) + img.addEventListener("error", syncHeight, { once: true }) + } + } + 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) } return () => resizeObserver.disconnect() - }, [html, isSpam, resolvedTheme]) + }, [parsedEmail, themeCss, cspContent]) useEffect(() => { const cleanup = injectContent() @@ -88,6 +146,7 @@ export function SandboxedContent({ return (