This commit is contained in:
R3D347HR4Y 2026-05-25 13:52:40 +02:00
parent c87670e90f
commit 5567e2f0c1
191 changed files with 13443 additions and 1028 deletions

14
.env.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<Suspense fallback={null}>
<AuthCompleteInner />
</Suspense>
)
}

View File

@ -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 lUI) ── */
html {
background-color: var(--mail-bg-fallback, var(--app-canvas));

View File

@ -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({
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
<ThemeInitScript />
<QueryProvider>
<FirstLaunchSplash>{children}</FirstLaunchSplash>
<AuthProvider>
<FirstLaunchSplash>{children}</FirstLaunchSplash>
</AuthProvider>
</QueryProvider>
<MailToaster />
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>
</html>

9
app/login/layout.tsx Normal file
View File

@ -0,0 +1,9 @@
import { LoginChrome } from "@/components/auth/login-chrome"
export default function LoginLayout({
children,
}: {
children: React.ReactNode
}) {
return <LoginChrome>{children}</LoginChrome>
}

72
app/login/page.tsx Normal file
View File

@ -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 (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
<Card className={LOGIN_CARD_CLASS}>
<CardHeader className="gap-4 px-0 text-center sm:px-0">
<UltiMailLogo variant="stacked" href={null} />
<CardDescription>
Connecte-toi avec ton compte Ulti (Authentik) pour accéder à la
messagerie.
</CardDescription>
{error ? (
<p className="text-sm text-destructive" role="alert">
{decodeURIComponent(error)}
</p>
) : null}
</CardHeader>
<CardContent className="flex justify-center px-0 sm:px-0">
<Button asChild size="lg" className="w-full sm:w-auto">
<a href={loginHref}>Se connecter</a>
</Button>
</CardContent>
<CardFooter className="px-0 sm:px-0">
<p className="w-full text-center text-sm text-muted-foreground">
Pas encore de compte ?{" "}
<a className="font-medium text-primary underline" href={signupHref}>
Créer un compte
</a>
</p>
</CardFooter>
</Card>
</div>
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginContent />
</Suspense>
)
}

View File

@ -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 (
<SidebarNavProvider
routeFolderId={route.folderId}
onRouteFolderIdChange={(nextFolderId) =>
onRouteFolderIdChange={(nextFolderId) => {
useMailUiStore.getState().requestSuppressSplitAutoOpen()
navigateRoute({
folderId: nextFolderId,
inboxTab: DEFAULT_INBOX_TAB,
page: 1,
mailId: null,
})
}
}}
>
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
{!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({
</div>
}
>
<MailAppInner />
{showSettingsPage ? (
<SidebarNavProvider>{routeOutlet}</SidebarNavProvider>
) : (
<MailAppInner />
)}
</Suspense>
<MailThemeApplier />
<MailSettingsSync />
<MailNavSync />
<ComposeIdentitiesSync />
<MailSignaturesSync />
<MailNotificationsBridge />
<QuickSettingsRoot />
<MoveDragIndicator />
<ComposeModalManager />
<MailToaster />
</EmailDragProvider>
</ScheduledMailProvider>
</ComposeProvider>

View File

@ -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 <MailSettingsSectionFromSegments segments={section} />
}

View File

@ -0,0 +1,9 @@
import { MailSettingsLayout } from "@/components/gmail/settings/mail-settings-layout"
export default function MailSettingsRootLayout({
children,
}: {
children: React.ReactNode
}) {
return <MailSettingsLayout>{children}</MailSettingsLayout>
}

View File

@ -1,10 +0,0 @@
export default function MailSettingsPage() {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-2 p-8 text-center">
<h1 className="text-xl font-medium text-[#3c4043]">Paramètres</h1>
<p className="text-sm text-[#5f6368]">
Page en cours de construction.
</p>
</div>
)
}

View File

@ -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<SessionPayload | null> {
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")
}
}

View File

@ -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 (
<div className="ultimail-login relative flex min-h-dvh flex-col bg-app-canvas sm:bg-transparent">
<div
className="pointer-events-none fixed inset-0 -z-10 hidden sm:block"
style={{
background: aurora.background,
backgroundColor: aurora.fallbackColor,
}}
aria-hidden
/>
{children}
</div>
)
}

View File

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

View File

@ -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<HTMLDivElement>(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 (
<div
ref={panelRef}
@ -99,7 +111,7 @@ export function AccountSwitcherDropdown({
>
<div className="relative px-4 pb-3 pt-4">
<p className="truncate pr-8 text-center text-sm text-foreground">
{activeAccount.email}
{identity.email}
</p>
<Button
type="button"
@ -114,7 +126,10 @@ export function AccountSwitcherDropdown({
<div className="mt-4 flex flex-col items-center">
<div className="relative">
<AccountAvatar account={activeAccount} size="lg" />
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
size="lg"
/>
<span className="absolute bottom-0 right-0 flex size-7 items-center justify-center rounded-full border-2 border-border bg-mail-surface text-muted-foreground shadow-sm">
<Camera className="size-3.5" aria-hidden />
</span>
@ -133,58 +148,66 @@ export function AccountSwitcherDropdown({
</div>
<div className="px-3 pb-3">
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
<button
type="button"
onClick={toggleOtherAccountsExpanded}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm text-foreground hover:bg-accent"
>
<span>
{otherAccountsExpanded
? "Masquer plus de comptes"
: "Afficher plus de comptes"}
</span>
{otherAccountsExpanded ? (
<ChevronUp className="size-5 text-muted-foreground" aria-hidden />
) : (
<ChevronDown className="size-5 text-muted-foreground" aria-hidden />
)}
</button>
{otherAccountsExpanded && (
<div className="border-t border-border px-1 pb-1 pt-0.5">
{otherAccounts.map((account) => (
<AccountRow
key={account.id}
account={account}
onSelect={() => handleSelectAccount(account.id)}
/>
))}
</div>
)}
<div className="border-t border-border px-1 py-1">
{hasMultipleMailAccounts ? (
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
<button
type="button"
onClick={toggleOtherAccountsExpanded}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm text-foreground hover:bg-accent"
>
<span>
{otherAccountsExpanded
? "Masquer les comptes mail"
: "Changer de compte mail"}
</span>
{otherAccountsExpanded ? (
<ChevronUp className="size-5 text-muted-foreground" aria-hidden />
) : (
<ChevronDown className="size-5 text-muted-foreground" aria-hidden />
)}
</button>
{otherAccountsExpanded && (
<div className="border-t border-border px-1 pb-1 pt-0.5">
{mailAccounts.map((account) => (
<AccountRow
key={account.id}
account={account}
onSelect={() => handleSelectAccount(account.id)}
/>
))}
</div>
)}
</div>
) : null}
<div
className={
hasMultipleMailAccounts
? "mt-3 overflow-hidden rounded-2xl border border-border bg-mail-surface"
: "overflow-hidden rounded-2xl border border-border bg-mail-surface"
}
>
<div className="px-1 py-1">
<a
href={addAccountHref}
onClick={() => 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"
>
<span className="flex size-8 items-center justify-center">
<Plus className="size-5 text-primary" aria-hidden />
</span>
Ajouter un compte
</button>
</a>
<button
type="button"
onClick={() => {
signOutAll()
onOpenChange(false)
}}
onClick={handleSignOut}
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"
>
<span className="flex size-8 items-center justify-center">
<LogOut className="size-5 text-muted-foreground" aria-hidden />
</span>
Se déconnecter de tous les comptes
Se déconnecter
</button>
</div>
</div>

View File

@ -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<ApiIdentity[] | { identities: ApiIdentity[] }>(
`/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 }
}

View File

@ -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({
</span>
Aucune signature
</DropdownMenuItem>
{SIGNATURES.map((sig) => (
{signatures.map((sig) => (
<DropdownMenuItem
key={sig.id}
onSelect={() => replaceSignature(sig.id)}

View File

@ -24,7 +24,7 @@ const LazyPicker = lazy(() => import("@emoji-mart/react"))
function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
const { resolvedTheme } = useTheme()
return (
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement</div>}>
<Suspense fallback={<div className="h-[435px] w-[352px]" aria-hidden />}>
<LazyPicker
data={data}
onEmojiSelect={onSelect}

View File

@ -12,7 +12,7 @@ import { ChevronDown, X } from "lucide-react"
import {
type ComposeState,
type Contact,
DEFAULT_IDENTITIES,
type Identity,
MOCK_CONTACTS,
} from "@/lib/compose-context"
import { cn } from "@/lib/utils"
@ -266,8 +266,9 @@ export interface ComposeRecipientFieldsProps {
compose: ComposeState
isInline: boolean
showFromField: boolean
identities?: Identity[]
updateCompose: (id: string, patch: Partial<ComposeState>) => void
handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void
handleIdentityChange: (identity: Identity) => void
clearFocusToMount: () => void
subjectInputRef: RefObject<HTMLInputElement | null>
onRecipientsActivate: () => void
@ -277,6 +278,7 @@ export function ComposeRecipientFields({
compose,
isInline,
showFromField,
identities = [],
updateCompose,
handleIdentityChange,
clearFocusToMount,
@ -307,17 +309,25 @@ export function ComposeRecipientFields({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
{DEFAULT_IDENTITIES.map((id) => (
<DropdownMenuItem
key={id.email}
onSelect={() => handleIdentityChange(id)}
>
<div className="flex flex-col">
<span className="font-medium">{id.name}</span>
<span className="text-xs text-muted-foreground">{id.email}</span>
</div>
{identities.length === 0 ? (
<DropdownMenuItem disabled>
<span className="text-sm text-muted-foreground">
Aucune identité d&apos;envoi ajoutez un compte mail dans les réglages.
</span>
</DropdownMenuItem>
))}
) : (
identities.map((id) => (
<DropdownMenuItem
key={id.id ?? id.email}
onSelect={() => handleIdentityChange(id)}
>
<div className="flex flex-col">
<span className="font-medium">{id.name}</span>
<span className="text-xs text-muted-foreground">{id.email}</span>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@ -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 + `<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
if (!sigIdOrHtml) return clean
const fromLibrary = getSignatureHtmlById(sigIdOrHtml)
const sigHtml = fromLibrary ?? (sigIdOrHtml.trimStart().startsWith("<") ? sigIdOrHtml : null)
if (!sigHtml?.trim()) return clean
return `${clean}<div id="ultimail-signature"><p>--</p>${sigHtml}</div>`
}
export function resolveSignatureContent(sigIdOrHtml: string | null): string | null {
if (!sigIdOrHtml) return null
return getSignatureHtmlById(sigIdOrHtml) ?? (sigIdOrHtml.trimStart().startsWith("<") ? sigIdOrHtml : null)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<HTMLInputElement>(null)
const createContactMutation = useCreateContact()
const { bookId } = useContactsList()
const [pendingFile, setPendingFile] = useState<File | null>(null)
const [previewCount, setPreviewCount] = useState(0)
const [error, setError] = useState<string | null>(null)
@ -94,7 +96,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
phones: partial.phones ?? [],
}
createContactMutation.mutate({
bookId: "default",
bookId,
contact: fullContactToApiContact(fullContact),
})
}

View File

@ -33,7 +33,7 @@ const REASON_LABELS: Record<DuplicateMatchReason, string> = {
export function MergeDuplicatesView() {
const [subView, setSubView] = useState<SubView>("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) },
)
}

View File

@ -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 (
<div className={cn("flex h-full min-w-0 flex-col overflow-hidden", CONTACTS_PANEL_SHELL_CLASS)}>
{/* Header */}
<div className={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
<ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
<div className={CONTACTS_PANEL_HEADER_CLASS}>
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
<div className="flex items-center gap-1">
<Button
type="button"

View File

@ -54,7 +54,7 @@ import {
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
CONTACTS_PANEL_HEADER_COMPACT_CLASS,
CONTACTS_PANEL_HEADER_CLASS,
CONTACTS_PANEL_ICON_BTN_CLASS,
CONTACTS_PANEL_LINK_TEXT_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
@ -136,7 +136,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
createDraft,
clearCreateDraft,
} = useContactsStore()
const { contacts } = useContactsList()
const { contacts, bookId } = useContactsList()
const createContactMutation = useCreateContact()
const updateContactMutation = useUpdateContact()
const labelRows = useNavStore((s) => s.labelRows)
@ -324,10 +324,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
updatedAt: Date.now(),
}
createContactMutation.mutate(
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
{ onSuccess: (created) => setView("view", created?.uid ?? tempId) },
{ bookId, contact: fullContactToApiContact(fullContact) },
{
onSuccess: (created) => {
const id = created?.uid ?? tempId
setView("view", id)
},
},
)
setView("view", tempId)
} else if (contactId) {
const fullContact: FullContact = {
id: contactId,
@ -354,8 +358,8 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
onSubmit={handleSubmit(onSubmit)}
className={cn("flex h-full flex-col", CONTACTS_PANEL_SHELL_CLASS)}
>
<div className={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
<ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
<div className={CONTACTS_PANEL_HEADER_CLASS}>
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
<div className="flex items-center gap-1">
<Button

View File

@ -84,7 +84,7 @@ export function ContactsListView() {
return (
<div className={CONTACTS_PANEL_SHELL_CLASS}>
<div className={cn(CONTACTS_PANEL_HEADER_SEARCH_CLASS, "gap-2")}>
<ContactsPanelLogo onClick={exitSearch} compact className="-ml-1 shrink-0" />
<ContactsPanelLogo onClick={exitSearch} className="-ml-1 shrink-0" />
<Search className={`h-4 w-4 shrink-0 ${CONTACTS_PANEL_MUTED_ICON_CLASS}`} />
<input
ref={searchInputRef}

View File

@ -10,15 +10,9 @@ import {
type ContactsPanelLogoProps = {
onClick: () => void
className?: string
/** Titre plus compact (barre détail / formulaire). */
compact?: boolean
}
export function ContactsPanelLogo({
onClick,
className,
compact = false,
}: ContactsPanelLogoProps) {
export function ContactsPanelLogo({ onClick, className }: ContactsPanelLogoProps) {
return (
<button
type="button"
@ -29,16 +23,8 @@ export function ContactsPanelLogo({
)}
aria-label="Liste des contacts"
>
<Users
className={cn(
"shrink-0",
compact ? "h-5 w-5" : "h-6 w-6",
CONTACTS_PANEL_MUTED_ICON_CLASS,
)}
/>
<span className={cn(CONTACTS_PANEL_TITLE_CLASS, compact && "text-base")}>
Contacts
</span>
<Users className={cn("h-6 w-6 shrink-0", CONTACTS_PANEL_MUTED_ICON_CLASS)} />
<span className={CONTACTS_PANEL_TITLE_CLASS}>Contacts</span>
</button>
)
}

View File

@ -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({
/>
</>
) : (
<TooltipProvider delayDuration={400}>
<>
<>
{selectedFolder === "scheduled" && <EmailListScheduledBanner />}
{displayListEmails.length === 0 ? (
selectedFolder === "scheduled" ? (
@ -251,7 +249,6 @@ export function EmailListBody({
</div>
)}
</>
</TooltipProvider>
)}
</div>
</div>

View File

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

View File

@ -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 (
<TooltipProvider delayDuration={400}>
<div className="flex h-full min-h-0 flex-1 flex-col">
<EmailListToolbar {...toolbarProps} part="mobile" />
{!isViewMode && touchNav && (
@ -206,6 +213,7 @@ export function EmailListLayout({
) : null}
</div>
</div>
</TooltipProvider>
)
}

View File

@ -688,28 +688,33 @@ function EmailListRowInner(props: EmailListRowProps) {
<div
className={cn(
"w-44 shrink-0 truncate pl-2 lg:w-40",
"flex w-44 shrink-0 items-center gap-1 pl-2 lg:w-40",
listRowPadTop,
isCompactListRow &&
"flex min-h-7 items-center leading-tight"
isCompactListRow && "min-h-7 leading-tight"
)}
data-selectable-text
>
{isScheduled ? (
<span
className={cn(
"text-sm",
"min-w-0 truncate text-sm leading-5",
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
)}
>
À : {email.scheduledToName ?? email.sender}
</span>
) : (
<ContactHoverCard displayName={email.sender} email={senderHoverEmail}>
<span className={cn(
"text-sm",
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
)}>
<ContactHoverCard
displayName={email.sender}
email={senderHoverEmail}
className="min-w-0 flex-1 truncate leading-5"
>
<span
className={cn(
"block truncate text-sm leading-5",
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
)}
>
{showDraftBadge && (
<span className="font-medium text-[#d93025]">Brouillon </span>
)}
@ -718,7 +723,7 @@ function EmailListRowInner(props: EmailListRowProps) {
</ContactHoverCard>
)}
{threadMessageCount > 1 && (
<span className="text-sm text-gray-500 ml-1">
<span className="shrink-0 text-sm leading-5 text-gray-500">
{threadMessageCount}
</span>
)}
@ -753,13 +758,15 @@ function EmailListRowInner(props: EmailListRowProps) {
/>
<span
className={cn(
"min-w-0 shrink truncate text-sm",
"min-w-0 shrink truncate text-sm leading-5",
!isRead ? "font-semibold text-gray-900" : "font-normal text-[#3c4043]"
)}
>
{email.subject}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">{email.preview}</span>
<span className="min-w-0 flex-1 truncate text-sm leading-5 text-gray-500">
{email.preview}
</span>
</div>
{showAttachmentPills && (
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />

View File

@ -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) => (
<TooltipProvider delayDuration={400}>
<>
{showBack ? (
<Tooltip>
<TooltipTrigger asChild>
@ -475,10 +487,15 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
</>
)}
</div>
</TooltipProvider>
</>
)
const mailPaginationControls = (mode: "list" | "view") => (
const mailPaginationControls = (mode: "list" | "view") => {
const totalCount =
paginationTotal ??
(mode === "view" ? displayListEmails.length : paginationRangeEnd)
return (
<div
className={cn(
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600",
@ -492,11 +509,31 @@ const mailPaginationControls = (mode: "list" | "view") => (
{openMailIndex >= 0 ? openMailIndex + 1 : ""} sur {displayListEmails.length}
</span>
) : (
<span>
{(listPage - 1) * LIST_PAGE_SIZE + 1}
{Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "}
{displayListEmails.length}
{totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null}
<span className="inline-flex items-center gap-1">
{paginationRangeStart} à{" "}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="rounded px-0.5 font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Choisir le nombre de messages par page"
>
{paginationRangeEnd}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className={dropdownSurfaceClass}>
{LIST_PAGE_SIZE_OPTIONS.map((size) => (
<DropdownMenuItem
key={size}
onSelect={() => onListPageSizeChange(size)}
className={cn(size === listPageSize && "font-medium")}
>
{size} par page
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>{" "}
sur {totalCount}
</span>
)}
<Tooltip>
@ -552,12 +589,13 @@ const mailPaginationControls = (mode: "list" | "view") => (
</TooltipContent>
</Tooltip>
</div>
)
)
}
if (variant === "reading-pane") {
return (
<div className="relative z-20 flex shrink-0 min-h-12 items-start gap-2 border-b border-gray-200 py-1.5 pl-2 pr-4">
{openMailToolbar(false)}
{openMailToolbar(true)}
<div className="flex-1" />
{mailPaginationControls("view")}
</div>
@ -783,7 +821,7 @@ const mailPaginationControls = (mode: "list" | "view") => (
</DropdownMenu>
{showBulkToolbar ? (
<TooltipProvider delayDuration={400}>
<>
<div className="flex min-w-0 items-center gap-0.5 pl-1">
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
@ -983,7 +1021,7 @@ const mailPaginationControls = (mode: "list" | "view") => (
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipProvider>
</>
) : (
<>
<Button

View File

@ -21,6 +21,7 @@ import type { Email, EmailAttachment } from "@/lib/email-data"
import {
isThreadHeadMessage,
} from "@/lib/mail-thread"
import { repairSnippet } from "@/lib/mail-mime-body"
import { useScheduledMail } from "@/lib/scheduled-mail-context"
import { useMailStore } from "@/lib/stores/mail-store"
import { useScheduledStore } from "@/lib/stores/scheduled-store"
@ -30,7 +31,11 @@ import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import type { MailNavFolderMaps } from "@/lib/mail-folder-filter"
import {
emailMatchesInboxTab,
type MailFolderFilterCtx,
type MailNavFolderMaps,
} from "@/lib/mail-folder-filter"
import {
getMailNavFolderLabel,
inboxTabDisplayLabel,
@ -55,7 +60,6 @@ import {
useComposeDrafts,
} from "@/lib/compose-context"
import {
LIST_PAGE_SIZE,
type EmailListProps,
buildInboxTabBarItems,
} from "@/components/gmail/email-list/email-list-helpers"
@ -69,6 +73,15 @@ import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import { useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav"
import type { MessageSearchFilter } from "@/lib/api/types"
import {
mailFlagIsRead,
mailFlagIsStarred,
mailFlagIsImportant,
mailFlagsWithRead,
mailFlagsWithStarred,
mailFlagsWithImportant,
} from "@/lib/mail-flags"
import { LIST_PAGE_SIZE, type ListPageSize } from "@/lib/mail-list-page-size"
function apiMessageToEmail(msg: ApiMessageSummary): Email {
const sender = msg.from[0]?.name || msg.from[0]?.address || ""
@ -78,11 +91,11 @@ function apiMessageToEmail(msg: ApiMessageSummary): Email {
sender,
senderEmail,
subject: msg.subject,
preview: msg.snippet,
preview: repairSnippet(msg.snippet) ?? msg.snippet,
date: msg.date,
read: msg.flags.includes("read"),
starred: msg.flags.includes("starred"),
important: msg.flags.includes("important"),
read: mailFlagIsRead(msg.flags),
starred: mailFlagIsStarred(msg.flags),
important: mailFlagIsImportant(msg.flags, msg.labels),
spam: msg.labels.includes("spam"),
hasAttachment: msg.has_attachments,
labels: msg.labels,
@ -166,15 +179,15 @@ export function useEmailListData({
const accountId = searchAccount?.id
const queryClient = useQueryClient()
const listPageSize = useMailSettingsStore((s) => s.listPageSize)
const setListPageSize = useMailSettingsStore((s) => s.setListPageSize)
const effectiveApiFolder = useMemo(() => {
if (isSearchMode) return "__search__"
if (selectedFolder === "scheduled" || selectedFolder === "snoozed") return "__local__"
if (selectedFolder !== "inbox") return selectedFolder
const tab = normalizeInboxTabSegment(inboxTab)
if (tab === INBOX_ALL_TAB) return "inbox"
return tab
}, [selectedFolder, inboxTab, isSearchMode])
if (selectedFolder === "inbox") return "inbox"
return selectedFolder
}, [selectedFolder, isSearchMode])
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
if (!isSearchMode || !searchParams) return null
@ -194,7 +207,8 @@ export function useEmailListData({
? "inbox"
: effectiveApiFolder,
accountId,
listPage
listPage,
listPageSize
)
const searchQuery = useMailSearch(searchFilter)
@ -366,12 +380,10 @@ export function useEmailListData({
for (const [id, isRead] of Object.entries(changes)) {
const msg = apiMessagesById.get(id)
if (!msg) continue
const flags = [...msg.flags]
if (isRead && !flags.includes("read")) {
updateFlags.mutate({ id, flags: [...flags, "read"] })
} else if (!isRead && flags.includes("read")) {
updateFlags.mutate({ id, flags: flags.filter((f) => f !== "read") })
}
const alreadyRead = mailFlagIsRead(msg.flags)
if (isRead === alreadyRead) continue
const flags = mailFlagsWithRead(msg.flags, isRead)
updateFlags.mutate({ id, flags })
}
},
[apiMessagesById, updateFlags]
@ -419,18 +431,14 @@ export function useEmailListData({
toggleStar: (id: string) => {
const msg = apiMessagesById.get(id)
if (!msg) return
const flags = msg.flags.includes("starred")
? msg.flags.filter((f) => f !== "starred")
: [...msg.flags, "starred"]
updateFlags.mutate({ id, flags })
const starred = mailFlagIsStarred(msg.flags)
updateFlags.mutate({ id, flags: mailFlagsWithStarred(msg.flags, !starred) })
},
toggleImportant: (id: string) => {
const msg = apiMessagesById.get(id)
if (!msg) return
const flags = msg.flags.includes("important")
? msg.flags.filter((f) => f !== "important")
: [...msg.flags, "important"]
updateFlags.mutate({ id, flags })
const important = mailFlagIsImportant(msg.flags, msg.labels)
updateFlags.mutate({ id, flags: mailFlagsWithImportant(msg.flags, !important) })
},
}), [deleteMessage, updateLabels, updateFlags, apiMessagesById])
@ -485,9 +493,20 @@ export function useEmailListData({
useMailStore.getState().markSeen(id)
}, [])
const folderFilterCtx = useMemo<MailFolderFilterCtx>(
() => ({
starredEmailIds: starredEmails,
importantEmailIds: importantEmails,
}),
[starredEmails, importantEmails]
)
const filteredEmails = useMemo(() => {
return allEmails
}, [allEmails])
if (selectedFolder !== "inbox") return allEmails
return allEmails.filter((e) =>
emailMatchesInboxTab(e, inboxTab, folderFilterCtx, navMaps)
)
}, [allEmails, selectedFolder, inboxTab, folderFilterCtx, navMaps])
const displayListEmails = useMemo(() => {
let rows = filteredEmails
@ -547,17 +566,51 @@ export function useEmailListData({
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data, allEmails.length])
const totalPages = useMemo(
() => Math.max(1, Math.ceil((paginationTotal ?? displayListEmails.length) / LIST_PAGE_SIZE)),
[paginationTotal, displayListEmails.length]
() =>
Math.max(
1,
Math.ceil((paginationTotal ?? displayListEmails.length) / listPageSize)
),
[paginationTotal, displayListEmails.length, listPageSize]
)
const paginationRangeStart = useMemo(() => {
if (displayListEmails.length === 0) return 0
return (listPage - 1) * listPageSize + 1
}, [displayListEmails.length, listPage, listPageSize])
const paginationRangeEnd = useMemo(() => {
if (displayListEmails.length === 0) return 0
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
const total = paginationTotal ?? displayListEmails.length
return Math.min(listPage * listPageSize, total)
}
return Math.min(listPage * listPageSize, displayListEmails.length)
}, [
displayListEmails.length,
listPage,
listPageSize,
paginationTotal,
effectiveApiFolder,
isSearchMode,
])
const handleListPageSizeChange = useCallback(
(size: ListPageSize) => {
if (size === listPageSize) return
setListPageSize(size)
onMailRouteNavigate({ page: 1 })
},
[listPageSize, setListPageSize, onMailRouteNavigate]
)
const pagedEmails = useMemo(() => {
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
return displayListEmails
}
const start = (listPage - 1) * LIST_PAGE_SIZE
return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode])
const start = (listPage - 1) * listPageSize
return displayListEmails.slice(start, start + listPageSize)
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode, listPageSize])
const listEmails = useMemo(() => {
if (isXs && !isViewMode) {
@ -568,14 +621,6 @@ export function useEmailListData({
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
const folderFilterCtx = useMemo(
() => ({
starredEmailIds: [] as string[],
importantEmailIds: [] as string[],
}),
[]
)
const listRowExtras = useMemo(() => {
const invitationById = new Map<
string,
@ -669,11 +714,26 @@ export function useEmailListData({
const seen = new Set(
seenSerialized.length > 0 ? seenSerialized.split(",") : []
)
const inboxPool = allEmails.filter((e) => !seen.has(e.id))
const subtreeIdsCache = new Map<string, string[] | null>()
const counts: Record<string, number> = {}
const preview: Record<string, string> = {}
for (const tab of inboxTabBarItems) {
const rows = inboxPool.filter((e) => !seen.has(e.id))
const rows: Email[] = []
for (const e of allEmails) {
if (seen.has(e.id)) continue
if (
!emailMatchesInboxTab(
e,
tab.id,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
) {
continue
}
rows.push(e)
}
counts[tab.id] = rows.length
if (inboxTabShowsInactiveMeta(tab.id)) {
const chain: string[] = []
@ -689,7 +749,13 @@ export function useEmailListData({
}
}
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [seenSerialized, allEmails, inboxTabBarItems])
}, [
seenSerialized,
allEmails,
inboxTabBarItems,
folderFilterCtx,
navMaps,
])
useEffect(() => {
onFolderUnreadCountsChange?.(folderUnreadCounts)
@ -720,8 +786,8 @@ export function useEmailListData({
if (e.read) continue
const msg = apiMessagesById.get(e.id)
if (!msg) continue
if (!msg.flags.includes("read")) {
updateFlags.mutate({ id: e.id, flags: [...msg.flags, "read"] })
if (!mailFlagIsRead(msg.flags)) {
updateFlags.mutate({ id: e.id, flags: mailFlagsWithRead(msg.flags, true) })
}
}
}, [displayListEmails, apiMessagesById, updateFlags])
@ -790,6 +856,11 @@ export function useEmailListData({
inboxCategoryTabLabel,
mobileUnreadCount,
mobileFolderLabel,
paginationTotal,
listPageSize,
paginationRangeStart,
paginationRangeEnd,
handleListPageSizeChange,
totalPages,
pagedEmails,
listEmails,

View File

@ -6,6 +6,7 @@ import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import type { Email } from "@/lib/email-data"
import { readStateTargets } from "@/lib/mail-thread"
@ -19,7 +20,6 @@ import {
parseMailNavVisitKey,
} from "@/lib/mail-folder-display"
import {
LIST_PAGE_SIZE,
escapeHtml,
} from "@/components/gmail/email-list/email-list-helpers"
import type { Contact } from "@/lib/compose-context"
@ -30,6 +30,7 @@ import {
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"
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
export function useEmailListReading(
props: EmailListProps,
@ -52,6 +53,7 @@ export function useEmailListReading(
emailById,
displayListEmails,
listPage,
listPageSize,
listRowsDep,
listViewportRef,
conversationMode,
@ -92,10 +94,20 @@ export function useEmailListReading(
[openMailId, displayListEmails]
)
// Guard: emailById/setReadOverrides get new refs after each messages refetch — without
// this guard, mark-read → invalidate → refetch → effect re-runs in a loop.
const readAppliedForMailRef = useRef<string | null>(null)
useEffect(() => {
if (!openMailId) return
if (!openMailId) {
readAppliedForMailRef.current = null
return
}
if (readAppliedForMailRef.current === openMailId) return
const message = emailById.get(openMailId)
if (!message) return
readAppliedForMailRef.current = openMailId
const targets = readStateTargets(message, conversationMode)
for (const id of targets) {
markEmailSeen(id)
@ -111,17 +123,22 @@ export function useEmailListReading(
const navigateToMail = useCallback(
(id: string | null) => {
if (id && splitView) {
const idx = displayListEmails.findIndex((e) => e.id === id)
if (idx >= 0) {
const page = Math.floor(idx / LIST_PAGE_SIZE) + 1
onMailRouteNavigate({ mailId: id, page })
return
}
if (id === null) {
useMailUiStore.getState().requestSuppressSplitAutoOpen()
}
onMailRouteNavigate({ mailId: id })
startTransition(() => {
if (id && splitView) {
const idx = displayListEmails.findIndex((e) => e.id === id)
if (idx >= 0) {
const page = Math.floor(idx / listPageSize) + 1
onMailRouteNavigate({ mailId: id, page })
return
}
}
onMailRouteNavigate({ mailId: id })
})
},
[splitView, displayListEmails, onMailRouteNavigate]
[splitView, displayListEmails, onMailRouteNavigate, listPageSize]
)
useEffect(() => {
@ -153,9 +170,12 @@ export function useEmailListReading(
}, [splitView, openMailId, navigateToMail, pickAdjacentMailId])
const goBack = useCallback(() => {
if (splitView) leaveReadingPane()
else navigateToMail(null)
}, [splitView, leaveReadingPane, navigateToMail])
if (splitView) {
navigateToMail(null)
return
}
navigateToMail(null)
}, [splitView, navigateToMail])
const closeViewIfShowingEmail = useCallback(
(emailId: string) => {
@ -207,6 +227,7 @@ export function useEmailListReading(
const handleCategoryInboxTabClick = useCallback(
(tabId: string) => {
useMailUiStore.getState().requestSuppressSplitAutoOpen()
startTransition(() => {
onMailRouteNavigate({
inboxTab: tabId,
@ -393,32 +414,40 @@ export function useEmailListReading(
)
}, [openEmail, openComposeWithInitial])
const xsViewChromeCallbacksRef = useRef({
onArchive: singleArchive,
onReply: singleReply,
moveTargets,
onMoveTo: singleMoveTo,
})
xsViewChromeCallbacksRef.current = {
onArchive: singleArchive,
onReply: singleReply,
moveTargets,
onMoveTo: singleMoveTo,
}
useEffect(() => {
if (!onXsViewChromeChange) return
if (!isXs || !isViewMode || !openEmail) {
if (!isXs || !isViewMode || !openMailId) {
onXsViewChromeChange(null)
return
}
onXsViewChromeChange({
onArchive: singleArchive,
onReply: singleReply,
moveTargets,
onMoveTo: singleMoveTo,
onArchive: () => xsViewChromeCallbacksRef.current.onArchive(),
onReply: () => xsViewChromeCallbacksRef.current.onReply(),
moveTargets: xsViewChromeCallbacksRef.current.moveTargets,
onMoveTo: (targetId) =>
xsViewChromeCallbacksRef.current.onMoveTo(targetId),
})
return () => onXsViewChromeChange(null)
}, [
onXsViewChromeChange,
isXs,
isViewMode,
openEmail,
singleArchive,
singleReply,
singleMoveTo,
moveTargets,
])
}, [onXsViewChromeChange, isXs, isViewMode, openMailId, moveTargets])
useEffect(() => {
if (!splitView) return
if (useMailUiStore.getState().consumeSuppressSplitAutoOpen()) {
return
}
const firstId = displayListEmails[0]?.id ?? null
if (!openMailId) {
if (firstId) navigateToMail(firstId)

View File

@ -8,9 +8,6 @@ import {
useState,
} from "react"
import { Reply, ReplyAll, Forward } from "lucide-react"
import {
TooltipProvider,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import {
avatarColor,
@ -19,11 +16,19 @@ import {
} from "@/lib/sender-display"
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
import type { Email, EmailAttachment } from "@/lib/email-data"
import {
mailFlagIsRead,
mailFlagIsStarred,
mailFlagIsImportant,
messageIsSpam,
messageHasFlag,
messageHasLabel,
} from "@/lib/mail-flags"
import { repairSnippet } from "@/lib/mail-mime-body"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import { useMessage, useThread } from "@/lib/api/hooks/use-mail-queries"
import { useMessage, useThread, unwrapThreadMessages } from "@/lib/api/hooks/use-mail-queries"
import {
useToggleStar,
useMarkRead,
useUpdateFlags,
useUpdateLabels,
} from "@/lib/api/hooks/use-mail-mutations"
@ -31,11 +36,16 @@ import {
useComposeActions,
useComposeDrafts,
useComposeWindows,
DEFAULT_IDENTITIES,
type ThreadComposeKind,
type ComposeOpenPreset,
savedThreadDraftToComposePreset,
} from "@/lib/compose-context"
import { resolveComposeIdentity } from "@/lib/compose/resolve-compose-identity"
import { useSelfMailEmails } from "@/lib/hooks/use-self-mail-emails"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { resolveMessageFrom } from "@/lib/mail-message-participants"
import { buildMessageHeaderDetails } from "@/lib/mail-message-header-details"
import { splitThreadAroundOpenMessage } from "@/lib/mail-thread"
import {
buildThreadComposePreset,
withTouchFullscreenComposePreset,
@ -51,9 +61,10 @@ import {
MAIL_REPLY_BUTTON_CLASS,
} from "@/lib/mail-chrome-classes"
import {
CollapsedMessage,
ExpandedMessage,
SpamWhyBanner,
ThreadPriorMessage,
formatApiMessageBody,
} from "@/components/gmail/email-view/email-view-messages"
function apiToLegacyEmail(
@ -67,13 +78,13 @@ function apiToLegacyEmail(
sender: senderName,
senderEmail: msg.from[0]?.address,
subject: msg.subject,
preview: msg.snippet,
preview: repairSnippet(msg.snippet) ?? msg.snippet,
body: full?.body_html ?? full?.body_text,
date: msg.date,
read: msg.flags.includes("read"),
starred: msg.flags.includes("starred"),
important: msg.flags.includes("important"),
spam: msg.flags.includes("spam") || msg.labels.includes("spam"),
read: mailFlagIsRead(msg.flags),
starred: mailFlagIsStarred(msg.flags),
important: mailFlagIsImportant(msg.flags, msg.labels),
spam: messageIsSpam(msg.flags, msg.labels),
labels: msg.labels,
hasAttachment: msg.has_attachments,
conversation: thread
@ -114,51 +125,59 @@ export function EmailView({
currentFolderId,
isSingleMessageView = false,
}: EmailViewProps) {
const { data: fullMessage } = useMessage(email.id)
const { data: fullMessage, isPending: fullMessagePending } = useMessage(email.id)
const { data: threadMessages } = useThread(email.thread_id ?? null)
const selfEmails = useSelfMailEmails()
const chromeIdentity = useChromeIdentity()
const selfDisplayName = chromeIdentity?.name
const toggleStar = useToggleStar()
const markRead = useMarkRead()
const updateFlags = useUpdateFlags()
const updateLabels = useUpdateLabels()
const flags = fullMessage?.flags ?? email.flags
const isStarred = flags.includes("starred")
const isSpam = flags.includes("spam") || email.labels.includes("spam")
const isStarred = mailFlagIsStarred(flags)
const isSpam = messageIsSpam(flags, fullMessage?.labels ?? email.labels)
const initialFlagsRef = useRef(flags)
useEffect(() => {
initialFlagsRef.current = email.flags
}, [email.id, email.flags])
useEffect(() => {
if (!initialFlagsRef.current.includes("read")) {
markRead.mutate({ id: email.id, flags: initialFlagsRef.current })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email.id])
const body =
fullMessage?.body_html ??
fullMessage?.body_text ??
`<p style="color:var(--muted-foreground);">${email.snippet}</p>`
const body = useMemo(
() =>
formatApiMessageBody(
fullMessage,
email.snippet,
fullMessagePending && !fullMessage
),
[fullMessage, fullMessagePending, email.snippet]
)
const [showFullThread, setShowFullThread] = useState(false)
const [mainDetailsOpen, setMainDetailsOpen] = useState(false)
const priorMessages = useMemo(() => {
if (!threadMessages) return []
return threadMessages.filter((m) => m.id !== email.id)
}, [threadMessages, email.id])
const threadOrdered = useMemo(
() => unwrapThreadMessages(threadMessages),
[threadMessages]
)
const priorCount = priorMessages.length
const { before: threadBefore, after: threadAfter } = useMemo(
() => splitThreadAroundOpenMessage(threadOrdered, email.id),
[threadOrdered, email.id]
)
const otherThreadCount = threadBefore.length + threadAfter.length
const showRepliesCta =
isSingleMessageView && !showFullThread && priorCount > 0
isSingleMessageView && !showFullThread && otherThreadCount > 0
const conversation =
isSingleMessageView && !showFullThread ? [] : priorMessages
const hasConversation = conversation.length > 0
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]
)
const toggleExpanded = (msgId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
@ -168,13 +187,32 @@ export function EmailView({
})
}
const mainSenderName = cleanSenderName(email.from[0]?.name ?? "")
const mainSenderAddr =
email.from[0]?.address ??
`${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
const mainFrom = useMemo(
() =>
resolveMessageFrom(fullMessage?.from ?? email.from, {
selfEmails,
selfDisplayName,
}),
[fullMessage?.from, email.from, selfEmails, selfDisplayName]
)
const mainHeaderDetails = useMemo(
() =>
buildMessageHeaderDetails(
{
...email,
...fullMessage,
from: fullMessage?.from ?? email.from,
to: fullMessage?.to ?? email.to,
cc: fullMessage?.cc,
subject: email.subject,
},
{ selfEmails, selfDisplayName, subject: email.subject }
),
[email, fullMessage, selfEmails, selfDisplayName]
)
const legacyEmail = useMemo(
() => apiToLegacyEmail(email, fullMessage, threadMessages),
() => apiToLegacyEmail(email, fullMessage, unwrapThreadMessages(threadMessages)),
[email, fullMessage, threadMessages]
)
@ -239,7 +277,16 @@ export function EmailView({
[legacyEmail, openThreadCompose]
)
const selfIdentity = DEFAULT_IDENTITIES[0]
const onToolbarReply = useCallback(
() => startThreadCompose("reply"),
[startThreadCompose]
)
const onToolbarForward = useCallback(
() => startThreadCompose("forward"),
[startThreadCompose]
)
const selfIdentity = resolveComposeIdentity()
const selfName = cleanSenderName(selfIdentity.name)
const calendarInvitation = useMemo(
@ -252,16 +299,16 @@ export function EmailView({
}, [email.id, flags, isStarred, toggleStar])
const handleNotSpam = useCallback(() => {
if (flags.includes("spam")) {
if (messageHasFlag(flags, "spam")) {
updateFlags.mutate({
id: email.id,
flags: flags.filter((f) => f !== "spam"),
flags: flags.filter((f) => f.toLowerCase() !== "spam"),
})
}
if (email.labels.includes("spam")) {
if (messageHasLabel(email.labels, "spam")) {
updateLabels.mutate({
id: email.id,
labels: email.labels.filter((l) => l !== "spam"),
labels: (email.labels ?? []).filter((l) => l.toLowerCase() !== "spam"),
})
}
}, [email.id, flags, email.labels, updateFlags, updateLabels])
@ -271,8 +318,7 @@ export function EmailView({
}, [legacyEmail])
return (
<TooltipProvider delayDuration={400}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
<div
className="h-[52px] shrink-0 bg-mail-surface sm:hidden"
@ -306,58 +352,65 @@ export function EmailView({
onClick={() => setShowFullThread(true)}
className="text-sm font-medium text-primary hover:underline"
>
{priorCount === 1
? "Afficher la réponse"
: `Afficher les ${priorCount} réponses`}
{otherThreadCount === 1
? "Afficher l'autre message du fil"
: `Afficher les ${otherThreadCount} autres messages du fil`}
</button>
</div>
) : null}
{hasConversation &&
conversation.map((msg) => {
const isExpanded = expandedIds.has(msg.id)
if (isExpanded) {
return (
<div key={msg.id} className="border-b border-border">
<ExpandedMessage
sender={msg.from[0]?.name ?? ""}
senderEmail={msg.from[0]?.address ?? ""}
dateIso={msg.date}
body={msg.body_html ?? msg.body_text ?? ""}
isSpam={false}
isLast={false}
starred={msg.flags.includes("starred")}
onCollapse={() => toggleExpanded(msg.id)}
onPrintConversation={handlePrint}
/>
</div>
)
}
return (
<div key={msg.id} className="border-b border-[#eceff1]">
<CollapsedMessage
message={msg}
onClick={() => toggleExpanded(msg.id)}
/>
</div>
)
})}
{messagesBefore.map((msg) => (
<div key={msg.id} className="border-b border-[#eceff1]">
<ThreadPriorMessage
message={msg}
isExpanded={isThreadMessageExpanded(msg.id)}
onToggle={() => toggleExpanded(msg.id)}
onPrintConversation={handlePrint}
onReply={onToolbarReply}
onForward={onToolbarForward}
selfEmails={selfEmails}
selfDisplayName={selfDisplayName}
collapseQuotedReplies={otherThreadCount > 0}
/>
</div>
))}
<ExpandedMessage
sender={mainSenderName}
senderEmail={mainSenderAddr}
sender={mainFrom.name}
senderEmail={mainFrom.email}
headerDetails={mainHeaderDetails}
dateIso={email.date}
body={body}
isSpam={isSpam}
isLast={true}
isLast={messagesAfter.length === 0}
starred={isStarred}
attachments={mainMessageAttachments}
onToggleStar={handleToggleStar}
onPrintConversation={handlePrint}
onReply={onToolbarReply}
onForward={onToolbarForward}
detailsOpen={mainDetailsOpen}
onDetailsOpenChange={setMainDetailsOpen}
collapseQuotedReplies={otherThreadCount > 0}
messageId={email.id}
/>
{messagesAfter.map((msg) => (
<div key={msg.id} className="border-b border-[#eceff1]">
<ThreadPriorMessage
message={msg}
isExpanded={isThreadMessageExpanded(msg.id)}
onToggle={() => toggleExpanded(msg.id)}
onPrintConversation={handlePrint}
onReply={onToolbarReply}
onForward={onToolbarForward}
selfEmails={selfEmails}
selfDisplayName={selfDisplayName}
collapseQuotedReplies={otherThreadCount > 0}
/>
</div>
))}
{showReplyForwardBar ? (
<div
className={cn(
@ -405,7 +458,7 @@ export function EmailView({
{inlineCompose ? (
<div
ref={threadComposeAnchorRef}
className="mt-6 px-4 pb-6 pl-[68px] max-sm:pl-4"
className="mt-6 px-4 pb-6 max-sm:px-4"
>
<div className="flex items-start gap-3">
<div
@ -427,6 +480,5 @@ export function EmailView({
) : null}
</div>
</div>
</TooltipProvider>
)
}

View File

@ -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 (
<>
<dt className="text-right text-muted-foreground">{label}</dt>
<dd className="min-w-0 text-foreground">{children}</dd>
</>
)
}
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<ReturnType<typeof setTimeout> | 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 (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
onMouseEnter={keepOpen}
onMouseLeave={() => {
if (open) scheduleClose()
}}
>
{summary}
<ChevronDown
className={cn("h-3 w-3 transition-transform", open && "rotate-180")}
aria-hidden
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-[min(100vw-2rem,28rem)] p-4"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => onOpenChange(false)}
onInteractOutside={() => onOpenChange(false)}
onEscapeKeyDown={() => onOpenChange(false)}
onMouseEnter={keepOpen}
onMouseLeave={scheduleClose}
>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs leading-snug">
<DetailRow label="de :">{details.fromLine}</DetailRow>
{details.replyToLine ? (
<DetailRow label="répondre à :">{details.replyToLine}</DetailRow>
) : null}
<DetailRow label="à :">{details.toLine}</DetailRow>
<DetailRow label="date :">
<MailDateText iso={details.dateIso} variant="detail" />
</DetailRow>
<DetailRow label="objet :">{details.subject}</DetailRow>
{details.mailedBy ? (
<DetailRow label="envoyé par :">{details.mailedBy}</DetailRow>
) : null}
{details.signedBy ? (
<DetailRow label="signé par :">{details.signedBy}</DetailRow>
) : null}
{details.unsubscribe ? (
<>
<dt className="text-right text-muted-foreground">se désabonner :</dt>
<dd>
<UnsubscribeActionButton
action={details.unsubscribe}
messageId={messageId}
/>
</dd>
</>
) : null}
<dt className="text-right text-muted-foreground">sécurité :</dt>
<dd className="space-y-1">
{details.dkimPass === true ? (
<p>Signature DKIM conforme</p>
) : details.dkimPass === false ? (
<p className="text-amber-700 dark:text-amber-400">
Signature DKIM non conforme
</p>
) : null}
{details.tls ? (
<p className="flex items-center gap-1.5">
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
Chiffrement standard (TLS)
</p>
) : null}
<p className="text-muted-foreground">
Chiffrement PGP : non disponible pour l&apos;instant
</p>
{isSpam ? (
<p className="text-destructive">
Ce message est marqué comme spam
</p>
) : null}
</dd>
</dl>
</PopoverContent>
</Popover>
)
}

View File

@ -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()
? `<p style="color:var(--muted-foreground);">${snippet.trim()}</p>`
: ""
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
return `<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${escaped}</pre>`
}
if (full) {
const s = snippet?.trim()
if (s) {
return `<p style="color:var(--muted-foreground);">${s}</p>`
}
return `<p style="color:var(--muted-foreground);">Ce message na pas de contenu.</p>`
}
const s = snippet?.trim()
return s
? `<p style="color:var(--muted-foreground);">${s}</p>`
: ""
}
/** 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 (
<CollapsedMessage
message={message}
senderName={resolved.name}
senderEmail={resolved.email}
onClick={onToggle}
/>
)
}
return (
<ExpandedMessage
sender={resolved.name}
senderEmail={resolved.email}
headerDetails={headerDetails}
dateIso={message.date}
body={body}
isSpam={isSpam}
isLast={false}
starred={mailFlagIsStarred(message.flags ?? [])}
onCollapse={onToggle}
onPrintConversation={onPrintConversation}
onReply={onReply}
onForward={onForward}
detailsOpen={detailsOpen}
onDetailsOpenChange={setDetailsOpen}
collapseQuotedReplies={collapseQuotedReplies}
messageId={message.id}
/>
)
}
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 (
<div>
<EmailViewMessageToolbar
sender={sender}
senderEmail={senderEmail}
headerDetails={headerDetails}
dateIso={dateIso}
isSpam={isSpam}
isLast={isLast}
@ -115,6 +265,11 @@ export function ExpandedMessage({
onToggleStar={onToggleStar}
onCollapse={onCollapse}
onPrintConversation={onPrintConversation}
onReply={onReply}
onForward={onForward}
detailsOpen={detailsOpen}
onDetailsOpenChange={onDetailsOpenChange}
messageId={messageId}
/>
<div
@ -124,7 +279,13 @@ export function ExpandedMessage({
)}
data-selectable-text
>
<SandboxedContent html={body} isSpam={isSpam} />
<MessageBodyContent
html={body}
isSpam={isSpam}
senderEmail={senderEmail}
messageId={messageId}
collapseQuotedReplies={collapseQuotedReplies}
/>
</div>
{attachments.length > 0 && (

View File

@ -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 (
<>
<div
className={cn("flex items-start gap-3 px-4 py-3", !isLast && "cursor-pointer")}
onClick={!isLast ? onCollapse : undefined}
onClick={
!isLast
? () => {
setDetailsIsOpen(false)
onCollapse?.()
}
: undefined
}
>
{isSpam ? (
<div
@ -105,46 +128,22 @@ export function EmailViewMessageToolbar({
className="inline min-w-0 max-w-full align-baseline"
>
<span className="font-semibold text-foreground">{name}</span>
<span className="text-muted-foreground"> &lt;{senderEmail}&gt;</span>
{senderEmail ? (
<span className="text-muted-foreground"> &lt;{senderEmail}&gt;</span>
) : null}
</ContactHoverCard>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setShowDetails(!showDetails)
}}
>
à moi
<ChevronDown
className={cn("h-3 w-3 transition-transform", showDetails && "rotate-180")}
/>
</button>
<EmailViewDetailsPopover
summary={headerDetails.recipientSummary}
details={headerDetails}
open={detailsIsOpen}
onOpenChange={setDetailsIsOpen}
isSpam={isSpam}
messageId={messageId}
/>
</div>
{showDetails && (
<div className="mt-1 space-y-0.5 text-xs text-muted-foreground">
<p>
de : <span className="text-foreground">{name} &lt;{senderEmail}&gt;</span>
</p>
<p>
à : <span className="text-foreground">moi</span>
</p>
<p>
date :{" "}
<MailDateText iso={dateIso} variant="detail" className="text-foreground" />
</p>
{isSpam && (
<p className="text-destructive">
sécurité : ce message est marqué comme spam les images et appels externes
sont bloqués
</p>
)}
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1 self-start pt-0.5">
@ -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?.()
}}
>
<Reply className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
@ -219,11 +221,19 @@ export function EmailViewMessageToolbar({
sideOffset={4}
className={MESSAGE_MORE_MENU_CLASS}
>
<DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onReply?.()
}}
>
<Reply className={MENU_ICON_CLASS} strokeWidth={1.5} />
Répondre
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onForward?.()
}}
>
<Forward className={MENU_ICON_CLASS} strokeWidth={1.5} />
Transférer
</DropdownMenuItem>

View File

@ -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 (
<div className="min-w-0">
{showRemoteBanner ? (
<RemoteContentBanner
senderEmail={senderEmail}
onShowOnce={() => allowMessageRemoteContent(messageId)}
onAlwaysShow={() => {
trustSender(senderEmail)
allowMessageRemoteContent(messageId)
}}
/>
) : null}
<SandboxedContent html={mainHtml} {...sandboxProps} />
{hasHiddenQuote ? (
<div className="mt-2">
<button
type="button"
onClick={() => setShowQuoted(true)}
className={cn(
"inline-flex h-6 min-w-[2rem] items-center justify-center rounded-full",
"border border-border bg-muted/80 px-2 text-sm font-medium leading-none text-muted-foreground",
"hover:bg-accent hover:text-foreground"
)}
aria-label="Afficher les messages cités inclus"
>
</button>
</div>
) : null}
{quotedHtml && showQuoted ? (
<div className="mt-2 border-t border-border/60 pt-2">
<SandboxedContent html={quotedHtml} {...sandboxProps} />
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,32 @@
"use client"
export function RemoteContentBanner({
senderEmail,
onShowOnce,
onAlwaysShow,
}: {
senderEmail: string
onShowOnce: () => void
onAlwaysShow: () => void
}) {
return (
<p className="mb-3 text-sm leading-snug text-muted-foreground">
Le contenu distant a é masqué&nbsp;:{" "}
<button
type="button"
onClick={onShowOnce}
className="text-primary hover:underline"
>
afficher le contenu distant
</button>
{" — "}
<button
type="button"
onClick={onAlwaysShow}
className="text-primary hover:underline"
>
toujours afficher le contenu distant venant de {senderEmail}
</button>
</p>
)
}

View File

@ -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<string, string>
}) {
const iframeRef = useRef<HTMLIFrameElement>(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
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">`
const isDark = documentIsDark()
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
const themeOverrides = isDark
? emailPreviewDarkOverrideCss()
: emailPreviewLightOverrideCss()
doc.open()
doc.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${cspMeta}
<style>
${emailPreviewBaseCss(isDark)}
${themeOverrides}
</style>
</head>
<body>${processedHtml}</body>
</html>`)
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 (
<iframe
key={blockRemoteContent ? "remote-blocked" : "remote-allowed"}
ref={iframeRef}
sandbox={sandboxValue}
title="Contenu du message"

View File

@ -0,0 +1,70 @@
"use client"
import { useState } from "react"
import { Loader2 } from "lucide-react"
import { useListUnsubscribeMailto } from "@/lib/api/hooks/use-list-unsubscribe-mailto"
import type { UnsubscribeAction } from "@/lib/mail-unsubscribe"
export function UnsubscribeActionButton({
action,
messageId,
}: {
action: UnsubscribeAction
messageId: string
}) {
const sendMailto = useListUnsubscribeMailto()
const [done, setDone] = useState(false)
const [error, setError] = useState<string | null>(null)
if (action.kind === "http") {
return (
<a
href={action.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Se désabonner de cet expéditeur
</a>
)
}
const handleClick = async () => {
setError(null)
try {
await sendMailto.mutateAsync(messageId)
setDone(true)
} catch (e) {
setError(e instanceof Error ? e.message : "Échec de lenvoi")
}
}
if (done) {
return (
<span className="text-muted-foreground">
Demande de désinscription envoyée à {action.mailto.address}
</span>
)
}
return (
<span className="inline-flex flex-col gap-1">
<button
type="button"
onClick={() => void handleClick()}
disabled={sendMailto.isPending}
className="text-left text-primary hover:underline disabled:opacity-60"
>
{sendMailto.isPending ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="size-3.5 animate-spin" />
Envoi
</span>
) : (
"Se désabonner de cet expéditeur"
)}
</button>
{error ? <span className="text-destructive">{error}</span> : null}
</span>
)
}

View File

@ -2,13 +2,14 @@
import { useState, useRef, useEffect } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi"
import { Pencil } from "lucide-react"
import { AccountAvatar } from "@/components/gmail/account-avatar"
import { AccountSwitcherDropdown } from "@/components/gmail/account-switcher-dropdown"
import { Button } from "@/components/ui/button"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { MAIL_HEADER_DROPDOWN_CLASS, MAIL_ICON_BTN } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
@ -92,9 +93,17 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
const appsMenuRef = useRef<HTMLDivElement>(null)
const accountMenuRef = useRef<HTMLDivElement>(null)
const activeAccount = useActiveAccount()
const identity = useChromeIdentity()
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
useEffect(() => {
const notice = sessionStorage.getItem("ulti_account_notice")
if (notice === "same") {
sessionStorage.removeItem("ulti_account_notice")
toast.message("Vous utilisez déjà ce compte Ulti.")
}
}, [])
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
@ -183,7 +192,7 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
variant="ghost"
size="icon-lg"
className="size-11 overflow-hidden rounded-full p-0"
aria-label={`Compte : ${activeAccount?.email ?? ""}`}
aria-label={`Compte : ${identity?.email ?? "Utilisateur"}`}
aria-expanded={accountMenuOpen}
aria-haspopup="dialog"
onClick={() => {
@ -191,7 +200,16 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
setAppsMenuOpen(false)
}}
>
{activeAccount && <AccountAvatar account={activeAccount} size="md" />}
{identity ? (
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
size="md"
/>
) : (
<span className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
?
</span>
)}
</Button>
<AccountSwitcherDropdown
open={accountMenuOpen}

View File

@ -0,0 +1,59 @@
"use client"
import { useEffect } from "react"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { useLabels } from "@/lib/api/hooks/use-folder-label-queries"
import { useUnifiedFolders } from "@/lib/api/hooks/use-unified-folder-queries"
import { useImapFolders } from "@/lib/api/hooks/use-imap-folders"
import { useMailSettings } from "@/lib/api/hooks/use-mail-settings"
import { useNavStore } from "@/lib/stores/nav-store"
import { buildFolderTreeFromUnified } from "@/lib/mail-settings/unified-folder-tree"
import { buildFolderTreeFromImap } from "@/lib/mail-settings/imap-folder-tree"
import { normalizeLabelRow } from "@/lib/sidebar-nav-data"
import type { LabelRowItem } from "@/lib/sidebar-nav-maps"
/** Hydrate sidebar navigation + display settings from backend when authenticated. */
export function MailNavSync() {
const { ready, authenticated } = useAuthReady()
const { data: labels } = useLabels()
const { data: globalFolders } = useUnifiedFolders("global")
const { data: allFolders } = useUnifiedFolders("all")
const { folders: imapFolders, isFetched: imapFoldersFetched } = useImapFolders()
useMailSettings(authenticated)
useEffect(() => {
if (!ready || !authenticated || !labels) return
const sorted = [...labels].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.name.localeCompare(b.name)
)
const apiRows: LabelRowItem[] = sorted.map((label) =>
normalizeLabelRow({
id: label.id,
label: label.name,
color: label.color || "bg-gray-500",
tabbed: false,
favorite: false,
excludeFromPrincipal: false,
showInMessageList: true,
enabled: true,
})
)
useNavStore.getState().hydrateLabelRowsFromApi(apiRows)
}, [ready, authenticated, labels])
useEffect(() => {
if (!ready || !authenticated || !allFolders || !imapFoldersFetched) return
const global = globalFolders ?? allFolders.filter((f) => f.scope === "global")
const tree = [
...buildFolderTreeFromUnified(global),
...buildFolderTreeFromImap(imapFolders),
]
if (tree.length === 0) return
useNavStore.getState().hydrateFolderTreeFromApi(tree)
}, [ready, authenticated, allFolders, globalFolders, imapFolders, imapFoldersFetched])
return null
}

View File

@ -0,0 +1,78 @@
"use client"
import { useCallback } from "react"
import { useRouter } from "next/navigation"
import { buildMailPath } from "@/lib/mail-url"
import { useWsEventListener } from "@/lib/api/ws"
import type { WsEvent } from "@/lib/api/types"
import { apiClient } from "@/lib/api/client"
import type { ApiMessageFull } from "@/lib/api/types"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import {
isReplyOrMentionSubject,
showDesktopNotification,
} from "@/lib/notifications/desktop-notifications"
function mailEventPayload(evt: WsEvent): { message_id: string; account_id: string } | null {
const payload = evt.payload
if (!payload || typeof payload !== "object" || !("message_id" in payload)) return null
const messageId = String(payload.message_id ?? "")
if (!messageId) return null
return {
message_id: messageId,
account_id: String(payload.account_id ?? ""),
}
}
export function MailNotificationsBridge() {
const router = useRouter()
const desktopNewMail = useMailSettingsStore((s) => s.desktopNewMail)
const desktopMentions = useMailSettingsStore((s) => s.desktopMentions)
const soundEnabled = useMailSettingsStore((s) => s.soundEnabled)
const handleMailCreated = useCallback(
async (evt: WsEvent) => {
if (evt.type !== "mail.created") return
if (!desktopNewMail && !desktopMentions) return
const payload = mailEventPayload(evt)
if (!payload) return
let subject = "Nouveau message"
let sender = ""
try {
const message = await apiClient.get<ApiMessageFull>(
`/mail/messages/${payload.message_id}`
)
subject = message.subject?.trim() || subject
sender = message.from?.[0]?.name || message.from?.[0]?.address || ""
} catch {}
const isMention = isReplyOrMentionSubject(subject)
if (isMention && !desktopMentions) return
if (!isMention && !desktopNewMail) return
showDesktopNotification({
title: subject,
body: sender ? `De ${sender}` : undefined,
tag: payload.message_id,
playSound: soundEnabled,
onClick: () => {
router.push(
buildMailPath({
folderId: "inbox",
inboxTab: "primary",
page: 1,
mailId: payload.message_id,
})
)
},
})
},
[desktopMentions, desktopNewMail, router, soundEnabled]
)
useWsEventListener(handleMailCreated)
return null
}

View File

@ -0,0 +1,312 @@
"use client"
import { cn } from "@/lib/utils"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type {
InboxSortMode,
MailBackgroundId,
MailDensity,
MailThemeMode,
ReadingPaneMode,
} from "@/lib/mail-settings/types"
import {
QuickSettingsCheckbox,
QuickSettingsOption,
} from "@/components/gmail/quick-settings/quick-settings-option"
import {
DensityCompactIcon,
DensityDefaultIcon,
DensityNormalIcon,
InboxDefaultIcon,
InboxImportantIcon,
InboxStarredIcon,
InboxUnreadIcon,
ReadingPaneBelowIcon,
ReadingPaneNoneIcon,
ReadingPaneRightIcon,
ThemeModePreview,
} from "@/components/gmail/quick-settings/settings-preview-icons"
import {
MAIL_BACKGROUND_PRESETS,
normalizeMailBackgroundId,
} from "@/lib/mail-settings/constants"
const DENSITY_OPTIONS: {
id: MailDensity
label: string
icon: React.ReactNode
}[] = [
{ id: "default", label: "Par défaut", icon: <DensityDefaultIcon /> },
{ id: "normal", label: "Normal", icon: <DensityNormalIcon /> },
{ id: "compact", label: "Compact", icon: <DensityCompactIcon /> },
]
const INBOX_OPTIONS: {
id: InboxSortMode
label: string
icon: React.ReactNode
}[] = [
{ id: "default", label: "Par défaut", icon: <InboxDefaultIcon /> },
{
id: "important",
label: "Importants d'abord",
icon: <InboxImportantIcon />,
},
{ id: "unread", label: "Non lus d'abord", icon: <InboxUnreadIcon /> },
{ id: "starred", label: "Suivis d'abord", icon: <InboxStarredIcon /> },
]
const READING_PANE_OPTIONS: {
id: ReadingPaneMode
label: string
icon: React.ReactNode
disabled?: boolean
}[] = [
{
id: "none",
label: "Aucune séparation",
icon: <ReadingPaneNoneIcon />,
},
{
id: "right",
label: "À droite de la boîte de réception",
icon: <ReadingPaneRightIcon />,
},
{
id: "below",
label: "Sous la boîte de réception",
icon: <ReadingPaneBelowIcon />,
disabled: true,
},
]
const THEME_OPTIONS: {
id: MailThemeMode
label: string
}[] = [
{ id: "light", label: "Clair" },
{ id: "dark", label: "Sombre" },
{ id: "system", label: "Système" },
]
function ThemeModePicker({
themeMode,
onSelect,
compact = false,
}: {
themeMode: MailThemeMode
onSelect: (mode: MailThemeMode) => void
compact?: boolean
}) {
return (
<div className={cn("grid grid-cols-3 gap-2", !compact && "mb-4 sm:max-w-md")}>
{THEME_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => onSelect(opt.id)}
className={cn(
"rounded-lg border-2 p-2 text-left transition-colors",
compact ? "p-1.5" : "p-2.5",
themeMode === opt.id
? "border-primary bg-accent/60"
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40"
)}
>
<ThemeModePreview
mode={opt.id}
className={compact ? "h-10" : "h-14"}
/>
<span
className={cn(
"mt-1.5 block text-foreground",
compact ? "text-center text-xs" : "text-sm"
)}
>
{opt.label}
</span>
</button>
))}
</div>
)
}
function SettingsSection({
title,
action,
children,
className,
}: {
title: string
action?: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<section className={cn("border-b border-border px-4 py-4", className)}>
<SectionHeader title={title} action={action} />
{children}
</section>
)
}
function SectionHeader({
title,
action,
}: {
title: string
action?: React.ReactNode
}) {
return (
<div className="mb-2 flex items-center justify-between gap-2">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{action}
</div>
)
}
export function MailSettingsFields({
variant = "panel",
onOpenThemeDialog,
}: {
variant?: "panel" | "page"
onOpenThemeDialog?: () => void
}) {
const density = useMailSettingsStore((s) => s.density)
const setDensity = useMailSettingsStore((s) => s.setDensity)
const themeMode = useMailSettingsStore((s) => s.themeMode)
const setThemeMode = useMailSettingsStore((s) => s.setThemeMode)
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
const setBackgroundId = useMailSettingsStore((s) => s.setBackgroundId)
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const setInboxSort = useMailSettingsStore((s) => s.setInboxSort)
const readingPane = useMailSettingsStore((s) => s.readingPane)
const setReadingPane = useMailSettingsStore((s) => s.setReadingPane)
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
const sectionClassName = variant === "page" ? "px-0 py-5" : undefined
return (
<>
<SettingsSection title="Densité" className={sectionClassName}>
{DENSITY_OPTIONS.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="density"
label={opt.label}
checked={density === opt.id}
onSelect={() => setDensity(opt.id)}
icon={opt.icon}
/>
))}
</SettingsSection>
<SettingsSection
title="Thème"
className={sectionClassName}
action={
variant === "panel" && onOpenThemeDialog ? (
<button
type="button"
className="text-sm text-[#1a73e8] hover:underline"
onClick={onOpenThemeDialog}
>
Arrière-plan
</button>
) : null
}
>
{variant === "panel" && onOpenThemeDialog ? (
<ThemeModePicker
themeMode={themeMode}
onSelect={setThemeMode}
compact
/>
) : (
<>
<ThemeModePicker themeMode={themeMode} onSelect={setThemeMode} />
<h3 className="mb-3 text-sm font-medium text-foreground">
Arrière-plan
</h3>
<div className="grid grid-cols-3 gap-2 sm:max-w-lg sm:grid-cols-4">
{MAIL_BACKGROUND_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() =>
setBackgroundId(preset.id as MailBackgroundId)
}
className={cn(
"flex flex-col items-center gap-1 rounded-lg p-1 transition-colors",
activeBackgroundId === preset.id &&
"ring-2 ring-[#1a73e8] ring-offset-1 ring-offset-background"
)}
title={preset.label}
>
<span
className="block h-14 w-full rounded-md border border-border bg-cover bg-center"
style={
preset.background === "none"
? { backgroundColor: "var(--app-canvas)" }
: {
backgroundColor: preset.fallbackColor,
background: preset.background,
}
}
/>
<span className="max-w-full truncate text-[10px] text-muted-foreground">
{preset.label}
</span>
</button>
))}
</div>
</>
)}
</SettingsSection>
<SettingsSection
title="Type de boîte de réception"
className={sectionClassName}
>
{INBOX_OPTIONS.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="inbox-sort"
label={opt.label}
checked={inboxSort === opt.id}
onSelect={() => setInboxSort(opt.id)}
icon={opt.icon}
/>
))}
</SettingsSection>
<SettingsSection title="Volet de lecture" className={sectionClassName}>
{READING_PANE_OPTIONS.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="reading-pane"
label={opt.label}
checked={readingPane === opt.id}
disabled={opt.disabled}
onSelect={() => {
if (!opt.disabled) setReadingPane(opt.id)
}}
icon={opt.icon}
/>
))}
</SettingsSection>
<section className={cn("px-4 py-4", variant === "page" && "px-0 py-5")}>
<SectionHeader title="Fils de discussion" />
<QuickSettingsCheckbox
label="Mode Conversation"
checked={conversationMode}
onChange={setConversationMode}
helpLabel="Regrouper les messages d'une même conversation"
/>
</section>
</>
)
}

View File

@ -0,0 +1,107 @@
"use client"
import { useEffect, useRef } from "react"
import { useAuthStore } from "@/lib/api/auth-store"
import {
useMailSettings,
useUpdateMailSettings,
} from "@/lib/api/hooks/use-mail-settings"
import {
apiSettingsToStore,
storeSettingsToPatch,
} from "@/lib/mail-settings/map-api-settings"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
type PersistedSettings = Pick<
ReturnType<typeof useMailSettingsStore.getState>,
| "density"
| "themeMode"
| "backgroundId"
| "inboxSort"
| "readingPane"
| "conversationMode"
| "desktopNewMail"
| "desktopMentions"
| "emailDigest"
| "soundEnabled"
>
function pickPersisted(state: ReturnType<typeof useMailSettingsStore.getState>): PersistedSettings {
return {
density: state.density,
themeMode: state.themeMode,
backgroundId: state.backgroundId,
inboxSort: state.inboxSort,
readingPane: state.readingPane,
conversationMode: state.conversationMode,
desktopNewMail: state.desktopNewMail,
desktopMentions: state.desktopMentions,
emailDigest: state.emailDigest,
soundEnabled: state.soundEnabled,
}
}
function diffPersisted(
prev: PersistedSettings,
next: PersistedSettings
): Partial<PersistedSettings> {
const changed: Partial<PersistedSettings> = {}
if (prev.density !== next.density) changed.density = next.density
if (prev.themeMode !== next.themeMode) changed.themeMode = next.themeMode
if (prev.backgroundId !== next.backgroundId) changed.backgroundId = next.backgroundId
if (prev.inboxSort !== next.inboxSort) changed.inboxSort = next.inboxSort
if (prev.readingPane !== next.readingPane) changed.readingPane = next.readingPane
if (prev.conversationMode !== next.conversationMode) {
changed.conversationMode = next.conversationMode
}
if (prev.desktopNewMail !== next.desktopNewMail) changed.desktopNewMail = next.desktopNewMail
if (prev.desktopMentions !== next.desktopMentions) {
changed.desktopMentions = next.desktopMentions
}
if (prev.emailDigest !== next.emailDigest) changed.emailDigest = next.emailDigest
if (prev.soundEnabled !== next.soundEnabled) changed.soundEnabled = next.soundEnabled
return changed
}
export function MailSettingsSync() {
const authenticated = useAuthStore((s) => s.isAuthenticated())
const { data } = useMailSettings(authenticated)
const updateMutation = useUpdateMailSettings()
const hydratingRef = useRef(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevRef = useRef<PersistedSettings>(pickPersisted(useMailSettingsStore.getState()))
useEffect(() => {
if (!data) return
hydratingRef.current = true
const mapped = apiSettingsToStore(data)
useMailSettingsStore.getState().hydrateFromApi(mapped)
prevRef.current = pickPersisted(useMailSettingsStore.getState())
queueMicrotask(() => {
hydratingRef.current = false
})
}, [data])
useEffect(() => {
const unsub = useMailSettingsStore.subscribe((state) => {
if (hydratingRef.current) return
const next = pickPersisted(state)
const changed = diffPersisted(prevRef.current, next)
if (Object.keys(changed).length === 0) return
prevRef.current = next
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
updateMutation.mutate(storeSettingsToPatch(changed))
}, 500)
})
return () => {
unsub()
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [updateMutation])
return null
}

View File

@ -0,0 +1,23 @@
'use client'
import { useEffect } from 'react'
import { useAuthReady } from '@/lib/api/use-auth-ready'
import { useMailSignatures } from '@/lib/api/hooks/use-mail-signatures'
import { useMailSignaturesStore } from '@/lib/stores/mail-signatures-store'
/** Hydrate signature library from server for compose and settings. */
export function MailSignaturesSync() {
const { ready, authenticated } = useAuthReady()
const { data: signatures = [], isSuccess } = useMailSignatures()
useEffect(() => {
if (!ready || !authenticated) {
useMailSignaturesStore.getState().clear()
return
}
if (!isSuccess) return
useMailSignaturesStore.getState().hydrateFromApi(signatures)
}, [ready, authenticated, isSuccess, signatures])
return null
}

View File

@ -0,0 +1,27 @@
"use client"
import { cn } from "@/lib/utils"
import { normalizeNavColorClass } from "@/lib/nav-color"
export function NavColorDot({
color,
className,
rounded = "sm",
}: {
color: string
className?: string
rounded?: "sm" | "full"
}) {
const colorClass = normalizeNavColorClass(color)
return (
<span
className={cn(
"block size-3 shrink-0 border border-black/10",
rounded === "full" ? "rounded-full" : "rounded-sm",
colorClass,
className
)}
aria-hidden
/>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import { useState } from "react"
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { normalizeNavColorClass } from "@/lib/nav-color"
export function NavColorPickerTrigger({
value,
onChange,
rounded = "full",
className,
"aria-label": ariaLabel = "Couleur",
}: {
value: string
onChange: (swatch: string) => void
rounded?: "sm" | "full"
className?: string
"aria-label"?: string
}) {
const [open, setOpen] = useState(false)
const colorClass = normalizeNavColorClass(value)
const isFull = rounded === "full"
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={ariaLabel}
className={cn(
"flex shrink-0 items-center justify-center border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
isFull ? "size-7 rounded-full p-0.5" : "size-7 rounded-sm bg-background hover:bg-muted",
className
)}
onClick={(e) => e.stopPropagation()}
>
<span
className={cn(
"block border border-black/10",
isFull ? "size-full rounded-full" : "size-4 rounded-sm",
colorClass
)}
aria-hidden
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="start" sideOffset={6}>
<NavColorPicker
variant="menu"
value={value}
onChange={(sw) => {
onChange(sw)
setOpen(false)
}}
/>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,68 @@
"use client"
import { cn } from "@/lib/utils"
import {
LABEL_MENU_COLOR_SWATCHES,
} from "@/components/gmail/sidebar/sidebar-nav-constants"
import {
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
} from "@/lib/mail-chrome-classes"
import { normalizeNavColorClass } from "@/lib/nav-color"
export type NavColorPickerVariant = "menu" | "sheet" | "field"
const SWATCH_SIZE: Record<NavColorPickerVariant, string> = {
menu: "size-6",
sheet: "size-8",
field: "size-8",
}
export function NavColorPicker({
value,
onChange,
variant = "field",
swatches = LABEL_MENU_COLOR_SWATCHES,
className,
"aria-label": ariaLabel = "Couleur",
}: {
value: string
onChange: (swatch: string) => void
variant?: NavColorPickerVariant
swatches?: readonly string[]
className?: string
"aria-label"?: string
}) {
const active = normalizeNavColorClass(value)
const swatchClass = SWATCH_SIZE[variant]
return (
<div
role="group"
aria-label={ariaLabel}
className={cn("grid grid-cols-6 gap-1.5", className)}
>
{swatches.map((sw) => (
<button
key={sw}
type="button"
title={sw}
aria-label={sw}
aria-pressed={active === sw}
onClick={() => onChange(sw)}
className={cn(
swatchClass,
"rounded-full border border-black/10 outline-none ring-offset-1",
variant === "menu"
? cn(
"hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
)
: "hover:ring-2 hover:ring-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
sw,
active === sw && "ring-2 ring-primary ring-offset-1"
)}
/>
))}
</div>
)
}

View File

@ -0,0 +1,93 @@
"use client"
import { cn } from "@/lib/utils"
import { Check } from "lucide-react"
import type {
LabelInMessageListVisibility,
LabelListSidebarVisibility,
} from "@/lib/stores/nav-store"
function VisibilityOption({
checked,
onPick,
children,
}: {
checked: boolean
onPick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onPick}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
checked
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-accent/50"
)}
>
<span>{children}</span>
<span className="flex size-4 shrink-0 items-center justify-center" aria-hidden={!checked}>
{checked ? <Check className="size-4" strokeWidth={2} /> : null}
</span>
</button>
)
}
export function NavSidebarVisibilityFields({
listKind,
value,
onChange,
}: {
listKind: "labels" | "folders"
value: LabelListSidebarVisibility
onChange: (v: LabelListSidebarVisibility) => void
}) {
const section =
listKind === "labels"
? "Dans la liste des libellés"
: "Dans la liste des dossiers"
return (
<fieldset className="space-y-1">
<legend className="mb-1 text-xs font-medium text-muted-foreground">
{section}
</legend>
<VisibilityOption checked={value === "show"} onPick={() => onChange("show")}>
Afficher
</VisibilityOption>
<VisibilityOption
checked={value === "showUnread"}
onPick={() => onChange("showUnread")}
>
Afficher si messages non lus
</VisibilityOption>
<VisibilityOption checked={value === "hide"} onPick={() => onChange("hide")}>
Masquer
</VisibilityOption>
</fieldset>
)
}
export function NavMessageVisibilityFields({
value,
onChange,
}: {
value: LabelInMessageListVisibility
onChange: (v: LabelInMessageListVisibility) => void
}) {
return (
<fieldset className="space-y-1">
<legend className="mb-1 text-xs font-medium text-muted-foreground">
Dans la liste des messages
</legend>
<VisibilityOption checked={value === "show"} onPick={() => onChange("show")}>
Afficher
</VisibilityOption>
<VisibilityOption checked={value === "hide"} onPick={() => onChange("hide")}>
Masquer
</VisibilityOption>
</fieldset>
)
}

View File

@ -3,118 +3,17 @@
import Link from "next/link"
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { MailSettingsFields } from "@/components/gmail/mail-settings-fields"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type {
InboxSortMode,
MailDensity,
ReadingPaneMode,
} from "@/lib/mail-settings/types"
import {
QuickSettingsCheckbox,
QuickSettingsOption,
} from "@/components/gmail/quick-settings/quick-settings-option"
import {
DensityCompactIcon,
DensityDefaultIcon,
DensityNormalIcon,
InboxDefaultIcon,
InboxImportantIcon,
InboxStarredIcon,
InboxUnreadIcon,
ReadingPaneBelowIcon,
ReadingPaneNoneIcon,
ReadingPaneRightIcon,
ThemeThumbnailIcon,
} from "@/components/gmail/quick-settings/settings-preview-icons"
function SettingsSection({
title,
action,
children,
className,
}: {
title: string
action?: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<section className={cn("border-b border-border px-4 py-4", className)}>
<div className="mb-2 flex items-center justify-between gap-2">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{action}
</div>
{children}
</section>
)
}
export function QuickSettingsPanel() {
const open = useMailSettingsStore((s) => s.quickSettingsOpen)
const themeDialogOpen = useMailSettingsStore((s) => s.themeDialogOpen)
const setOpen = useMailSettingsStore((s) => s.setQuickSettingsOpen)
const setThemeDialogOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
const density = useMailSettingsStore((s) => s.density)
const setDensity = useMailSettingsStore((s) => s.setDensity)
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const setInboxSort = useMailSettingsStore((s) => s.setInboxSort)
const readingPane = useMailSettingsStore((s) => s.readingPane)
const setReadingPane = useMailSettingsStore((s) => s.setReadingPane)
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
if (!open) return null
const densityOptions: {
id: MailDensity
label: string
icon: React.ReactNode
}[] = [
{ id: "default", label: "Par défaut", icon: <DensityDefaultIcon /> },
{ id: "normal", label: "Normal", icon: <DensityNormalIcon /> },
{ id: "compact", label: "Compact", icon: <DensityCompactIcon /> },
]
const inboxOptions: {
id: InboxSortMode
label: string
icon: React.ReactNode
}[] = [
{ id: "default", label: "Par défaut", icon: <InboxDefaultIcon /> },
{
id: "important",
label: "Importants d'abord",
icon: <InboxImportantIcon />,
},
{ id: "unread", label: "Non lus d'abord", icon: <InboxUnreadIcon /> },
{ id: "starred", label: "Suivis d'abord", icon: <InboxStarredIcon /> },
]
const readingPaneOptions: {
id: ReadingPaneMode
label: string
icon: React.ReactNode
disabled?: boolean
}[] = [
{
id: "none",
label: "Aucune séparation",
icon: <ReadingPaneNoneIcon />,
},
{
id: "right",
label: "À droite de la boîte de réception",
icon: <ReadingPaneRightIcon />,
},
{
id: "below",
label: "Sous la boîte de réception",
icon: <ReadingPaneBelowIcon />,
disabled: true,
},
]
return (
<>
{!themeDialogOpen && (
@ -148,92 +47,21 @@ export function QuickSettingsPanel() {
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-4 pb-4">
<Button
variant="outline"
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50"
asChild
onClick={() => setOpen(false)}
>
<Link href="/mail/settings">Voir tous les paramètres</Link>
</Button>
<Button
variant="outline"
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50"
asChild
>
<Link href="/mail/settings" onClick={() => setOpen(false)}>
Voir tous les paramètres
</Link>
</Button>
</div>
<SettingsSection title="Densité">
{densityOptions.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="density"
label={opt.label}
checked={density === opt.id}
onSelect={() => setDensity(opt.id)}
icon={opt.icon}
/>
))}
</SettingsSection>
<SettingsSection
title="Thème"
action={
<button
type="button"
className="text-sm text-[#1a73e8] hover:underline"
onClick={() => {
setThemeDialogOpen(true)
}}
>
Tout afficher
</button>
}
>
<button
type="button"
className="flex w-full items-center justify-end rounded-md py-1 hover:bg-accent"
onClick={() => setThemeDialogOpen(true)}
>
<ThemeThumbnailIcon />
</button>
</SettingsSection>
<SettingsSection title="Type de boîte de réception">
{inboxOptions.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="inbox-sort"
label={opt.label}
checked={inboxSort === opt.id}
onSelect={() => setInboxSort(opt.id)}
icon={opt.icon}
/>
))}
</SettingsSection>
<SettingsSection title="Volet de lecture">
{readingPaneOptions.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="reading-pane"
label={opt.label}
checked={readingPane === opt.id}
disabled={opt.disabled}
onSelect={() => {
if (!opt.disabled) setReadingPane(opt.id)
}}
icon={opt.icon}
/>
))}
</SettingsSection>
<section className="px-4 py-4">
<h2 className="mb-2 text-sm font-medium text-foreground">
Fils de discussion
</h2>
<QuickSettingsCheckbox
label="Mode Conversation"
checked={conversationMode}
onChange={setConversationMode}
helpLabel="Regrouper les messages d'une même conversation"
/>
</section>
<MailSettingsFields
variant="panel"
onOpenThemeDialog={() => setThemeDialogOpen(true)}
/>
</div>
</aside>
</>

View File

@ -190,3 +190,121 @@ export function ThemeThumbnailIcon() {
</PreviewFrame>
)
}
function ThemeModePreviewFrame({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"flex w-full flex-col overflow-hidden rounded-md border border-border",
className
)}
aria-hidden
>
{children}
</div>
)
}
function MailChromePreview({
headerBg,
sidebarBg,
listBg,
contentBg,
lineBg,
}: {
headerBg: string
sidebarBg: string
listBg: string
contentBg: string
lineBg: string
}) {
return (
<>
<div className={cn("h-2 shrink-0", headerBg)} />
<div className="flex min-h-0 flex-1">
<div className={cn("w-[24%] shrink-0", sidebarBg)} />
<div className={cn("flex min-w-0 flex-1 flex-col p-0.5", listBg)}>
<div className={cn("h-px w-full", lineBg)} />
<div className={cn("mt-0.5 h-px w-3/4", lineBg)} />
<div className={cn("mt-0.5 h-px w-1/2", lineBg)} />
</div>
<div className={cn("w-[30%] shrink-0", contentBg)} />
</div>
</>
)
}
export function ThemeLightPreview({ className }: { className?: string }) {
return (
<ThemeModePreviewFrame className={cn("h-12", className)}>
<MailChromePreview
headerBg="bg-white"
sidebarBg="bg-[#f1f3f4]"
listBg="bg-white"
contentBg="bg-[#e8f0fe]"
lineBg="bg-[#dadce0]"
/>
</ThemeModePreviewFrame>
)
}
export function ThemeDarkPreview({ className }: { className?: string }) {
return (
<ThemeModePreviewFrame className={cn("h-12", className)}>
<MailChromePreview
headerBg="bg-[#202124]"
sidebarBg="bg-[#3c4043]"
listBg="bg-[#202124]"
contentBg="bg-[#394457]"
lineBg="bg-[#5f6368]"
/>
</ThemeModePreviewFrame>
)
}
export function ThemeSystemPreview({ className }: { className?: string }) {
return (
<ThemeModePreviewFrame className={cn("h-12", className)}>
<div className="flex min-h-0 flex-1">
<div className="flex w-1/2 min-w-0 flex-col">
<div className="h-2 shrink-0 bg-white" />
<div className="flex min-h-0 flex-1">
<div className="w-[24%] shrink-0 bg-[#f1f3f4]" />
<div className="flex min-w-0 flex-1 flex-col bg-white p-0.5">
<div className="h-px w-full bg-[#dadce0]" />
<div className="mt-0.5 h-px w-3/4 bg-[#dadce0]" />
</div>
</div>
</div>
<div className="flex w-1/2 min-w-0 flex-col">
<div className="h-2 shrink-0 bg-[#202124]" />
<div className="flex min-h-0 flex-1">
<div className="w-[24%] shrink-0 bg-[#3c4043]" />
<div className="flex min-w-0 flex-1 flex-col bg-[#202124] p-0.5">
<div className="h-px w-full bg-[#5f6368]" />
<div className="mt-0.5 h-px w-3/4 bg-[#5f6368]" />
</div>
</div>
</div>
</div>
</ThemeModePreviewFrame>
)
}
export function ThemeModePreview({
mode,
className,
}: {
mode: "light" | "dark" | "system"
className?: string
}) {
if (mode === "light") return <ThemeLightPreview className={className} />
if (mode === "dark") return <ThemeDarkPreview className={className} />
return <ThemeSystemPreview className={className} />
}

View File

@ -11,27 +11,13 @@ import {
MAIL_BACKGROUND_PRESETS,
normalizeMailBackgroundId,
} from "@/lib/mail-settings/constants"
import type { MailBackgroundId, MailThemeMode } from "@/lib/mail-settings/types"
import type { MailBackgroundId } from "@/lib/mail-settings/types"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
const THEME_OPTIONS: { id: MailThemeMode; label: string; previewClass: string }[] =
[
{ id: "light", label: "Clair", previewClass: "bg-white" },
{ id: "dark", label: "Sombre", previewClass: "bg-[#202124]" },
{
id: "system",
label: "Système",
previewClass:
"bg-gradient-to-br from-white from-50% to-[#202124] to-50%",
},
]
export function ThemeSettingsDialog() {
const open = useMailSettingsStore((s) => s.themeDialogOpen)
const setOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
const themeMode = useMailSettingsStore((s) => s.themeMode)
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
const setThemeMode = useMailSettingsStore((s) => s.setThemeMode)
const setBackgroundId = useMailSettingsStore((s) => s.setBackgroundId)
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
@ -43,41 +29,11 @@ export function ThemeSettingsDialog() {
>
<DialogHeader>
<DialogTitle className="text-left text-base font-normal text-foreground">
Thème
Arrière-plan
</DialogTitle>
</DialogHeader>
<section>
<h3 className="mb-3 text-sm font-medium text-foreground">Mode</h3>
<div className="grid grid-cols-3 gap-2">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setThemeMode(opt.id)}
className={cn(
"rounded-lg border-2 p-2.5 text-left transition-colors",
themeMode === opt.id
? "border-primary bg-accent/60"
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40"
)}
>
<div
className={cn(
"mb-2 h-14 rounded-md border border-border",
opt.previewClass
)}
/>
<span className="text-sm text-foreground">{opt.label}</span>
</button>
))}
</div>
</section>
<section>
<h3 className="mb-3 text-sm font-medium text-foreground">
Arrière-plan
</h3>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{MAIL_BACKGROUND_PRESETS.map((preset) => (
<button

View File

@ -0,0 +1,573 @@
"use client"
import { useEffect, useState } from "react"
import { ChevronDown, ChevronUp, Loader2, Mail } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ProtonBridgeWizard } from "@/components/gmail/settings/proton-bridge-wizard"
import { useDiscoverMailAccount } from "@/lib/api/hooks/use-mail-account-discover"
import { useTestMailAccount } from "@/lib/api/hooks/use-mail-account-test"
import { useStartMailOAuth } from "@/lib/api/hooks/use-mail-oauth"
import { useMailOAuthProviders } from "@/lib/api/hooks/use-mail-oauth-providers"
import type { CreateMailAccountPayload, MailAccountDiscoverResult } from "@/lib/api/types"
import { manualMailDiscoverResult } from "@/lib/mail-settings/manual-account-discover"
type Step = "email" | "proton" | "credentials"
function displayNameFromEmail(email: string): string {
const local = email.split("@")[0] ?? email
return local.replace(/[._-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
function usernameForHint(email: string, hint: string): string {
if (hint === "local") {
return email.split("@")[0] ?? email
}
return email
}
function shouldShowAdvanced(discover: MailAccountDiscoverResult | null): boolean {
if (!discover) return true
if (discover.confidence === "low") return true
if (!discover.imap_host || !discover.smtp_host) return true
if (discover.provider_id === "tutanota") return true
return false
}
function oauthProviderForDiscover(
discover: MailAccountDiscoverResult,
enabled: string[]
): "google" | "microsoft" | null {
if (!discover.auth_methods.includes("oauth2")) return null
const id = discover.provider_id
if ((id === "gmail" || id === "google_workspace") && enabled.includes("google")) {
return "google"
}
if (
(id === "outlook" || id === "microsoft365") &&
enabled.includes("microsoft")
) {
return "microsoft"
}
return null
}
export function AddMailAccountForm({
pending,
onSubmit,
}: {
pending: boolean
onSubmit: (payload: CreateMailAccountPayload) => void
}) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState<Step>("email")
const [showAdvanced, setShowAdvanced] = useState(false)
const [discover, setDiscover] = useState<MailAccountDiscoverResult | null>(null)
const [testOk, setTestOk] = useState<boolean | null>(null)
const discoverMutation = useDiscoverMailAccount()
const testMutation = useTestMailAccount()
const oauthStart = useStartMailOAuth()
const { data: oauthProviders } = useMailOAuthProviders()
const enabledOAuth = oauthProviders?.providers ?? []
const [email, setEmail] = useState("")
const [form, setForm] = useState({
name: "",
imap_host: "127.0.0.1",
imap_port: "1143",
imap_tls: true,
smtp_host: "127.0.0.1",
smtp_port: "1025",
smtp_tls: true,
username: "",
password: "",
})
useEffect(() => {
if (!discover) return
setForm((prev) => ({
...prev,
imap_host: discover.imap_host || prev.imap_host,
imap_port: String(discover.imap_port || 993),
imap_tls: discover.imap_tls,
smtp_host: discover.smtp_host || prev.smtp_host,
smtp_port: String(discover.smtp_port || 587),
smtp_tls: discover.smtp_tls,
username: prev.username || usernameForHint(discover.email, discover.username_hint),
name: prev.name || displayNameFromEmail(discover.email),
}))
setShowAdvanced(shouldShowAdvanced(discover))
setTestOk(null)
}, [discover])
function resetForm() {
setStep("email")
setEmail("")
setDiscover(null)
setShowAdvanced(false)
setTestOk(null)
setForm({
name: "",
imap_host: "127.0.0.1",
imap_port: "1143",
imap_tls: true,
smtp_host: "127.0.0.1",
smtp_port: "1025",
smtp_tls: true,
username: "",
password: "",
})
discoverMutation.reset()
testMutation.reset()
oauthStart.reset()
}
async function handleEmailContinue() {
const trimmed = email.trim()
if (!trimmed) return
const result = await discoverMutation.mutateAsync(trimmed)
goToCredentials(result)
}
function goToCredentials(result: MailAccountDiscoverResult) {
setDiscover(result)
if (result.provider_id === "proton" && result.source !== "manual") {
setStep("proton")
} else {
setStep("credentials")
}
}
function handleManualContinue() {
const trimmed = email.trim()
if (!trimmed || !trimmed.includes("@")) return
goToCredentials(manualMailDiscoverResult(trimmed))
}
async function runConnectionTest() {
const result = await testMutation.mutateAsync({
imap_host: form.imap_host,
imap_port: Number(form.imap_port) || 993,
imap_tls: form.imap_tls,
smtp_host: form.smtp_host,
smtp_port: Number(form.smtp_port) || 587,
smtp_tls: form.smtp_tls,
username: form.username,
password: form.password,
})
setTestOk(result.ok)
return result
}
async function handleProtonContinue() {
setForm((prev) => ({
...prev,
imap_host: "127.0.0.1",
smtp_host: "127.0.0.1",
username: discover?.email ?? prev.username,
password: prev.password,
}))
const result = await runConnectionTest()
if (result.ok) {
setStep("credentials")
}
}
function handleSubmit() {
if (!discover) return
onSubmit({
name: form.name.trim() || displayNameFromEmail(discover.email),
email: discover.email,
provider: discover.provider_id,
imap_host: form.imap_host,
imap_port: Number(form.imap_port) || 993,
imap_tls: form.imap_tls,
smtp_host: form.smtp_host,
smtp_port: Number(form.smtp_port) || 587,
smtp_tls: form.smtp_tls,
username: form.username,
password: form.password,
})
setOpen(false)
resetForm()
}
async function handleOAuth(provider: "google" | "microsoft") {
if (!discover) return
const { authorization_url } = await oauthStart.mutateAsync({
provider,
email: discover.email,
name: form.name.trim() || displayNameFromEmail(discover.email),
provider_id: discover.provider_id,
imap_host: form.imap_host,
imap_port: Number(form.imap_port) || 993,
imap_tls: form.imap_tls,
smtp_host: form.smtp_host,
smtp_port: Number(form.smtp_port) || 587,
smtp_tls: form.smtp_tls,
})
window.location.href = authorization_url
}
const oauthProvider = discover ? oauthProviderForDiscover(discover, enabledOAuth) : null
if (!open) {
return (
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Ajouter un compte mail
</Button>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Nouveau compte mail</CardTitle>
<CardDescription>
Saisissez votre adresse e-mail : nous détectons le fournisseur et préremplissons IMAP/SMTP.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{step === "email" ? (
<div className="space-y-3 max-w-md">
<Field
label="Adresse e-mail"
value={email}
onChange={setEmail}
type="email"
autoComplete="email"
placeholder="vous@exemple.com"
/>
{discoverMutation.isError ? (
<p className="text-sm text-destructive">
Détection impossible. Utilisez « Configurer manuellement » ou réessayez.
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={!email.trim() || discoverMutation.isPending}
onClick={() => void handleEmailContinue()}
>
{discoverMutation.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Détection
</>
) : (
"Détecter la configuration"
)}
</Button>
<Button
type="button"
variant="secondary"
disabled={!email.trim() || !email.includes("@")}
onClick={handleManualContinue}
>
Configurer manuellement
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false)
resetForm()
}}
>
Annuler
</Button>
</div>
<p className="text-xs text-muted-foreground">
La détection préremplit IMAP/SMTP. Choisissez « Configurer manuellement » pour saisir
vos paramètres sans appel réseau.
</p>
</div>
) : null}
{step === "proton" && discover ? (
<ProtonBridgeWizard
email={discover.email}
imapPort={form.imap_port}
smtpPort={form.smtp_port}
bridgePassword={form.password}
onBridgePasswordChange={(v) => setForm({ ...form, password: v })}
onImapPortChange={(v) => setForm({ ...form, imap_port: v })}
onSmtpPortChange={(v) => setForm({ ...form, smtp_port: v })}
onContinue={() => void handleProtonContinue()}
onBack={() => setStep("email")}
/>
) : null}
{step === "credentials" && discover ? (
<>
<ProviderBanner discover={discover} onBack={() => setStep("email")} />
{discover.notes?.length ? (
<ul className="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground space-y-1">
{discover.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
) : null}
{oauthProvider ? (
<div className="space-y-2 max-w-md">
<p className="text-sm text-muted-foreground">
Connexion recommandée sans mot de passe :
</p>
<Button
type="button"
variant="secondary"
disabled={oauthStart.isPending}
onClick={() => void handleOAuth(oauthProvider)}
>
{oauthStart.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{oauthProvider === "google"
? "Continuer avec Google"
: "Continuer avec Microsoft"}
</Button>
<p className="text-xs text-muted-foreground">ou identifiant / mot de passe ci-dessous</p>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2 max-w-2xl">
<Field
label="Nom affiché"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
/>
<Field
label="Identifiant"
value={form.username}
onChange={(v) => setForm({ ...form, username: v })}
autoComplete="username"
/>
<Field
label="Mot de passe"
type="password"
value={form.password}
onChange={(v) => {
setTestOk(null)
setForm({ ...form, password: v })
}}
autoComplete="current-password"
/>
</div>
<div>
<button
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((v) => !v)}
>
{showAdvanced ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
Paramètres serveur {showAdvanced ? "" : "(avancé)"}
</button>
{showAdvanced ? (
<div className="mt-3 grid gap-3 sm:grid-cols-2 max-w-2xl">
<Field
label="IMAP hôte"
value={form.imap_host}
onChange={(v) => setForm({ ...form, imap_host: v })}
/>
<Field
label="IMAP port"
value={form.imap_port}
onChange={(v) => setForm({ ...form, imap_port: v })}
/>
<Field
label="SMTP hôte"
value={form.smtp_host}
onChange={(v) => setForm({ ...form, smtp_host: v })}
/>
<Field
label="SMTP port"
value={form.smtp_port}
onChange={(v) => setForm({ ...form, smtp_port: v })}
/>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
checked={form.imap_tls}
onChange={(e) => setForm({ ...form, imap_tls: e.target.checked })}
/>
IMAP TLS
</label>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
checked={form.smtp_tls}
onChange={(e) => setForm({ ...form, smtp_tls: e.target.checked })}
/>
SMTP TLS / STARTTLS
</label>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Réception {form.imap_host}:{form.imap_port} · Envoi {form.smtp_host}:{form.smtp_port}
</p>
)}
</div>
<TestResultBanner
testing={testMutation.isPending}
result={testMutation.data}
testOk={testOk}
error={testMutation.isError}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={
testMutation.isPending ||
!form.password ||
!form.username ||
!form.imap_host ||
!form.smtp_host
}
onClick={() => void runConnectionTest()}
>
{testMutation.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Test
</>
) : (
"Tester la connexion"
)}
</Button>
<Button
type="button"
disabled={
pending ||
!form.password ||
!form.username ||
!form.imap_host ||
!form.smtp_host ||
testOk === false
}
onClick={handleSubmit}
>
Enregistrer
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false)
resetForm()
}}
>
Annuler
</Button>
</div>
</>
) : null}
</CardContent>
</Card>
)
}
function TestResultBanner({
testing,
result,
testOk,
error,
}: {
testing: boolean
result?: { ok: boolean; imap_ok: boolean; imap_error?: string; smtp_ok: boolean; smtp_error?: string }
testOk: boolean | null
error: boolean
}) {
if (testing || testOk === null) {
if (error) {
return <p className="text-sm text-destructive">Échec du test de connexion.</p>
}
return null
}
if (testOk && result?.ok) {
return (
<p className="text-sm text-green-600 dark:text-green-500">
Connexion IMAP et SMTP validée.
</p>
)
}
return (
<div className="text-sm text-destructive space-y-1">
<p>Échec du test de connexion.</p>
{result?.imap_error ? <p className="text-xs">IMAP : {result.imap_error}</p> : null}
{result?.smtp_error ? <p className="text-xs">SMTP : {result.smtp_error}</p> : null}
</div>
)
}
function ProviderBanner({
discover,
onBack,
}: {
discover: MailAccountDiscoverResult
onBack: () => void
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Mail className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{discover.email}</p>
<p className="text-xs text-muted-foreground">
{discover.provider_name}
{discover.confidence !== "high" ? ` · confiance ${discover.confidence}` : null}
</p>
</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
Modifier l&apos;adresse
</Button>
</div>
)
}
function Field({
label,
value,
onChange,
type = "text",
autoComplete,
placeholder,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
autoComplete?: string
placeholder?: string
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs">{label}</Label>
<Input
value={value}
type={type}
autoComplete={autoComplete}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
}

View File

@ -0,0 +1,194 @@
'use client'
import { useMemo, useState } from 'react'
import { Pencil, Plus, Trash2, FunctionSquare, Workflow } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
useMailRules,
useCreateMailRule,
useUpdateMailRule,
useDeleteMailRule,
} from '@/lib/api/hooks/use-mail-automation-queries'
import { useAuthReady } from '@/lib/api/use-auth-ready'
import { SettingsSyncBanner } from '@/components/gmail/settings/settings-sync-banner'
import { RuleWorkflowEditor } from './rule-workflow-editor'
import { RuleSimulatorPanel } from './rule-simulator-panel'
import type { ApiRule } from '@/lib/api/types'
import type { RuleEditorState } from '@/lib/mail-automation/types'
import {
createDefaultRuleEditorState,
workflowToApiPayload,
} from '@/lib/mail-automation/defaults'
import { parseWorkflowFromRule } from '@/lib/mail-automation/workflow-flow'
import { AutomationSuggestionsProvider } from './automation-suggest-input'
export function AutomationRulesPanel() {
const { ready, authenticated } = useAuthReady()
const { data: rules = [], isFetching, isError, refetch, isPending } = useMailRules()
const createRule = useCreateMailRule()
const updateRule = useUpdateMailRule()
const deleteRule = useDeleteMailRule()
const [editorOpen, setEditorOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editorState, setEditorState] = useState<RuleEditorState>(() =>
createDefaultRuleEditorState('rule')
)
const showInitialLoad = ready && authenticated && isPending && rules.length === 0
const rulesOnly = useMemo(() => rules.filter((r) => (r.rule_kind ?? 'rule') === 'rule'), [rules])
const functionsOnly = useMemo(() => rules.filter((r) => r.rule_kind === 'function'), [rules])
function openNew(kind: 'rule' | 'function') {
setEditingId(null)
setEditorState(createDefaultRuleEditorState(kind))
setEditorOpen(true)
}
function openEdit(rule: ApiRule) {
const kind = rule.rule_kind ?? 'rule'
const workflow = parseWorkflowFromRule(rule.workflow, kind)
setEditingId(rule.id)
setEditorState({
name: rule.name,
priority: rule.priority,
is_active: rule.is_active,
rule_kind: kind,
account_id: rule.account_id,
workflow:
workflow ??
createDefaultRuleEditorState(kind).workflow,
})
setEditorOpen(true)
}
async function handleSave() {
const payload = workflowToApiPayload(editorState)
if (editingId) {
await updateRule.mutateAsync({ ruleId: editingId, ...payload })
} else {
await createRule.mutateAsync(payload)
}
setEditorOpen(false)
}
return (
<div className="space-y-4">
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => openNew('rule')}>
<Plus className="mr-1 size-3.5" />
Nouvelle règle
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => openNew('function')}>
<FunctionSquare className="mr-1 size-3.5" />
Nouvelle fonction
</Button>
</div>
{showInitialLoad ? null : rules.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucune règle. Créez une règle graphique avec déclencheurs, conditions et actions.
</p>
) : (
<>
<RuleList title="Règles actives" icon={Workflow} items={rulesOnly} onEdit={openEdit} onDelete={(id) => deleteRule.mutate(id)} />
{functionsOnly.length > 0 ? (
<RuleList title="Fonctions réutilisables" icon={FunctionSquare} items={functionsOnly} onEdit={openEdit} onDelete={(id) => deleteRule.mutate(id)} />
) : null}
</>
)}
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="flex max-h-[95vh] max-w-[95vw] flex-col gap-0 overflow-hidden p-0 sm:max-w-6xl">
<DialogHeader className="border-b border-border px-4 py-3">
<DialogTitle className="text-base">
{editingId ? 'Modifier' : 'Créer'}{' '}
{editorState.rule_kind === 'function' ? 'une fonction' : 'une règle'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4">
<AutomationSuggestionsProvider>
<RuleWorkflowEditor
key={editingId ?? `new-${editorState.rule_kind}`}
state={editorState}
allRules={rules}
onChange={setEditorState}
/>
<div className="mt-4">
<RuleSimulatorPanel state={editorState} ruleId={editingId ?? undefined} />
</div>
</AutomationSuggestionsProvider>
</div>
<div className="flex justify-end gap-2 border-t border-border px-4 py-3">
<Button type="button" variant="outline" onClick={() => setEditorOpen(false)}>
Annuler
</Button>
<Button
type="button"
disabled={!editorState.name.trim() || createRule.isPending || updateRule.isPending}
onClick={handleSave}
>
Enregistrer
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
function RuleList({
title,
icon: Icon,
items,
onEdit,
onDelete,
}: {
title: string
icon: typeof Workflow
items: ApiRule[]
onEdit: (rule: ApiRule) => void
onDelete: (id: string) => void
}) {
if (items.length === 0) return null
return (
<section className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4 opacity-70" />
{title}
</div>
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((rule) => (
<li key={rule.id} className="flex items-start justify-between gap-2 px-3 py-3">
<div className="min-w-0">
<p className="text-sm font-medium">{rule.name}</p>
<p className="text-xs text-muted-foreground">
Priorité {rule.priority}
{rule.is_active === false ? ' · inactive' : ''}
{rule.match_count != null ? ` · ${rule.match_count} exécutions` : ''}
{rule.workflow ? ' · graphique' : ' · legacy'}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(rule)}>
<Pencil className="size-4" />
</Button>
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(rule.id)}>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -0,0 +1,116 @@
'use client'
import { createContext, useContext, useMemo, useState } from 'react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import {
useAutomationSuggestions,
type AutomationSuggestion,
} from '@/lib/mail-automation/use-automation-suggestions'
import type { AutomationSuggestionKind } from '@/lib/mail-automation/condition-helpers'
interface AutomationSuggestionsContextValue {
suggestionsFor: (kind: AutomationSuggestionKind) => AutomationSuggestion[]
}
const AutomationSuggestionsContext = createContext<AutomationSuggestionsContextValue | null>(null)
export function AutomationSuggestionsProvider({ children }: { children: React.ReactNode }) {
const { suggestionsFor } = useAutomationSuggestions()
const value = useMemo(() => ({ suggestionsFor }), [suggestionsFor])
return (
<AutomationSuggestionsContext.Provider value={value}>
{children}
</AutomationSuggestionsContext.Provider>
)
}
function useAutomationSuggestionsContext() {
return useContext(AutomationSuggestionsContext)
}
interface AutomationSuggestInputProps {
kind: AutomationSuggestionKind
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
disabled?: boolean
}
export function AutomationSuggestInput({
kind,
value,
onChange,
placeholder,
className,
disabled,
}: AutomationSuggestInputProps) {
const ctx = useAutomationSuggestionsContext()
const [listId] = useState(() => `automation-suggest-${kind}-${Math.random().toString(36).slice(2, 9)}`)
const suggestions = kind === 'none' || !ctx ? [] : ctx.suggestionsFor(kind)
const filtered = useMemo(() => {
if (!value.trim()) return suggestions.slice(0, 40)
const q = value.trim().toLowerCase()
return suggestions
.filter(
(s) =>
s.value.toLowerCase().includes(q) ||
(s.label?.toLowerCase().includes(q) ?? false)
)
.slice(0, 40)
}, [suggestions, value])
if (kind === 'none' || suggestions.length === 0) {
return (
<Input
className={cn('h-8 text-xs', className)}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
/>
)
}
return (
<div className="relative">
<Input
className={cn('h-8 text-xs', className)}
value={value}
placeholder={placeholder}
disabled={disabled}
list={listId}
onChange={(e) => onChange(e.target.value)}
/>
<datalist id={listId}>
{filtered.map((s) => (
<option key={`${s.value}-${s.label ?? ''}`} value={s.value}>
{s.label && s.label !== s.value ? s.label : undefined}
</option>
))}
</datalist>
{filtered.length > 0 && value.trim() && !disabled ? (
<ul className="absolute z-20 mt-1 max-h-40 w-full overflow-y-auto rounded-md border border-border bg-popover py-1 shadow-md">
{filtered.slice(0, 8).map((s) => (
<li key={`pick-${s.value}-${s.label ?? ''}`}>
<button
type="button"
className="w-full px-2 py-1.5 text-left text-xs hover:bg-muted"
onMouseDown={(e) => {
e.preventDefault()
onChange(s.value)
}}
>
<span className="block truncate">{s.label ?? s.value}</span>
{s.label && s.label !== s.value ? (
<span className="block truncate font-mono text-[10px] text-muted-foreground">{s.value}</span>
) : null}
</button>
</li>
))}
</ul>
) : null}
</div>
)
}

View File

@ -0,0 +1,103 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Play } from 'lucide-react'
import { useSimulateMailRule } from '@/lib/api/hooks/use-mail-automation-queries'
import type { RuleEditorState, RuleSimulationResult } from '@/lib/mail-automation/types'
import { DEFAULT_SIMULATION_MESSAGE, workflowToApiPayload } from '@/lib/mail-automation/defaults'
interface RuleSimulatorPanelProps {
state: RuleEditorState
ruleId?: string
}
export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
const simulate = useSimulateMailRule()
const [message, setMessage] = useState(DEFAULT_SIMULATION_MESSAGE)
const [result, setResult] = useState<RuleSimulationResult | null>(null)
async function runSimulation() {
const payload = workflowToApiPayload(state)
const res = await simulate.mutateAsync({
message,
...(ruleId
? { rule_id: ruleId }
: {
rule: {
conditions: payload.conditions,
actions: payload.actions,
workflow: payload.workflow,
},
}),
})
setResult(res)
}
return (
<div className="space-y-3 rounded-lg border border-border bg-muted/10 p-3">
<p className="text-xs font-medium">Tester avec un message exemple</p>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<Label className="text-[10px]">De</Label>
<Input className="h-8 text-xs" value={message.from} onChange={(e) => setMessage({ ...message, from: e.target.value })} />
</div>
<div>
<Label className="text-[10px]">Sujet</Label>
<Input className="h-8 text-xs" value={message.subject} onChange={(e) => setMessage({ ...message, subject: e.target.value })} />
</div>
</div>
<div>
<Label className="text-[10px]">Corps</Label>
<textarea
className="mt-1 min-h-16 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
value={message.body_text}
onChange={(e) => setMessage({ ...message, body_text: e.target.value })}
/>
</div>
<Button type="button" size="sm" disabled={simulate.isPending} onClick={runSimulation}>
<Play className="mr-1 size-3.5" />
Simuler
</Button>
{result ? (
<div className="space-y-2 rounded-md border border-border/60 bg-background p-2 text-xs">
<p>
Correspondance :{' '}
<span className={result.matched ? 'text-emerald-600' : 'text-muted-foreground'}>
{result.matched ? 'oui' : 'non'}
</span>
</p>
{result.steps?.length ? (
<div>
<p className="font-medium">Parcours</p>
<ol className="mt-1 list-decimal pl-4 text-muted-foreground">
{result.steps.map((s, i) => (
<li key={i}>
{s.node_type}
{s.handle ? `${s.handle}` : ''}
</li>
))}
</ol>
</div>
) : null}
{result.actions?.length ? (
<div>
<p className="font-medium">Actions</p>
<ul className="mt-1 space-y-0.5 text-muted-foreground">
{result.actions.map((a, i) => (
<li key={i}>
{a.type}
{a.value ? `: ${a.value}` : ''} {a.ok ? '✓' : `${a.error ?? ''}`}
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,330 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
useReactFlow,
type Connection,
type Node,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Plus, Variable } from 'lucide-react'
import { workflowNodeTypes } from './workflow-nodes'
import { WorkflowTriggersPanel } from './workflow-triggers-panel'
import { WorkflowNodeInspector } from './workflow-node-inspector'
import {
PALETTE_NODE_TYPES,
NODE_TYPE_LABELS,
NODE_TYPE_DESCRIPTIONS,
} from '@/lib/mail-automation/node-definitions'
import {
createFlowNode,
flowToWorkflow,
workflowEdgesToFlow,
workflowNodesToFlow,
} from '@/lib/mail-automation/workflow-flow'
import type { ApiRule } from '@/lib/api/types'
import type { ExecVariable, RuleEditorState, WorkflowNodeType } from '@/lib/mail-automation/types'
import { nextNodeId } from '@/lib/mail-automation/defaults'
interface RuleWorkflowEditorProps {
state: RuleEditorState
allRules: ApiRule[]
onChange: (state: RuleEditorState) => void
readOnly?: boolean
}
export function RuleWorkflowEditor(props: RuleWorkflowEditorProps) {
return (
<ReactFlowProvider>
<RuleWorkflowEditorInner {...props} />
</ReactFlowProvider>
)
}
function RuleWorkflowEditorInner({
state,
allRules,
onChange,
readOnly,
}: RuleWorkflowEditorProps) {
const { setCenter, getNodes } = useReactFlow()
const initialNodes = useMemo(() => workflowNodesToFlow(state.workflow.nodes), [state.workflow.nodes])
const initialEdges = useMemo(() => workflowEdgesToFlow(state.workflow.edges), [state.workflow.edges])
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
const [selectedId, setSelectedId] = useState<string | null>(null)
const selectedNode = nodes.find((n) => n.id === selectedId) ?? null
const syncWorkflow = useCallback(
(nextNodes: Node[], nextEdges: typeof edges) => {
onChange({
...state,
workflow: flowToWorkflow(
state.rule_kind,
state.workflow.triggers,
state.workflow.variables,
nextNodes,
nextEdges
),
})
},
[onChange, state]
)
const applySelection = useCallback(
(nodeId: string | null) => {
setSelectedId(nodeId)
setNodes((nds) =>
nds.map((n) => ({
...n,
selected: nodeId !== null && n.id === nodeId,
}))
)
},
[setNodes]
)
const focusNode = useCallback(
(nodeId: string) => {
requestAnimationFrame(() => {
const node = getNodes().find((n) => n.id === nodeId)
if (!node) return
const w = node.measured?.width ?? 200
const h = node.measured?.height ?? 80
setCenter(node.position.x + w / 2, node.position.y + h / 2, {
zoom: 1,
duration: 300,
})
})
},
[getNodes, setCenter]
)
const onConnect = useCallback(
(connection: Connection) => {
if (readOnly) return
const next = addEdge({ ...connection, animated: true, id: nextNodeId('e') }, edges)
setEdges(next)
syncWorkflow(nodes, next)
},
[edges, nodes, readOnly, setEdges, syncWorkflow]
)
const onNodeDragStop = useCallback(() => {
syncWorkflow(nodes, edges)
}, [edges, nodes, syncWorkflow])
function addNode(type: WorkflowNodeType) {
if (readOnly || type === 'start' || type === 'end') return
const last = nodes[nodes.length - 1]
const x = last ? last.position.x + 220 : 300
const y = last ? last.position.y : 200
const node = createFlowNode(type, { x, y })
const nextNodes = nodes.map((n) => ({ ...n, selected: false })).concat({ ...node, selected: true })
setNodes(nextNodes)
syncWorkflow(nextNodes, edges)
applySelection(node.id)
setTimeout(() => focusNode(node.id), 50)
}
function updateNodeData(nodeId: string, data: Record<string, unknown>) {
const nextNodes = nodes.map((n) => (n.id === nodeId ? { ...n, data } : n))
setNodes(nextNodes)
syncWorkflow(nextNodes, edges)
}
function deleteNode(nodeId: string) {
const node = nodes.find((n) => n.id === nodeId)
if (!node || node.type === 'start' || node.type === 'end') return
const nextNodes = nodes.filter((n) => n.id !== nodeId)
const nextEdges = edges.filter((e) => e.source !== nodeId && e.target !== nodeId)
setNodes(nextNodes)
setEdges(nextEdges)
syncWorkflow(nextNodes, nextEdges)
applySelection(null)
}
function updateVariables(variables: ExecVariable[]) {
onChange({
...state,
workflow: { ...state.workflow, variables },
})
}
return (
<div className="flex min-h-[520px] flex-col gap-3 lg:flex-row">
<aside className="w-full shrink-0 space-y-2 lg:w-52">
<p className="text-xs font-medium text-muted-foreground">Ajouter un nœud</p>
<div className="space-y-1">
{PALETTE_NODE_TYPES.map((type) => (
<Button
key={type}
type="button"
variant="outline"
size="sm"
className="h-auto w-full min-w-0 justify-start px-2 py-1.5 text-left text-xs"
disabled={readOnly}
onClick={() => addNode(type)}
title={NODE_TYPE_DESCRIPTIONS[type]}
>
<Plus className="mr-1.5 mt-0.5 size-3 shrink-0 self-start" />
<span className="min-w-0 flex-1">
<span className="block font-medium leading-tight">{NODE_TYPE_LABELS[type]}</span>
<span className="mt-0.5 block text-wrap break-words text-[10px] leading-snug text-muted-foreground">
{NODE_TYPE_DESCRIPTIONS[type]}
</span>
</span>
</Button>
))}
</div>
<VariablesPanel variables={state.workflow.variables} disabled={readOnly} onChange={updateVariables} />
</aside>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<div>
<Label className="text-xs">Nom</Label>
<Input
className="mt-1 h-8 text-xs"
value={state.name}
disabled={readOnly}
onChange={(e) => onChange({ ...state, name: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Priorité</Label>
<Input
type="number"
className="mt-1 h-8 text-xs"
value={state.priority}
disabled={readOnly}
onChange={(e) => onChange({ ...state, priority: Number(e.target.value) || 0 })}
/>
</div>
<div className="flex items-end gap-2 pb-1">
<Switch
checked={state.is_active}
disabled={readOnly}
onCheckedChange={(v) => onChange({ ...state, is_active: v })}
/>
<Label className="text-xs">Active</Label>
</div>
<div>
<Label className="text-xs">Type</Label>
<Input className="mt-1 h-8 text-xs" value={state.rule_kind === 'function' ? 'Fonction' : 'Règle'} readOnly />
</div>
</div>
{state.rule_kind === 'rule' ? (
<WorkflowTriggersPanel
triggers={state.workflow.triggers}
disabled={readOnly}
onChange={(triggers) =>
onChange({ ...state, workflow: { ...state.workflow, triggers } })
}
/>
) : null}
<div className="h-[420px] overflow-hidden rounded-lg border border-border">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={workflowNodeTypes}
onNodesChange={readOnly ? undefined : onNodesChange}
onEdgesChange={readOnly ? undefined : onEdgesChange}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
onNodeClick={(_, n) => applySelection(n.id)}
onPaneClick={() => applySelection(null)}
fitView
proOptions={{ hideAttribution: true }}
>
<Background gap={16} />
<Controls showInteractive={!readOnly} />
<MiniMap pannable zoomable />
</ReactFlow>
</div>
</div>
<aside className="w-full shrink-0 overflow-hidden rounded-lg border border-border lg:w-64">
<WorkflowNodeInspector
node={selectedNode}
allRules={allRules}
onUpdate={updateNodeData}
onDelete={deleteNode}
/>
</aside>
</div>
)
}
function VariablesPanel({
variables,
onChange,
disabled,
}: {
variables: ExecVariable[]
onChange: (v: ExecVariable[]) => void
disabled?: boolean
}) {
return (
<div className="mt-4 space-y-2 rounded-lg border border-border/60 p-2">
<div className="flex items-center gap-1 text-xs font-medium">
<Variable className="size-3.5" />
Variables d&apos;exécution
</div>
{variables.map((v, i) => (
<div key={i} className="flex gap-1">
<Input
className="h-7 flex-1 font-mono text-[10px]"
placeholder="nom"
value={v.name}
disabled={disabled}
onChange={(e) => {
const next = [...variables]
next[i] = { ...v, name: e.target.value }
onChange(next)
}}
/>
<Input
className="h-7 flex-1 text-[10px]"
placeholder="défaut"
value={v.default ?? ''}
disabled={disabled}
onChange={(e) => {
const next = [...variables]
next[i] = { ...v, default: e.target.value }
onChange(next)
}}
/>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full text-xs"
disabled={disabled}
onClick={() => onChange([...variables, { name: '', type: 'string', default: '' }])}
>
<Plus className="mr-1 size-3" />
Variable
</Button>
</div>
)
}

View File

@ -0,0 +1,84 @@
"use client"
import { useState } from "react"
import { Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
useMailWebhooks,
useCreateMailWebhook,
useDeleteMailWebhook,
} from "@/lib/api/hooks/use-mail-automation-queries"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
export function WebhooksPanel() {
const { ready, authenticated } = useAuthReady()
const { data: webhooks = [], isFetching, isError, refetch, isPending } = useMailWebhooks()
const createWebhook = useCreateMailWebhook()
const deleteWebhook = useDeleteMailWebhook()
const [name, setName] = useState("")
const [url, setUrl] = useState("")
const [template, setTemplate] = useState(
'{"text":"Nouveau mail de $sender.name : $subject"}'
)
const showInitialLoad = ready && authenticated && isPending && webhooks.length === 0
return (
<div className="space-y-4">
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="space-y-2 rounded-lg border border-border p-4">
<Label className="text-xs">Nouveau webhook</Label>
<Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} />
<Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} />
<textarea
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder="body_template JSON"
/>
<Button
type="button"
disabled={!name.trim() || !url.trim() || createWebhook.isPending}
onClick={() =>
createWebhook.mutate({
name: name.trim(),
url: url.trim(),
method: "POST",
body_template: template,
})
}
>
Créer le webhook
</Button>
</div>
{showInitialLoad ? null : webhooks.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun webhook.</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{webhooks.map((hook) => (
<li
key={hook.id}
className="flex items-start justify-between gap-2 px-3 py-3"
>
<div className="min-w-0">
<p className="text-sm font-medium">{hook.name}</p>
<p className="truncate text-xs text-muted-foreground">{hook.url}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => deleteWebhook.mutate(hook.id)}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -0,0 +1,398 @@
'use client'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
import type { Node } from '@xyflow/react'
import {
ACTION_TYPES,
CONDITION_FIELDS,
NODE_TYPE_LABELS,
} from '@/lib/mail-automation/node-definitions'
import {
actionValueSuggestionKind,
conditionValueSuggestionKind,
defaultOperatorForField,
formatConditionSummary,
operatorOptionsForConditionField,
switchFieldSuggestionKind,
} from '@/lib/mail-automation/condition-helpers'
import type {
ActionsNodeData,
ConditionNodeData,
LLMCheckNodeData,
SetVarNodeData,
SwitchNodeData,
CallRuleNodeData,
WorkflowNodeType,
ConditionField,
} from '@/lib/mail-automation/types'
import type { ApiRule } from '@/lib/api/types'
import { createEmptyAction } from '@/lib/mail-automation/defaults'
import { AutomationSuggestInput } from './automation-suggest-input'
import { Input } from '@/components/ui/input'
interface WorkflowNodeInspectorProps {
node: Node | null
allRules: ApiRule[]
onUpdate: (nodeId: string, data: Record<string, unknown>) => void
onDelete: (nodeId: string) => void
}
export function WorkflowNodeInspector({
node,
allRules,
onUpdate,
onDelete,
}: WorkflowNodeInspectorProps) {
if (!node || node.type === 'start' || node.type === 'end') {
return (
<div className="flex h-full items-center justify-center p-4 text-center text-xs text-muted-foreground">
Sélectionnez un nœud pour le configurer
</div>
)
}
const type = node.type as WorkflowNodeType
const data = node.data as unknown as Record<string, unknown>
function patch(partial: Record<string, unknown>) {
onUpdate(node!.id, { ...data, ...partial })
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-start justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-medium">{NODE_TYPE_LABELS[type]}</p>
<p className="truncate font-mono text-[10px] text-muted-foreground">{node.id}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
title="Supprimer le nœud"
onClick={() => onDelete(node.id)}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="flex-1 space-y-3 overflow-y-auto p-3">
{type === 'condition' || type === 'label_check' ? (
<ConditionEditor
data={
type === 'label_check'
? {
field: 'label',
operator: (data as { operator?: string }).operator === 'not_has' ? 'not_has' : 'has',
value: String((data as { label?: string }).label ?? ''),
}
: (data as unknown as ConditionNodeData)
}
onChange={patch}
/>
) : null}
{type === 'switch' ? (
<SwitchEditor data={data as unknown as SwitchNodeData} onChange={patch} />
) : null}
{type === 'llm_check' ? (
<LLMEditor data={data as unknown as LLMCheckNodeData} onChange={patch} />
) : null}
{type === 'actions' ? (
<ActionsEditor data={data as unknown as ActionsNodeData} onChange={patch} />
) : null}
{type === 'set_var' ? (
<SetVarEditor data={data as unknown as SetVarNodeData} onChange={patch} />
) : null}
{type === 'call_function' || type === 'call_rule' ? (
<CallRuleEditor
data={data as unknown as CallRuleNodeData}
allRules={allRules}
kindFilter={type === 'call_function' ? 'function' : 'rule'}
onChange={patch}
/>
) : null}
</div>
</div>
)
}
function ConditionEditor({
data,
onChange,
}: {
data: ConditionNodeData
onChange: (p: Partial<ConditionNodeData>) => void
}) {
const operatorOptions = operatorOptionsForConditionField(data.field)
const valueKind = conditionValueSuggestionKind(data.field, data.operator)
const isRegex = data.operator === 'regex' || data.operator === 'not_regex'
function onFieldChange(field: ConditionField) {
onChange({
field,
operator: defaultOperatorForField(field),
value: field === 'has_attachment' ? 'true' : '',
})
}
return (
<>
<FieldSelect
label="Champ"
value={data.field}
options={CONDITION_FIELDS}
onChange={(v) => onFieldChange(v as ConditionField)}
/>
<FieldSelect
label={data.field === 'label' ? 'Mode' : 'Opérateur'}
value={data.operator}
options={operatorOptions}
onChange={(v) => onChange({ operator: v as ConditionNodeData['operator'] })}
/>
<div>
<Label className="text-xs">
{data.field === 'label' ? 'Libellé' : isRegex ? 'Expression régulière' : 'Valeur'}
</Label>
<div className="mt-1">
{data.field === 'has_attachment' ? (
<FieldSelect
label=""
value={data.value || 'true'}
options={[
{ value: 'true', label: 'Oui' },
{ value: 'false', label: 'Non' },
]}
onChange={(v) => onChange({ value: v })}
/>
) : (
<AutomationSuggestInput
kind={valueKind}
value={data.value}
placeholder={isRegex ? '(?i)facture|invoice' : undefined}
onChange={(value) => onChange({ value })}
/>
)}
</div>
</div>
</>
)
}
function SwitchEditor({
data,
onChange,
}: {
data: SwitchNodeData
onChange: (p: Partial<SwitchNodeData>) => void
}) {
const cases = data.cases ?? []
const fieldKind = switchFieldSuggestionKind(data.field)
return (
<>
<FieldSelect label="Champ" value={data.field} options={CONDITION_FIELDS} onChange={(v) => onChange({ field: v })} />
<div className="space-y-2">
<Label className="text-xs">Cas de sortie</Label>
{cases.map((c, i) => (
<div key={i} className="flex gap-1">
<AutomationSuggestInput
kind={fieldKind}
className="flex-1"
placeholder="Valeur"
value={c.value}
onChange={(value) => {
const next = [...cases]
next[i] = { ...c, value }
onChange({ cases: next })
}}
/>
<Input
className="h-8 flex-1 text-xs"
placeholder="Libellé"
value={c.label ?? ''}
onChange={(e) => {
const next = [...cases]
next[i] = { ...c, label: e.target.value }
onChange({ cases: next })
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={() => onChange({ cases: cases.filter((_, j) => j !== i) })}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => onChange({ cases: [...cases, { value: '', label: '' }] })}>
<Plus className="mr-1 size-3" />
Ajouter un cas
</Button>
</div>
</>
)
}
function LLMEditor({
data,
onChange,
}: {
data: LLMCheckNodeData
onChange: (p: Partial<LLMCheckNodeData>) => void
}) {
return (
<>
<div>
<Label className="text-xs">Prompt</Label>
<textarea
className="mt-1 min-h-20 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
value={data.prompt}
onChange={(e) => onChange({ prompt: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Fournisseur (optionnel)</Label>
<Input className="mt-1 h-8 text-xs" value={data.provider ?? ''} onChange={(e) => onChange({ provider: e.target.value })} />
</div>
</>
)
}
function ActionsEditor({
data,
onChange,
}: {
data: ActionsNodeData
onChange: (p: Partial<ActionsNodeData>) => void
}) {
const actions = data.actions ?? []
return (
<div className="space-y-2">
{actions.map((action, i) => {
const meta = ACTION_TYPES.find((a) => a.value === action.type)
const suggestKind = actionValueSuggestionKind(action.type)
return (
<div key={i} className="space-y-1 rounded border border-border/60 p-2">
<Select value={action.type} onValueChange={(v) => {
const next = [...actions]
next[i] = { ...action, type: v as typeof action.type, value: '' }
onChange({ actions: next })
}}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{ACTION_TYPES.map((a) => (
<SelectItem key={a.value} value={a.value} className="text-xs">{a.label}</SelectItem>
))}
</SelectContent>
</Select>
{meta?.needsValue ? (
<AutomationSuggestInput
kind={suggestKind}
value={action.value}
placeholder={meta.placeholder}
onChange={(value) => {
const next = [...actions]
next[i] = { ...action, value }
onChange({ actions: next })
}}
/>
) : null}
<Button type="button" variant="ghost" size="sm" className="h-6 text-xs" onClick={() => onChange({ actions: actions.filter((_, j) => j !== i) })}>
Retirer
</Button>
</div>
)
})}
<Button type="button" variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => onChange({ actions: [...actions, createEmptyAction()] })}>
<Plus className="mr-1 size-3" />
Ajouter une action
</Button>
</div>
)
}
function SetVarEditor({
data,
onChange,
}: {
data: SetVarNodeData
onChange: (p: Partial<SetVarNodeData>) => void
}) {
return (
<>
<div>
<Label className="text-xs">Nom</Label>
<Input className="mt-1 h-8 font-mono text-xs" value={data.name} onChange={(e) => onChange({ name: e.target.value })} />
</div>
<div>
<Label className="text-xs">Valeur ({"{{var}}"} pour interpolation)</Label>
<Input className="mt-1 h-8 font-mono text-xs" value={data.value} onChange={(e) => onChange({ value: e.target.value })} />
</div>
</>
)
}
function CallRuleEditor({
data,
allRules,
kindFilter,
onChange,
}: {
data: CallRuleNodeData
allRules: ApiRule[]
kindFilter: 'rule' | 'function'
onChange: (p: Partial<CallRuleNodeData>) => void
}) {
const options = allRules.filter((r) => (r.rule_kind ?? 'rule') === kindFilter)
return (
<div>
<Label className="text-xs">{kindFilter === 'function' ? 'Fonction' : 'Règle'}</Label>
<Select value={data.rule_id || undefined} onValueChange={(v) => onChange({ rule_id: v })}>
<SelectTrigger className="mt-1 h-8 text-xs"><SelectValue placeholder="Choisir…" /></SelectTrigger>
<SelectContent>
{options.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">{r.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
function FieldSelect({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: { value: string; label: string }[]
onChange: (v: string) => void
}) {
return (
<div>
{label ? <Label className="text-xs">{label}</Label> : null}
<Select value={value} onValueChange={onChange}>
<SelectTrigger className={label ? 'mt-1 h-8 text-xs' : 'h-8 text-xs'}><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -0,0 +1,291 @@
'use client'
import { memo, type ReactNode } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { cn } from '@/lib/utils'
import {
NODE_COLORS,
CONDITION_FIELDS,
CONDITION_OPERATORS,
LABEL_CONDITION_OPERATORS,
ACTION_TYPES,
} from '@/lib/mail-automation/node-definitions'
import { formatConditionSummary } from '@/lib/mail-automation/condition-helpers'
import type {
ActionsNodeData,
ConditionNodeData,
LabelCheckNodeData,
LLMCheckNodeData,
SetVarNodeData,
SwitchNodeData,
CallRuleNodeData,
WorkflowNodeType,
} from '@/lib/mail-automation/types'
function fieldLabel(field: string) {
return CONDITION_FIELDS.find((f) => f.value === field)?.label ?? field
}
function opLabel(op: string) {
const fromString = CONDITION_OPERATORS.find((o) => o.value === op)?.label
if (fromString) return fromString
return LABEL_CONDITION_OPERATORS.find((o) => o.value === op)?.label ?? op
}
function actionLabel(type: string) {
return ACTION_TYPES.find((a) => a.value === type)?.label ?? type
}
function nodeShell(
type: WorkflowNodeType,
selected: boolean | undefined,
className: string,
children: ReactNode
) {
return (
<div
className={cn(
'rounded-lg border-2 px-3 py-2 shadow-sm transition-shadow',
NODE_COLORS[type],
selected && 'border-primary ring-2 ring-primary/40 shadow-md',
className
)}
>
{children}
</div>
)
}
function BranchOutputRow({
id,
label,
labelClassName,
handleClassName,
top,
}: {
id: string
label: string
labelClassName?: string
handleClassName?: string
top: string
}) {
return (
<>
<span
className={cn(
'pointer-events-none absolute right-5 -translate-y-1/2 text-[10px] font-medium',
labelClassName
)}
style={{ top }}
>
{label}
</span>
<Handle
type="source"
position={Position.Right}
id={id}
style={{ top }}
className={cn('!size-2.5', handleClassName)}
/>
</>
)
}
function BranchOutputs({
branches,
}: {
branches: {
id: string
label: string
labelClassName?: string
handleClassName?: string
}[]
}) {
const rowHeight = 24
const blockHeight = branches.length * rowHeight
return (
<div
className="relative mt-3 border-t border-border/40 pt-2"
style={{ height: blockHeight }}
>
{branches.map((b, i) => {
const topPx = rowHeight * i + rowHeight / 2
return <BranchOutputRow key={b.id} {...b} top={`${topPx}px`} />
})}
</div>
)
}
export const StartNode = memo(function StartNode({ selected }: NodeProps) {
return nodeShell('start', selected, 'min-w-[120px]', (
<>
<p className="text-xs font-semibold">Début</p>
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
</>
))
})
export const EndNode = memo(function EndNode({ selected }: NodeProps) {
return nodeShell('end', selected, 'min-w-[100px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold text-muted-foreground">Fin</p>
</>
))
})
export const ConditionNode = memo(function ConditionNode({ data, selected }: NodeProps) {
const d = data as unknown as ConditionNodeData
return nodeShell('condition', selected, 'min-w-[200px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold">Si</p>
<p className="mt-1 text-[11px] text-muted-foreground">
{formatConditionSummary(d, fieldLabel, opLabel)}
</p>
<BranchOutputs
branches={[
{ id: 'true', label: 'vrai', labelClassName: 'text-emerald-600', handleClassName: '!bg-emerald-500' },
{ id: 'false', label: 'faux', labelClassName: 'text-rose-600', handleClassName: '!bg-rose-500' },
]}
/>
</>
))
})
export const LabelCheckNode = memo(function LabelCheckNode({ data, selected }: NodeProps) {
const d = data as unknown as LabelCheckNodeData
return nodeShell('label_check', selected, 'min-w-[200px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold">Libellé</p>
<p className="mt-1 text-[11px] text-muted-foreground">
{d.operator === 'not_has' ? 'Sans' : 'A'} « {d.label || '…'} »
</p>
<BranchOutputs
branches={[
{ id: 'true', label: 'vrai', labelClassName: 'text-emerald-600', handleClassName: '!bg-emerald-500' },
{ id: 'false', label: 'faux', labelClassName: 'text-rose-600', handleClassName: '!bg-rose-500' },
]}
/>
</>
))
})
export const SwitchNode = memo(function SwitchNode({ data, selected }: NodeProps) {
const d = data as unknown as SwitchNodeData
const cases = d.cases ?? []
const caseBranches = cases.map((c, i) => ({
id: `case-${i}`,
label: c.label || c.value || `Cas ${i + 1}`,
labelClassName: 'text-violet-600',
handleClassName: '!bg-violet-500',
}))
return nodeShell('switch', selected, 'min-w-[220px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold">Switch · {fieldLabel(d.field)}</p>
<BranchOutputs
branches={[
...caseBranches,
{
id: 'default',
label: 'défaut',
labelClassName: 'text-muted-foreground',
handleClassName: '!bg-muted-foreground',
},
]}
/>
</>
))
})
export const LLMCheckNode = memo(function LLMCheckNode({ data, selected }: NodeProps) {
const d = data as unknown as LLMCheckNodeData
return nodeShell('llm_check', selected, 'min-w-[220px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold">LLM</p>
<p className="mt-1 line-clamp-2 text-[11px] text-muted-foreground">{d.prompt || 'Prompt…'}</p>
<BranchOutputs
branches={[
{ id: 'true', label: 'vrai', labelClassName: 'text-emerald-600', handleClassName: '!bg-emerald-500' },
{ id: 'false', label: 'faux', labelClassName: 'text-rose-600', handleClassName: '!bg-rose-500' },
]}
/>
</>
))
})
export const ActionsNode = memo(function ActionsNode({ data, selected }: NodeProps) {
const d = data as unknown as ActionsNodeData
const actions = d.actions ?? []
return nodeShell('actions', selected, 'min-w-[200px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold">Actions ({actions.length})</p>
<ul className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
{actions.slice(0, 4).map((a, i) => (
<li key={i}>
{actionLabel(a.type)}
{a.value ? `: ${a.value}` : ''}
</li>
))}
</ul>
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
</>
))
})
export const SetVarNode = memo(function SetVarNode({ data, selected }: NodeProps) {
const d = data as unknown as SetVarNodeData
return nodeShell('set_var', selected, 'min-w-[180px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold">Variable</p>
<p className="mt-1 font-mono text-[11px] text-muted-foreground">
{d.name || 'var'} = {d.value || '…'}
</p>
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
</>
))
})
export const CallFunctionNode = memo(function CallFunctionNode({ data, selected }: NodeProps) {
const d = data as unknown as CallRuleNodeData
return nodeShell('call_function', selected, 'min-w-[180px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold">Fonction</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.rule_id || 'Choisir…'}</p>
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
</>
))
})
export const CallRuleNode = memo(function CallRuleNode({ data, selected }: NodeProps) {
const d = data as unknown as CallRuleNodeData
return nodeShell('call_rule', selected, 'min-w-[180px]', (
<>
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
<p className="text-xs font-semibold">Règle cascade</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.rule_id || 'Choisir…'}</p>
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
</>
))
})
export const workflowNodeTypes = {
start: StartNode,
end: EndNode,
condition: ConditionNode,
label_check: LabelCheckNode,
switch: SwitchNode,
llm_check: LLMCheckNode,
actions: ActionsNode,
set_var: SetVarNode,
call_function: CallFunctionNode,
call_rule: CallRuleNode,
}

View File

@ -0,0 +1,214 @@
'use client'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
import type {
AutomationTrigger,
TriggerAndGroup,
TriggerOrGroup,
TriggerType,
} from '@/lib/mail-automation/types'
import { TRIGGER_LABELS } from '@/lib/mail-automation/node-definitions'
import { AutomationSuggestInput } from './automation-suggest-input'
const TRIGGER_TYPES: TriggerType[] = ['message_received', 'label_added', 'label_removed']
interface WorkflowTriggersPanelProps {
triggers: TriggerOrGroup
onChange: (triggers: TriggerOrGroup) => void
disabled?: boolean
}
export function WorkflowTriggersPanel({
triggers,
onChange,
disabled,
}: WorkflowTriggersPanelProps) {
const groups =
triggers.groups.length > 0
? triggers.groups
: [{ operator: 'and' as const, items: [{ type: 'message_received' as const }] }]
function updateGroups(next: TriggerAndGroup[]) {
onChange({
operator: 'or',
groups: next.length > 0 ? next : [{ operator: 'and', items: [] }],
})
}
function updateGroup(gi: number, group: TriggerAndGroup) {
const next = [...groups]
next[gi] = group
updateGroups(next)
}
function addOrGroup() {
onChange({
operator: 'or',
groups: [...groups, { operator: 'and', items: [{ type: 'message_received' }] }],
})
}
function removeOrGroup(gi: number) {
updateGroups(groups.filter((_, i) => i !== gi))
}
function removeTrigger(gi: number, ti: number) {
const group = groups[gi]
const items = group.items.filter((_, i) => i !== ti)
if (items.length === 0) {
removeOrGroup(gi)
return
}
updateGroup(gi, { ...group, items })
}
return (
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium">Déclencheurs (OU entre groupes, ET dans un groupe)</Label>
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={addOrGroup}>
<Plus className="mr-1 size-3" />
Groupe OU
</Button>
</div>
{groups.map((group, gi) => (
<div key={gi} className="space-y-2 rounded-md border border-border/60 bg-background p-2">
<div className="flex items-center justify-between gap-2">
{gi > 0 ? (
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">OU</p>
) : (
<span />
)}
{groups.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-destructive"
disabled={disabled}
onClick={() => removeOrGroup(gi)}
>
<Trash2 className="mr-1 size-3" />
Retirer le groupe
</Button>
) : null}
</div>
{group.items.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Aucun déclencheur ajoutez-en un ci-dessous</p>
) : null}
{group.items.map((item, ti) => (
<TriggerRow
key={ti}
item={item}
disabled={disabled}
onChange={(next) => {
const items = [...group.items]
items[ti] = next
updateGroup(gi, { ...group, items })
}}
onRemove={() => removeTrigger(gi, ti)}
/>
))}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={disabled}
onClick={() =>
updateGroup(gi, {
...group,
items: [...group.items, { type: 'message_received' }],
})
}
>
<Plus className="mr-1 size-3" />
ET ajouter déclencheur
</Button>
</div>
))}
</div>
)
}
function TriggerRow({
item,
onChange,
onRemove,
disabled,
}: {
item: AutomationTrigger
onChange: (item: AutomationTrigger) => void
onRemove: () => void
disabled?: boolean
}) {
return (
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[140px] flex-1">
<Label className="text-[10px] text-muted-foreground">Type</Label>
<Select
value={item.type}
disabled={disabled}
onValueChange={(v) => onChange({ ...item, type: v as TriggerType })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{TRIGGER_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{item.type === 'message_received' ? (
<div className="min-w-[120px] flex-1">
<Label className="text-[10px] text-muted-foreground">Dossier (optionnel)</Label>
<AutomationSuggestInput
kind="folder_id"
placeholder="Choisir un dossier…"
value={item.folder_id ?? ''}
disabled={disabled}
onChange={(value) => onChange({ ...item, folder_id: value || undefined })}
/>
</div>
) : (
<div className="min-w-[120px] flex-1">
<Label className="text-[10px] text-muted-foreground">Libellé</Label>
<AutomationSuggestInput
kind="label"
placeholder="Nom libellé"
value={item.label ?? ''}
disabled={disabled}
onChange={(value) => onChange({ ...item, label: value || undefined })}
/>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
disabled={disabled}
title="Retirer ce déclencheur"
onClick={onRemove}
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}

View File

@ -0,0 +1,335 @@
"use client"
import { useEffect, useState } from "react"
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useMailAccount } from "@/lib/api/hooks/use-mail-account"
import { useUpdateMailAccount } from "@/lib/api/hooks/use-mail-account-mutations"
import { useTestMailAccount } from "@/lib/api/hooks/use-mail-account-test"
import type { ApiMailAccount, ApiMailAccountDetail, UpdateMailAccountPayload } from "@/lib/api/types"
function parsePort(value: string, fallback: number): number {
const n = Number.parseInt(value, 10)
return Number.isFinite(n) && n > 0 ? n : fallback
}
function asString(value: string | undefined | null): string {
return value ?? ""
}
function trimField(value: string | undefined | null): string {
return asString(value).trim()
}
type EditAccountFormState = {
name: string
email: string
username: string
password: string
imap_host: string
imap_port: string
imap_tls: boolean
smtp_host: string
smtp_port: string
smtp_tls: boolean
}
function formStateFromAccount(
account: ApiMailAccount,
detail?: ApiMailAccountDetail | null
): EditAccountFormState {
const src = detail ?? account
return {
name: asString(src.name ?? account.name),
email: asString(src.email ?? account.email),
username: asString(detail?.username ?? detail?.email ?? account.email),
password: "",
imap_host: asString(detail?.imap_host ?? account.imap_host),
imap_port: String(detail?.imap_port ?? 993),
imap_tls: detail?.imap_tls ?? true,
smtp_host: asString(detail?.smtp_host ?? account.smtp_host),
smtp_port: String(detail?.smtp_port ?? 587),
smtp_tls: detail?.smtp_tls ?? true,
}
}
export function EditMailAccountForm({
account,
onCancel,
}: {
account: ApiMailAccount
onCancel: () => void
}) {
const { data: detail, isPending, isError, refetch } = useMailAccount(account.id)
const updateAccount = useUpdateMailAccount(account.id)
const testMutation = useTestMailAccount(account.id)
const [showAdvanced, setShowAdvanced] = useState(false)
const [testOk, setTestOk] = useState<boolean | null>(null)
const [form, setForm] = useState<EditAccountFormState>(() => formStateFromAccount(account))
const isOAuth = detail?.auth_type === "oauth2"
useEffect(() => {
if (!detail) return
setForm(formStateFromAccount(account, detail))
setTestOk(null)
}, [account, detail])
if (isPending && !detail) {
return null
}
if (isError) {
return (
<div className="space-y-2">
<p className="text-sm text-destructive">Impossible de charger ce compte.</p>
<Button type="button" size="sm" variant="outline" onClick={() => void refetch()}>
Réessayer
</Button>
</div>
)
}
function buildPayload(): UpdateMailAccountPayload {
return {
name: trimField(form.name) || trimField(form.email),
email: trimField(form.email),
provider: detail?.provider,
imap_host: trimField(form.imap_host),
imap_port: parsePort(form.imap_port, 993),
imap_tls: form.imap_tls,
smtp_host: trimField(form.smtp_host),
smtp_port: parsePort(form.smtp_port, 587),
smtp_tls: form.smtp_tls,
username: trimField(form.username),
password: form.password || undefined,
}
}
async function runConnectionTest() {
setTestOk(null)
try {
const result = await testMutation.mutateAsync({
imap_host: trimField(form.imap_host),
imap_port: parsePort(form.imap_port, 993),
imap_tls: form.imap_tls,
smtp_host: trimField(form.smtp_host),
smtp_port: parsePort(form.smtp_port, 587),
smtp_tls: form.smtp_tls,
username: trimField(form.username) || undefined,
password: form.password || undefined,
...(isOAuth ? { auth_type: "oauth2" as const } : {}),
})
setTestOk(result.ok)
} catch {
setTestOk(false)
}
}
const canTestConnection =
Boolean(trimField(form.imap_host)) && Boolean(trimField(form.smtp_host))
function handleSave() {
const payload = buildPayload()
if (!isOAuth && form.password && testOk === false) return
updateAccount.mutate(payload, { onSuccess: onCancel })
}
const passwordChanged = Boolean(form.password)
const canSave =
!updateAccount.isPending &&
Boolean(trimField(form.email)) &&
Boolean(trimField(form.imap_host)) &&
Boolean(trimField(form.smtp_host)) &&
(isOAuth || Boolean(trimField(form.username))) &&
(!passwordChanged || testOk !== false)
return (
<div className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
<h3 className="text-sm font-medium">Modifier la connexion</h3>
{isOAuth ? (
<p className="text-xs text-muted-foreground">
Compte connecté via OAuth
{detail?.oauth_provider ? ` (${detail.oauth_provider})` : ""}. Vous pouvez ajuster le
nom et les serveurs ; le mot de passe n&apos;est pas utilisé.
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2 max-w-2xl">
<Field
label="Nom affiché"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
/>
<Field
label="Adresse e-mail"
type="email"
value={form.email}
onChange={(v) => setForm({ ...form, email: v })}
/>
<Field
label="Identifiant"
value={form.username}
onChange={(v) => setForm({ ...form, username: v })}
autoComplete="username"
/>
{!isOAuth ? (
<Field
label="Nouveau mot de passe"
type="password"
value={form.password}
placeholder="Laisser vide pour ne pas changer"
onChange={(v) => {
setTestOk(null)
setForm({ ...form, password: v })
}}
autoComplete="new-password"
/>
) : null}
</div>
<div>
<button
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((v) => !v)}
>
{showAdvanced ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
Paramètres serveur {showAdvanced ? "" : "(avancé)"}
</button>
{showAdvanced ? (
<div className="mt-3 grid gap-3 sm:grid-cols-2 max-w-2xl">
<Field
label="IMAP hôte"
value={form.imap_host}
onChange={(v) => setForm({ ...form, imap_host: v })}
/>
<Field
label="IMAP port"
value={form.imap_port}
onChange={(v) => setForm({ ...form, imap_port: v })}
/>
<Field
label="SMTP hôte"
value={form.smtp_host}
onChange={(v) => setForm({ ...form, smtp_host: v })}
/>
<Field
label="SMTP port"
value={form.smtp_port}
onChange={(v) => setForm({ ...form, smtp_port: v })}
/>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
checked={form.imap_tls}
onChange={(e) => setForm({ ...form, imap_tls: e.target.checked })}
/>
IMAP TLS
</label>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
checked={form.smtp_tls}
onChange={(e) => setForm({ ...form, smtp_tls: e.target.checked })}
/>
SMTP TLS / STARTTLS
</label>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Réception {form.imap_host}:{form.imap_port} · Envoi {form.smtp_host}:{form.smtp_port}
</p>
)}
</div>
<div className="space-y-2">
{testOk === true ? (
<p className="text-sm text-green-600 dark:text-green-500">
Connexion IMAP et SMTP validée.
</p>
) : null}
{testOk === false ? (
<div className="text-sm text-destructive space-y-1">
<p>Échec du test de connexion.</p>
{testMutation.data?.imap_error ? (
<p className="text-xs">IMAP : {testMutation.data.imap_error}</p>
) : null}
{testMutation.data?.smtp_error ? (
<p className="text-xs">SMTP : {testMutation.data.smtp_error}</p>
) : null}
</div>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={testMutation.isPending || !canTestConnection}
onClick={() => void runConnectionTest()}
>
{testMutation.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Test
</>
) : (
"Tester la connexion"
)}
</Button>
{!isOAuth && !passwordChanged ? (
<p className="text-xs text-muted-foreground">
Le mot de passe enregistré est utilisé si le champ ci-dessus est vide.
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={!canSave} onClick={handleSave}>
{updateAccount.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Enregistrement
</>
) : (
"Enregistrer"
)}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>
Annuler
</Button>
</div>
</div>
)
}
function Field({
label,
value,
onChange,
type = "text",
autoComplete,
placeholder,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
autoComplete?: string
placeholder?: string
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs">{label}</Label>
<Input
value={value}
type={type}
autoComplete={autoComplete}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
}

View File

@ -0,0 +1,106 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { cn } from "@/lib/utils"
import {
isMailSettingsNavActive,
MAIL_SETTINGS_NAV,
} from "@/lib/mail-settings/settings-nav"
export function MailSettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<div className="flex h-dvh max-h-dvh flex-col overflow-hidden bg-background">
<header className="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-6">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<Link
href="/mail/inbox"
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Retour à la boîte de réception"
>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-normal text-foreground">
Paramètres Ultimail
</h1>
<p className="text-sm text-muted-foreground">
Configuration du compte, de l&apos;affichage et des automatisations
</p>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<aside className="hidden w-64 shrink-0 overflow-y-auto border-r border-border bg-muted/20 p-3 md:block lg:w-72">
<nav className="space-y-1" aria-label="Sections des paramètres">
{MAIL_SETTINGS_NAV.map((item) => {
const active = isMailSettingsNavActive(pathname, item)
const Icon = item.icon
return (
<Link
key={item.id}
href={item.href}
className={cn(
"flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-accent/50"
)}
>
<Icon className="mt-0.5 size-4 shrink-0 opacity-70" />
<span className="min-w-0">
<span className="block text-sm font-medium">{item.label}</span>
<span className="block text-xs text-muted-foreground">
{item.description}
</span>
</span>
</Link>
)
})}
</nav>
</aside>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<nav
className="shrink-0 border-b border-border bg-muted/20 px-2 py-2 md:hidden"
aria-label="Sections des paramètres"
>
<div className="flex gap-1 overflow-x-auto">
{MAIL_SETTINGS_NAV.map((item) => {
const active = isMailSettingsNavActive(pathname, item)
const Icon = item.icon
return (
<Link
key={item.id}
href={item.href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
className={cn(
"flex shrink-0 items-center rounded-lg transition-colors",
active
? "gap-2 bg-accent px-3 py-2 text-accent-foreground"
: "size-9 justify-center text-foreground hover:bg-accent/50"
)}
>
<Icon className="size-4 shrink-0 opacity-70" />
{active ? (
<span className="text-sm font-medium">{item.label}</span>
) : null}
</Link>
)
})}
</div>
</nav>
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
<div className="mx-auto max-w-3xl">{children}</div>
</main>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,39 @@
"use client"
import {
resolveMailSettingsSection,
type MailSettingsSectionId,
} from "@/lib/mail-settings/settings-nav"
import { DisplaySettingsSection } from "@/components/gmail/settings/sections/display-settings-section"
import { AccountsSettingsSection } from "@/components/gmail/settings/sections/accounts-settings-section"
import { SignaturesSettingsSection } from "@/components/gmail/settings/sections/signatures-settings-section"
import { LabelsFoldersSettingsSection } from "@/components/gmail/settings/sections/labels-folders-settings-section"
import { NotificationsSettingsSection } from "@/components/gmail/settings/sections/notifications-settings-section"
import { AutomationSettingsSection } from "@/components/gmail/settings/sections/automation-settings-section"
const SECTIONS: Record<MailSettingsSectionId, React.ComponentType> = {
display: DisplaySettingsSection,
accounts: AccountsSettingsSection,
signatures: SignaturesSettingsSection,
labels: LabelsFoldersSettingsSection,
notifications: NotificationsSettingsSection,
automation: AutomationSettingsSection,
}
export function MailSettingsSectionView({
sectionId,
}: {
sectionId: MailSettingsSectionId
}) {
const Section = SECTIONS[sectionId]
return <Section />
}
export function MailSettingsSectionFromSegments({
segments,
}: {
segments?: string[]
}) {
const sectionId = resolveMailSettingsSection(segments)
return <MailSettingsSectionView sectionId={sectionId} />
}

View File

@ -0,0 +1,443 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { NavColorPickerTrigger } from "@/components/gmail/nav/nav-color-picker-trigger"
import {
NavMessageVisibilityFields,
NavSidebarVisibilityFields,
} from "@/components/gmail/nav/nav-visibility-fields"
import { useSidebarNav, folderMoveParentOptions } from "@/lib/sidebar-nav-context"
import { normalizeNavColorClass } from "@/lib/nav-color"
import { cn } from "@/lib/utils"
export function NavLabelSettingsCard({
id,
name,
color,
depth = 0,
}: {
id: string
name: string
color: string
depth?: number
}) {
const nav = useSidebarNav()
const prefs = nav.getNavItemPrefs(id)
const colorClass = normalizeNavColorClass(color)
const [renameDraft, setRenameDraft] = useState(name)
const [sublabelName, setSublabelName] = useState("")
useEffect(() => {
setRenameDraft(name)
}, [name])
return (
<NavItemSettingsShell
title={name}
color={colorClass}
depth={depth}
onColorChange={(sw) => nav.updateFolderOrLabelColor(id, sw)}
onDelete={() => nav.removeFolderOrLabelRow(id)}
deleteLabel="Supprimer le libellé"
>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<NavSidebarVisibilityFields
listKind="labels"
value={prefs.sidebar}
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
/>
<NavMessageVisibilityFields
value={prefs.messages}
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs" htmlFor={`rename-label-${id}`}>
Renommer
</Label>
<div className="flex flex-wrap gap-2">
<Input
id={`rename-label-${id}`}
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
className="min-w-[160px] flex-1"
/>
<Button
type="button"
variant="secondary"
disabled={!renameDraft.trim() || renameDraft.trim() === name}
onClick={() => nav.renameFolderOrLabel(id, renameDraft.trim())}
>
Enregistrer
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs" htmlFor={`sublabel-${id}`}>
Ajouter un sous-libellé
</Label>
<div className="flex flex-wrap gap-2">
<Input
id={`sublabel-${id}`}
value={sublabelName}
onChange={(e) => setSublabelName(e.target.value)}
placeholder="Nom du sous-libellé"
className="min-w-[160px] flex-1"
/>
<Button
type="button"
variant="secondary"
disabled={!sublabelName.trim()}
onClick={() => {
nav.addChildLabelRow(id, sublabelName.trim())
setSublabelName("")
}}
>
Ajouter
</Button>
</div>
</div>
</div>
</NavItemSettingsShell>
)
}
export function NavImapFolderSettingsCard({
id,
name,
remoteName,
depth = 0,
}: {
id: string
name: string
remoteName?: string
depth?: number
}) {
const nav = useSidebarNav()
const prefs = nav.getNavItemPrefs(id)
const subtitle =
remoteName && remoteName !== name ? remoteName : undefined
return (
<NavItemSettingsShell
title={name}
subtitle={subtitle}
depth={depth}
hideDelete
hideColor
>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<NavSidebarVisibilityFields
listKind="folders"
value={prefs.sidebar}
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
/>
<NavMessageVisibilityFields
value={prefs.messages}
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
/>
</div>
<p className="text-xs text-muted-foreground">
Dossier synchronisé depuis le serveur mail structure non modifiable ici.
</p>
</div>
</NavItemSettingsShell>
)
}
export function NavFolderSettingsCard({
id,
name,
color,
depth = 0,
}: {
id: string
name: string
color?: string
depth?: number
}) {
const nav = useSidebarNav()
const prefs = nav.getNavItemPrefs(id)
const colorClass = normalizeNavColorClass(color)
const [renameDraft, setRenameDraft] = useState(name)
const [moveParent, setMoveParent] = useState("__root__")
const [subfolderName, setSubfolderName] = useState("")
const moveTargets = useMemo(
() => folderMoveParentOptions(nav.folderTree, id),
[nav.folderTree, id]
)
useEffect(() => {
setRenameDraft(name)
}, [name])
return (
<NavItemSettingsShell
title={name}
color={colorClass}
depth={depth}
onColorChange={(sw) => nav.updateFolderOrLabelColor(id, sw)}
onDelete={() => nav.removeFolderOrLabelRow(id)}
deleteLabel="Supprimer le dossier"
>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<NavSidebarVisibilityFields
listKind="folders"
value={prefs.sidebar}
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
/>
<NavMessageVisibilityFields
value={prefs.messages}
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs" htmlFor={`rename-folder-${id}`}>
Renommer
</Label>
<div className="flex flex-wrap gap-2">
<Input
id={`rename-folder-${id}`}
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
className="min-w-[160px] flex-1"
/>
<Button
type="button"
variant="secondary"
disabled={!renameDraft.trim() || renameDraft.trim() === name}
onClick={() => nav.renameFolderOrLabel(id, renameDraft.trim())}
>
Enregistrer
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Déplacer vers</Label>
<div className="flex flex-wrap gap-2">
<Select value={moveParent} onValueChange={setMoveParent}>
<SelectTrigger className="min-w-[200px] flex-1">
<SelectValue placeholder="Emplacement" />
</SelectTrigger>
<SelectContent>
{moveTargets.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="secondary"
onClick={() =>
nav.moveFolder(id, moveParent === "__root__" ? null : moveParent)
}
>
Déplacer
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs" htmlFor={`subfolder-${id}`}>
Nouveau sous-dossier
</Label>
<div className="flex flex-wrap gap-2">
<Input
id={`subfolder-${id}`}
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="Nom du sous-dossier"
className="min-w-[160px] flex-1"
/>
<Button
type="button"
variant="secondary"
disabled={!subfolderName.trim()}
onClick={() => {
nav.addSubfolder(id, subfolderName.trim())
setSubfolderName("")
}}
>
Créer
</Button>
</div>
</div>
</div>
</NavItemSettingsShell>
)
}
function NavItemSettingsShell({
title,
subtitle,
color,
depth,
onColorChange,
onDelete,
deleteLabel,
hideDelete = false,
hideColor = false,
children,
}: {
title: string
subtitle?: string
color?: string
depth: number
onColorChange?: (swatch: string) => void
onDelete?: () => void
deleteLabel?: string
hideDelete?: boolean
hideColor?: boolean
children: import("react").ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="rounded-lg border border-border bg-card"
>
<div
className="flex items-center gap-1 px-3 py-2"
style={{ paddingLeft: `${depth * 12 + 12}px` }}
>
{!hideColor && color && onColorChange ? (
<NavColorPickerTrigger
value={color}
onChange={onColorChange}
aria-label="Couleur"
/>
) : null}
<CollapsibleTrigger className="flex min-w-0 flex-1 flex-col items-start text-left">
<span className="truncate text-sm font-medium">{title}</span>
{subtitle ? (
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
) : null}
</CollapsibleTrigger>
{!hideDelete && onDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
aria-label={deleteLabel ?? "Supprimer"}
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
<CollapsibleContent className="border-t border-border px-3 py-4">
{children}
</CollapsibleContent>
</Collapsible>
)
}
/** Flatten label rows for settings (parent/child via `/` in name). */
export function flattenLabelRowsForSettings(
rows: { id: string; label: string; color: string }[]
) {
return rows.map((row) => ({
...row,
depth: Math.max(0, row.label.split("/").length - 1),
}))
}
type FolderSettingsNode = {
id: string
label: string
color?: string
children?: FolderSettingsNode[]
}
export function FolderSettingsTree({
nodes,
depth = 0,
}: {
nodes: FolderSettingsNode[]
depth?: number
}) {
return (
<ul className={cn("space-y-2", depth > 0 && "mt-2")}>
{nodes.map((node) => (
<li key={node.id}>
<NavFolderSettingsCard
id={node.id}
name={node.label}
color={node.color}
depth={depth}
/>
{node.children?.length ? (
<FolderSettingsTree nodes={node.children} depth={depth + 1} />
) : null}
</li>
))}
</ul>
)
}
type ImapFolderSettingsNode = {
id: string
label: string
remoteName?: string
children?: ImapFolderSettingsNode[]
}
export function ImapFolderSettingsTree({
nodes,
depth = 0,
}: {
nodes: ImapFolderSettingsNode[]
depth?: number
}) {
return (
<ul className={cn("space-y-2", depth > 0 && "mt-2")}>
{nodes.map((node) => (
<li key={node.id}>
<NavImapFolderSettingsCard
id={node.id}
name={node.label}
remoteName={node.remoteName}
depth={depth}
/>
{node.children?.length ? (
<ImapFolderSettingsTree nodes={node.children} depth={depth + 1} />
) : null}
</li>
))}
</ul>
)
}

View File

@ -0,0 +1,112 @@
"use client"
import { ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const PROTON_BRIDGE_DOWNLOAD = "https://proton.me/mail/bridge"
export function ProtonBridgeWizard({
email,
imapPort,
smtpPort,
bridgePassword,
onBridgePasswordChange,
onImapPortChange,
onSmtpPortChange,
onContinue,
onBack,
}: {
email: string
imapPort: string
smtpPort: string
bridgePassword: string
onBridgePasswordChange: (v: string) => void
onImapPortChange: (v: string) => void
onSmtpPortChange: (v: string) => void
onContinue: () => void
onBack: () => void
}) {
return (
<div className="space-y-4 max-w-xl">
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3 text-sm">
<p className="font-medium">Proton Mail via Bridge</p>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground">
<li>
Installez{" "}
<a
href={PROTON_BRIDGE_DOWNLOAD}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-foreground underline"
>
Proton Mail Bridge
<ExternalLink className="size-3" />
</a>{" "}
sur cet ordinateur.
</li>
<li>
Connectez-vous avec <span className="text-foreground">{email}</span>.
</li>
<li>Copiez le mot de passe Bridge généré pour IMAP/SMTP.</li>
<li>Vérifiez que Bridge écoute sur localhost (ports par défaut ci-dessous).</li>
</ol>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="IMAP port (Bridge)" value={imapPort} onChange={onImapPortChange} />
<Field label="SMTP port (Bridge)" value={smtpPort} onChange={onSmtpPortChange} />
<div className="sm:col-span-2">
<Field
label="Mot de passe Bridge"
type="password"
value={bridgePassword}
onChange={onBridgePasswordChange}
placeholder="Généré par Proton Bridge"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Bridge doit rester ouvert en arrière-plan. Ultimail se connecte à{" "}
<code className="text-foreground">127.0.0.1</code>.
</p>
<div className="flex gap-2">
<Button type="button" disabled={!bridgePassword} onClick={onContinue}>
Tester et continuer
</Button>
<Button type="button" variant="ghost" onClick={onBack}>
Retour
</Button>
</div>
</div>
)
}
function Field({
label,
value,
onChange,
type = "text",
placeholder,
}: {
label: string
value: string
onChange: (v: string) => void
type?: string
placeholder?: string
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs">{label}</Label>
<Input
value={value}
type={type}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
}

View File

@ -0,0 +1,377 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form"
import { EditMailAccountForm } from "@/components/gmail/settings/edit-mail-account-form"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import {
useCreateMailAccount,
useDeleteMailAccount,
useResanitizeBodies,
useSyncMailAccount,
} from "@/lib/api/hooks/use-mail-account-mutations"
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
import {
useCreateIdentity,
useUpdateIdentity,
useDeleteIdentity,
} from "@/lib/api/hooks/use-identity-mutations"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import type { ApiMailAccount } from "@/lib/api/types"
export function AccountsSettingsSection() {
const router = useRouter()
const searchParams = useSearchParams()
const oauthStatus = searchParams.get("oauth")
const { ready, authenticated } = useAuthReady()
const { data: accounts = [], isFetching, isError, refetch, isPending } = useMailAccounts()
const createAccount = useCreateMailAccount()
const showInitialLoad = ready && authenticated && isPending && accounts.length === 0
useEffect(() => {
if (oauthStatus === "success") {
void refetch()
router.replace("/mail/settings/accounts")
}
}, [oauthStatus, refetch, router])
return (
<>
<SettingsSectionHeader
title="Comptes mail"
description="Connexions IMAP/SMTP et identités d'envoi par compte."
/>
{oauthStatus === "success" ? (
<p className="text-sm text-green-600 dark:text-green-500">
Compte mail connecté via OAuth.
</p>
) : null}
{oauthStatus === "error" ? (
<p className="text-sm text-destructive">
Échec de la connexion OAuth
{searchParams.get("code") ? ` (${searchParams.get("code")})` : ""}.
</p>
) : null}
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="space-y-6">
<AddMailAccountForm
pending={createAccount.isPending}
onSubmit={(payload) => createAccount.mutate(payload)}
/>
{showInitialLoad ? null : accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucun compte mail configuré. Ajoutez votre adresse e-mail ci-dessus pour commencer.
</p>
) : (
accounts.map((account) => <AccountCard key={account.id} account={account} />)
)}
</div>
</>
)
}
function AccountCard({ account }: { account: ApiMailAccount }) {
const deleteAccount = useDeleteMailAccount()
const resanitizeBodies = useResanitizeBodies(account.id)
const syncAccount = useSyncMailAccount(account.id)
const { data: identities = [] } = useIdentities(account.id)
const [editing, setEditing] = useState(false)
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null)
async function runResanitize() {
setMaintenanceMessage(null)
try {
const result = await resanitizeBodies.mutateAsync()
setMaintenanceMessage(
`HTML re-sanitisé : ${result.updated} message(s) mis à jour sur ${result.scanned} analysé(s).`
)
} catch {
setMaintenanceMessage("Échec de la re-sanitisation du HTML.")
}
}
async function runSync() {
setMaintenanceMessage(null)
try {
await syncAccount.mutateAsync()
setMaintenanceMessage("Synchronisation IMAP terminée.")
} catch {
setMaintenanceMessage("Échec de la synchronisation IMAP.")
}
}
const maintenancePending = resanitizeBodies.isPending || syncAccount.isPending
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base">{account.name}</CardTitle>
<CardDescription>{account.email}</CardDescription>
<p className="mt-1 text-xs text-muted-foreground">
IMAP {account.imap_host} · SMTP {account.smtp_host}
{account.last_sync_at
? ` · Dernière sync : ${new Date(account.last_sync_at).toLocaleString("fr-FR")}`
: null}
</p>
</div>
<div className="flex shrink-0 gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Actions avancées du compte"
disabled={maintenancePending}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={maintenancePending}
onClick={() => void runResanitize()}
>
{resanitizeBodies.isPending
? "Re-sanitisation…"
: "Re-sanitiser le HTML"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={maintenancePending}
onClick={() => void runSync()}
>
{syncAccount.isPending ? "Synchronisation…" : "Synchroniser IMAP"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Modifier le compte"
aria-pressed={editing}
onClick={() => setEditing((v) => !v)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer le compte"
onClick={() => deleteAccount.mutate(account.id)}
>
<Trash2 className="size-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{maintenanceMessage ? (
<p className="text-sm text-muted-foreground">{maintenanceMessage}</p>
) : null}
{editing ? (
<EditMailAccountForm account={account} onCancel={() => setEditing(false)} />
) : null}
<IdentitiesBlock
accountId={account.id}
accountEmail={account.email}
identities={identities}
/>
</CardContent>
</Card>
)
}
function IdentitiesBlock({
accountId,
accountEmail,
identities,
}: {
accountId: string
accountEmail: string
identities: Array<{
id: string
email: string
name: string
is_default: boolean
signature_html?: string
default_signature_id?: string
reply_to_addrs?: string[]
}>
}) {
const createIdentity = useCreateIdentity(accountId)
const updateIdentity = useUpdateIdentity(accountId)
const deleteIdentity = useDeleteIdentity(accountId)
const [showAddForm, setShowAddForm] = useState(false)
const [newIdentity, setNewIdentity] = useState({ email: accountEmail, name: "" })
useEffect(() => {
if (!showAddForm) {
setNewIdentity({ email: accountEmail, name: "" })
}
}, [accountEmail, showAddForm])
function identityPayload(
identity: (typeof identities)[number],
patch: Partial<{
email: string
name: string
is_default: boolean
}> = {}
) {
return {
identityId: identity.id,
email: patch.email ?? identity.email,
name: patch.name ?? identity.name,
is_default: patch.is_default ?? identity.is_default,
signature_html: identity.signature_html ?? "",
default_signature_id: identity.default_signature_id ?? "",
reply_to_addrs: identity.reply_to_addrs,
}
}
function handleCreateIdentity() {
const email = newIdentity.email.trim()
const name = newIdentity.name.trim()
if (!email) return
createIdentity.mutate(
{
email,
name: name || email.split("@")[0] || "Identité",
is_default: identities.length === 0,
},
{
onSuccess: () => {
setShowAddForm(false)
setNewIdentity({ email: accountEmail, name: "" })
},
}
)
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium">Identités d&apos;envoi</h3>
{identities.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune identité configurée.</p>
) : (
<ul className="space-y-3">
{identities.map((identity) => (
<li key={identity.id} className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="grid flex-1 gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Nom affiché</Label>
<Input
defaultValue={identity.name}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === identity.name) return
updateIdentity.mutate(identityPayload(identity, { name: next }))
}}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Adresse d&apos;envoi</Label>
<Input
type="email"
defaultValue={identity.email}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === identity.email) return
updateIdentity.mutate(identityPayload(identity, { email: next }))
}}
/>
</div>
{identity.is_default ? (
<p className="text-xs text-muted-foreground sm:col-span-2">
Identité par défaut
</p>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer l'identité"
onClick={() => deleteIdentity.mutate(identity.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
{showAddForm ? (
<div className="rounded-lg border border-border p-3 space-y-3 max-w-lg">
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Nom affiché</Label>
<Input
value={newIdentity.name}
placeholder="Nom visible"
onChange={(e) => setNewIdentity({ ...newIdentity, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Adresse d&apos;envoi</Label>
<Input
type="email"
value={newIdentity.email}
onChange={(e) => setNewIdentity({ ...newIdentity, email: e.target.value })}
/>
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={createIdentity.isPending || !newIdentity.email.trim()}
onClick={handleCreateIdentity}
>
Créer l&apos;identité
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
Annuler
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={createIdentity.isPending}
onClick={() => setShowAddForm(true)}
>
Ajouter une identité
</Button>
)}
</div>
)
}

View File

@ -0,0 +1,45 @@
"use client"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsComingSoon } from "@/components/gmail/settings/settings-coming-soon"
import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel"
import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
export function AutomationSettingsSection() {
return (
<>
<SettingsSectionHeader
title="Automatisations"
description="Règles graphiques de tri, webhooks, fonctions réutilisables et variables d'exécution."
/>
<Tabs defaultValue="rules">
<TabsList className="flex h-auto flex-wrap">
<TabsTrigger value="rules">Règles</TabsTrigger>
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
<TabsTrigger value="llm">Fournisseurs LLM</TabsTrigger>
<TabsTrigger value="tokens">Tokens API</TabsTrigger>
</TabsList>
<TabsContent value="rules" className="mt-4">
<AutomationRulesPanel />
</TabsContent>
<TabsContent value="webhooks" className="mt-4">
<WebhooksPanel />
</TabsContent>
<TabsContent value="llm" className="mt-4">
<SettingsComingSoon
title="Tri par LLM"
description="Configurez des fournisseurs OpenAI-compatibles. Les nœuds LLM utilisent un heuristique en attendant le branchement complet."
/>
</TabsContent>
<TabsContent value="tokens" className="mt-4">
<SettingsComingSoon
title="Tokens API agents"
description="Créez des jetons fine-grained pour agents IA (lecture partielle, envoi, catégorisation)."
/>
</TabsContent>
</Tabs>
</>
)
}

View File

@ -0,0 +1,21 @@
"use client"
import { useMailSettings } from "@/lib/api/hooks/use-mail-settings"
import { MailSettingsFields } from "@/components/gmail/mail-settings-fields"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
export function DisplaySettingsSection() {
const { isFetching, isError, refetch } = useMailSettings()
return (
<>
<SettingsSectionHeader
title="Réglages d'affichage"
description="Densité, thème, type de boîte de réception et volet de lecture."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<MailSettingsFields variant="page" />
</>
)
}

View File

@ -0,0 +1,370 @@
"use client"
import { useMemo, useState, type ReactNode } from "react"
import { Folder, Tag, type LucideIcon } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { useLabels } from "@/lib/api/hooks/use-folder-label-queries"
import { useCreateUnifiedFolder, useUnifiedFolders } from "@/lib/api/hooks/use-unified-folder-queries"
import { useImapFoldersForAccount } from "@/lib/api/hooks/use-imap-folders"
import { buildImapFolderSettingsTree } from "@/lib/mail-settings/imap-folder-tree"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { buildFolderTreeFromUnified } from "@/lib/mail-settings/unified-folder-tree"
import { useSidebarNav } from "@/lib/sidebar-nav-context"
import { isSystemNavLabelId } from "@/lib/sidebar-nav-data"
import { NavColorPickerTrigger } from "@/components/gmail/nav/nav-color-picker-trigger"
import { DEFAULT_NAV_COLOR } from "@/lib/nav-color"
import {
flattenLabelRowsForSettings,
FolderSettingsTree,
ImapFolderSettingsTree,
NavLabelSettingsCard,
} from "@/components/gmail/settings/nav-item-settings-card"
function SettingsFormHeading({
icon: Icon,
children,
}: {
icon: LucideIcon
children: ReactNode
}) {
return (
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Icon className="size-4 shrink-0 opacity-70" aria-hidden />
{children}
</div>
)
}
function NavSettingsListPanel({
title,
icon: Icon,
loading,
emptyTitle,
emptyDescription,
children,
}: {
title: string
icon?: LucideIcon
loading: boolean
emptyTitle: string
emptyDescription?: string
children?: ReactNode
}) {
return (
<section className="space-y-3">
<div className="flex items-center gap-2">
{Icon ? <Icon className="size-4 shrink-0 opacity-70" aria-hidden /> : null}
<h3 className="text-sm font-medium text-foreground">{title}</h3>
</div>
{loading ? (
<div
className="min-h-[120px] rounded-lg border border-border bg-muted/20"
aria-hidden
/>
) : children ? (
children
) : (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-border bg-muted/10 px-4 py-8 text-center">
<p className="text-sm text-foreground">{emptyTitle}</p>
{emptyDescription ? (
<p className="max-w-sm text-xs text-muted-foreground">{emptyDescription}</p>
) : null}
</div>
)}
</section>
)
}
export function LabelsFoldersSettingsSection() {
return (
<>
<SettingsSectionHeader
title="Libellés et dossiers"
description="Mêmes réglages que dans la barre latérale : couleur, affichage dans les listes, arborescence, renommage."
/>
<Tabs defaultValue="labels">
<TabsList>
<TabsTrigger value="labels">Libellés</TabsTrigger>
<TabsTrigger value="folders-global">Dossiers globaux</TabsTrigger>
<TabsTrigger value="folders-account">Dossiers par compte</TabsTrigger>
</TabsList>
<TabsContent value="labels" className="mt-4">
<LabelsPanel />
</TabsContent>
<TabsContent value="folders-global" className="mt-4">
<UnifiedFoldersPanel />
</TabsContent>
<TabsContent value="folders-account" className="mt-4">
<ImapAccountFoldersPanel />
</TabsContent>
</Tabs>
</>
)
}
function LabelsPanel() {
const { ready, authenticated } = useAuthReady()
const nav = useSidebarNav()
const { isPending } = useLabels()
const [name, setName] = useState("")
const [color, setColor] = useState(DEFAULT_NAV_COLOR)
const userLabels = useMemo(
() =>
flattenLabelRowsForSettings(
nav.labelRows.filter((row) => !isSystemNavLabelId(row.id))
),
[nav.labelRows]
)
const listLoading = ready && authenticated && isPending && userLabels.length === 0
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Cliquez sur un libellé pour modifier couleur, visibilité et sous-libellés comme le menu
clic droit dans la barre latérale.
</p>
<div className="rounded-lg border border-border p-4 space-y-3">
<SettingsFormHeading icon={Tag}>Nouveau libellé</SettingsFormHeading>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[160px] flex-1 space-y-1">
<Label className="text-xs" htmlFor="new-label-name">
Nom
</Label>
<div className="flex items-center gap-1">
<NavColorPickerTrigger
value={color}
onChange={setColor}
aria-label="Couleur du libellé"
/>
<Input
id="new-label-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1"
/>
</div>
</div>
<Button
type="button"
className="shrink-0"
disabled={!name.trim()}
onClick={() => {
nav.addLabelRowFromSidebar(name.trim(), color)
setName("")
}}
>
Créer
</Button>
</div>
</div>
<NavSettingsListPanel
title="Vos libellés"
icon={Tag}
loading={listLoading}
emptyTitle="Aucun libellé personnalisé"
emptyDescription="Utilisez le formulaire ci-dessus pour en créer un."
>
{userLabels.length > 0 ? (
<ul className="space-y-2">
{userLabels.map((row) => (
<li key={row.id}>
<NavLabelSettingsCard
id={row.id}
name={row.label}
color={row.color}
depth={row.depth}
/>
</li>
))}
</ul>
) : null}
</NavSettingsListPanel>
</div>
)
}
function UnifiedFoldersPanel() {
const { ready, authenticated } = useAuthReady()
const createFolder = useCreateUnifiedFolder()
const { data: folders = [], isFetching, isError, refetch, isPending } =
useUnifiedFolders("global")
const [name, setName] = useState("")
const [color, setColor] = useState(DEFAULT_NAV_COLOR)
const [parentId, setParentId] = useState<string>("__root__")
const visible = folders.filter((f) => f.scope === "global")
const tree = useMemo(() => buildFolderTreeFromUnified(visible), [visible])
const parentOptions = useMemo(() => {
const opts: { value: string; label: string }[] = [{ value: "__root__", label: "Racine" }]
const walk = (nodes: typeof tree, depth: number) => {
for (const n of nodes) {
opts.push({
value: n.id,
label: `${"\u2003".repeat(depth * 2)}${n.label}`,
})
if (n.children?.length) walk(n.children, depth + 1)
}
}
walk(tree, 0)
return opts
}, [tree])
const listLoading = ready && authenticated && isPending && visible.length === 0
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Dossiers Ultimail globaux organisation virtuelle cross-comptes.
</p>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="rounded-lg border border-border p-4 space-y-3">
<SettingsFormHeading icon={Folder}>Nouveau dossier</SettingsFormHeading>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[160px] flex-1 space-y-1">
<Label className="text-xs" htmlFor="new-folder-name-global">
Nom
</Label>
<div className="flex items-center gap-1">
<NavColorPickerTrigger
value={color}
onChange={setColor}
aria-label="Couleur du dossier"
/>
<Input
id="new-folder-name-global"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1"
/>
</div>
</div>
<div className="min-w-[200px] flex-1 space-y-1">
<Label className="text-xs">Emplacement</Label>
<Select value={parentId} onValueChange={setParentId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{parentOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
className="shrink-0"
disabled={!name.trim() || createFolder.isPending}
onClick={() => {
const parent = parentId === "__root__" ? undefined : parentId
createFolder.mutate({
name: name.trim(),
color,
parent_id: parent,
})
setName("")
}}
>
Créer
</Button>
</div>
</div>
<NavSettingsListPanel
title="Vos dossiers"
icon={Folder}
loading={listLoading}
emptyTitle="Aucun dossier Ultimail"
emptyDescription="Utilisez le formulaire ci-dessus pour en créer un."
>
{visible.length > 0 ? <FolderSettingsTree nodes={tree} /> : null}
</NavSettingsListPanel>
</div>
)
}
function ImapAccountFoldersPanel() {
const { ready, authenticated } = useAuthReady()
const { data: accounts = [] } = useMailAccounts()
const [accountId, setAccountId] = useState("")
const selectedAccountId = accountId || accounts[0]?.id
const {
data: folders = [],
isFetching,
isError,
refetch,
isPending,
} = useImapFoldersForAccount(selectedAccountId)
const tree = useMemo(
() =>
selectedAccountId
? buildImapFolderSettingsTree(folders, selectedAccountId)
: [],
[folders, selectedAccountId]
)
const listLoading = ready && authenticated && isPending && folders.length === 0
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Dossiers synchronisés depuis vos serveurs mail (IMAP). Masquez ceux que vous ne voulez
pas voir dans la barre latérale.
</p>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="max-w-xs space-y-1">
<Label className="text-xs">Compte mail</Label>
<Select
value={selectedAccountId ?? ""}
onValueChange={setAccountId}
disabled={accounts.length === 0}
>
<SelectTrigger>
<SelectValue placeholder="Choisir un compte" />
</SelectTrigger>
<SelectContent>
{accounts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<NavSettingsListPanel
title="Dossiers IMAP"
icon={Folder}
loading={listLoading}
emptyTitle="Aucun dossier IMAP personnalisé"
emptyDescription="Les dossiers système (Boîte de réception, Envoyés…) restent dans la navigation principale. Les dossiers personnalisés apparaissent ici après synchronisation."
>
{tree.length > 0 ? <ImapFolderSettingsTree nodes={tree} /> : null}
</NavSettingsListPanel>
</div>
)
}

View File

@ -0,0 +1,80 @@
"use client"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useMailSettings } from "@/lib/api/hooks/use-mail-settings"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { requestDesktopNotificationPermission } from "@/lib/notifications/desktop-notifications"
export function NotificationsSettingsSection() {
const { isFetching, isError, refetch } = useMailSettings()
const desktopNewMail = useMailSettingsStore((s) => s.desktopNewMail)
const desktopMentions = useMailSettingsStore((s) => s.desktopMentions)
const emailDigest = useMailSettingsStore((s) => s.emailDigest)
const soundEnabled = useMailSettingsStore((s) => s.soundEnabled)
const setDesktopNewMail = useMailSettingsStore((s) => s.setDesktopNewMail)
const setDesktopMentions = useMailSettingsStore((s) => s.setDesktopMentions)
const setEmailDigest = useMailSettingsStore((s) => s.setEmailDigest)
const setSoundEnabled = useMailSettingsStore((s) => s.setSoundEnabled)
return (
<>
<SettingsSectionHeader
title="Notifications"
description="Préférences d'alertes synchronisées avec votre compte Ultimail."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="space-y-4">
<ToggleRow
label="Nouveaux messages (bureau)"
checked={desktopNewMail}
onChange={async (checked) => {
if (checked) await requestDesktopNotificationPermission()
setDesktopNewMail(checked)
}}
/>
<ToggleRow
label="Mentions et réponses"
checked={desktopMentions}
onChange={async (checked) => {
if (checked) await requestDesktopNotificationPermission()
setDesktopMentions(checked)
}}
/>
<ToggleRow
label="Résumé quotidien par e-mail"
checked={emailDigest}
onChange={setEmailDigest}
/>
<ToggleRow
label="Son de notification"
checked={soundEnabled}
onChange={setSoundEnabled}
/>
</div>
</>
)
}
function ToggleRow({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (checked: boolean) => void | Promise<void>
}) {
return (
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-lg border border-border px-4 py-3">
<span className="text-sm text-foreground">{label}</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="size-4 accent-[#1a73e8]"
/>
</label>
)
}

View File

@ -0,0 +1,378 @@
"use client"
import { useMemo, useState } from "react"
import { PenLine, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
import {
useCreateMailSignature,
useDeleteMailSignature,
useMailSignatures,
useUpdateMailSignature,
} from "@/lib/api/hooks/use-mail-signatures"
import { useUpdateIdentity } from "@/lib/api/hooks/use-identity-mutations"
import type { ApiIdentity, ApiMailSignature } from "@/lib/api/types"
const NONE_SIGNATURE = "__none__"
export function SignaturesSettingsSection() {
const { ready, authenticated } = useAuthReady()
const {
data: signatures = [],
isFetching,
isError,
refetch,
isPending,
} = useMailSignatures()
const showInitialLoad = ready && authenticated && isPending && signatures.length === 0
return (
<>
<SettingsSectionHeader
title="Signatures"
description="Bibliothèque de signatures réutilisables et attribution par identité d'envoi."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="space-y-6">
<SignatureLibrary
signatures={signatures}
showInitialLoad={showInitialLoad}
/>
<IdentitySignatureAssignments signatures={signatures} />
</div>
</>
)
}
function SignatureLibrary({
signatures,
showInitialLoad,
}: {
signatures: ApiMailSignature[]
showInitialLoad: boolean
}) {
const createSignature = useCreateMailSignature()
const updateSignature = useUpdateMailSignature()
const deleteSignature = useDeleteMailSignature()
const [showAddForm, setShowAddForm] = useState(false)
const [draft, setDraft] = useState({ name: "", html: "" })
function handleCreate() {
const name = draft.name.trim()
if (!name) return
createSignature.mutate(
{ name, html: draft.html },
{
onSuccess: () => {
setShowAddForm(false)
setDraft({ name: "", html: "" })
},
}
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<PenLine className="size-4" />
Bibliothèque
</CardTitle>
<CardDescription>
Créez des signatures nommées que vous pourrez réutiliser sur plusieurs identités.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{showInitialLoad ? null : signatures.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune signature enregistrée.</p>
) : (
<ul className="space-y-3">
{signatures.map((signature) => (
<li
key={signature.id}
className="rounded-lg border border-border p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-2">
<div className="space-y-1">
<Label className="text-xs">Nom</Label>
<Input
defaultValue={signature.name}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === signature.name) return
updateSignature.mutate({
signatureId: signature.id,
name: next,
html: signature.html,
})
}}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer la signature"
onClick={() => deleteSignature.mutate(signature.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<div className="space-y-1">
<Label className="text-xs">Contenu HTML</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
defaultValue={signature.html}
placeholder="<div>…</div>"
onBlur={(e) => {
if (e.target.value === signature.html) return
updateSignature.mutate({
signatureId: signature.id,
name: signature.name,
html: e.target.value,
})
}}
/>
</div>
{signature.html?.trim() ? (
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
<p className="mb-2 text-xs text-muted-foreground">Aperçu</p>
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: signature.html }}
/>
</div>
) : null}
</li>
))}
</ul>
)}
{showAddForm ? (
<div className="rounded-lg border border-border p-3 space-y-3 max-w-2xl">
<div className="space-y-1">
<Label className="text-xs">Nom</Label>
<Input
value={draft.name}
placeholder="Professionnelle"
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Contenu HTML</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
value={draft.html}
placeholder="<div style=&quot;color:#5f6368&quot;>…</div>"
onChange={(e) => setDraft({ ...draft, html: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={createSignature.isPending || !draft.name.trim()}
onClick={handleCreate}
>
Enregistrer
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setShowAddForm(false)
setDraft({ name: "", html: "" })
}}
>
Annuler
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowAddForm(true)}
>
<Plus className="size-3.5 mr-1.5" />
Ajouter une signature
</Button>
)}
</CardContent>
</Card>
)
}
function IdentitySignatureAssignments({
signatures,
}: {
signatures: ApiMailSignature[]
}) {
const { data: accounts = [] } = useMailAccounts()
if (accounts.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Attribution par identité</CardTitle>
<CardDescription>
Ajoutez un compte mail pour configurer les signatures par défaut.
</CardDescription>
</CardHeader>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Attribution par identité</CardTitle>
<CardDescription>
Choisissez la signature insérée par défaut pour chaque adresse d&apos;envoi.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{accounts.map((account) => (
<AccountIdentitySignatures
key={account.id}
accountId={account.id}
accountLabel={`${account.name} · ${account.email}`}
signatures={signatures}
/>
))}
</CardContent>
</Card>
)
}
function AccountIdentitySignatures({
accountId,
accountLabel,
signatures,
}: {
accountId: string
accountLabel: string
signatures: ApiMailSignature[]
}) {
const { data: identities = [] } = useIdentities(accountId)
const updateIdentity = useUpdateIdentity(accountId)
const signatureOptions = useMemo(
() => [
{ value: NONE_SIGNATURE, label: "Aucune" },
...signatures.map((s) => ({ value: s.id, label: s.name })),
],
[signatures]
)
if (identities.length === 0) {
return (
<div className="space-y-1">
<p className="text-sm font-medium">{accountLabel}</p>
<p className="text-xs text-muted-foreground">Aucune identité d&apos;envoi.</p>
</div>
)
}
return (
<div className="space-y-3">
<p className="text-sm font-medium">{accountLabel}</p>
<ul className="space-y-2">
{identities.map((identity) => (
<IdentitySignatureRow
key={identity.id}
identity={identity}
options={signatureOptions}
pending={updateIdentity.isPending}
onAssign={(defaultSignatureId) =>
updateIdentity.mutate({
identityId: identity.id,
email: identity.email,
name: identity.name,
is_default: identity.is_default,
signature_html: identity.signature_html ?? "",
default_signature_id: defaultSignatureId,
reply_to_addrs: identity.reply_to_addrs,
})
}
/>
))}
</ul>
</div>
)
}
function IdentitySignatureRow({
identity,
options,
pending,
onAssign,
}: {
identity: ApiIdentity
options: Array<{ value: string; label: string }>
pending: boolean
onAssign: (defaultSignatureId: string) => void
}) {
const current =
identity.default_signature_id && identity.default_signature_id !== ""
? identity.default_signature_id
: NONE_SIGNATURE
return (
<li className="flex flex-col gap-2 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{identity.name}</p>
<p className="text-xs text-muted-foreground truncate">{identity.email}</p>
{identity.is_default ? (
<p className="text-xs text-muted-foreground mt-0.5">Identité par défaut</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2 sm:w-64">
<Label className="text-xs sr-only">Signature par défaut</Label>
<Select
value={current}
disabled={pending}
onValueChange={(value) =>
onAssign(value === NONE_SIGNATURE ? "" : value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Signature" />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</li>
)
}

View File

@ -0,0 +1,20 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export function SettingsComingSoon({
title,
description,
}: {
title: string
description: string
}) {
return (
<Card className="border-dashed bg-muted/20 py-4 shadow-none">
<CardHeader className="px-4 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader>
<CardContent className="px-4 pt-0 text-sm text-muted-foreground">
{description}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,16 @@
export function SettingsSectionHeader({
title,
description,
}: {
title: string
description?: string
}) {
return (
<header className="mb-6">
<h2 className="text-lg font-medium text-foreground">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
) : null}
</header>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import { Button } from "@/components/ui/button"
import { useAuthReady } from "@/lib/api/use-auth-ready"
export function SettingsSyncBanner({
isFetching,
isError,
onRetry,
}: {
isFetching?: boolean
isError?: boolean
onRetry?: () => void
}) {
const { ready, authenticated } = useAuthReady()
if (!ready || isFetching) {
return null
}
if (!authenticated) {
return (
<p className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
Connectez-vous pour synchroniser avec le serveur. Les réglages locaux restent
disponibles hors ligne.
</p>
)
}
if (isError) {
return <SyncErrorBanner onRetry={onRetry} />
}
return null
}
function SyncErrorBanner({ onRetry }: { onRetry?: () => void }) {
return (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<span>Échec de synchronisation avec le serveur.</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" className="h-7" onClick={onRetry}>
Réessayer
</Button>
) : null}
</div>
)
}

View File

@ -14,7 +14,6 @@ import { useEmailDropTarget } from "@/lib/drag-context"
import {
MAIL_SIDEBAR_BLUR_SURFACE_CLASS,
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
@ -77,6 +76,8 @@ import {
mailSidebarFolderBranchStickyTopPx,
mailSidebarFolderBranchStickyZ,
} from "@/components/gmail/sidebar/sidebar-nav-constants"
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
import { normalizeNavColorClass } from "@/lib/nav-color"
import {
SidebarNavOptionsSheet,
SidebarNavSheetAction,
@ -151,7 +152,7 @@ export function SidebarFolderRowExpanded({
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
const hasChildren = !!(node.children?.length)
const isBranchOpen = expandedFolderIds.has(node.id)
const dotClass = node.color ?? "bg-gray-400"
const dotClass = normalizeNavColorClass(node.color)
const isSelected = selectedFolder === node.id
const unread = folderUnreadCounts[node.id] ?? 0
const hasUnread = unread > 0
@ -223,26 +224,15 @@ export function SidebarFolderRowExpanded({
<span className="flex-1 text-left text-sm">Couleur du dossier</span>
</SubTr>
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
<div className="grid grid-cols-6 gap-1.5">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button
key={sw}
type="button"
title={sw}
onClick={() => {
updateFolderOrLabelColor(node.id, sw)
setMenuOpen(false)
}}
className={cn(
cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
),
sw
)}
/>
))}
</div>
<NavColorPicker
variant="menu"
value={dotClass}
swatches={LABEL_MENU_COLOR_SWATCHES}
onChange={(sw) => {
updateFolderOrLabelColor(node.id, sw)
setMenuOpen(false)
}}
/>
</SubCo>
</Sub>
)

View File

@ -6,7 +6,6 @@ import { cn } from "@/lib/utils"
import { useEmailDropTarget } from "@/lib/drag-context"
import {
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
@ -56,6 +55,8 @@ import {
setSidebarNavDragData,
} from "@/lib/sidebar-nav-dnd"
import { LABEL_MENU_COLOR_SWATCHES } from "@/components/gmail/sidebar/sidebar-nav-constants"
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
import { normalizeNavColorClass } from "@/lib/nav-color"
import {
SidebarNavOptionsSheet,
SidebarNavSheetAction,
@ -146,7 +147,7 @@ export function SidebarLabelItemRow({
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400"
const labelDotClass = normalizeNavColorClass(item.color)
const labelMenuSurface =
MAIL_SIDEBAR_MENU_SURFACE_CLASS
@ -176,26 +177,15 @@ export function SidebarLabelItemRow({
<span className="flex-1 text-left text-sm">Couleur du libellé</span>
</SubTr>
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
<div className="grid grid-cols-6 gap-1.5">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button
key={sw}
type="button"
title={sw}
onClick={() => {
updateFolderOrLabelColor(item.id, sw)
setMenuOpen(false)
}}
className={cn(
cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
),
sw
)}
/>
))}
</div>
<NavColorPicker
variant="menu"
value={labelDotClass}
swatches={LABEL_MENU_COLOR_SWATCHES}
onChange={(sw) => {
updateFolderOrLabelColor(item.id, sw)
setMenuOpen(false)
}}
/>
</SubCo>
</Sub>
)

View File

@ -10,6 +10,8 @@ import {
} from "@/components/ui/sheet"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { NavColorDot } from "@/components/gmail/nav/nav-color-dot"
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
const sheetContentClass =
"max-h-[min(85vh,560px)] gap-0 overflow-hidden rounded-t-2xl border-[#dadce0] px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-0 select-none left-1/2 right-auto w-[calc(100%-2rem)] max-w-md -translate-x-1/2 sm:max-w-lg"
@ -89,27 +91,16 @@ export function SidebarNavSheetColorPicker({
<div className="px-4 py-2">
<div className="mb-2 flex items-center gap-2 text-sm text-[#3c4043]">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white">
<span
className={cn("block size-3 rounded-sm border border-black/10", dotClass)}
aria-hidden
/>
<NavColorDot color={dotClass} />
</span>
{title}
</div>
<div className="grid grid-cols-6 gap-1.5">
{swatches.map((sw) => (
<button
key={sw}
type="button"
title={sw}
onClick={() => onPick(sw)}
className={cn(
"size-8 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-gray-400 focus-visible:ring-2 focus-visible:ring-gray-500",
sw
)}
/>
))}
</div>
<NavColorPicker
variant="sheet"
value={dotClass}
swatches={swatches}
onChange={onPick}
/>
</div>
)
}
@ -136,12 +127,7 @@ export function SidebarNavOptionsSheet({
className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white"
aria-hidden
>
<span
className={cn(
"block size-3 rounded-sm border border-black/10",
colorDotClass
)}
/>
<NavColorDot color={colorDotClass} />
</span>
) : null}
<SheetTitle className="min-w-0 flex-1 truncate text-left text-base font-medium leading-5 text-[#3c4043]">

View File

@ -6,6 +6,23 @@ import {
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
export function ThemeProvider({
children,
scriptProps,
...props
}: ThemeProviderProps) {
// next-themes renders an inline <script> to prevent theme flicker.
// React 19 warns about script tags inside client components on hydration.
// SSR keeps the default executable script; the client uses a non-executable
// type so React does not warn (the blocking script already ran from HTML).
const resolvedScriptProps =
typeof window === 'undefined'
? scriptProps
: { ...scriptProps, type: 'application/json' as const }
return (
<NextThemesProvider {...props} scriptProps={resolvedScriptProps}>
{children}
</NextThemesProvider>
)
}

View File

@ -21,11 +21,7 @@ function TooltipProvider({
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({

View File

@ -5,14 +5,15 @@ import { cn } from "@/lib/utils"
type UltiMailLogoProps = {
className?: string
/** `horizontal` = picto source + « Ultimail » (lisible, aligné barre). `mark` = picto seul (launcher). */
variant?: "horizontal" | "mark"
/** `horizontal` = picto + texte. `mark` = picto seul. `stacked` = wordmark empilé. */
variant?: "horizontal" | "mark" | "stacked"
/** Lien au clic ; `null` = pas de lien. Défaut : boîte de réception. */
href?: string | null
}
/** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */
const HEADER_ICON = "/brand/ultimail-header-icon.png"
const STACKED_WORDMARK = "/brand/ultimail-wordmark-stacked.png"
const DEFAULT_INBOX_HREF = "/mail/inbox"
export function UltiMailLogo({
@ -53,6 +54,38 @@ export function UltiMailLogo({
)
}
if (variant === "stacked") {
const stacked = (
<div className="flex h-[6.25rem] items-center justify-center overflow-hidden sm:h-[6.75rem]">
<img
src={STACKED_WORDMARK}
alt="Ultimail"
width={320}
height={320}
draggable={false}
className="h-[11.25rem] w-auto max-w-none shrink-0 object-contain select-none sm:h-[12rem]"
/>
</div>
)
if (href === null) {
return <div className={cn("flex justify-center", className)}>{stacked}</div>
}
return (
<Link
href={href}
className={cn(
"flex justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
className
)}
aria-label="Ultimail — Boîte de réception"
>
{stacked}
</Link>
)
}
const body = (
<div
role="img"

View File

@ -20,7 +20,14 @@ export function useMailRoute() {
const pathname = usePathname()
const currentSearchParams = useSearchParams()
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
const route = useMemo(() => parseMailSegments(segments), [segments])
const route = useMemo(() => {
const parsed = parseMailSegments(segments)
const queryMailId = currentSearchParams.get("message")?.trim()
if (!parsed.mailId && queryMailId) {
return { ...parsed, mailId: queryMailId }
}
return parsed
}, [segments, currentSearchParams])
const navigateRoute = useCallback(
(patch: Partial<MailRouteState>) => {
@ -34,8 +41,11 @@ export function useMailRoute() {
mailId: patch.mailId !== undefined ? patch.mailId : route.mailId,
}
let url = buildMailPath(next)
if (next.folderId === "search" && currentSearchParams.toString()) {
url += `?${currentSearchParams.toString()}`
if (next.folderId === "search") {
const qs = new URLSearchParams(currentSearchParams.toString())
qs.delete("message")
const q = qs.toString()
if (q) url += `?${q}`
}
router.push(url, { scroll: false })
},

View File

@ -6,6 +6,7 @@ import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type { ReadingPaneMode } from "@/lib/mail-settings/types"
const MD_MQ = `(min-width: ${MD_MIN_PX}px)`
const LG_MQ = "(min-width: 1024px)"
const LANDSCAPE_MQ = "(orientation: landscape)"
export function readMailSplitViewMatches(
@ -37,22 +38,26 @@ export function useMailSplitView() {
const mqlCoarse = window.matchMedia(
"(hover: none) and (pointer: coarse)"
)
const update = () =>
setSplitView(readMailSplitViewMatches(readingPane))
const mqlLg = window.matchMedia(LG_MQ)
let raf = 0
const update = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
const next = readMailSplitViewMatches(readingPane)
setSplitView((prev) => (prev === next ? prev : next))
})
}
update()
mqlMd.addEventListener("change", update)
mqlLandscape.addEventListener("change", update)
mqlCoarse.addEventListener("change", update)
window
.matchMedia(`(min-width: 1024px)`)
.addEventListener("change", update)
mqlLg.addEventListener("change", update)
return () => {
mqlMd.removeEventListener("change", update)
mqlLandscape.removeEventListener("change", update)
mqlCoarse.removeEventListener("change", update)
window
.matchMedia(`(min-width: 1024px)`)
.removeEventListener("change", update)
mqlLg.removeEventListener("change", update)
cancelAnimationFrame(raf)
}
}, [readingPane])

28
hooks/use-match-media.ts Normal file
View File

@ -0,0 +1,28 @@
import { useLayoutEffect, useState } from "react"
/** matchMedia with rAF batching — avoids resize storms re-rendering every pixel. */
export function useMatchMedia(query: string): boolean {
const [matches, setMatches] = useState(false)
useLayoutEffect(() => {
const mql = window.matchMedia(query)
let raf = 0
const sync = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
const next = mql.matches
setMatches((prev) => (prev === next ? prev : next))
})
}
sync()
mql.addEventListener("change", sync)
return () => {
mql.removeEventListener("change", sync)
cancelAnimationFrame(raf)
}
}, [query])
return matches
}

View File

@ -9,7 +9,7 @@ type PersistApi = {
/** True after a zustand `persist` store has rehydrated from storage (client-only). */
export function usePersistHydrated(store: { persist: PersistApi }): boolean {
const [hydrated, setHydrated] = useState(() => store.persist.hasHydrated())
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
if (store.persist.hasHydrated()) {

View File

@ -1,4 +1,4 @@
import { useLayoutEffect, useState } from "react"
import { useMatchMedia } from "@/hooks/use-match-media"
/** Tailwind `sm` breakpoint — viewports below are treated as xs. */
export const XS_MAX_PX = 639
@ -12,15 +12,5 @@ export function readXsMatches(): boolean {
}
export function useIsXs() {
const [isXs, setIsXs] = useState(false)
useLayoutEffect(() => {
const mql = window.matchMedia(XS_MQ)
const update = () => setIsXs(mql.matches)
update()
mql.addEventListener("change", update)
return () => mql.removeEventListener("change", update)
}, [])
return isXs
return useMatchMedia(XS_MQ)
}

View File

@ -3,12 +3,19 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
interface AuthState {
accessToken: string | null
refreshToken: string | null
expiresAt: number | null
login: (accessToken: string, refreshToken: string, expiresAt: number) => void
user: PlatformUser | null
login: (
accessToken: string,
refreshToken: string,
expiresAt: number,
user?: PlatformUser | null
) => void
logout: () => void
isAuthenticated: () => boolean
}
@ -19,14 +26,22 @@ export const useAuthStore = create<AuthState>()(
accessToken: null,
refreshToken: null,
expiresAt: null,
login: (accessToken, refreshToken, expiresAt) =>
set({ accessToken, refreshToken, expiresAt }),
user: null,
login: (accessToken, refreshToken, expiresAt, user = null) =>
set({ accessToken, refreshToken, expiresAt, user }),
logout: () =>
set({ accessToken: null, refreshToken: null, expiresAt: null }),
set({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
}),
isAuthenticated: () => {
const { accessToken, expiresAt } = get()
if (!accessToken || !expiresAt) return false
return Date.now() < expiresAt
const { accessToken, expiresAt, refreshToken } = get()
if (!accessToken) return false
if (expiresAt && Date.now() < expiresAt) return true
// Access token expired — session may still be renewed via refresh token.
return Boolean(refreshToken)
},
}),
{
@ -36,6 +51,7 @@ export const useAuthStore = create<AuthState>()(
accessToken: state.accessToken,
refreshToken: state.refreshToken,
expiresAt: state.expiresAt,
user: state.user,
}),
}
)

View File

@ -1,5 +1,6 @@
import { useAuthStore } from "./auth-store"
import type { ApiError } from "./types"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
export class OfflineError extends Error {
constructor() {
@ -22,13 +23,49 @@ export class ApiRequestError extends Error {
}
}
const DEFAULT_TIMEOUT = 30_000
const DEFAULT_TIMEOUT = 10_000
const DEFAULT_RETRIES = 3
const BASE_DELAY = 1000
async function tryRefreshSession(): Promise<boolean> {
try {
const res = await fetch("/api/auth/session", { credentials: "include" })
if (!res.ok) return false
const data = (await res.json()) as {
authenticated?: boolean
accessToken?: string
refreshToken?: string | null
expiresAt?: number
user?: unknown
}
if (data.authenticated && data.accessToken && data.expiresAt) {
useAuthStore.getState().login(
data.accessToken,
data.refreshToken ?? "",
data.expiresAt,
(data.user as PlatformUser | null | undefined) ?? null
)
return true
}
} catch {
// ignore
}
return false
}
class ApiClient {
constructor(private baseUrl: string) {}
private resolveUrl(path: string): URL {
const base = this.baseUrl.startsWith("http")
? this.baseUrl
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`
// Absolute path (leading /) would replace /api/v1 — keep base path segment.
const normalizedBase = base.endsWith("/") ? base : `${base}/`
const normalizedPath = path.startsWith("/") ? path.slice(1) : path
return new URL(normalizedPath, normalizedBase)
}
private getHeaders(): HeadersInit {
const headers: Record<string, string> = {
"Content-Type": "application/json",
@ -54,7 +91,7 @@ class ApiClient {
throw new OfflineError()
}
const url = new URL(path, this.baseUrl.startsWith("http") ? this.baseUrl : `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`)
const url = this.resolveUrl(path)
if (opts?.params) {
for (const [key, value] of Object.entries(opts.params)) {
if (value !== undefined) {
@ -67,6 +104,7 @@ class ApiClient {
const maxRetries = opts?.retries ?? DEFAULT_RETRIES
let lastError: Error | null = null
let authRetried = false
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
@ -100,6 +138,13 @@ class ApiClient {
errorBody?.details
)
if (response.status === 401 && !authRetried) {
authRetried = true
if (await tryRefreshSession()) {
continue
}
}
if (response.status >= 400 && response.status < 500) {
throw err
}
@ -112,7 +157,12 @@ class ApiClient {
return undefined as T
}
return await response.json()
const text = await response.text()
if (!text.trim()) {
return undefined as T
}
return JSON.parse(text) as T
} catch (err) {
clearTimeout(timer)
@ -143,6 +193,10 @@ class ApiClient {
return this.request<T>("PUT", path, { body })
}
async patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PATCH", path, { body })
}
async delete(path: string): Promise<void> {
await this.request<void>("DELETE", path)
}

View File

@ -5,13 +5,34 @@ import { apiClient, OfflineError } from '../client'
import { enqueue } from '../offline-queue'
import type { ApiContact } from '../types'
function appendContactToBookCache(
queryClient: ReturnType<typeof useQueryClient>,
bookId: string,
contact: ApiContact,
) {
queryClient.setQueryData<ApiContact[]>(['contacts', bookId], (old) => {
const list = old ?? []
if (list.some((item) => item.uid === contact.uid)) return list
return [...list, contact]
})
}
export function useCreateContact() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: { bookId: string; contact: Partial<ApiContact> }) =>
apiClient.post<ApiContact>(`/contacts/books/${vars.bookId}`, vars.contact),
onSuccess: (_data, vars) => {
mutationFn: async (vars: { bookId: string; contact: Partial<ApiContact> }) => {
const created = await apiClient.post<ApiContact | undefined>(
`/contacts/books/${vars.bookId}`,
vars.contact,
)
if (created?.uid) return created
return vars.contact as ApiContact
},
onSuccess: (data, vars) => {
if (data?.uid) {
appendContactToBookCache(queryClient, vars.bookId, data)
}
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
},
onError: (err, vars) => {

View File

@ -1,14 +1,49 @@
'use client'
import { useMemo } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import type { ApiContact, ApiContactSyncResponse } from '../types'
export const FALLBACK_CONTACT_BOOK_ID = 'contacts'
type ApiContactBook = { id: string; name: string }
type ApiContactsListResponse = {
contacts: ApiContact[]
}
export function normalizeContactBooksResponse(booksRaw: unknown): ApiContactBook[] {
if (Array.isArray(booksRaw)) return booksRaw as ApiContactBook[]
if (booksRaw && typeof booksRaw === 'object' && 'address_books' in booksRaw) {
return (booksRaw as { address_books: ApiContactBook[] }).address_books ?? []
}
return []
}
export async function fetchContactsForBook(bookId: string): Promise<ApiContact[]> {
const res = await apiClient.get<ApiContact[] | ApiContactsListResponse>(
`/contacts/books/${bookId}`,
)
return Array.isArray(res) ? res : (res.contacts ?? [])
}
export function useDefaultContactBookId() {
const { data: booksRaw } = useContactBooks()
return useMemo(() => {
const books = normalizeContactBooksResponse(booksRaw)
return books[0]?.id ?? FALLBACK_CONTACT_BOOK_ID
}, [booksRaw])
}
export function useContacts(bookId?: string) {
const defaultBookId = useDefaultContactBookId()
const resolvedBookId = bookId ?? defaultBookId
return useQuery({
queryKey: ['contacts', bookId],
queryFn: () => apiClient.get<ApiContact[]>(`/contacts/books/${bookId}`),
enabled: !!bookId,
queryKey: ['contacts', resolvedBookId],
queryFn: () => fetchContactsForBook(resolvedBookId),
enabled: !!resolvedBookId,
staleTime: 5 * 60_000,
})
}
@ -16,7 +51,12 @@ export function useContacts(bookId?: string) {
export function useContactBooks() {
return useQuery({
queryKey: ['contact-books'],
queryFn: () => apiClient.get<{ id: string; name: string }[]>('/contacts/books'),
queryFn: async () => {
const res = await apiClient.get<ApiContactBook[] | { address_books: ApiContactBook[] }>(
'/contacts/books',
)
return normalizeContactBooksResponse(res)
},
staleTime: 10 * 60_000,
})
}

View File

@ -2,23 +2,38 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../client'
import { useAuthReady } from '../use-auth-ready'
import type { ApiFolder, ApiLabel, ApiIdentity } from '../types'
export function useFolders(accountId?: string) {
return useQuery({
queryKey: ['folders', accountId],
queryFn: () =>
apiClient.get<ApiFolder[]>('/mail/folders', { account_id: accountId }),
queryFn: async () => {
const res = await apiClient.get<ApiFolder[] | { folders: ApiFolder[] }>(
'/mail/folders',
{ account_id: accountId }
)
return Array.isArray(res) ? res : (res.folders ?? [])
},
enabled: !!accountId,
staleTime: 5 * 60_000,
})
}
export function useLabels() {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ['labels'],
queryFn: () => apiClient.get<ApiLabel[]>('/mail/labels'),
queryFn: async () => {
const res = await apiClient.get<ApiLabel[] | { labels: ApiLabel[] }>(
'/mail/labels'
)
return Array.isArray(res) ? res : (res.labels ?? [])
},
staleTime: 5 * 60_000,
enabled: ready && authenticated,
retry: 1,
})
}
@ -70,11 +85,27 @@ export function useDeleteLabel() {
})
}
export function useReorderLabels() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (items: { id: string; sort_order: number }[]) =>
apiClient.post<void>('/mail/labels/reorder', { items }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels'] })
},
})
}
export function useIdentities(accountId?: string) {
return useQuery({
queryKey: ['identities', accountId],
queryFn: () =>
apiClient.get<ApiIdentity[]>(`/mail/accounts/${accountId}/identities`),
queryFn: async () => {
const res = await apiClient.get<ApiIdentity[] | { identities: ApiIdentity[] }>(
`/mail/accounts/${accountId}/identities`
)
return Array.isArray(res) ? res : (res.identities ?? [])
},
enabled: !!accountId,
})
}

Some files were not shown because too many files have changed in this diff Show More