Merge branch 'main' into copilot/fix-status-tracking-issue

This commit is contained in:
Christian Krakau-Louis
2026-03-28 21:19:31 +01:00
committed by GitHub
10 changed files with 171 additions and 121 deletions
+17
View File
@@ -14,6 +14,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix timezone display in Mailbox Activity / Admin Logs pages: timestamps from the server were parsed as local time when no timezone indicator was present, causing relative times ("1h ago") and absolute dates to be shifted by the client's UTC offset.
- Worker tasks: use a fresh DB session for `send_user_notification` calls and move notifications after `db.commit()` to prevent the post-rollback `greenlet_spawn` SQLAlchemy error.
- Worker tasks: ensure `last_check_at` and error status are always committed before notifications, fixing accounts being endlessly re-queued after IMAP auth failures.
## v0.4.0 (2026-03-28)
### Features
- **dashboard**: Replace per-run table with per-account Mailbox Status view
([`6afd7c8`](https://github.com/christianlouis/InboxConverge/commit/6afd7c871e7c694a93f119aa66f1b798c9b5b2bd))
## [Unreleased]
### Changed
- Dashboard "Recent Processing Runs" table replaced with a per-account **Mailbox Status** view: each account now shows its last-check status (OK / Error / Pending), relative last-check time, any error message, and lifetime processed/failed counters. The noisy per-run table is gone; full activity history remains available on the Logs page.
- Stats cards updated: "Emails Forwarded Today" → "Emails Processed" (all-time total from account records); "Errors" → "Accounts with Errors" (count of accounts currently showing an error).
### Fixed
- Provider logos now appear on the Mail Accounts page: `provider_name` is correctly saved when creating accounts via the provider wizard and propagated through backend/frontend schemas.
- Fetch-emails button now shows a text label ("Fetch"), a descriptive tooltip, a "Fetching…" loading state, and a brief green "Queued!" confirmation after the action completes.
## v0.3.2 (2026-03-28)
@@ -104,6 +104,7 @@ async def create_mail_account(
check_interval_minutes=account_in.check_interval_minutes,
max_emails_per_check=account_in.max_emails_per_check,
delete_after_forward=account_in.delete_after_forward,
provider_name=account_in.provider_name,
)
db.add(account)
+2
View File
@@ -113,6 +113,7 @@ class MailAccountBase(BaseModel):
check_interval_minutes: int = Field(default=5, gt=0, le=1440)
max_emails_per_check: int = Field(default=50, gt=0, le=1000)
delete_after_forward: bool = True
provider_name: Optional[str] = Field(None, max_length=100)
class MailAccountCreate(MailAccountBase):
@@ -135,6 +136,7 @@ class MailAccountUpdate(BaseModel):
check_interval_minutes: Optional[int] = Field(None, gt=0, le=1440)
max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000)
delete_after_forward: Optional[bool] = None
provider_name: Optional[str] = Field(None, max_length=100)
class MailAccountResponse(MailAccountBase):
+4
View File
@@ -6,6 +6,10 @@ Comprehensive task breakdown for repository improvements and production readines
- [x] Fixed timezone display bug in Mailbox Activity and Admin Logs pages: ISO timestamps without a `Z` suffix were parsed as local time by JavaScript, shifting "Xm ago" / "Xh ago" displays and absolute dates by the client's UTC offset.
- [x] Fixed worker `send_user_notification` using rolled-back DB session causing `greenlet_spawn has not been called` errors; status/`last_check_at` now always committed before sending notifications via a fresh session.
- [x] **Dashboard redesign**: Replaced noisy "Recent Processing Runs" table with a per-account "Mailbox Status" view showing last-check status (OK/Error/Pending), relative timestamp, error messages, and lifetime counters. Stats cards updated to show all-time processed count and accounts-with-errors count.
- [x] **Provider logos now saved on account creation**: `provider_name` field added to `MailAccountCreate` and `MailAccountUpdate` schemas (backend and frontend). `ProviderWizard` now passes `provider_name` in its `onSelect` callback; `AddMailAccountModal` stores it so logos are displayed correctly on the accounts page.
- [x] **Fetch button UX improvements**: The "fetch emails" button on the accounts page now shows a "Fetch" text label for clarity, a tooltip explaining its purpose, a spinning "Fetching…" state during the API call, and a brief green "Queued!" confirmation after success.
- [x] **Pull Now**: Added "Pull Now" button on Accounts page that immediately queues a `process_mail_account` Celery task via `POST /mail-accounts/{id}/pull-now`. Button shows spinner while in flight and is disabled for inactive accounts.
- [x] Fixed 21 mypy type errors: `Column[T]` vs native type mismatches in `notification_service.py`, `mail_processor.py`, `auth.py`, `tasks.py`, `providers.py`, `mail_accounts.py`, and `main.py` (`lifespan` parameter rename).
- [x] **Provider logos rework**: Logos now displayed as full-width banner strips at the top of each account card using `next/image fill + object-contain`. Handles all aspect ratios (1:1 square to 6:1 wordmark) without distortion. Proton Mail added.
+23 -3
View File
@@ -53,6 +53,7 @@ export default function AccountsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingAccount, setEditingAccount] = useState<MailAccount | null>(null);
const [pullingIds, setPullingIds] = useState<Set<number>>(new Set());
const [successIds, setSuccessIds] = useState<Set<number>>(new Set());
const queryClient = useQueryClient();
const { data: accounts, isLoading } = useQuery({
@@ -101,6 +102,14 @@ export default function AccountsPage() {
setPullingIds((prev) => new Set(prev).add(id));
try {
await mailAccountsApi.pullNow(id);
setSuccessIds((prev) => new Set(prev).add(id));
setTimeout(() => {
setSuccessIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 2000);
} catch {
alert('Failed to queue pull');
} finally {
@@ -227,11 +236,22 @@ export default function AccountsPage() {
<button
onClick={() => handlePullNow(account.id)}
disabled={!account.is_enabled || pullingIds.has(account.id)}
title="Pull emails now"
aria-label="Pull emails now"
className="flex items-center justify-center px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors disabled:opacity-50"
title="Fetch new emails from this account now"
aria-label="Fetch emails now"
className={`flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 ${
successIds.has(account.id)
? 'text-green-600 bg-green-50 hover:bg-green-100'
: 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100'
}`}
>
<RefreshCw className={`h-4 w-4 ${pullingIds.has(account.id) ? 'animate-spin' : ''}`} />
<span>
{pullingIds.has(account.id)
? 'Fetching…'
: successIds.has(account.id)
? 'Queued!'
: 'Fetch'}
</span>
</button>
<button
onClick={() => handleEdit(account)}
+116 -116
View File
@@ -3,38 +3,44 @@
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query';
import { mailAccountsApi, processingRunsApi } from '@/lib/api';
import { mailAccountsApi, MailAccount } from '@/lib/api';
import Link from 'next/link';
import {
Mail,
Send,
CheckCircle,
import {
Mail,
Send,
CheckCircle,
AlertCircle,
TrendingUp,
Clock
Clock,
XCircle,
AlertTriangle,
Inbox,
} from 'lucide-react';
function formatRelative(iso?: string | null): string {
if (!iso) return 'Never';
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
interface StatCardProps {
title: string;
value: string | number;
icon: React.ComponentType<{ className?: string }>;
iconColor: string;
trend?: string;
}
function StatCard({ title, value, icon: Icon, iconColor, trend }: StatCardProps) {
function StatCard({ title, value, icon: Icon, iconColor }: StatCardProps) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
{trend && (
<div className="mt-2 flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
<span className="text-green-600">{trend}</span>
</div>
)}
</div>
<div className={`p-3 rounded-full ${iconColor}`}>
<Icon className="h-8 w-8 text-white" />
@@ -44,27 +50,78 @@ function StatCard({ title, value, icon: Icon, iconColor, trend }: StatCardProps)
);
}
function AccountStatusRow({ account }: { account: MailAccount }) {
const hasError = !!account.last_error_message;
const lastChecked = account.last_check_at;
return (
<div className="px-5 py-4 border-b border-gray-100 last:border-b-0">
<div className="flex items-start justify-between gap-4">
{/* Left: name + email */}
<div className="flex items-center gap-3 min-w-0">
<Inbox className="h-4 w-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{account.name}</p>
<p className="text-xs text-gray-400 truncate">{account.email_address}</p>
</div>
</div>
{/* Right: status badge + last check */}
<div className="text-right shrink-0">
{hasError ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600">
<XCircle className="h-3.5 w-3.5" />
Error
</span>
) : lastChecked ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600">
<CheckCircle className="h-3.5 w-3.5" />
OK
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-400">
<Clock className="h-3.5 w-3.5" />
Pending
</span>
)}
<p className="text-xs text-gray-400 mt-0.5">
{formatRelative(lastChecked)}
</p>
</div>
</div>
{/* Error message */}
{hasError && (
<div className="mt-2 flex items-start gap-1.5 p-2 bg-red-50 border border-red-200 rounded">
<AlertTriangle className="h-3.5 w-3.5 text-red-500 shrink-0 mt-0.5" />
<p className="text-xs text-red-700 line-clamp-2">{account.last_error_message}</p>
</div>
)}
{/* Lifetime counters (only when there's activity) */}
{(account.total_emails_processed > 0 || account.total_emails_failed > 0) && (
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
<span>{account.total_emails_processed.toLocaleString()} processed</span>
{account.total_emails_failed > 0 && (
<span className="text-red-500">{account.total_emails_failed.toLocaleString()} failed</span>
)}
</div>
)}
</div>
);
}
export default function DashboardPage() {
const { data: accounts } = useQuery({
const { data: accounts, isLoading: accountsLoading } = useQuery({
queryKey: ['mail-accounts'],
queryFn: mailAccountsApi.list,
});
const { data: runs, isLoading: runsLoading } = useQuery({
queryKey: ['processing-runs'],
queryFn: () => processingRunsApi.list({ page: 1, page_size: 10 }),
});
const stats = {
totalAccounts: accounts?.length || 0,
activeAccounts: accounts?.filter((a) => a.is_enabled).length || 0,
emailsToday: runs?.items
?.filter((r) => {
const today = new Date().toDateString();
return new Date(r.started_at).toDateString() === today;
})
.reduce((sum, r) => sum + r.emails_forwarded, 0) || 0,
errors: runs?.items?.filter((r) => r.emails_failed > 0).length || 0,
totalProcessed: accounts?.reduce((sum, a) => sum + a.total_emails_processed, 0) || 0,
accountsWithErrors: accounts?.filter((a) => !!a.last_error_message).length || 0,
};
return (
@@ -80,8 +137,8 @@ export default function DashboardPage() {
iconColor="bg-blue-500"
/>
<StatCard
title="Emails Forwarded Today"
value={stats.emailsToday}
title="Emails Processed"
value={stats.totalProcessed.toLocaleString()}
icon={Send}
iconColor="bg-green-500"
/>
@@ -92,101 +149,44 @@ export default function DashboardPage() {
iconColor="bg-purple-500"
/>
<StatCard
title="Errors"
value={stats.errors}
title="Accounts with Errors"
value={stats.accountsWithErrors}
icon={AlertCircle}
iconColor="bg-red-500"
iconColor={stats.accountsWithErrors > 0 ? 'bg-red-500' : 'bg-gray-400'}
/>
</div>
{/* Recent Processing Runs */}
{/* Mailbox Status Overview */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Recent Processing Runs</h3>
<h3 className="text-lg font-semibold text-gray-900">Mailbox Status</h3>
<Link href="/logs" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
View all logs
View activity & history
</Link>
</div>
<div className="overflow-x-auto">
{runsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : runs && runs.items && runs.items.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Account
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fetched
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Forwarded
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Errors
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{runs.items.map((run) => {
const account = accounts?.find((a) => a.id === run.mail_account_id);
return (
<tr key={run.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{run.account_name || account?.name || `Account ${run.mail_account_id}`}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<Clock className="h-4 w-4 mr-1 text-gray-400" />
{new Date(run.started_at).toLocaleString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
run.status === 'completed'
? 'bg-green-100 text-green-800'
: run.status === 'failed'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{run.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{run.emails_fetched}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{run.emails_forwarded}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{run.emails_failed > 0 ? (
<span className="text-red-600 font-medium">{run.emails_failed}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="text-center py-12">
<p className="text-gray-500">No processing runs yet</p>
</div>
)}
</div>
{accountsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : accounts && accounts.length > 0 ? (
<div>
{accounts.map((account) => (
<AccountStatusRow key={account.id} account={account} />
))}
</div>
) : (
<div className="text-center py-12">
<Clock className="mx-auto h-10 w-10 text-gray-300 mb-3" />
<p className="text-sm text-gray-500">No mail accounts configured yet.</p>
<Link
href="/accounts"
className="mt-3 inline-block text-sm text-blue-600 hover:text-blue-800 font-medium"
>
Add your first account
</Link>
</div>
)}
</div>
</div>
</DashboardLayout>
@@ -39,6 +39,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
check_interval_minutes: account?.check_interval_minutes || 5,
max_emails_per_check: account?.max_emails_per_check || 50,
delete_after_forward: account?.delete_after_forward ?? true,
provider_name: account?.provider_name ?? null,
});
const onMutationSuccess = () => {
@@ -75,7 +76,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
});
};
const handleProviderSelect = (config: { name: string; protocol: string; host: string; port: number; use_ssl: boolean }) => {
const handleProviderSelect = (config: { name: string; provider_name: string; protocol: string; host: string; port: number; use_ssl: boolean }) => {
setFormData((prev) => ({
...prev,
name: config.name,
@@ -83,6 +84,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
host: config.host,
port: config.port,
use_ssl: config.use_ssl,
provider_name: config.provider_name,
}));
setWizardStep('form');
};
@@ -15,6 +15,7 @@ interface ProviderPreset {
interface ProviderConfig {
name: string;
provider_name: string;
protocol: string;
host: string;
port: number;
@@ -160,6 +161,7 @@ export function ProviderWizard({ onSelect, onManual }: ProviderWizardProps) {
onSelect({
name: selectedProvider.name,
provider_name: selectedProvider.name,
protocol: selectedProtocol === 'imap_ssl' ? 'imap_ssl' : 'pop3_ssl',
host: config.host,
port: config.port,
+2
View File
@@ -95,6 +95,7 @@ export interface MailAccountCreate {
check_interval_minutes?: number;
max_emails_per_check?: number;
delete_after_forward?: boolean;
provider_name?: string | null;
}
export interface MailAccountUpdate {
@@ -113,6 +114,7 @@ export interface MailAccountUpdate {
check_interval_minutes?: number;
max_emails_per_check?: number;
delete_after_forward?: boolean;
provider_name?: string | null;
}
export interface ProcessingRun {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "inboxconverge"
version = "0.3.2"
version = "0.4.0"
description = "Multi-account email forwarding and processing service"
readme = "README.md"
requires-python = ">=3.12"