Gmail label sync + no-dup backfill, live label/UI updates, account-delete cleanup, Sync-SPAM toggle, sidebar logo
Gmail sync (SPEC: Gmail sync): - Provider fetches X-GM-LABELS when X-GM-EXT-1 is advertised; parses custom labels (system labels dropped, mUTF-7 decoded) onto RemoteMessage. - Engine syncs only system folders + All Mail for Gmail, skips every label folder, and dedupes the All Mail pass by Message-ID — kills the INBOX/All-Mail duplication. - Custom labels ride the message.new event; the labels plugin subscribes and maps them to chips as "<name> [Gmail]" (per-message + thread aggregate + known set), so labels fill in gradually with the sync window. Non-ASCII labels decode right. Labels self-healing (message-driven reconcile): - Per-message keys are the source of truth; t: aggregates and _labels are derived. Reconcile drops labels of deleted messages, regroups survivors by their current thread (fixing JWZ thread merges), rewrites changed t: keys, and rebuilds _labels from live messages — keeping user labels, dropping orphaned Gmail ones. - New SDK reads: sdk.threads.existing / sdk.messages.threads_of. Sidebar refreshes live via a plugin.labels.changed WS event on new labels. Account deletion: prune threads left empty after the cascade (they were user-scoped, so only messages cascaded) + de-list the account from surviving cross-account threads. Sync SPAM: opt-in per-account sync_spam (migration 0015, default off) — the engine skips the Junk folder in backfill and poll unless enabled; wizard checkbox added. Robustness / UX: - WebSocket auto-reconnects with backoff and resyncs on (re)connect, and account add/delete invalidates threads/labels — no more hard refresh for live updates. - Backfill banner + sidebar ring also require done < total, so a completed account can't leave a stuck "X of X". - Scrub lone UTF-16 surrogates from parsed headers/bodies so bad messages no longer crash the backfill on Postgres text/JSONB. - Ultimo signet logo in the Mail/Calendar/Contacts sidebar header (and at the top of the collapsed rail), via the shared plugin-ui.css + MailLayout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="44.943924mm"
|
||||
height="42.975948mm"
|
||||
viewBox="0 0 44.943923 42.975948"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1"><linearGradient
|
||||
id="linearGradient11"><stop
|
||||
style="stop-color:#f89629;stop-opacity:1;"
|
||||
offset="0.20198774"
|
||||
id="stop13" /><stop
|
||||
style="stop-color:#f74176;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop12" /></linearGradient><linearGradient
|
||||
xlink:href="#linearGradient11"
|
||||
id="linearGradient12"
|
||||
x1="54.340946"
|
||||
y1="122.84999"
|
||||
x2="54.340946"
|
||||
y2="162.66415"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
|
||||
id="layer1"
|
||||
transform="translate(-21.70051,-187.41349)"><path
|
||||
id="path1-2"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="165.16425mm"
|
||||
height="43.229576mm"
|
||||
viewBox="0 0 165.16424 43.229576"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1"><linearGradient
|
||||
id="linearGradient11"><stop
|
||||
style="stop-color:#f89629;stop-opacity:1;"
|
||||
offset="0.20198774"
|
||||
id="stop13" /><stop
|
||||
style="stop-color:#f74176;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop12" /></linearGradient><linearGradient
|
||||
xlink:href="#linearGradient11"
|
||||
id="linearGradient12"
|
||||
x1="54.340946"
|
||||
y1="122.84999"
|
||||
x2="54.340946"
|
||||
y2="162.66415"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
|
||||
id="layer1"
|
||||
transform="translate(-21.70051,-187.41349)"><path
|
||||
id="path1-2"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /><path
|
||||
style="font-weight:bold;font-size:44.7887px;line-height:1.5;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';letter-spacing:0.223944px;fill:#f74e6a;stroke-width:56.4265;stroke-linecap:round"
|
||||
d="m 73.758258,226.15765 q -1.70197,0 -3.09042,-0.85098 -1.38845,-0.85099 -2.239435,-2.23944 -0.850985,-1.38845 -0.850985,-3.09042 v -28.35124 h 5.912108 v 28.1273 q 0,0.22394 0.134366,0.4031 0.179155,0.13436 0.403099,0.13436 h 4.971545 v 5.86732 z m 14.869774,0 q -1.701971,0 -3.090421,-0.85098 -1.388449,-0.85099 -2.239435,-2.23944 -0.850985,-1.38845 -0.850985,-3.09042 v -27.76899 h 5.86732 v 7.97239 h 10.122246 v 5.86732 H 88.314511 v 13.70534 q 0,0.22394 0.134366,0.4031 0.179155,0.13436 0.403098,0.13436 h 9.584782 v 5.86732 z m 13.705278,0 v 0 -25.97744 h 5.86732 v 25.97744 z m 0,-28.57519 v 0 -5.91211 h 5.86732 v 5.91211 z m 10.25654,28.57519 v 0 -25.97744 h 32.20307 q 1.74676,0 3.13521,0.85098 1.38845,0.85099 2.19465,2.23944 0.85098,1.38845 0.85098,3.09042 v 19.7966 h -5.82253 v -19.57266 q 0,-0.22394 -0.17915,-0.35831 -0.17916,-0.17915 -0.4031,-0.17915 h -9.27126 q -0.22395,0 -0.4031,0.17915 -0.13437,0.13437 -0.13437,0.35831 v 19.57266 h -5.91211 v -19.57266 q 0,-0.22394 -0.17915,-0.35831 -0.13437,-0.17915 -0.35831,-0.17915 h -9.31605 q -0.22394,0 -0.4031,0.17915 -0.13436,0.13437 -0.13436,0.35831 v 19.57266 z m 50.07371,0 q -1.70197,0 -3.09042,-0.85098 -1.38845,-0.85099 -2.23944,-2.23944 -0.85098,-1.38845 -0.85098,-3.09042 v -13.61576 q 0,-1.70197 0.85098,-3.09042 0.85099,-1.38845 2.23944,-2.23944 1.38845,-0.85098 3.09042,-0.85098 h 14.01886 q 1.70197,0 3.09042,0.85098 1.43324,0.85099 2.23944,2.23944 0.85098,1.38845 0.85098,3.09042 v 13.61576 q 0,1.70197 -0.85098,3.09042 -0.8062,1.38845 -2.19465,2.23944 -1.38845,0.85098 -3.13521,0.85098 z m 0.22394,-5.86732 h 13.57098 q 0.22394,0 0.35831,-0.13436 0.17915,-0.17916 0.17915,-0.4031 v -13.16788 q 0,-0.22394 -0.17915,-0.35831 -0.13437,-0.17915 -0.35831,-0.17915 H 162.8875 q -0.22394,0 -0.4031,0.17915 -0.13436,0.13437 -0.13436,0.35831 v 13.16788 q 0,0.22394 0.13436,0.4031 0.17916,0.13436 0.4031,0.13436 z"
|
||||
id="text13"
|
||||
aria-label="ltimo" /></g></svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="44.943924mm"
|
||||
height="42.975948mm"
|
||||
viewBox="0 0 44.943923 42.975948"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1"><linearGradient
|
||||
id="linearGradient11"><stop
|
||||
style="stop-color:#f89629;stop-opacity:1;"
|
||||
offset="0.20198774"
|
||||
id="stop13" /><stop
|
||||
style="stop-color:#f74176;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop12" /></linearGradient><linearGradient
|
||||
xlink:href="#linearGradient11"
|
||||
id="linearGradient12"
|
||||
x1="54.340946"
|
||||
y1="122.84999"
|
||||
x2="54.340946"
|
||||
y2="162.66415"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
|
||||
id="layer1"
|
||||
transform="translate(-21.70051,-187.41349)"><path
|
||||
id="path1-2"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -10,7 +10,6 @@ import { Login } from "./routes/Login";
|
||||
import { MailLayout } from "./routes/MailLayout";
|
||||
import { Register } from "./routes/Register";
|
||||
import { Settings } from "./routes/settings/Settings";
|
||||
import { AccountWizard } from "./routes/wizard/AccountWizard";
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { data, isLoading, isError } = useMe();
|
||||
@@ -64,7 +63,7 @@ export function App() {
|
||||
path="/settings/accounts/new"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AccountWizard />
|
||||
<Settings />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,9 @@ export function BackfillBanner() {
|
||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
|
||||
const importing = (accounts.data ?? []).filter(
|
||||
(a) => a.state === "backfilling" && a.backfill_total > 0,
|
||||
// Also require done < total so a completed account whose state event was missed
|
||||
// (e.g. a dropped WS) never leaves a stuck "X of X" banner.
|
||||
(a) => a.state === "backfilling" && a.backfill_total > 0 && a.backfill_done < a.backfill_total,
|
||||
);
|
||||
const active = importing.length > 0;
|
||||
|
||||
|
||||
@@ -239,6 +239,7 @@ export const en = {
|
||||
"wizard.creating": "Adding…",
|
||||
"wizard.created": "Account added — syncing your mail.",
|
||||
"wizard.startReading": "Start reading now",
|
||||
"wizard.backToAccounts": "Back to accounts",
|
||||
"wizard.manualConfig": "Enter settings manually",
|
||||
"wizard.displayName": "Display name",
|
||||
"wizard.syncWindow": "How much mail to sync",
|
||||
@@ -249,6 +250,8 @@ export const en = {
|
||||
"wizard.syncWindow.new": "New mail only",
|
||||
"wizard.syncWindow.new.hint":
|
||||
"Nothing from before now — download older mail anytime from account settings.",
|
||||
"wizard.syncSpam": "Sync spam?",
|
||||
"wizard.syncSpam.hint": "Also import the Spam/Junk folder. Off by default.",
|
||||
|
||||
// Provider credential-helper panel (app passwords, SPEC §6.2.2).
|
||||
"wizard.appPassword.title": "Create an app password",
|
||||
@@ -272,8 +275,9 @@ export const en = {
|
||||
"wizard.appPassword.oauthInstead": "Sign in with Google instead",
|
||||
// Gmail steps.
|
||||
"wizard.help.gmail.step1": "Turn on 2-Step Verification for your Google account if you haven't.",
|
||||
"wizard.help.gmail.step2": "On the App passwords page, name the password (e.g. \"ultimo.email\") and create it.",
|
||||
"wizard.help.gmail.step3": "Copy the 16-character password and paste it below.",
|
||||
"wizard.help.gmail.step2": "Click the button to generate an app password.",
|
||||
"wizard.help.gmail.step3": "On the App passwords page, name the password (e.g. \"ultimo.email\") and create it.",
|
||||
"wizard.help.gmail.step4": "Copy the 16-character password and paste it below.",
|
||||
// Outlook / Microsoft personal steps.
|
||||
"wizard.help.outlook.step1": "Turn on two-step verification in your Microsoft account security settings.",
|
||||
"wizard.help.outlook.step2": "Under Advanced security options, create a new app password.",
|
||||
|
||||
@@ -133,6 +133,7 @@ export const api = {
|
||||
smtp: ServerConfig;
|
||||
password: string;
|
||||
sync_window?: SyncWindow;
|
||||
sync_spam?: boolean;
|
||||
}) => apiFetch<AccountDetail>("/accounts", { method: "POST", body: JSON.stringify(input) }),
|
||||
accountDetail: (id: string) => apiFetch<AccountDetail>(`/accounts/${id}`),
|
||||
updateAccount: (
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface HelpBlock {
|
||||
app_name_hint: string;
|
||||
steps: string[]; // i18n message keys
|
||||
docs_url: string;
|
||||
setup_link: string; // optional 2FA page linked from the first step
|
||||
button_after_step: number; // 1-based step to place the app-password button after; 0 = top
|
||||
}
|
||||
|
||||
export interface CandidateConfig {
|
||||
|
||||
+57
-8
@@ -37,15 +37,29 @@ export function subscribeWs(listener: WsListener): () => void {
|
||||
|
||||
export function connectWs(queryClient: QueryClient): () => void {
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
|
||||
const url = `${proto}://${window.location.host}/api/ws`;
|
||||
|
||||
const ping = window.setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
let socket: WebSocket | null = null;
|
||||
let ping: number | undefined;
|
||||
let reconnectTimer: number | undefined;
|
||||
let attempts = 0;
|
||||
let stopped = false;
|
||||
|
||||
// Refetch the live-updated lists — used on every (re)connect so a dropped socket
|
||||
// (server restart, sleep, network blip) self-heals and catches up on missed events.
|
||||
const resync = () => {
|
||||
for (const key of [
|
||||
["threads"],
|
||||
["accounts"],
|
||||
["folder-counts"],
|
||||
["quota"],
|
||||
["drafts-remote"],
|
||||
]) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(event.data);
|
||||
@@ -87,7 +101,15 @@ export function connectWs(queryClient: QueryClient): () => void {
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
}
|
||||
if (name === "account.state_changed") {
|
||||
// Adding/deleting an account changes threads and (Gmail) labels wholesale —
|
||||
// refetch the list, chips, and sidebar so nothing stale lingers.
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["thread-labels"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["message-labels"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["labels-all"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
window.dispatchEvent(new Event("webmail:labels-changed"));
|
||||
}
|
||||
if (name === "message.sent") {
|
||||
// The sent message's draft is deleted server-side; refresh the Drafts list.
|
||||
@@ -96,8 +118,35 @@ export function connectWs(queryClient: QueryClient): () => void {
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
socket = new WebSocket(url);
|
||||
socket.onopen = () => {
|
||||
attempts = 0;
|
||||
ping = window.setInterval(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
resync(); // catch up on anything published while we were disconnected
|
||||
};
|
||||
socket.onmessage = handleMessage;
|
||||
socket.onclose = () => {
|
||||
if (ping !== undefined) window.clearInterval(ping);
|
||||
if (stopped) return;
|
||||
// Exponential backoff, capped at 30s, so a downed server is retried politely.
|
||||
const delay = Math.min(30000, 1000 * 2 ** attempts);
|
||||
attempts += 1;
|
||||
reconnectTimer = window.setTimeout(connect, delay);
|
||||
};
|
||||
socket.onerror = () => socket?.close(); // route errors through onclose -> reconnect
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
window.clearInterval(ping);
|
||||
socket.close();
|
||||
stopped = true;
|
||||
if (ping !== undefined) window.clearInterval(ping);
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,7 +148,25 @@
|
||||
@apply flex items-center justify-between pt-3 px-3 pb-1;
|
||||
}
|
||||
.wm-side-switch {
|
||||
@apply relative;
|
||||
@apply relative flex items-center gap-2;
|
||||
}
|
||||
/* Ultimo signet in front of the app-switcher menu (Calendar / Contacts headers). */
|
||||
.wm-side-switch::before {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: url("/ultimo-logo-signet.svg") center / contain no-repeat;
|
||||
}
|
||||
/* And at the top of the collapsed rail, above the expand/theme buttons. */
|
||||
.cal-rail::before,
|
||||
.ct-rail::before {
|
||||
content: "";
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 10px auto 6px;
|
||||
background: url("/ultimo-logo-signet.svg") center / contain no-repeat;
|
||||
}
|
||||
.wm-side-switch > button {
|
||||
@apply flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import signetLogo from "../../icons/ultimo-logo-signet.svg";
|
||||
import { AppSwitcher } from "../components/AppSwitcher";
|
||||
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
||||
import { DraftsList } from "../components/list/DraftsList";
|
||||
@@ -512,7 +513,14 @@ export function MailLayout() {
|
||||
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
|
||||
}`}
|
||||
>
|
||||
{!collapsed && <AppSwitcher />}
|
||||
{collapsed ? (
|
||||
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
|
||||
<AppSwitcher />
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
|
||||
<button
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
@@ -573,7 +581,11 @@ export function MailLayout() {
|
||||
>
|
||||
<AccountDot
|
||||
color={a.color}
|
||||
syncing={a.state === "backfilling" && a.backfill_total > 0}
|
||||
syncing={
|
||||
a.state === "backfilling" &&
|
||||
a.backfill_total > 0 &&
|
||||
a.backfill_done < a.backfill_total
|
||||
}
|
||||
total={a.backfill_total}
|
||||
done={a.backfill_done}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -11,6 +11,7 @@ import { useMe } from "../../lib/auth";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
import { setTimeFormat } from "../../lib/timeFormat";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
import { AccountWizard } from "../wizard/AccountWizard";
|
||||
|
||||
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
|
||||
type Section = (typeof SECTIONS)[number];
|
||||
@@ -21,10 +22,20 @@ export function Settings() {
|
||||
const [searchParams] = useSearchParams();
|
||||
// Where the back button returns to — set by whoever opened Settings (Mail or a
|
||||
// plugin like Calendar via ?from=). Only accept internal paths; default to Mail.
|
||||
const navigate = useNavigate();
|
||||
const rawFrom = searchParams.get("from") ?? "";
|
||||
const from = rawFrom.startsWith("/") ? rawFrom : "/mail";
|
||||
const fromQuery = `?from=${encodeURIComponent(from)}`;
|
||||
const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section;
|
||||
// The add-account wizard renders inside this layout (route /settings/accounts/new),
|
||||
// with the Accounts nav item highlighted.
|
||||
const addingAccount = useLocation().pathname.endsWith("/accounts/new");
|
||||
const section = (
|
||||
addingAccount
|
||||
? "accounts"
|
||||
: SECTIONS.includes(params.section as Section)
|
||||
? params.section
|
||||
: "profile"
|
||||
) as Section;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -49,15 +60,16 @@ export function Settings() {
|
||||
))}
|
||||
</nav>
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{section === "profile" && <ProfileSection />}
|
||||
{section === "security" && <SecuritySection />}
|
||||
{section === "accounts" && <AccountsSection />}
|
||||
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
|
||||
{!addingAccount && section === "profile" && <ProfileSection />}
|
||||
{!addingAccount && section === "security" && <SecuritySection />}
|
||||
{!addingAccount && section === "accounts" && <AccountsSection />}
|
||||
{section === "calendars" && <CalendarsSection />}
|
||||
{section === "appearance" && <AppearanceSection />}
|
||||
{section === "integrations" && <IntegrationsSection />}
|
||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
|
||||
Matches the accounts section width (max-w-5xl) with a gap above it. */}
|
||||
{section === "accounts" && (
|
||||
{!addingAccount && section === "accounts" && (
|
||||
<div className="mt-8 max-w-5xl">
|
||||
<PluginSlot name="settings.page" />
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,14 @@ const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password"
|
||||
// 1 = email, 15 = provider app-password panel (step "1.5"), 2 = details, 3 = done.
|
||||
type Step = 1 | 15 | 2 | 3;
|
||||
|
||||
export function AccountWizard() {
|
||||
interface WizardProps {
|
||||
// When embedded in the Settings layout, drop the full-screen framing and return
|
||||
// to the accounts list on completion instead of navigating away.
|
||||
embedded?: boolean;
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -30,6 +37,7 @@ export function AccountWizard() {
|
||||
const [googleOauthEnabled, setGoogleOauthEnabled] = useState(false);
|
||||
const [usedPanel, setUsedPanel] = useState(false);
|
||||
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
|
||||
const [syncSpam, setSyncSpam] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tested, setTested] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -89,6 +97,7 @@ export function AccountWizard() {
|
||||
smtp,
|
||||
password,
|
||||
sync_window: syncWindow,
|
||||
sync_spam: syncSpam,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
setStep(3);
|
||||
@@ -173,12 +182,24 @@ export function AccountWizard() {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="mt-2 flex cursor-pointer items-start gap-2 border-t border-app-border pt-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={syncSpam}
|
||||
onChange={(e) => setSyncSpam(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{t("wizard.syncSpam")}</span>
|
||||
<span className="block text-xs text-app-faint">{t("wizard.syncSpam.hint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-lg px-4">
|
||||
<div className={embedded ? "max-w-lg" : "mx-auto mt-16 max-w-lg px-4"}>
|
||||
<h1 className="mb-6 text-2xl font-semibold">{t("wizard.title")}</h1>
|
||||
|
||||
{step === 1 && (
|
||||
@@ -198,12 +219,19 @@ export function AccountWizard() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
disabled={busy || !email.includes("@")}
|
||||
>
|
||||
{busy ? t("wizard.discovering") : t("wizard.discover")}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
|
||||
disabled={busy || !email.includes("@")}
|
||||
>
|
||||
{busy ? t("wizard.discovering") : t("wizard.discover")}
|
||||
</button>
|
||||
{embedded && onDone && (
|
||||
<button type="button" className="px-3 py-2 text-sm text-app-muted" onClick={onDone}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -311,12 +339,19 @@ export function AccountWizard() {
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-emerald-500">{t("wizard.created")}</p>
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg"
|
||||
onClick={() => navigate("/mail")}
|
||||
>
|
||||
{t("wizard.startReading")}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg"
|
||||
onClick={() => navigate("/mail")}
|
||||
>
|
||||
{t("wizard.startReading")}
|
||||
</button>
|
||||
{embedded && onDone && (
|
||||
<button className="px-3 py-2 text-sm text-app-muted" onClick={onDone}>
|
||||
{t("wizard.backToAccounts")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -94,22 +94,31 @@ export function ProviderCredentialPanel({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const openDeepLink = () => {
|
||||
// Must fire from this click (a direct user gesture) so popup blockers allow it.
|
||||
// Sized features open a popup window rather than a new tab.
|
||||
// Open a provider page in a popup window (not a tab). Must fire from a direct
|
||||
// user gesture (this click) so popup blockers allow it.
|
||||
const openPopup = (url: string, name: string) => {
|
||||
const w = 720;
|
||||
const h = 680;
|
||||
// Position the popup at the right (3/3) of the screen, pulled 200px back from
|
||||
// the edge so it doesn't touch it; clamped to stay on-screen.
|
||||
// Position at the right (3/3) of the screen, pulled 200px back from the edge.
|
||||
const left = Math.max(0, window.screen.width - w - 200);
|
||||
const top = window.screenY + Math.max(0, (window.outerHeight - h) / 2);
|
||||
window.open(
|
||||
help.deep_link,
|
||||
`${help.help_id}-app-passwords`,
|
||||
url,
|
||||
`${help.help_id}-${name}`,
|
||||
`popup,noopener,noreferrer,width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
const appPasswordButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPopup(help.deep_link, "app-passwords")}
|
||||
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg"
|
||||
>
|
||||
{t("wizard.appPassword.open", { provider: providerLabel })}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -117,24 +126,27 @@ export function ProviderCredentialPanel({
|
||||
<p className="mt-1 text-sm text-app-muted">{t("wizard.appPassword.intro")}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDeepLink}
|
||||
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg"
|
||||
>
|
||||
{t("wizard.appPassword.open", { provider: providerLabel })}
|
||||
</button>
|
||||
{/* Button above the list only when no step placement is configured. */}
|
||||
{help.button_after_step === 0 && appPasswordButton}
|
||||
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm text-app-muted">
|
||||
{help.steps.map((key) => (
|
||||
<li key={key}>{t(key as MessageKey)}</li>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-sm text-app-muted">
|
||||
{help.steps.map((key, i) => (
|
||||
<li key={key}>
|
||||
{i === 0 && help.setup_link ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPopup(help.setup_link, "2fa")}
|
||||
className="text-left text-app-accent hover:underline"
|
||||
>
|
||||
{t(key as MessageKey)}
|
||||
</button>
|
||||
) : (
|
||||
t(key as MessageKey)
|
||||
)}
|
||||
{help.button_after_step === i + 1 && <div className="mt-2">{appPasswordButton}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{help.app_name_hint && (
|
||||
<p className="text-xs text-app-faint">
|
||||
{t("wizard.appPassword.appNameHint", { hint: help.app_name_hint })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user