Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions apps/local-mail/src/up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,11 @@ export async function runUp(): Promise<number> {
return 1;
}

// Re-bind the non-null values: TS drops the post-guard narrowing of the
// destructured `runtime`/`session` bindings inside the fetch/loop/SIGINT
// closures below, so capture them here where the narrowing holds.
const rt = runtime;
const sess = session;
const { db } = sess.deps;
const readOnly = rt.config.readOnly;
// `runtime`/`session` are `const`, so their post-guard non-null narrowing
// flows into the closures below (`handleApi` is an arrow, not a hoisted
// declaration, so it inherits the narrowing rather than the widened type).
const { db } = session.deps;
const readOnly = runtime.config.readOnly;

// The valid-bearer set. Dev pre-seeds the fixed proxy token; prod fills it
// only through the bootstrap exchange.
Expand All @@ -174,7 +172,7 @@ export async function runUp(): Promise<number> {
const devToken = process.env.LOCAL_MAIL_TOKEN;
if (!devToken) {
lock.release();
sess.close();
session.close();
console.error(
'LOCAL_MAIL_DEV=1 requires LOCAL_MAIL_TOKEN so the Vite proxy can authenticate.',
);
Expand All @@ -196,7 +194,7 @@ export async function runUp(): Promise<number> {
return header.slice('Bearer '.length);
}

async function handleApi(req: Request, url: URL): Promise<Response> {
const handleApi = async (req: Request, url: URL): Promise<Response> => {
const { pathname } = url;

// The one unauthenticated mutation: exchange the bootstrap for a bearer.
Expand Down Expand Up @@ -226,7 +224,7 @@ export async function runUp(): Promise<number> {
}

if (pathname === '/api/status' && req.method === 'GET') {
const status = await readMailStatus(rt);
const status = await readMailStatus(runtime);
return json({
accountEmail: status.accountEmail,
connected: status.connected,
Expand Down Expand Up @@ -264,7 +262,7 @@ export async function runUp(): Promise<number> {

if (pathname === '/api/sync' && req.method === 'POST') {
const outcome = await gate(() =>
syncMailbox(sess.deps, { forceFull: false }),
syncMailbox(session.deps, { forceFull: false }),
);
return json(outcome);
}
Expand All @@ -282,7 +280,7 @@ export async function runUp(): Promise<number> {
);
}
const { data, error } = await resolveAndModifyMessageLabels({
deps: sess.deps,
deps: session.deps,
ids: body.ids,
addLabels: body.addLabels ?? [],
removeLabels: body.removeLabels ?? [],
Expand All @@ -293,7 +291,7 @@ export async function runUp(): Promise<number> {
}

return json({ error: 'Not found.' }, 404);
}
};

const server = Bun.serve({
hostname: '127.0.0.1',
Expand All @@ -317,7 +315,7 @@ export async function runUp(): Promise<number> {
// The background sync loop, serialized through the same gate as POST /api/sync.
(async () => {
while (!controller.signal.aborted) {
await gate(() => syncMailbox(sess.deps, { forceFull: false })).catch(
await gate(() => syncMailbox(session.deps, { forceFull: false })).catch(
(cause) => console.error(`[sync] loop pass failed: ${cause}`),
);
if (controller.signal.aborted) break;
Expand Down Expand Up @@ -348,7 +346,7 @@ export async function runUp(): Promise<number> {
process.on('SIGINT', () => {
controller.abort();
server.stop();
sess.close();
session.close();
lock.release();
resolve();
});
Expand Down
40 changes: 1 addition & 39 deletions apps/local-mail/ui/src/lib/components/MessageDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
import * as DropdownMenu from '@epicenter/ui/dropdown-menu';
import * as Empty from '@epicenter/ui/empty';
import { Loading } from '@epicenter/ui/loading';
import { Separator } from '@epicenter/ui/separator';
import ArchiveIcon from '@lucide/svelte/icons/archive';
import ArchiveRestoreIcon from '@lucide/svelte/icons/archive-restore';
import CheckIcon from '@lucide/svelte/icons/check';
import MailOpenIcon from '@lucide/svelte/icons/mail-open';
import MailIcon from '@lucide/svelte/icons/mail';
import MousePointerClickIcon from '@lucide/svelte/icons/mouse-pointer-click';
Expand All @@ -18,7 +16,7 @@
import { toast } from 'svelte-sonner';
import { api } from '$lib/api';
import { fullDate, labelDisplayName } from '$lib/format';
import type { MailLabel, ModifyMessageLabelsOutcome } from '$lib/types';
import type { MailLabel } from '$lib/types';

let {
id,
Expand All @@ -38,13 +36,11 @@
}));

let lastVerb = $state<string | null>(null);
let lastOutcome = $state<ModifyMessageLabelsOutcome | null>(null);

const modify = createMutation(() => ({
mutationFn: (input: { addLabels?: string[]; removeLabels?: string[] }) =>
api.modify({ ids: id ? [id] : [], ...input }),
onSuccess: (outcome) => {
lastOutcome = outcome;
const failed = outcome.results.filter((r) => r.error).length;
const ok = outcome.results.length - failed;
if (outcome.aborted) {
Expand Down Expand Up @@ -92,13 +88,6 @@
if (present) run(`Removed ${name}`, { removeLabels: [labelId] });
else run(`Added ${name}`, { addLabels: [labelId] });
}

// Reset the inline outcome strip when a different message opens.
$effect(() => {
id;
lastOutcome = null;
lastVerb = null;
});
</script>

{#snippet actionButton(
Expand Down Expand Up @@ -234,33 +223,6 @@
</DropdownMenu.Root>
</div>

<!-- Last-action outcome strip -->
{#if lastOutcome}
{@const result = lastOutcome.results[0]}
<div
class="flex shrink-0 items-center gap-2 border-b px-5 py-1.5 text-xs
{lastOutcome.aborted || result?.error
? 'border-destructive/30 bg-destructive/10 text-destructive'
: result && !result.folded
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'}"
>
{#if lastOutcome.aborted}
<TriangleAlertIcon class="size-3.5" />
<span>Aborted: {lastOutcome.aborted.message}</span>
{:else if result?.error}
<TriangleAlertIcon class="size-3.5" />
<span>{lastVerb} failed: {result.error.message}</span>
{:else if result && !result.folded}
<CheckIcon class="size-3.5" />
<span>{lastVerb}. Gmail accepted it; the mirror catches up on the next sync (folded: false).</span>
{:else}
<CheckIcon class="size-3.5" />
<span>{lastVerb}. Mirror updated from Gmail's response.</span>
{/if}
</div>
{/if}

<!-- Body: the pre-extracted plain text; raw HTML is never rendered. -->
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4">
{#if detail.bodyText}
Expand Down
18 changes: 9 additions & 9 deletions apps/local-mail/ui/src/lib/components/MessageList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@
selectedId,
loading,
error,
isFiltered,
mirrorEmpty,
onSelect,
}: {
messages: MessageSummary[];
labels: MailLabel[];
selectedId: string | null;
loading: boolean;
error: string | null;
isFiltered: boolean;
mirrorEmpty: boolean;
onSelect: (id: string) => void;
} = $props();

Expand Down Expand Up @@ -63,19 +63,19 @@
{:else if messages.length === 0}
<Empty.Root class="flex-1 border-0">
<Empty.Media variant="icon">
{#if isFiltered}
<SearchXIcon class="size-5" />
{:else}
{#if mirrorEmpty}
<InboxIcon class="size-5" />
{:else}
<SearchXIcon class="size-5" />
{/if}
</Empty.Media>
<Empty.Title>
{isFiltered ? 'No messages match' : 'No messages mirrored'}
{mirrorEmpty ? 'No messages mirrored' : 'No messages match'}
</Empty.Title>
<Empty.Description>
{isFiltered
? 'Try a different label or search term.'
: 'Run local-mail sync --full to populate the mirror.'}
{mirrorEmpty
? 'Run local-mail sync --full to populate the mirror.'
: 'Try a different label or search term.'}
</Empty.Description>
</Empty.Root>
{:else}
Expand Down
7 changes: 5 additions & 2 deletions apps/local-mail/ui/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@

const labelList = $derived(labels.data?.labels ?? []);
const messageList = $derived(messages.data?.messages ?? []);
const isFiltered = $derived(search.trim().length > 0 || selectedLabel !== null);
// True when the mirror holds no messages at all (nothing synced yet), as
// opposed to this label/search view simply matching none. Drives which empty
// state the list shows: "run sync" vs "no match".
const mirrorEmpty = $derived((status.data?.rows.messages ?? 0) === 0);
const syncError = $derived(
sync.error?.message ?? sync.data?.failure?.message ?? null,
);
Expand Down Expand Up @@ -90,7 +93,7 @@
{selectedId}
loading={messages.isPending}
error={messages.error?.message ?? null}
{isFiltered}
{mirrorEmpty}
onSelect={(id) => (selectedId = id)}
/>

Expand Down
Loading