Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50bf6d977a |
@@ -1,13 +0,0 @@
|
||||
# Root editor/formatter config. end_of_line=lf makes dotnet-format agree with
|
||||
# .gitattributes (eol=lf) — without this, format-on-Windows wants CRLF while git
|
||||
# stores LF, and the pre-commit/CI format gates flip-flop forever.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.cs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
@@ -52,21 +52,3 @@ jobs:
|
||||
# (Vite/PostCSS/etc.) shouldn't block a merge.
|
||||
working-directory: frontend
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
# RECOMMENDATIONS #9: SAST. Semgrep community rules for C#/JS + OWASP/secrets patterns —
|
||||
# catches injection/crypto-misuse classes the other gates (gitleaks, dep-audit, tests)
|
||||
# don't look for. Advisory at first (not a required check); promote once tuned.
|
||||
sast:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The runner image lacks pip, and a semgrep job-container lacks the node that
|
||||
# actions/checkout needs — so install pip via apt on the standard image.
|
||||
- name: Install semgrep
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip pipx
|
||||
pipx install semgrep
|
||||
- name: Semgrep scan
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
semgrep scan --config p/csharp --config p/javascript --config p/security-audit --exclude 'frontend/dist' --exclude '**/bin' --exclude '**/obj' --error --quiet
|
||||
|
||||
@@ -70,6 +70,3 @@ vendor/
|
||||
coverage/
|
||||
.cache/
|
||||
tmp/
|
||||
|
||||
# agent worktrees (never commit)
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -53,16 +53,6 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# One-shot: ensure the DataProtection 'keys' volume is owned by the API's non-root
|
||||
# 'app' user (uid 1654). A volume created by an older root-running image is root-owned,
|
||||
# which makes the app fail to read its key ring and 500s on login. Runs as root, chowns,
|
||||
# exits; the api waits for it. Idempotent and cheap.
|
||||
init-keys:
|
||||
image: busybox
|
||||
command: ["sh", "-c", "chown -R 1654:1654 /keys"]
|
||||
volumes:
|
||||
- keys:/keys
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -77,8 +67,6 @@ services:
|
||||
Ai__Mode: ${AI_MODE:-Disabled}
|
||||
# Points at the compose 'ollama' service when the ai profile is up; harmless otherwise.
|
||||
Ai__OllamaBaseUrl: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
# OTLP export activates only when set (e.g. http://lgtm:4317 with the observability profile).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_ENDPOINT:-}
|
||||
# Dev mode shows the dev banner and caps the initial sync. Set DEV_MODE=true
|
||||
# and MAX_MESSAGES=1000 in deploy/.env to exercise it in this Docker setup.
|
||||
App__DevMode: ${DEV_MODE:-false}
|
||||
@@ -89,8 +77,6 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
init-keys:
|
||||
condition: service_completed_successfully
|
||||
# V-08: bind to loopback so the API is not directly reachable from the network
|
||||
# (only via the frontend/nginx proxy over the internal compose network). This
|
||||
# prevents external clients from bypassing the proxy to spoof X-Forwarded-* headers.
|
||||
@@ -123,16 +109,6 @@ services:
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
|
||||
# Observability (RECOMMENDATIONS #5): all-in-one Grafana+Tempo+Prometheus+Loki.
|
||||
# Enable with: docker compose --profile observability up -d
|
||||
# then set OTEL_ENDPOINT=http://lgtm:4317 in deploy/.env and restart the api.
|
||||
# Grafana UI: http://localhost:3000 (admin/admin on first run).
|
||||
lgtm:
|
||||
image: grafana/otel-lgtm
|
||||
profiles: ["observability"]
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
||||
# Optional reverse proxy. Enable with: docker compose --profile proxy up
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
|
||||
@@ -38,6 +38,7 @@ export const AnalyticsApi = {
|
||||
health: () => api.get('/analytics/health').then((r) => r.data),
|
||||
topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data),
|
||||
volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data),
|
||||
heatmap: () => api.get('/analytics/heatmap').then((r) => r.data),
|
||||
categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data),
|
||||
attachments: () => api.get('/analytics/attachments').then((r) => r.data),
|
||||
sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data),
|
||||
@@ -93,22 +94,13 @@ function folderToRequest(slug, page, pageSize) {
|
||||
case 'starred': return { ...base, isStarred: true };
|
||||
case 'sent': return { ...base, gmailLabel: 'SENT' };
|
||||
case 'drafts': return { ...base, gmailLabel: 'DRAFT' };
|
||||
// Archive = filed away: not in inbox, not trashed, and NOT Sent/Spam/Draft/Chat.
|
||||
// Excluding those labels stops sent mail leaking into Archive.
|
||||
case 'archive': return { ...base, isInInbox: false, isTrashed: false, excludeGmailLabels: ['SENT', 'DRAFT', 'SPAM', 'TRASH', 'CHAT'] };
|
||||
case 'archive': return { ...base, isInInbox: false, isTrashed: false };
|
||||
case 'spam': return { ...base, gmailLabel: 'SPAM' };
|
||||
case 'trash': return { ...base, isTrashed: true };
|
||||
// Pinned = Gmail's "Important" marker (a real per-message flag), not "everything".
|
||||
case 'pinned': return { ...base, isImportant: true };
|
||||
// Read Later = only emails the user explicitly flagged (local marker).
|
||||
case 'readlater': return { ...base, isReadLater: true };
|
||||
// Unlabelled = emails not filed under any user-created label.
|
||||
case 'unlabelled': return { ...base, hasUserLabels: false };
|
||||
// ── Special filters ──
|
||||
case 'large': return { ...base, minSizeBytes: 5_000_000 };
|
||||
// Old Mail = strictly older than 6 months.
|
||||
case 'old': {
|
||||
const d = new Date(); d.setMonth(d.getMonth() - 6);
|
||||
const d = new Date(); d.setFullYear(d.getFullYear() - 1);
|
||||
return { ...base, to: d.toISOString().slice(0, 10) };
|
||||
}
|
||||
// ── Smart folders ──
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { MailOpen, Mail, Star, Archive, Trash2, X } from 'lucide-react';
|
||||
import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react';
|
||||
import { BulkApi } from '../api/client.js';
|
||||
import {
|
||||
Button, useToast,
|
||||
@@ -49,54 +49,25 @@ export default function BulkToolbar({ selectedIds, onDone, onClear }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={`${count} email${count === 1 ? '' : 's'} selected`}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm"
|
||||
>
|
||||
{/* Selection count — primary emphasis so it reads first. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold tabular-nums text-primary-foreground"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
selected
|
||||
</span>
|
||||
{/* Obvious clear-selection affordance, kept next to the count. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={busy}
|
||||
onClick={onClear}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 basis-full sm:basis-0" />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
|
||||
<MailOpen /> Read
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
|
||||
<Mail /> Unread
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
|
||||
<Star /> Star
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
|
||||
<Archive /> Archive
|
||||
</Button>
|
||||
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
|
||||
<Trash2 /> Trash
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm">
|
||||
<span className="text-sm font-medium">{count} selected</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
|
||||
<MailOpen /> Read
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
|
||||
<Mail /> Unread
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
|
||||
<Star /> Star
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
|
||||
<Archive /> Archive
|
||||
</Button>
|
||||
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
|
||||
<Trash2 /> Trash
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={onClear}>Cancel</Button>
|
||||
|
||||
<Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { EmailApi } from '../api/client.js';
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtSize = (b) => {
|
||||
if (!b) return '';
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||||
return `${(b / 1048576).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared reading-pane / full single-email view.
|
||||
*
|
||||
* Accepts either an `email` summary object (as emitted by the list rows) or a
|
||||
* bare `emailId`. Fetches the full detail via EmailApi.get(id) and the AI
|
||||
* summary lazily via EmailApi.summary(id). Renders subject, metadata, an AI
|
||||
* summary button, the body, and per-email actions including "Open in Gmail".
|
||||
*
|
||||
* `onClose` collapses the pane.
|
||||
*/
|
||||
export default function EmailDetail({ email: summary, emailId, onClose }) {
|
||||
const id = summary?.id ?? emailId;
|
||||
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [aiSummary, setAiSummary] = useState(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id == null) return;
|
||||
let cancelled = false;
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
setAiSummary(null);
|
||||
setAiLoading(false);
|
||||
EmailApi.get(id)
|
||||
.then((d) => { if (!cancelled) setDetail(d); })
|
||||
.catch(() => { if (!cancelled) setDetail(null); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
const email = detail ?? summary ?? {};
|
||||
|
||||
const fetchAiSummary = () => {
|
||||
if (id == null) return;
|
||||
setAiLoading(true);
|
||||
EmailApi.summary(id)
|
||||
.then((r) => setAiSummary(r.summary))
|
||||
.catch(() => setAiSummary(null))
|
||||
.finally(() => setAiLoading(false));
|
||||
};
|
||||
|
||||
const openInGmail = () => window.open(
|
||||
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
|
||||
'_blank', 'noopener,noreferrer'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sv-detail">
|
||||
<div className="sv-detail-topbar">
|
||||
<button className="sv-close" onClick={onClose} aria-label="Close reading pane" title="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sv-detail-header">
|
||||
<div className="sv-detail-subject">{email.subject || '(no subject)'}</div>
|
||||
<div className="sv-detail-meta">
|
||||
<span>{email.senderDisplayName || email.senderAddress}</span>
|
||||
{email.sentAtUtc && (
|
||||
<>
|
||||
<span className="sv-detail-sep">·</span>
|
||||
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
{email.sizeEstimateBytes > 0 && (
|
||||
<>
|
||||
<span className="sv-detail-sep">·</span>
|
||||
<span>{fmtSize(email.sizeEstimateBytes)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && (
|
||||
<div className="sv-ai-summary">
|
||||
{aiSummary != null ? (
|
||||
<div className="sv-ai-summary-text">✨ {aiSummary}</div>
|
||||
) : (
|
||||
<button className="btn-sm" onClick={fetchAiSummary} disabled={aiLoading}>
|
||||
{aiLoading ? 'Summarising…' : '✨ AI summary'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="sv-body-loading muted">Loading message…</div>}
|
||||
|
||||
{!loading && detail?.bodyText && (
|
||||
<div className="sv-body">{detail.bodyText}</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail?.bodyText && email.snippet && (
|
||||
<div className="sv-snippet">{email.snippet}</div>
|
||||
)}
|
||||
|
||||
<div className="sv-detail-actions">
|
||||
<button className="btn-sm" onClick={openInGmail}>Open in Gmail ↗</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { EmailApi } from '../api/client.js';
|
||||
import { Checkbox } from './ui/checkbox.jsx';
|
||||
|
||||
const fmtDate = (iso) => {
|
||||
const d = new Date(iso);
|
||||
@@ -36,7 +35,7 @@ function renderHighlight(s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused, onOpen }) {
|
||||
export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) {
|
||||
const [email, setEmail] = useState(initial);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
@@ -89,16 +88,12 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
|
||||
return (
|
||||
<tr
|
||||
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
|
||||
onClick={() => onOpen ? onOpen(email) : openInGmail()}
|
||||
title={onOpen ? 'Open' : 'Open in Gmail'}
|
||||
onClick={openInGmail}
|
||||
title="Open in Gmail"
|
||||
>
|
||||
{onToggleSelect && (
|
||||
<td className="el-select" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={!!selected}
|
||||
onCheckedChange={() => onToggleSelect(email.id)}
|
||||
aria-label={selected ? 'Deselect email' : 'Select email'}
|
||||
/>
|
||||
<input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
|
||||
</td>
|
||||
)}
|
||||
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
|
||||
@@ -108,8 +103,8 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
|
||||
<td className="el-subject">
|
||||
<span className="el-subj-text">{email.subject || '(no subject)'}</span>
|
||||
{email.matchHighlight
|
||||
? <span className="el-snippet">{renderHighlight(email.matchHighlight)}</span>
|
||||
: email.snippet && <span className="el-snippet">{email.snippet}</span>}
|
||||
? <span className="el-snippet"> — {renderHighlight(email.matchHighlight)}</span>
|
||||
: email.snippet && <span className="el-snippet"> — {email.snippet}</span>}
|
||||
</td>
|
||||
<td className="el-meta">
|
||||
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
||||
@@ -139,11 +134,6 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
|
||||
disabled={email._unsubDone}
|
||||
>{email._unsubDone ? '✓' : '✉✕'}</button>
|
||||
)}
|
||||
<button
|
||||
className="action-btn"
|
||||
title="Open in Gmail"
|
||||
onClick={(e) => { e.stopPropagation(); openInGmail(); }}
|
||||
>↗</button>
|
||||
<button
|
||||
className="action-btn action-btn--danger"
|
||||
title="Move to trash"
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
/**
|
||||
* Accessible, design-token styled checkbox.
|
||||
* Wraps Radix Checkbox so it is keyboard-accessible with a visible focus ring.
|
||||
* Accepts `checked`, `onCheckedChange` (Radix) and, for convenience, `onChange`
|
||||
* (called with a synthetic-ish `{ target: { checked } }`) so it can drop into
|
||||
* places that previously used a bare <input type="checkbox">.
|
||||
*/
|
||||
const Checkbox = forwardRef(function Checkbox(
|
||||
{ className, onCheckedChange, onChange, ...props },
|
||||
ref
|
||||
) {
|
||||
const handleCheckedChange = (checked) => {
|
||||
onCheckedChange?.(checked);
|
||||
onChange?.({ target: { checked } });
|
||||
};
|
||||
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className={cn(
|
||||
'peer inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] border border-border bg-card transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
||||
'hover:border-primary/60',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||
<Check className="h-3 w-3" strokeWidth={3} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export { Checkbox };
|
||||
@@ -2,7 +2,6 @@
|
||||
export { Button, buttonVariants } from './button.jsx';
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card.jsx';
|
||||
export { Badge, badgeVariants } from './badge.jsx';
|
||||
export { Checkbox } from './checkbox.jsx';
|
||||
export { Input, Textarea } from './input.jsx';
|
||||
export { Switch } from './switch.jsx';
|
||||
export { Separator } from './separator.jsx';
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Line, Doughnut } from 'react-chartjs-2';
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { Bar, Line, Doughnut } from 'react-chartjs-2';
|
||||
import { AnalyticsApi } from '../api/client.js';
|
||||
import { Skeleton } from './ui/skeleton.jsx';
|
||||
import { EmptyState } from './ui/misc.jsx';
|
||||
import {
|
||||
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
||||
Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
|
||||
LineElement, ArcElement, Tooltip, Legend
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend);
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend);
|
||||
|
||||
const fmtBytes = (b) => {
|
||||
if (!b) return '0 B';
|
||||
@@ -80,6 +77,32 @@ export function VolumeWidget({ volume }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function HeatmapWidget({ heatmap }) {
|
||||
if (!heatmap) return <Empty />;
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const max = Math.max(1, ...heatmap.map((c) => c.count));
|
||||
const grid = {};
|
||||
heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; });
|
||||
return (
|
||||
<div className="widget">
|
||||
<h3>Activity Heatmap</h3>
|
||||
<div className="heatmap">
|
||||
{days.map((d, dow) => (
|
||||
<div className="hm-row" key={dow}>
|
||||
<span className="hm-day">{d}</span>
|
||||
{Array.from({ length: 24 }, (_, h) => {
|
||||
const v = grid[`${dow}-${h}`] || 0;
|
||||
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
// Maps EmailCategory enum name → folder slug or search query
|
||||
const CAT_SLUG = {
|
||||
Finance: '/app/folder/finance',
|
||||
@@ -112,64 +135,41 @@ const CAT_SLUG = {
|
||||
Unknown: '/app/folder/allmail',
|
||||
};
|
||||
|
||||
// "Emails by Category" — ranked horizontal bar list. Aggregates the
|
||||
// category×day-of-week cells into per-category totals; each bar links to
|
||||
// that category's folder via CAT_SLUG.
|
||||
export function CategoryBreakdownWidget() {
|
||||
export function CategoryHeatmapWidget() {
|
||||
const [cells, setCells] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
|
||||
|
||||
if (!cells) {
|
||||
return (
|
||||
<div className="widget">
|
||||
<h3>Emails by Category</h3>
|
||||
<div className="cat-bars">
|
||||
{Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-6 w-full" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!cells) return <div className="widget"><div className="muted">Loading…</div></div>;
|
||||
if (cells.length === 0) return <div className="widget"><h3>Category Heatmap</h3><div className="muted">No data yet — run a sync.</div></div>;
|
||||
|
||||
const totals = {};
|
||||
cells.forEach((c) => { totals[c.category] = (totals[c.category] || 0) + c.count; });
|
||||
const ranked = Object.entries(totals)
|
||||
.map(([category, count]) => ({ category, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return (
|
||||
<div className="widget">
|
||||
<h3>Emails by Category</h3>
|
||||
<EmptyState icon={Inbox} title="No data yet — run a sync" description="Category totals appear once your inbox has been synced." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(1, ...ranked.map((r) => r.count));
|
||||
const categories = [...new Set(cells.map((c) => c.category))].sort();
|
||||
const max = Math.max(1, ...cells.map((c) => c.count));
|
||||
const grid = {};
|
||||
cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; });
|
||||
|
||||
return (
|
||||
<div className="widget">
|
||||
<h3>Emails by Category</h3>
|
||||
<div className="cat-bars">
|
||||
{ranked.map(({ category, count }) => {
|
||||
const dest = CAT_SLUG[category];
|
||||
<h3>Category Heatmap</h3>
|
||||
<div className="cat-heatmap">
|
||||
<div className="chm-row chm-head">
|
||||
<span className="chm-label" />
|
||||
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
|
||||
</div>
|
||||
{categories.map((cat) => {
|
||||
const dest = CAT_SLUG[cat];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`cat-bar${dest ? ' cat-bar--link' : ''}`}
|
||||
key={category}
|
||||
disabled={!dest}
|
||||
title={dest ? `View ${category} emails` : category}
|
||||
onClick={dest ? () => navigate(dest) : undefined}
|
||||
>
|
||||
<span className="cat-bar-label">{category}</span>
|
||||
<span className="cat-bar-track">
|
||||
<span className="cat-bar-fill" style={{ width: `${(count / max) * 100}%` }} />
|
||||
</span>
|
||||
<span className="cat-bar-count">{count.toLocaleString()}</span>
|
||||
</button>
|
||||
<div className="chm-row" key={cat}>
|
||||
<span
|
||||
className={`chm-label${dest ? ' chm-label--link' : ''}`}
|
||||
title={dest ? `View ${cat} emails` : cat}
|
||||
onClick={dest ? () => navigate(dest) : undefined}
|
||||
>{cat}</span>
|
||||
{DOW.map((_, dow) => {
|
||||
const v = grid[`${cat}-${dow}`] || 0;
|
||||
return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]} — ${v}`}>{v || ''}</span>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -196,10 +196,4 @@ export function StorageWidget({ bytes }) {
|
||||
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="widget">
|
||||
<EmptyState icon={Inbox} title="No data yet — run a sync" description="This widget populates after your inbox has been synced." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Empty() { return <div className="widget"><div className="muted">No data yet — run a sync.</div></div>; }
|
||||
|
||||
@@ -2,15 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { BulkApi } from '../api/client.js';
|
||||
|
||||
/// <summary>
|
||||
/// Keyboard navigation for an email list. Shortcuts:
|
||||
/// j / ArrowDown move focus down
|
||||
/// k / ArrowUp move focus up
|
||||
/// e archive the focused email
|
||||
/// u mark the focused email unread
|
||||
/// # (shift+3) trash the focused email
|
||||
/// All shortcuts are ignored while an input/textarea/select (or any
|
||||
/// contenteditable) has focus, and modifier chords (Ctrl/Cmd/Alt) are left
|
||||
/// alone, so typing and browser/OS shortcuts are never hijacked.
|
||||
/// j/k move focus down/up the list, e archives the focused email, # (shift+3)
|
||||
/// trashes it. Ignored while an input/textarea/select has focus, or while
|
||||
/// the "/" search shortcut is active, so typing is never hijacked.
|
||||
/// `onRemoved(id)` lets the caller drop the row from local state after a
|
||||
/// successful archive/trash.
|
||||
/// </summary>
|
||||
@@ -18,59 +12,31 @@ export default function useListKeyboardNav(emails, onRemoved) {
|
||||
const [focusedId, setFocusedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const isEditable = (el) => {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
|
||||
};
|
||||
|
||||
const handler = async (e) => {
|
||||
// Never hijack typing or modifier chords (Ctrl+C, Cmd+K, Alt+…).
|
||||
if (isEditable(document.activeElement)) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (!emails.length) return;
|
||||
|
||||
const idx = emails.findIndex((x) => x.id === focusedId);
|
||||
|
||||
switch (e.key) {
|
||||
case 'j':
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1);
|
||||
setFocusedId(emails[next].id);
|
||||
break;
|
||||
}
|
||||
case 'k':
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
const prev = idx < 0 ? 0 : Math.max(idx - 1, 0);
|
||||
setFocusedId(emails[prev].id);
|
||||
break;
|
||||
}
|
||||
case 'e': {
|
||||
if (idx < 0) break;
|
||||
e.preventDefault();
|
||||
const id = emails[idx].id;
|
||||
await BulkApi.archive([id]);
|
||||
onRemoved(id);
|
||||
break;
|
||||
}
|
||||
case 'u': {
|
||||
if (idx < 0) break;
|
||||
e.preventDefault();
|
||||
await BulkApi.markUnread([emails[idx].id]);
|
||||
break;
|
||||
}
|
||||
case '#': {
|
||||
if (idx < 0) break;
|
||||
e.preventDefault();
|
||||
const id = emails[idx].id;
|
||||
await BulkApi.trash([id]);
|
||||
onRemoved(id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
if (e.key === 'j') {
|
||||
e.preventDefault();
|
||||
const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1);
|
||||
setFocusedId(emails[next].id);
|
||||
} else if (e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const prev = idx < 0 ? 0 : Math.max(idx - 1, 0);
|
||||
setFocusedId(emails[prev].id);
|
||||
} else if (e.key === 'e' && idx >= 0) {
|
||||
e.preventDefault();
|
||||
const id = emails[idx].id;
|
||||
await BulkApi.archive([id]);
|
||||
onRemoved(id);
|
||||
} else if (e.key === '#' && idx >= 0) {
|
||||
e.preventDefault();
|
||||
const id = emails[idx].id;
|
||||
await BulkApi.trash([id]);
|
||||
onRemoved(id);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ToastProvider, TooltipProvider } from './components/ui';
|
||||
import '@fontsource-variable/inter';
|
||||
import './index.css';
|
||||
import './styles.css';
|
||||
import './split.css';
|
||||
|
||||
// Route-level code splitting: each page loads its own chunk on first visit, so the
|
||||
// initial bundle no longer carries Chart.js / grid-layout / every page at once.
|
||||
|
||||
@@ -6,9 +6,8 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
|
||||
import SyncSplash from '../components/SyncSplash.jsx';
|
||||
import {
|
||||
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
|
||||
CategoryBreakdownWidget, AttachmentsWidget, StorageWidget
|
||||
CategoryHeatmapWidget, AttachmentsWidget, StorageWidget
|
||||
} from '../components/widgets.jsx';
|
||||
import { Skeleton } from '../components/ui/skeleton.jsx';
|
||||
|
||||
// Default grid geometry; overridden by the user's saved layout.
|
||||
const DEFAULT_LAYOUT = [
|
||||
@@ -18,23 +17,12 @@ const DEFAULT_LAYOUT = [
|
||||
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 },
|
||||
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
|
||||
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 },
|
||||
{ i: 'category-breakdown', x: 0, y: 5, w: 6, h: 5 },
|
||||
{ i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 },
|
||||
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
|
||||
];
|
||||
|
||||
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
|
||||
|
||||
function WidgetSkeleton() {
|
||||
return (
|
||||
<div className="widget">
|
||||
<Skeleton className="mb-3 h-4 w-1/3" />
|
||||
<Skeleton className="mb-2 h-3 w-full" />
|
||||
<Skeleton className="mb-2 h-3 w-5/6" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState(null);
|
||||
const [layout, setLayout] = useState(DEFAULT_LAYOUT);
|
||||
@@ -99,18 +87,15 @@ export default function Dashboard() {
|
||||
const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]);
|
||||
|
||||
const render = (key) => {
|
||||
// category-breakdown self-fetches, so it renders regardless of dashboard load state.
|
||||
if (key === 'category-breakdown') return <CategoryBreakdownWidget />;
|
||||
// While the dashboard payload loads, show a skeleton placeholder per widget.
|
||||
if (!data) return <WidgetSkeleton />;
|
||||
switch (key) {
|
||||
case 'inbox-health': return <HealthWidget health={data.health} />;
|
||||
case 'total-emails': return <StatCard label="Total Emails" value={(data.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
|
||||
case 'unread-emails': return <StatCard label="Unread" value={(data.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
|
||||
case 'storage': return <StorageWidget bytes={data.storageEstimateBytes} />;
|
||||
case 'top-senders': return <TopSendersWidget senders={data.topSenders} />;
|
||||
case 'volume': return <VolumeWidget volume={data.volumeOverTime} />;
|
||||
case 'attachments': return <AttachmentsWidget attachments={data.attachmentBreakdown} />;
|
||||
case 'inbox-health': return <HealthWidget health={data?.health} />;
|
||||
case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
|
||||
case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
|
||||
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
|
||||
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
|
||||
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
|
||||
case 'category-heatmap': return <CategoryHeatmapWidget />;
|
||||
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,9 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SearchApi } from '../api/client.js';
|
||||
import EmailRow from '../components/EmailRow.jsx';
|
||||
import EmailDetail from '../components/EmailDetail.jsx';
|
||||
import BulkToolbar from '../components/BulkToolbar.jsx';
|
||||
import { Skeleton, EmptyState } from '../components/ui';
|
||||
import useSelection from '../hooks/useSelection.js';
|
||||
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
||||
|
||||
@@ -37,20 +35,6 @@ const FOLDER_META = {
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function ListSkeleton({ rows = 6 }) {
|
||||
return (
|
||||
<div className="sv-skeleton-list" aria-hidden="true">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div className="sv-skeleton-row" key={i}>
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function FolderView() {
|
||||
const { slug } = useParams();
|
||||
@@ -62,7 +46,6 @@ export default function FolderView() {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedEmail, setSelectedEmail] = useState(null);
|
||||
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
||||
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
|
||||
|
||||
@@ -73,7 +56,6 @@ export default function FolderView() {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setError(null);
|
||||
setSelectedEmail(null);
|
||||
clear();
|
||||
}, [slug, clear]);
|
||||
|
||||
@@ -133,57 +115,34 @@ export default function FolderView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}>
|
||||
<div className="sv-list-pane">
|
||||
{loading && emails.length === 0 && !error && <ListSkeleton />}
|
||||
{!loading && !error && emails.length === 0 && (
|
||||
<div className="fv-empty">No emails in this folder.</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && emails.length === 0 && (
|
||||
<EmptyState
|
||||
title="No emails in this folder"
|
||||
description="Nothing here yet — try another folder or run a sync."
|
||||
/>
|
||||
)}
|
||||
{emails.length > 0 && (
|
||||
<table className="email-list">
|
||||
<tbody>
|
||||
{emails.map((e) => (
|
||||
<EmailRow
|
||||
key={e.id}
|
||||
email={e}
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{emails.length > 0 && (
|
||||
<table className="email-list">
|
||||
<tbody>
|
||||
{emails.map((e) => (
|
||||
<EmailRow
|
||||
key={e.id}
|
||||
email={e}
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onOpen={(email) => setSelectedEmail(email)}
|
||||
onRemove={(id) => {
|
||||
setEmails((prev) => prev.filter((x) => x.id !== id));
|
||||
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{/* Sentinel — triggers next page load when scrolled into view */}
|
||||
<div ref={sentinelRef} className="fv-sentinel" />
|
||||
|
||||
{/* Sentinel — triggers next page load when scrolled into view */}
|
||||
<div ref={sentinelRef} className="fv-sentinel" />
|
||||
|
||||
{loading && emails.length > 0 && <div className="fv-loading-more">Loading…</div>}
|
||||
{!hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} emails —</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEmail && (
|
||||
<aside className="sv-reading-pane">
|
||||
<EmailDetail
|
||||
key={selectedEmail.id}
|
||||
email={selectedEmail}
|
||||
onClose={() => setSelectedEmail(null)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
{loading && <div className="fv-loading-more">Loading…</div>}
|
||||
{!hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} emails —</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,29 +2,13 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SearchApi } from '../api/client.js';
|
||||
import EmailRow from '../components/EmailRow.jsx';
|
||||
import EmailDetail from '../components/EmailDetail.jsx';
|
||||
import BulkToolbar from '../components/BulkToolbar.jsx';
|
||||
import { Skeleton, EmptyState } from '../components/ui';
|
||||
import useSelection from '../hooks/useSelection.js';
|
||||
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
||||
import useSavedSearches from '../hooks/useSavedSearches.js';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function ListSkeleton({ rows = 6 }) {
|
||||
return (
|
||||
<div className="sv-skeleton-list" aria-hidden="true">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div className="sv-skeleton-row" key={i}>
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function SearchResults() {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -36,7 +20,6 @@ export default function SearchResults() {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedEmail, setSelectedEmail] = useState(null);
|
||||
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
||||
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
|
||||
const { searches: savedSearches, add: addSavedSearch } = useSavedSearches();
|
||||
@@ -53,7 +36,6 @@ export default function SearchResults() {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setError(null);
|
||||
setSelectedEmail(null);
|
||||
clear();
|
||||
}, [q, clear]);
|
||||
|
||||
@@ -101,13 +83,11 @@ export default function SearchResults() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!q.trim() && (
|
||||
<EmptyState
|
||||
title="Search your mail"
|
||||
description="Enter a search query above to find emails."
|
||||
/>
|
||||
)}
|
||||
{!q.trim() && <div className="fv-empty">Enter a search query above.</div>}
|
||||
{error && <div className="fv-error">{error}</div>}
|
||||
{!loading && !error && q.trim() && emails.length === 0 && !hasMore && (
|
||||
<div className="fv-empty">No results for "{q}".</div>
|
||||
)}
|
||||
|
||||
<BulkToolbar
|
||||
selectedIds={selectedIds}
|
||||
@@ -120,56 +100,27 @@ export default function SearchResults() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{q.trim() && (
|
||||
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}>
|
||||
<div className="sv-list-pane">
|
||||
{loading && emails.length === 0 && !error && <ListSkeleton />}
|
||||
|
||||
{!loading && !error && emails.length === 0 && !hasMore && (
|
||||
<EmptyState
|
||||
title={`No results for "${q}"`}
|
||||
description="Try a different search term or filter."
|
||||
{emails.length > 0 && (
|
||||
<table className="email-list">
|
||||
<tbody>
|
||||
{emails.map((e) => (
|
||||
<EmailRow
|
||||
key={e.id}
|
||||
email={e}
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{emails.length > 0 && (
|
||||
<table className="email-list">
|
||||
<tbody>
|
||||
{emails.map((e) => (
|
||||
<EmailRow
|
||||
key={e.id}
|
||||
email={e}
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onOpen={(email) => setSelectedEmail(email)}
|
||||
onRemove={(id) => {
|
||||
setEmails((prev) => prev.filter((x) => x.id !== id));
|
||||
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} className="fv-sentinel" />
|
||||
{loading && emails.length > 0 && <div className="fv-loading-more">Loading…</div>}
|
||||
{!hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} results —</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEmail && (
|
||||
<aside className="sv-reading-pane">
|
||||
<EmailDetail
|
||||
key={selectedEmail.id}
|
||||
email={selectedEmail}
|
||||
onClose={() => setSelectedEmail(null)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
<div ref={sentinelRef} className="fv-sentinel" />
|
||||
{loading && <div className="fv-loading-more">Loading…</div>}
|
||||
{!hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} results —</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/* ── Split-view reading pane ────────────────────────────────────────────────
|
||||
* Owned by Unit 1. Gmail/Outlook-style master-detail: the email list stays on
|
||||
* the left, a collapsible + horizontally resizable reading pane sits on the
|
||||
* right. Below 768px the pane overlays the list (mobile stack) instead of
|
||||
* squishing the columns side-by-side.
|
||||
*
|
||||
* These pages (.folder-view) are styled from styles.css, so we reference its
|
||||
* legacy CSS variables (--panel, --panel-2, --text, --muted, --accent) with
|
||||
* hardcoded hex fallbacks. The modern index.css tokens are stored as raw HSL
|
||||
* channel triplets and only work via hsl(var(--x)), so they are NOT used bare
|
||||
* here.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.sv-split {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left column — the list. Flexes to fill remaining space and scrolls itself. */
|
||||
.sv-list-pane {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Right column — the reading pane. Resizable via the native handle; the width
|
||||
* gives it a sensible default, and the user can drag the bottom-right corner.
|
||||
* `resize: horizontal` needs `overflow` other than visible. */
|
||||
.sv-reading-pane {
|
||||
flex: 0 0 auto;
|
||||
width: clamp(320px, 42%, 640px);
|
||||
min-width: 300px;
|
||||
max-width: 80vw;
|
||||
border-left: 1px solid var(--panel-2, #222a3d);
|
||||
overflow: auto;
|
||||
resize: horizontal;
|
||||
background: var(--panel, #1a2030);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* ── Reading pane inner content ─────────────────────────────────────────── */
|
||||
.sv-detail { padding: 18px 20px; }
|
||||
|
||||
.sv-detail-topbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sv-close {
|
||||
background: transparent;
|
||||
border: 1px solid var(--panel-2, #222a3d);
|
||||
color: var(--muted, #8b93a7);
|
||||
border-radius: 6px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sv-close:hover {
|
||||
background: var(--panel-2, #222a3d);
|
||||
color: var(--text, #e6e9f0);
|
||||
}
|
||||
|
||||
.sv-detail-header { margin-bottom: 14px; }
|
||||
.sv-detail-subject {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text, #e6e9f0);
|
||||
word-break: break-word;
|
||||
}
|
||||
.sv-detail-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted, #8b93a7);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.sv-detail-sep { opacity: 0.4; }
|
||||
|
||||
.sv-ai-summary { margin-bottom: 14px; }
|
||||
.sv-ai-summary-text {
|
||||
background: var(--panel-2, #222a3d);
|
||||
border: 1px solid var(--panel-2, #222a3d);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e6e9f0);
|
||||
}
|
||||
|
||||
.sv-body-loading { padding: 8px 0 18px; }
|
||||
.sv-body {
|
||||
background: var(--panel, #1a2030);
|
||||
border: 1px solid var(--panel-2, #222a3d);
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text, #e6e9f0);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 18px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sv-snippet {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted, #8b93a7);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.sv-detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* ── List loading / empty states ───────────────────────────────────────── */
|
||||
.sv-skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.sv-skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* ── Mobile: stack / overlay the reading pane ──────────────────────────── */
|
||||
@media (max-width: 767px) {
|
||||
.sv-reading-pane {
|
||||
position: fixed;
|
||||
inset: 56px 0 0 0; /* below the topbar */
|
||||
width: 100% !important;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
border-left: none;
|
||||
resize: none;
|
||||
z-index: 40;
|
||||
}
|
||||
/* When the pane is open, hide the underlying list to avoid double-scroll. */
|
||||
.sv-split.sv-split--open .sv-list-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
+27
-53
@@ -117,6 +117,8 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
.widget--link:hover { border-color: var(--accent); }
|
||||
.mini-row--link { cursor: pointer; }
|
||||
.mini-row--link:hover td { color: var(--accent); }
|
||||
.chm-label--link { cursor: pointer; text-decoration: underline dotted; }
|
||||
.chm-label--link:hover { color: var(--accent); }
|
||||
.widget canvas { flex: 1; min-height: 0; }
|
||||
|
||||
.stat { align-items: flex-start; justify-content: center; }
|
||||
@@ -135,21 +137,17 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #332f2b; t
|
||||
.num { text-align: right; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* Emails by Category — ranked horizontal bars */
|
||||
.cat-bars { display: flex; flex-direction: column; gap: 6px; overflow: auto; }
|
||||
.cat-bar {
|
||||
display: grid; grid-template-columns: 92px 1fr 40px; gap: 8px; align-items: center;
|
||||
width: 100%; padding: 3px 4px; margin: 0; border: none; background: transparent;
|
||||
border-radius: 6px; text-align: left; font: inherit; color: inherit;
|
||||
}
|
||||
.cat-bar--link { cursor: pointer; }
|
||||
.cat-bar--link:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.cat-bar:disabled { cursor: default; }
|
||||
.cat-bar-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cat-bar-track { height: 14px; background: rgba(255, 255, 255, 0.06); border-radius: 4px; overflow: hidden; }
|
||||
.cat-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; }
|
||||
.cat-bar-count { font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.cat-bar--link:hover .cat-bar-label { color: var(--accent); }
|
||||
.heatmap { display: flex; flex-direction: column; gap: 2px; }
|
||||
.hm-row { display: flex; align-items: center; gap: 2px; }
|
||||
.hm-day { width: 30px; font-size: 10px; color: var(--muted); }
|
||||
.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; }
|
||||
|
||||
/* Category heatmap */
|
||||
.cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; }
|
||||
.chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; }
|
||||
.chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; }
|
||||
.chm-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chm-cell { background: var(--accent); border-radius: 3px; min-height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; }
|
||||
|
||||
/* react-grid-layout resize handle — make it clearly visible on the dark theme */
|
||||
.react-resizable-handle {
|
||||
@@ -298,57 +296,32 @@ input, select { background: var(--panel-2); border: 1px solid #332f2b; color: va
|
||||
.fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; }
|
||||
|
||||
.email-list { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.email-row {
|
||||
height: 44px;
|
||||
border-bottom: 1px solid #332f2b;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
.email-row > td { padding-top: 0; padding-bottom: 0; vertical-align: middle; }
|
||||
/* Single, consistent hover state for the whole row. */
|
||||
.email-row { border-bottom: 1px solid #332f2b; cursor: pointer; }
|
||||
.email-row:hover { background: var(--panel); }
|
||||
/* Unread: prominent subject, keep sender readable but not shouty. */
|
||||
.email-row--unread .el-sender,
|
||||
.email-row--unread .el-subj-text { font-weight: 700; color: var(--text); }
|
||||
.email-row--unread .el-sender { font-weight: 600; color: var(--text); }
|
||||
|
||||
.el-select { width: 34px; padding: 0 4px 0 10px; text-align: center; }
|
||||
.el-select > * { vertical-align: middle; }
|
||||
|
||||
.el-unread { width: 14px; padding: 0 4px 0 0; text-align: center; }
|
||||
.el-unread { width: 14px; padding: 10px 4px 10px 0; }
|
||||
.unread-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
|
||||
/* Sender: secondary in the hierarchy — muted by default. */
|
||||
.el-sender {
|
||||
width: 180px; padding: 0 12px 0 4px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
color: var(--muted); font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* Subject + snippet share one line: subject prominent, snippet muted. */
|
||||
.el-subject { padding: 0 8px; overflow: hidden; max-width: 0; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.el-sender { width: 180px; padding: 10px 12px 10px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
|
||||
.el-subject { padding: 10px 8px; overflow: hidden; }
|
||||
.el-subj-text { color: var(--text); }
|
||||
.el-snippet {
|
||||
color: var(--muted); font-size: 12.5px;
|
||||
}
|
||||
.el-snippet::before { content: '—'; margin: 0 6px; opacity: 0.55; }
|
||||
|
||||
.el-meta { width: 80px; padding: 0 8px; text-align: right; white-space: nowrap; }
|
||||
.el-snippet { color: var(--muted); }
|
||||
.el-meta { width: 80px; padding: 10px 8px; text-align: right; white-space: nowrap; }
|
||||
.el-attach { margin-right: 4px; font-size: 12px; }
|
||||
.el-size { font-size: 11px; color: var(--muted); }
|
||||
.el-date { width: 70px; padding: 0 0 0 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; }
|
||||
.el-date { width: 70px; padding: 10px 0 10px 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; }
|
||||
|
||||
.el-actions { width: 96px; padding: 0 6px; text-align: right; white-space: nowrap; }
|
||||
.el-actions { width: 80px; padding: 0 6px; text-align: right; white-space: nowrap; }
|
||||
.action-btn {
|
||||
background: none; border: none; padding: 4px 5px; cursor: pointer;
|
||||
font-size: 13px; line-height: 1; opacity: 0;
|
||||
transition: opacity 0.1s ease, color 0.1s ease, background 0.1s ease;
|
||||
background: none; border: none; padding: 3px 4px; cursor: pointer;
|
||||
font-size: 13px; opacity: 0; transition: opacity 0.1s, color 0.1s;
|
||||
border-radius: 4px; color: var(--muted);
|
||||
}
|
||||
.action-btn:hover { background: var(--panel-2); color: var(--text); opacity: 1 !important; }
|
||||
.action-btn:hover { background: var(--panel-2); opacity: 1 !important; }
|
||||
.action-btn--active { opacity: 1 !important; }
|
||||
.action-btn--danger:hover { color: var(--danger); }
|
||||
.email-row:hover .action-btn { opacity: 0.65; }
|
||||
.action-btn:focus-visible { opacity: 1 !important; outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.email-row:hover .action-btn { opacity: 0.6; }
|
||||
.email-row--acting { opacity: 0.6; pointer-events: none; }
|
||||
.action-btn--unsub { font-size: 11px; }
|
||||
.action-btn--done { opacity: 1 !important; color: var(--ok); }
|
||||
@@ -432,6 +405,7 @@ input, select { background: var(--panel-2); border: 1px solid #332f2b; color: va
|
||||
}
|
||||
.bulk-btn:hover { border-color: var(--accent); }
|
||||
.bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||
.el-select { width: 28px; text-align: center; }
|
||||
|
||||
/* ── Keyboard shortcut help ──────────────────────────────────────────────── */
|
||||
.kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #332f2b; border-radius: 6px; padding: 4px 10px; opacity: 0.7; }
|
||||
|
||||
@@ -24,6 +24,9 @@ public class AnalyticsController : ApiControllerBase
|
||||
public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default)
|
||||
=> Ok(await _analytics.GetVolumeOverTimeAsync(UserId, Math.Clamp(days, 1, 3660), ct));
|
||||
|
||||
[HttpGet("heatmap")]
|
||||
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
|
||||
|
||||
[HttpGet("category-heatmap")]
|
||||
public async Task<IActionResult> CategoryHeatmap(CancellationToken ct) => Ok(await _analytics.GetCategoryHeatmapAsync(UserId, ct));
|
||||
|
||||
|
||||
@@ -72,22 +72,6 @@ public class EmailController : ApiControllerBase
|
||||
[HttpPost("{id:guid}/untrash")]
|
||||
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
|
||||
|
||||
/// <summary>Toggle the local-only Read Later marker (no Gmail side effect).</summary>
|
||||
[HttpPost("{id:guid}/readlater")]
|
||||
public Task<IActionResult> ReadLater(Guid id, CancellationToken ct) => SetReadLater(id, true, ct);
|
||||
|
||||
[HttpPost("{id:guid}/unreadlater")]
|
||||
public Task<IActionResult> UnreadLater(Guid id, CancellationToken ct) => SetReadLater(id, false, ct);
|
||||
|
||||
private async Task<IActionResult> SetReadLater(Guid id, bool value, CancellationToken ct)
|
||||
{
|
||||
var email = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct);
|
||||
if (email is null) return NotFound();
|
||||
email.IsReadLater = value;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender,
|
||||
/// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent
|
||||
|
||||
@@ -15,14 +15,6 @@ RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/p
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# The slim aspnet:10.0 image dropped libgssapi_krb5, which Npgsql tries to load during
|
||||
# connection negotiation ("Cannot load library libgssapi_krb5.so.2"). Harmless for password
|
||||
# auth but noisy and a latent failure on some paths — install the Kerberos runtime lib.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libgssapi-krb5-2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# V-12: run as the non-root 'app' user shipped in the .NET 8 images. Pre-create the
|
||||
|
||||
@@ -15,11 +15,6 @@
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.3" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
|
||||
@@ -16,10 +16,6 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -126,28 +122,6 @@ builder.Services.AddAuthentication(options =>
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// RECOMMENDATIONS #5: OpenTelemetry traces + metrics (ASP.NET, outbound HTTP, Npgsql).
|
||||
// The OTLP exporter only activates when Otel:Endpoint (or the standard
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT env var) is configured — zero overhead otherwise.
|
||||
// Logs stay on Serilog. Pair with the compose "observability" profile (grafana/otel-lgtm).
|
||||
var otlpEndpoint = builder.Configuration["Otel:Endpoint"]
|
||||
?? Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
|
||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint))
|
||||
{
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService("inboxintel-api"))
|
||||
.WithTracing(t => t
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddNpgsql()
|
||||
.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)))
|
||||
.WithMetrics(m => m
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddNpgsqlInstrumentation()
|
||||
.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)));
|
||||
}
|
||||
|
||||
builder.Services.AddApiVersioning(o =>
|
||||
{
|
||||
o.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
|
||||
@@ -22,6 +22,7 @@ public interface IAnalyticsService
|
||||
Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default);
|
||||
@@ -119,15 +120,3 @@ public interface IDigestService
|
||||
{
|
||||
Task SendDigestAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>System feature flags (fail-closed: unknown key = disabled).</summary>
|
||||
public interface IFeatureFlags
|
||||
{
|
||||
Task<bool> IsEnabledAsync(string key, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Policy gate for AI features: system flag AND the user's opt-in.</summary>
|
||||
public interface IAiGate
|
||||
{
|
||||
Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public record InboxHealthDto(
|
||||
|
||||
public record TimeSeriesPointDto(DateOnly Day, int Count);
|
||||
|
||||
public record HeatmapCellDto(int DayOfWeek, int Hour, int Count);
|
||||
|
||||
/// <summary>Email counts per category per day-of-week, for the category heatmap.</summary>
|
||||
public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count);
|
||||
@@ -38,5 +39,6 @@ public record DashboardSummaryDto(
|
||||
int UnreadEmails,
|
||||
IReadOnlyList<SenderStatDto> TopSenders,
|
||||
IReadOnlyList<TimeSeriesPointDto> VolumeOverTime,
|
||||
IReadOnlyList<HeatmapCellDto> Heatmap,
|
||||
IReadOnlyList<AttachmentBreakdownDto> AttachmentBreakdown,
|
||||
long StorageEstimateBytes);
|
||||
|
||||
@@ -21,19 +21,6 @@ public record SearchRequestDto(
|
||||
bool? IsInInbox = null,
|
||||
bool? IsStarred = null,
|
||||
bool? IsTrashed = null,
|
||||
bool? IsImportant = null, // Gmail "important" marker — backs the Pinned smart folder
|
||||
bool? IsReadLater = null, // local Read Later marker
|
||||
string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM"
|
||||
// Emails carrying NONE of these Gmail labels (by GmailLabelId). Backs Archive, which
|
||||
// must exclude Sent/Spam/Draft/Chat/Trash rather than just "not in inbox".
|
||||
IReadOnlyList<string>? ExcludeGmailLabels = null,
|
||||
// true = only emails with at least one USER label; false = only emails with NO user
|
||||
// labels (the Unlabelled folder). System labels (INBOX/SENT/…) don't count.
|
||||
bool? HasUserLabels = null,
|
||||
string? Category = null, // EmailCategory name, e.g. "Finance"
|
||||
long? MinSizeBytes = null,
|
||||
// Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last
|
||||
// row's SentAtUtc+Id to fetch the next window without OFFSET (O(pageSize), not O(page)).
|
||||
// When set, TotalCount is not recomputed (-1). Additive; offset paging still works.
|
||||
DateTimeOffset? AfterSentAtUtc = null,
|
||||
Guid? AfterId = null);
|
||||
long? MinSizeBytes = null);
|
||||
|
||||
@@ -40,10 +40,6 @@ public class Email : AuditableEntity
|
||||
public bool IsTrashed { get; set; }
|
||||
public bool HasAttachments { get; set; }
|
||||
|
||||
/// <summary>Local "Read Later" marker (not a Gmail concept) — toggled by the user so the
|
||||
/// Read Later smart folder shows only explicitly flagged mail, never everything.</summary>
|
||||
public bool IsReadLater { get; set; }
|
||||
|
||||
// Unsubscribe signals captured at parse time.
|
||||
public bool HasListUnsubscribe { get; set; }
|
||||
public string? ListUnsubscribeRaw { get; set; }
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
using InboxIntel.Domain.Common;
|
||||
|
||||
namespace InboxIntel.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// System-wide feature flag (docs/discovery/multi-provider/04). The admin master switches:
|
||||
/// a disabled flag turns its feature off for EVERYONE regardless of user preferences.
|
||||
/// Reads are fail-closed — an unknown key counts as disabled.
|
||||
/// </summary>
|
||||
public class FeatureFlag : AuditableEntity
|
||||
{
|
||||
/// <summary>Stable key, e.g. "ai.enabled", "provider.google".</summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>True = a user preference may turn the feature OFF for themselves
|
||||
/// (never on beyond the flag); false = system-only switch.</summary>
|
||||
public bool UserOverridable { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-user preferences (docs/discovery/multi-provider/04). One row per user, created
|
||||
/// lazily; absent row = defaults. AiOptIn defaults true so enabling the ai.enabled flag
|
||||
/// behaves exactly like today until a user opts out.
|
||||
/// </summary>
|
||||
public class UserSetting : AuditableEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
/// <summary>"system" | "light" | "dark".</summary>
|
||||
public string Theme { get; set; } = "dark";
|
||||
|
||||
/// <summary>Master per-user AI opt-in (effective only while ai.enabled is on).</summary>
|
||||
public bool AiOptIn { get; set; } = true;
|
||||
|
||||
/// <summary>Free-form UI preferences (layout, density, notifications) as JSON.</summary>
|
||||
public string? PreferencesJson { get; set; }
|
||||
}
|
||||
@@ -16,10 +16,11 @@ public class AnalyticsService : IAnalyticsService
|
||||
var health = await GetInboxHealthAsync(userId, ct);
|
||||
var top = await GetTopSendersAsync(userId, 10, ct);
|
||||
var volume = await GetVolumeOverTimeAsync(userId, 90, ct);
|
||||
var heatmap = await GetHeatmapAsync(userId, ct);
|
||||
var attachments = await GetAttachmentBreakdownAsync(userId, ct);
|
||||
|
||||
return new DashboardSummaryDto(
|
||||
health, health.TotalEmails, health.UnreadEmails, top, volume, attachments,
|
||||
health, health.TotalEmails, health.UnreadEmails, top, volume, heatmap, attachments,
|
||||
health.EstimatedStorageBytes);
|
||||
}
|
||||
|
||||
@@ -74,6 +75,19 @@ public class AnalyticsService : IAnalyticsService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var raw = await _db.Emails
|
||||
.Where(e => e.UserId == userId)
|
||||
.Select(e => new { e.SentAtUtc })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return raw
|
||||
.GroupBy(x => new { Dow = (int)x.SentAtUtc.DayOfWeek, Hour = x.SentAtUtc.Hour })
|
||||
.Select(g => new HeatmapCellDto(g.Key.Dow, g.Key.Hour, g.Count()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var raw = await _db.Emails
|
||||
|
||||
@@ -92,11 +92,6 @@ public static class DependencyInjection
|
||||
break;
|
||||
}
|
||||
services.AddScoped<IAiService, AiService>();
|
||||
// Feature flags + AI policy gate (docs/discovery/multi-provider/04). Cached 15s,
|
||||
// fail-closed. Admin toggle surface arrives with the multi-provider admin phase.
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<IFeatureFlags, Features.FeatureFlagService>();
|
||||
services.AddScoped<IAiGate, Features.AiGate>();
|
||||
// Semantic search: fills Email.Embedding in the background; no-ops when the
|
||||
// embedding provider is unavailable (AI disabled), so lexical search is unaffected.
|
||||
services.AddHostedService<EmbeddingBackfillWorker>();
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Flag evaluation (docs/discovery/multi-provider/04). DB-backed with a short cache so an
|
||||
/// admin toggle takes effect within seconds and per-request reads stay free.
|
||||
/// FAIL-CLOSED: unknown keys and read errors evaluate to disabled.
|
||||
/// </summary>
|
||||
public class FeatureFlagService : IFeatureFlags
|
||||
{
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15);
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public FeatureFlagService(AppDbContext db, IMemoryCache cache)
|
||||
{
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<bool> IsEnabledAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var flags = await _cache.GetOrCreateAsync("feature-flags", async e =>
|
||||
{
|
||||
e.AbsoluteExpirationRelativeToNow = CacheTtl;
|
||||
return await _db.FeatureFlags.AsNoTracking()
|
||||
.ToDictionaryAsync(f => f.Key, f => f.Enabled, ct);
|
||||
});
|
||||
return flags is not null && flags.TryGetValue(key, out var enabled) && enabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // fail closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The AI gate (the audit/design requirement that AI is governed by a FLAG, not only user
|
||||
/// settings): effective AI = ai.enabled (admin, global) AND the user's opt-in (default true,
|
||||
/// only consulted while the flag is on). Callers still check provider availability
|
||||
/// (IAiService.IsEnabled / IEmbeddingProvider.IsAvailable) — this gate is policy, not plumbing.
|
||||
/// </summary>
|
||||
public class AiGate : IAiGate
|
||||
{
|
||||
public const string MasterFlag = "ai.enabled";
|
||||
private readonly IFeatureFlags _flags;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AiGate(IFeatureFlags flags, AppDbContext db)
|
||||
{
|
||||
_flags = flags;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _flags.IsEnabledAsync(MasterFlag, ct)) return false;
|
||||
// Absent settings row = default opt-in true.
|
||||
var optIn = await _db.UserSettings.AsNoTracking()
|
||||
.Where(s => s.UserId == userId)
|
||||
.Select(s => (bool?)s.AiOptIn)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return optIn ?? true;
|
||||
}
|
||||
}
|
||||
@@ -95,9 +95,7 @@ public class GmailApiService : IGmailService
|
||||
var req = client.Users.Messages.List("me");
|
||||
req.MaxResults = _options.PageSize;
|
||||
req.PageToken = pageToken;
|
||||
// Include spam & trash so those folders aren't structurally empty; their state is
|
||||
// captured via the SPAM/TRASH labels (IsTrashed + EmailLabels) during upsert.
|
||||
req.IncludeSpamTrash = true;
|
||||
req.IncludeSpamTrash = false;
|
||||
return await req.ExecuteAsync(token);
|
||||
}, ct);
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
Generated
-831
@@ -1,831 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260702152850_FeatureFlagsAndUserSettings")]
|
||||
partial class FeatureFlagsAndUserSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Day")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("HourHistogramJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("NewsletterCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalReceived")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("TotalUnread")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("WithAttachments")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Day")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("analytics_aggregates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("GmailAttachmentId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmailId");
|
||||
|
||||
b.HasIndex("UserId", "MimeType");
|
||||
|
||||
b.ToTable("attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BodyText")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Vector>("Embedding")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("GmailMessageId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("HasAttachments")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HasListUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsImportant")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsInInbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsTrashed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsUnread")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ListUnsubscribeRaw")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReceivedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchVector")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("SentAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("SizeEstimateBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<bool>("SupportsOneClickUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("ThreadId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Embedding");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
|
||||
|
||||
b.HasIndex("SearchVector");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
|
||||
|
||||
b.HasIndex("SenderId");
|
||||
|
||||
b.HasIndex("ThreadId");
|
||||
|
||||
b.HasIndex("UserId", "Category");
|
||||
|
||||
b.HasIndex("UserId", "GmailMessageId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "IsInInbox");
|
||||
|
||||
b.HasIndex("UserId", "IsUnread");
|
||||
|
||||
b.HasIndex("UserId", "SenderId");
|
||||
|
||||
b.HasIndex("UserId", "SentAtUtc");
|
||||
|
||||
b.ToTable("emails", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
|
||||
{
|
||||
b.Property<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("LabelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("EmailId", "LabelId");
|
||||
|
||||
b.HasIndex("LabelId");
|
||||
|
||||
b.ToTable("email_labels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("UserOverridable")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("feature_flags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailLabelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "GmailLabelId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("labels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsBulkSender")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("UserId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("domains", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("FirstMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailThreadId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MessageCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "GmailThreadId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("threads", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("HasUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastReceivedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("UnreadCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Address");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("DisplayName");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("DomainId");
|
||||
|
||||
b.HasIndex("UserId", "Address")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "EmailCount");
|
||||
|
||||
b.ToTable("senders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("LastHistoryId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("LastSyncType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MessagesProcessed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResumePageToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalMessagesEstimate")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("sync_states", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAttemptUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Method")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResultMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UnsubscribeTarget")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SenderId");
|
||||
|
||||
b.HasIndex("UserId", "SenderId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("unsubscribe_items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DigestEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<byte[]>("EncryptedRefreshToken")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("GoogleSubjectId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastDigestSentUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PictureUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("GoogleSubjectId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AiOptIn")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreferencesJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("H")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SettingsJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Visible")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("W")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("WidgetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("X")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Y")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "WidgetKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("widget_layouts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
|
||||
.WithMany("Attachments")
|
||||
.HasForeignKey("EmailId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Email");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
|
||||
.WithMany("Emails")
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
|
||||
.WithMany("Emails")
|
||||
.HasForeignKey("ThreadId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||
.WithMany("Emails")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Sender");
|
||||
|
||||
b.Navigation("Thread");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
|
||||
.WithMany("EmailLabels")
|
||||
.HasForeignKey("EmailId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
|
||||
.WithMany("EmailLabels")
|
||||
.HasForeignKey("LabelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Email");
|
||||
|
||||
b.Navigation("Label");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
|
||||
.WithMany("Senders")
|
||||
.HasForeignKey("DomainId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Domain");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
|
||||
.WithMany()
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", "User")
|
||||
.WithOne()
|
||||
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||
.WithMany("WidgetLayouts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||
{
|
||||
b.Navigation("Attachments");
|
||||
|
||||
b.Navigation("EmailLabels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Navigation("EmailLabels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
|
||||
{
|
||||
b.Navigation("Senders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
|
||||
{
|
||||
b.Navigation("Emails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||
{
|
||||
b.Navigation("Emails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Navigation("Emails");
|
||||
|
||||
b.Navigation("WidgetLayouts");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeatureFlagsAndUserSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "feature_flags",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UserOverridable = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_feature_flags", x => x.Key);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "user_settings",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Theme = table.Column<string>(type: "text", nullable: false),
|
||||
AiOptIn = table.Column<bool>(type: "boolean", nullable: false),
|
||||
PreferencesJson = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_user_settings", x => x.UserId);
|
||||
table.ForeignKey(
|
||||
name: "FK_user_settings_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
// Behaviour-preserving defaults (docs/discovery/multi-provider/04): ai.enabled on
|
||||
// (AI availability still requires Ai:Mode + provider), Google on, others off.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO feature_flags ("Key", "Enabled", "UserOverridable", "Description", "CreatedAtUtc", "UpdatedAtUtc")
|
||||
VALUES
|
||||
('ai.enabled', TRUE, TRUE, 'Master AI switch: off hides AI for everyone', NOW(), NOW()),
|
||||
('provider.google', TRUE, FALSE, 'Google/Gmail provider', NOW(), NOW()),
|
||||
('provider.microsoft', FALSE, FALSE, 'Microsoft/Outlook provider (future)', NOW(), NOW()),
|
||||
('provider.imap', FALSE, FALSE, 'IMAP provider (future)', NOW(), NOW())
|
||||
ON CONFLICT ("Key") DO NOTHING;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "feature_flags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
-834
@@ -1,834 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260704141744_AddIsReadLater")]
|
||||
partial class AddIsReadLater
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Day")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("HourHistogramJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("NewsletterCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalReceived")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("TotalUnread")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("WithAttachments")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Day")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("analytics_aggregates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("GmailAttachmentId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EmailId");
|
||||
|
||||
b.HasIndex("UserId", "MimeType");
|
||||
|
||||
b.ToTable("attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BodyText")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Vector>("Embedding")
|
||||
.HasColumnType("vector(768)");
|
||||
|
||||
b.Property<string>("GmailMessageId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("HasAttachments")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HasListUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsImportant")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsInInbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsReadLater")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsTrashed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsUnread")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ListUnsubscribeRaw")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReceivedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchVector")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("SentAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("SizeEstimateBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<bool>("SupportsOneClickUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("ThreadId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Embedding");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
|
||||
|
||||
b.HasIndex("SearchVector");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
|
||||
|
||||
b.HasIndex("SenderId");
|
||||
|
||||
b.HasIndex("ThreadId");
|
||||
|
||||
b.HasIndex("UserId", "Category");
|
||||
|
||||
b.HasIndex("UserId", "GmailMessageId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "IsInInbox");
|
||||
|
||||
b.HasIndex("UserId", "IsUnread");
|
||||
|
||||
b.HasIndex("UserId", "SenderId");
|
||||
|
||||
b.HasIndex("UserId", "SentAtUtc");
|
||||
|
||||
b.ToTable("emails", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
|
||||
{
|
||||
b.Property<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("LabelId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("EmailId", "LabelId");
|
||||
|
||||
b.HasIndex("LabelId");
|
||||
|
||||
b.ToTable("email_labels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("UserOverridable")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("feature_flags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailLabelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "GmailLabelId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("labels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsBulkSender")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("UserId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("domains", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("FirstMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailThreadId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MessageCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "GmailThreadId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("threads", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("HasUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastReceivedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("UnreadCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Address");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("DisplayName");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("DomainId");
|
||||
|
||||
b.HasIndex("UserId", "Address")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "EmailCount");
|
||||
|
||||
b.ToTable("senders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("LastHistoryId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("LastSyncType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MessagesProcessed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResumePageToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalMessagesEstimate")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("sync_states", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAttemptUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Method")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResultMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UnsubscribeTarget")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SenderId");
|
||||
|
||||
b.HasIndex("UserId", "SenderId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("unsubscribe_items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DigestEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<byte[]>("EncryptedRefreshToken")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("GoogleSubjectId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastDigestSentUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PictureUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("GoogleSubjectId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AiOptIn")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreferencesJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("H")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SettingsJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Visible")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("W")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("WidgetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("X")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Y")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "WidgetKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("widget_layouts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
|
||||
.WithMany("Attachments")
|
||||
.HasForeignKey("EmailId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Email");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
|
||||
.WithMany("Emails")
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
|
||||
.WithMany("Emails")
|
||||
.HasForeignKey("ThreadId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||
.WithMany("Emails")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Sender");
|
||||
|
||||
b.Navigation("Thread");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
|
||||
.WithMany("EmailLabels")
|
||||
.HasForeignKey("EmailId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
|
||||
.WithMany("EmailLabels")
|
||||
.HasForeignKey("LabelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Email");
|
||||
|
||||
b.Navigation("Label");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
|
||||
.WithMany("Senders")
|
||||
.HasForeignKey("DomainId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Domain");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
|
||||
.WithMany()
|
||||
.HasForeignKey("SenderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", "User")
|
||||
.WithOne()
|
||||
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||
.WithMany("WidgetLayouts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||
{
|
||||
b.Navigation("Attachments");
|
||||
|
||||
b.Navigation("EmailLabels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Navigation("EmailLabels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
|
||||
{
|
||||
b.Navigation("Senders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
|
||||
{
|
||||
b.Navigation("Emails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||
{
|
||||
b.Navigation("Emails");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
|
||||
{
|
||||
b.Navigation("Emails");
|
||||
|
||||
b.Navigation("WidgetLayouts");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsReadLater : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsReadLater",
|
||||
table: "emails",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsReadLater",
|
||||
table: "emails");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -19,7 +19,7 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
@@ -147,9 +147,6 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.Property<bool>("IsInInbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsReadLater")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -246,32 +243,6 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.ToTable("email_labels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("UserOverridable")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("feature_flags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -620,32 +591,6 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AiOptIn")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreferencesJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -772,17 +717,6 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", "User")
|
||||
.WithOne()
|
||||
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||
|
||||
@@ -29,8 +29,6 @@ public class AppDbContext : DbContext, IAppDbContext
|
||||
public DbSet<Email> Emails => Set<Email>();
|
||||
public DbSet<MailThread> Threads => Set<MailThread>();
|
||||
public DbSet<Sender> Senders => Set<Sender>();
|
||||
public DbSet<FeatureFlag> FeatureFlags => Set<FeatureFlag>();
|
||||
public DbSet<UserSetting> UserSettings => Set<UserSetting>();
|
||||
public DbSet<MailDomain> Domains => Set<MailDomain>();
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
public DbSet<Label> Labels => Set<Label>();
|
||||
@@ -49,36 +47,20 @@ public class AppDbContext : DbContext, IAppDbContext
|
||||
// its manual `WHERE UserId ==` clause cannot leak across tenants. Applied
|
||||
// uniformly to all user-scoped entities so EF sees no filtered/unfiltered
|
||||
// navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers).
|
||||
modelBuilder.Entity<Email>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Sender>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<MailThread>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<MailDomain>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Attachment>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Label>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Email>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Sender>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<MailThread>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<MailDomain>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Attachment>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Label>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
// AUDIT M-6: EmailLabel is the required end of a relationship with the filtered Email
|
||||
// entity; without a matching filter EF warns on boot and joins could surface rows whose
|
||||
// parent is filtered out. Filter via the Email navigation so the pair is consistent.
|
||||
modelBuilder.Entity<EmailLabel>().HasQueryFilter("Tenant", el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<SyncState>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<WidgetLayout>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<UserSetting>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
|
||||
// Feature flags are system-wide (no tenant filter). Key is the natural PK.
|
||||
modelBuilder.Entity<FeatureFlag>(b =>
|
||||
{
|
||||
b.ToTable("feature_flags");
|
||||
b.HasKey(f => f.Key);
|
||||
b.Property(f => f.Key).HasMaxLength(128);
|
||||
});
|
||||
modelBuilder.Entity<UserSetting>(b =>
|
||||
{
|
||||
b.ToTable("user_settings");
|
||||
b.HasKey(x => x.UserId);
|
||||
// 1:1 with User sharing the PK — prevents a shadow UserId1 FK column.
|
||||
b.HasOne(x => x.User).WithOne().HasForeignKey<UserSetting>(x => x.UserId);
|
||||
});
|
||||
modelBuilder.Entity<EmailLabel>().HasQueryFilter(el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<SyncState>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<WidgetLayout>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
|
||||
// PostgreSQL full-text search: generated tsvector over subject + body with a
|
||||
// GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
|
||||
|
||||
@@ -52,32 +52,8 @@ public class SearchService : ISearchService
|
||||
q = q.Where(e => e.IsStarred == starred);
|
||||
if (r.IsTrashed is { } trashed)
|
||||
q = q.Where(e => e.IsTrashed == trashed);
|
||||
if (r.IsImportant is { } important)
|
||||
q = q.Where(e => e.IsImportant == important);
|
||||
if (r.IsReadLater is { } readLater)
|
||||
q = q.Where(e => e.IsReadLater == readLater);
|
||||
if (r.MinSizeBytes is { } minSize)
|
||||
q = q.Where(e => e.SizeEstimateBytes >= minSize);
|
||||
// Unlabelled: emails with (no) USER labels. System labels (INBOX/SENT/…) don't count,
|
||||
// so Unlabelled means "not filed into any user-created label".
|
||||
if (r.HasUserLabels is { } hasUserLabels)
|
||||
{
|
||||
if (hasUserLabels)
|
||||
q = q.Where(e => e.EmailLabels.Any(el => el.Label!.Type == "user"));
|
||||
else
|
||||
q = q.Where(e => !e.EmailLabels.Any(el => el.Label!.Type == "user"));
|
||||
}
|
||||
// Archive excludes Sent/Spam/Draft/Chat/Trash by label absence, not just "not in inbox".
|
||||
if (r.ExcludeGmailLabels is { Count: > 0 } exclude)
|
||||
{
|
||||
var excludeUpper = exclude.Select(x => x.ToUpperInvariant()).ToList();
|
||||
var excludeIds = await _db.Labels
|
||||
.Where(l => l.UserId == userId && excludeUpper.Contains(l.GmailLabelId.ToUpper()))
|
||||
.Select(l => l.Id)
|
||||
.ToListAsync(ct);
|
||||
if (excludeIds.Count > 0)
|
||||
q = q.Where(e => !e.EmailLabels.Any(el => excludeIds.Contains(el.LabelId)));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(r.Category) && Enum.TryParse<EmailCategory>(r.Category, true, out var cat))
|
||||
q = q.Where(e => e.Category == cat);
|
||||
if (!string.IsNullOrWhiteSpace(r.GmailLabel))
|
||||
@@ -138,23 +114,9 @@ public class SearchService : ISearchService
|
||||
ranked = matched.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
|
||||
.ThenByDescending(e => e.SentAtUtc);
|
||||
else
|
||||
ranked = matched.OrderByDescending(e => e.SentAtUtc).ThenByDescending(e => e.Id);
|
||||
ranked = matched.OrderByDescending(e => e.SentAtUtc);
|
||||
|
||||
// Keyset (cursor) pagination for the browse path: O(pageSize) regardless of depth,
|
||||
// vs OFFSET's O(page*pageSize). The (SentAtUtc, Id) pair with the Id tie-break above
|
||||
// makes the ordering total, so windows never duplicate or skip rows.
|
||||
IQueryable<Email> paged;
|
||||
if (!hasFreeTextQuery && r is { AfterSentAtUtc: { } afterAt, AfterId: { } afterId })
|
||||
{
|
||||
paged = ranked
|
||||
.Where(e => e.SentAtUtc < afterAt || (e.SentAtUtc == afterAt && e.Id.CompareTo(afterId) < 0))
|
||||
.Take(r.PageSize);
|
||||
total = -1; // not recomputed on cursor windows (that's the point)
|
||||
}
|
||||
else
|
||||
{
|
||||
paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
|
||||
}
|
||||
var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
|
||||
|
||||
// "Why this matched" ts_headline only for EXACT free-text hits (fuzzy/browse get no
|
||||
// highlight — a fuzzy hit has no literal match to headline). Unconditional projections
|
||||
|
||||
@@ -228,34 +228,9 @@ public class SyncService : ISyncService
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Per-sync-run cache of the user's GmailLabelId -> local Label.Id, so email/label linkage
|
||||
// costs no extra query per message. Populated lazily; labels are synced before messages.
|
||||
private Dictionary<string, Guid>? _labelCache;
|
||||
private Guid _labelCacheUserId;
|
||||
|
||||
private async Task<Dictionary<string, Guid>> GetLabelMapAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
if (_labelCache is null || _labelCacheUserId != userId)
|
||||
{
|
||||
_labelCache = await _db.Labels
|
||||
.Where(l => l.UserId == userId)
|
||||
.ToDictionaryAsync(l => l.GmailLabelId, l => l.Id, ct);
|
||||
_labelCacheUserId = userId;
|
||||
}
|
||||
return _labelCache;
|
||||
}
|
||||
|
||||
/// <summary>Resolves domain/sender/thread, then upserts the email, its labels, and
|
||||
/// attachment metadata. Idempotent: re-syncing a message replaces the prior row (and its
|
||||
/// labels) rather than duplicating it.</summary>
|
||||
/// <summary>Resolves domain/sender/thread, then inserts the email and attachment metadata.</summary>
|
||||
private async Task UpsertMessageAsync(Guid userId, GmailMessageDetail d, CancellationToken ct)
|
||||
{
|
||||
// True upsert: drop any existing copy first so labels/flags re-populate cleanly and
|
||||
// an incrementally-changed message can't be duplicated. EmailLabels/Attachments cascade.
|
||||
var prior = await _db.Emails.FirstOrDefaultAsync(
|
||||
e => e.UserId == userId && e.GmailMessageId == d.GmailMessageId, ct);
|
||||
if (prior is not null) _db.Emails.Remove(prior);
|
||||
|
||||
var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct);
|
||||
var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct);
|
||||
|
||||
@@ -281,7 +256,6 @@ public class SyncService : ISyncService
|
||||
IsInInbox = d.LabelIds.Contains("INBOX"),
|
||||
IsStarred = d.LabelIds.Contains("STARRED"),
|
||||
IsImportant = d.LabelIds.Contains("IMPORTANT"),
|
||||
IsTrashed = d.LabelIds.Contains("TRASH"),
|
||||
HasAttachments = d.HasAttachments,
|
||||
HasListUnsubscribe = d.HasListUnsubscribe,
|
||||
ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048),
|
||||
@@ -290,14 +264,6 @@ public class SyncService : ISyncService
|
||||
};
|
||||
_db.Emails.Add(email);
|
||||
|
||||
// Link the email to its Gmail labels (system + user) so label-based folders — Sent,
|
||||
// Spam, and "Unlabelled" (no user label) — resolve correctly. Previously no EmailLabel
|
||||
// rows were ever created, so every label folder was empty and every email looked unlabelled.
|
||||
var labelMap = await GetLabelMapAsync(userId, ct);
|
||||
foreach (var gmailLabelId in d.LabelIds.Distinct())
|
||||
if (labelMap.TryGetValue(gmailLabelId, out var localLabelId))
|
||||
_db.Set<EmailLabel>().Add(new EmailLabel { EmailId = email.Id, LabelId = localLabelId });
|
||||
|
||||
foreach (var (fileName, mime, size, attId) in d.Attachments)
|
||||
{
|
||||
_db.Attachments.Add(new Attachment
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Application.DTOs;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using InboxIntel.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PHASE 2 (smart-category correctness): each reported folder bug — Archive leaking Sent,
|
||||
/// Read Later / Pinned / Unlabelled returning everything, Old Mail duration — is fixed at the
|
||||
/// filter source. These prove the SearchService filters that back the corrected folder mappings.
|
||||
/// </summary>
|
||||
public class CategoryFilterTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
private static AppDbContext Db(string name, Guid uid) =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options,
|
||||
new FakeCurrentUser { UserId = uid });
|
||||
|
||||
// A small fixture: an inbox email, a sent email (SENT label), one important, one read-later,
|
||||
// and one filed under a user label.
|
||||
private static async Task<Guid> SeedAsync(string name)
|
||||
{
|
||||
var uid = Guid.NewGuid();
|
||||
using var db = Db(name, Guid.Empty);
|
||||
var sender = new Sender { UserId = uid, Address = "s@x.x" };
|
||||
db.Senders.Add(sender);
|
||||
var sentLabel = new Label { UserId = uid, GmailLabelId = "SENT", Name = "Sent", Type = "system" };
|
||||
var userLabel = new Label { UserId = uid, GmailLabelId = "Label_1", Name = "Projects", Type = "user" };
|
||||
db.Labels.AddRange(sentLabel, userLabel);
|
||||
|
||||
Email E(string id, Action<Email> cfg)
|
||||
{
|
||||
var e = new Email { UserId = uid, GmailMessageId = id, Subject = id, Sender = sender, SentAtUtc = DateTimeOffset.UtcNow };
|
||||
cfg(e);
|
||||
db.Emails.Add(e);
|
||||
return e;
|
||||
}
|
||||
var inbox = E("inbox", e => e.IsInInbox = true);
|
||||
var sent = E("sent", e => e.IsInInbox = false); // archived-looking, but it's Sent
|
||||
var important = E("important", e => e.IsImportant = true);
|
||||
var later = E("later", e => e.IsReadLater = true);
|
||||
var filed = E("filed", e => e.IsInInbox = false);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Set<EmailLabel>().AddRange(
|
||||
new EmailLabel { EmailId = sent.Id, LabelId = sentLabel.Id },
|
||||
new EmailLabel { EmailId = filed.Id, LabelId = userLabel.Id });
|
||||
await db.SaveChangesAsync();
|
||||
return uid;
|
||||
}
|
||||
|
||||
private static async Task<List<string>> RunAsync(string name, Guid uid, SearchRequestDto req)
|
||||
{
|
||||
using var db = Db(name, uid);
|
||||
var res = await new SearchService(db).SearchAsync(uid, req);
|
||||
return res.Items.Select(i => i.GmailMessageId).OrderBy(x => x).ToList();
|
||||
}
|
||||
|
||||
private static SearchRequestDto Base() => new(null, null, null, null, null, null, null, false, 1, 50);
|
||||
|
||||
[Fact]
|
||||
public async Task Archive_excludes_sent_mail()
|
||||
{
|
||||
var name = nameof(Archive_excludes_sent_mail);
|
||||
var uid = await SeedAsync(name);
|
||||
// Archive: not-in-inbox, not-trashed, excluding SENT — must NOT contain "sent".
|
||||
var items = await RunAsync(name, uid, Base() with
|
||||
{
|
||||
IsInInbox = false,
|
||||
IsTrashed = false,
|
||||
ExcludeGmailLabels = new[] { "SENT", "SPAM", "DRAFT" }
|
||||
});
|
||||
items.Should().NotContain("sent");
|
||||
items.Should().Contain("filed"); // a genuinely archived, user-filed mail stays
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pinned_returns_only_important_not_everything()
|
||||
{
|
||||
var name = nameof(Pinned_returns_only_important_not_everything);
|
||||
var uid = await SeedAsync(name);
|
||||
var items = await RunAsync(name, uid, Base() with { IsImportant = true });
|
||||
items.Should().Equal("important");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadLater_returns_only_flagged_not_everything()
|
||||
{
|
||||
var name = nameof(ReadLater_returns_only_flagged_not_everything);
|
||||
var uid = await SeedAsync(name);
|
||||
var items = await RunAsync(name, uid, Base() with { IsReadLater = true });
|
||||
items.Should().Equal("later");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlabelled_excludes_user_labelled_mail()
|
||||
{
|
||||
var name = nameof(Unlabelled_excludes_user_labelled_mail);
|
||||
var uid = await SeedAsync(name);
|
||||
// "filed" has a user label → must be absent; everything else (no user label) present.
|
||||
var items = await RunAsync(name, uid, Base() with { HasUserLabels = false });
|
||||
items.Should().NotContain("filed");
|
||||
items.Should().Contain("inbox");
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Features;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Feature-flag + AI-gate contracts (docs/discovery/multi-provider/04):
|
||||
/// flags are FAIL-CLOSED, and the admin master switch (ai.enabled) beats any user opt-in.
|
||||
/// </summary>
|
||||
public class FeatureFlagTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
private static AppDbContext NewDb(string name) =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options, new FakeCurrentUser());
|
||||
|
||||
private static FeatureFlagService Flags(AppDbContext db) =>
|
||||
new(db, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_flag_is_disabled_fail_closed()
|
||||
{
|
||||
using var db = NewDb(nameof(Unknown_flag_is_disabled_fail_closed));
|
||||
(await Flags(db).IsEnabledAsync("does.not.exist")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabled_flag_reads_true_disabled_reads_false()
|
||||
{
|
||||
using var db = NewDb(nameof(Enabled_flag_reads_true_disabled_reads_false));
|
||||
db.FeatureFlags.AddRange(
|
||||
new FeatureFlag { Key = "on.flag", Enabled = true },
|
||||
new FeatureFlag { Key = "off.flag", Enabled = false });
|
||||
await db.SaveChangesAsync();
|
||||
var flags = Flags(db);
|
||||
(await flags.IsEnabledAsync("on.flag")).Should().BeTrue();
|
||||
(await flags.IsEnabledAsync("off.flag")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ai_gate_master_flag_off_beats_user_opt_in()
|
||||
{
|
||||
using var db = NewDb(nameof(Ai_gate_master_flag_off_beats_user_opt_in));
|
||||
var user = Guid.NewGuid();
|
||||
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = false });
|
||||
db.UserSettings.Add(new UserSetting { UserId = user, AiOptIn = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
(await new AiGate(Flags(db), db).IsAiEnabledForUserAsync(user)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out()
|
||||
{
|
||||
using var db = NewDb(nameof(Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out));
|
||||
var optedOut = Guid.NewGuid();
|
||||
var noRow = Guid.NewGuid();
|
||||
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = true });
|
||||
db.UserSettings.Add(new UserSetting { UserId = optedOut, AiOptIn = false });
|
||||
await db.SaveChangesAsync();
|
||||
var gate = new AiGate(Flags(db), db);
|
||||
|
||||
(await gate.IsAiEnabledForUserAsync(noRow)).Should().BeTrue("no settings row = default opt-in");
|
||||
(await gate.IsAiEnabledForUserAsync(optedOut)).Should().BeFalse("explicit opt-out wins while the flag is on");
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Application.DTOs;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using InboxIntel.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// RECOMMENDATIONS #8: keyset (cursor) pagination for the browse path — the window after a
|
||||
/// (SentAtUtc, Id) cursor returns the next rows with no duplicates/skips and no OFFSET scan.
|
||||
/// </summary>
|
||||
public class KeysetPaginationTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cursor_window_continues_exactly_after_the_previous_page()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(nameof(Cursor_window_continues_exactly_after_the_previous_page)).Options;
|
||||
|
||||
var baseline = DateTimeOffset.UtcNow;
|
||||
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
|
||||
{
|
||||
var sender = new Sender { UserId = user, Address = "s@x.x" };
|
||||
seed.Senders.Add(sender);
|
||||
for (var i = 0; i < 5; i++)
|
||||
seed.Emails.Add(new Email { UserId = user, GmailMessageId = $"m{i}", Sender = sender, SentAtUtc = baseline.AddMinutes(-i) });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user });
|
||||
var svc = new SearchService(ctx);
|
||||
|
||||
// First window via offset (page 1, size 2): m0, m1 (newest first).
|
||||
var page1 = await svc.SearchAsync(user, new SearchRequestDto(null, null, null, null, null, null, null, false, 1, 2));
|
||||
page1.Items.Select(i => i.GmailMessageId).Should().Equal("m0", "m1");
|
||||
|
||||
// Next window via cursor from the last row of page 1.
|
||||
var last = page1.Items[^1];
|
||||
var page2 = await svc.SearchAsync(user, new SearchRequestDto(
|
||||
null, null, null, null, null, null, null, false, 1, 2,
|
||||
AfterSentAtUtc: last.SentAtUtc, AfterId: last.Id));
|
||||
|
||||
page2.Items.Select(i => i.GmailMessageId).Should().Equal("m2", "m3"); // no dupes, no skips
|
||||
page2.TotalCount.Should().Be(-1, "cursor windows skip the COUNT — that's the perf win");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user