Merge branch 'main' into copilot/add-apprise-alerting-capabilities

This commit is contained in:
Christian Krakau-Louis
2026-03-26 19:39:27 +01:00
committed by GitHub
63 changed files with 1601 additions and 223 deletions
+13 -1
View File
@@ -351,7 +351,7 @@ export default function AdminPage() {
</div>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<Link
href="/admin/users"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
@@ -376,6 +376,18 @@ export default function AdminPage() {
<p className="text-sm text-gray-500">Create and configure subscription plans</p>
</div>
</Link>
<Link
href="/admin/logs"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
>
<div className="p-3 rounded-full bg-green-100 group-hover:bg-green-200 transition-colors">
<Activity className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Activity Logs</p>
<p className="text-sm text-gray-500">Processing runs and per-email logs across all users</p>
</div>
</Link>
</div>
{/* System Alert Channels */}
+11 -7
View File
@@ -4,6 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query';
import { mailAccountsApi, processingRunsApi } from '@/lib/api';
import Link from 'next/link';
import {
Mail,
Send,
@@ -51,19 +52,19 @@ export default function DashboardPage() {
const { data: runs, isLoading: runsLoading } = useQuery({
queryKey: ['processing-runs'],
queryFn: () => processingRunsApi.list(),
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
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?.filter((r) => r.emails_failed > 0).length || 0,
errors: runs?.items?.filter((r) => r.emails_failed > 0).length || 0,
};
return (
@@ -100,15 +101,18 @@ export default function DashboardPage() {
{/* Recent Processing Runs */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<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>
<Link href="/logs" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
View all logs
</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.length > 0 ? (
) : runs && runs.items && runs.items.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
@@ -133,12 +137,12 @@ export default function DashboardPage() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{runs.slice(0, 10).map((run) => {
{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">
{account?.name || `Account ${run.mail_account_id}`}
{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">
+5 -3
View File
@@ -1,12 +1,14 @@
import Link from 'next/link';
import type { Metadata } from 'next';
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
export const metadata: Metadata = {
title: 'Datenschutz POP3 Forwarder',
title: `Datenschutz ${APP_NAME}`,
};
const LAST_UPDATED = 'March 26, 2026';
const CONTACT_EMAIL = 'christianlouis@gmail.com';
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
export default function DatenschutzPage() {
return (
@@ -82,7 +84,7 @@ export default function DatenschutzPage() {
2. Geltungsbereich dieser Datenschutzerklärung
</h2>
<p className="text-gray-700">
Dieser Hinweis gilt für die POP3 Forwarder-Webanwendung. Er gilt
Dieser Hinweis gilt für die {APP_NAME}-Webanwendung. Er gilt
für alle Nutzer weltweit, einschließlich derjenigen in der
Europäischen Union (EU), dem Europäischen Wirtschaftsraum (EWR),
Deutschland, dem Vereinigten Königreich (UK), der Schweiz, der
+6 -3
View File
@@ -1,8 +1,11 @@
import Link from 'next/link';
import type { Metadata } from 'next';
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
export const metadata: Metadata = {
title: 'Impressum POP3 Forwarder',
title: `Impressum ${APP_NAME}`,
};
export default function ImpressumPage() {
@@ -36,10 +39,10 @@ export default function ImpressumPage() {
Fax: +49 40 97074609<br />
E-Mail:{' '}
<a
href="mailto:christianlouis@gmail.com"
href={`mailto:${CONTACT_EMAIL}`}
className="text-blue-600 hover:text-blue-500"
>
christianlouis@gmail.com
{CONTACT_EMAIL}
</a>
</p>
</section>
+1 -1
View File
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "InboxRescue — your old inboxes, delivered to Gmail",
title: "InboxConverge — your old inboxes, delivered to Gmail",
description: "Poll your legacy POP3 and IMAP mailboxes and have everything land quietly in Gmail. Set it once, forget it exists.",
};
+1 -1
View File
@@ -46,7 +46,7 @@ export default function LoginPage() {
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
POP3 to Gmail Forwarder
InboxConverge
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to your account
+6 -6
View File
@@ -106,7 +106,7 @@ export default function Home() {
<div className="flex justify-between items-center py-4">
<div className="flex items-center">
<Mail className="h-8 w-8 text-blue-600 mr-2" />
<h1 className="text-2xl font-bold text-gray-900">InboxRescue</h1>
<h1 className="text-2xl font-bold text-gray-900">InboxConverge</h1>
</div>
<div className="flex items-center gap-4">
<Link
@@ -136,7 +136,7 @@ export default function Home() {
</h2>
<p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto">
You know the ones that GMX account from 2009, the old ISP address your
bank still sends to, the Hotmail you gave out in school. InboxRescue
bank still sends to, the Hotmail you gave out in school. InboxConverge
quietly polls them all and drops everything into your Gmail. Set it once,
forget it exists.
</p>
@@ -171,7 +171,7 @@ export default function Home() {
Auto-detects everything
</h3>
<p className="text-gray-600">
Type your old email address and InboxRescue figures out the server
Type your old email address and InboxConverge figures out the server
settings. No Googling port numbers required.
</p>
</div>
@@ -215,7 +215,7 @@ export default function Home() {
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
<p className="text-gray-600">
Paste the email address InboxRescue auto-detects the POP3/IMAP
Paste the email address InboxConverge auto-detects the POP3/IMAP
settings in seconds.
</p>
</div>
@@ -225,7 +225,7 @@ export default function Home() {
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
<p className="text-gray-600">
Sign in with Google once. InboxRescue delivers mail directly into
Sign in with Google once. InboxConverge delivers mail directly into
your inbox using the Gmail API no SMTP relay needed.
</p>
</div>
@@ -270,7 +270,7 @@ export default function Home() {
<footer className="mt-20 border-t border-gray-200 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<p className="text-center text-gray-600 text-sm">
© {new Date().getFullYear()} InboxRescue made for people, not enterprises.
© {new Date().getFullYear()} InboxConverge made for people, not enterprises.
</p>
</div>
</footer>
+129 -2
View File
@@ -16,8 +16,112 @@ import {
AlertTriangle,
XCircle,
Bug,
RotateCcw,
Tags,
} from 'lucide-react';
const DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES = ['{{source_email}}', 'imported'];
function parseImportLabelTemplates(input: string): string[] {
return input
.split('\n')
.map((value) => value.trim())
.filter((value, index, values) => value.length > 0 && values.indexOf(value) === index);
}
function GmailImportLabelsForm({
gmailCredential,
onSave,
isSaving,
}: {
gmailCredential: {
gmail_email: string;
import_label_templates: string[];
default_import_label_templates: string[];
};
onSave: (labels: string[]) => void;
isSaving: boolean;
}) {
const [labelsInput, setLabelsInput] = useState(
gmailCredential.import_label_templates.join('\n')
);
const defaultTemplates =
gmailCredential.default_import_label_templates.length > 0
? gmailCredential.default_import_label_templates
: DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES;
const parsedLabels = parseImportLabelTemplates(labelsInput);
const isDefaultSelection =
parsedLabels.length === defaultTemplates.length &&
parsedLabels.every((value, index) => value === defaultTemplates[index]);
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-3 flex items-center gap-2">
<Tags className="h-4 w-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">Import labels</h3>
</div>
<p className="text-sm text-gray-600">
One label is created per line. We recommend keeping{' '}
<code className="rounded bg-white px-1 py-0.5 text-xs text-gray-700">
{'{{source_email}}'}
</code>{' '}
so each imported message is tagged with the mailbox it came from, plus a
catch-all label like <strong>imported</strong>.
</p>
<p className="mt-2 text-xs text-gray-500">
Example: a mail pulled from <strong>billing@example.com</strong> will be
labeled as <strong>billing@example.com</strong> when{' '}
<code className="rounded bg-white px-1 py-0.5 text-xs text-gray-700">
{'{{source_email}}'}
</code>{' '}
is present.
</p>
<textarea
value={labelsInput}
onChange={(e) => setLabelsInput(e.target.value)}
rows={4}
className="mt-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`{{source_email}}\nimported`}
/>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>Suggested defaults:</span>
{defaultTemplates.map((label) => (
<span
key={label}
className="rounded-full border border-gray-200 bg-white px-2 py-1 text-gray-700"
>
{label}
</span>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => onSave(parsedLabels)}
disabled={isSaving}
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Save label setup
</button>
<button
type="button"
onClick={() => setLabelsInput(defaultTemplates.join('\n'))}
disabled={isSaving || isDefaultSelection}
className="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm text-gray-700 ring-1 ring-gray-300 transition-colors hover:bg-gray-50 disabled:opacity-50"
>
<RotateCcw className="h-4 w-4" />
Reset defaults
</button>
</div>
<p className="mt-3 text-xs text-gray-500">
Connected Gmail target: <strong>{gmailCredential.gmail_email}</strong>
</p>
</div>
);
}
export default function SettingsPage() {
return (
<AuthGuard>
@@ -106,6 +210,7 @@ function SettingsContent() {
});
const [debugEmailResult, setDebugEmailResult] = useState<string | null>(null);
const [gmailLabelsSaved, setGmailLabelsSaved] = useState(false);
const sendDebugEmailMutation = useMutation({
mutationFn: gmailApi.sendDebugEmail,
onSuccess: () => {
@@ -118,6 +223,15 @@ function SettingsContent() {
},
});
const updateGmailLabelsMutation = useMutation({
mutationFn: gmailApi.updateImportLabels,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gmail-credential'] });
setGmailLabelsSaved(true);
setTimeout(() => setGmailLabelsSaved(false), 3000);
},
});
const saveSmtpMutation = useMutation({
mutationFn: smtpApi.save,
onSuccess: () => {
@@ -362,7 +476,9 @@ function SettingsContent() {
{debugEmailResult === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
<CheckCircle className="h-4 w-4 flex-shrink-0" />
Debug email injected successfully. Check your Gmail inbox it should be labelled <strong className="mx-1">test</strong> and <strong className="mx-1">imported</strong>.
Debug email injected successfully. Check your Gmail inbox for the
configured import labels plus a <strong className="mx-1">test</strong>{' '}
label.
</div>
)}
{debugEmailResult === 'error' && (
@@ -371,6 +487,18 @@ function SettingsContent() {
Failed to inject debug email. Check that Gmail API access is still valid.
</div>
)}
<GmailImportLabelsForm
key={gmailCredential.updated_at}
gmailCredential={gmailCredential}
isSaving={updateGmailLabelsMutation.isPending}
onSave={(labels) => updateGmailLabelsMutation.mutate(labels)}
/>
{gmailLabelsSaved && (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
<CheckCircle className="h-4 w-4 flex-shrink-0" />
Gmail import labels saved.
</div>
)}
</div>
)}
@@ -589,4 +717,3 @@ function SettingsContent() {
</div>
);
}
+6 -2
View File
@@ -16,6 +16,8 @@ import {
Users,
CreditCard,
Bell
FileText,
Activity
} from 'lucide-react';
interface DashboardLayoutProps {
@@ -37,6 +39,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Mail Accounts', href: '/accounts', icon: Mail },
{ name: 'Notifications', href: '/notifications', icon: Bell },
{ name: 'Logs', href: '/logs', icon: FileText },
{ name: 'Settings', href: '/settings', icon: Settings },
];
@@ -45,6 +48,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ name: 'Admin Overview', href: '/admin', icon: Shield },
{ name: 'Manage Users', href: '/admin/users', icon: Users },
{ name: 'Manage Plans', href: '/admin/plans', icon: CreditCard },
{ name: 'Activity Logs', href: '/admin/logs', icon: Activity },
]
: [];
@@ -56,7 +60,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">InboxRescue</h1>
<h1 className="text-xl font-bold text-gray-900">InboxConverge</h1>
</div>
<nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map((item) => {
@@ -119,7 +123,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="fixed inset-0 bg-gray-600/75" onClick={() => setSidebarOpen(false)} />
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white">
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">InboxRescue</h1>
<h1 className="text-xl font-bold text-gray-900">InboxConverge</h1>
<button onClick={() => setSidebarOpen(false)} className="text-gray-500 hover:text-gray-700">
<X className="h-6 w-6" />
</button>
+143 -4
View File
@@ -126,6 +126,64 @@ export interface ProcessingRun {
emails_failed: number;
status: string;
error_message?: string | null;
account_name?: string | null;
account_email?: string | null;
}
export interface ProcessingLog {
id: number;
timestamp: string;
level: string;
message: string;
email_subject?: string | null;
email_from?: string | null;
success: boolean;
mail_account_id: number;
processing_run_id?: number | null;
email_size_bytes?: number | null;
error_details?: Record<string, unknown> | null;
}
export interface PaginatedProcessingRuns {
items: ProcessingRun[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface PaginatedProcessingLogs {
items: ProcessingLog[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface AdminProcessingRun extends ProcessingRun {
user_id?: number | null;
user_email?: string | null;
}
export interface AdminProcessingLog extends ProcessingLog {
user_id: number;
user_email?: string | null;
}
export interface PaginatedAdminRuns {
items: AdminProcessingRun[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface PaginatedAdminLogsResponse {
items: AdminProcessingLog[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface AutoDetectSuggestion {
@@ -141,6 +199,8 @@ export interface GmailCredential {
user_id: number;
gmail_email: string;
is_valid: boolean;
import_label_templates: string[];
default_import_label_templates: string[];
last_verified_at?: string | null;
created_at: string;
updated_at: string;
@@ -292,10 +352,54 @@ export const mailAccountsApi = {
// ── Processing Runs API ─────────────────────────────────────────────────
export const processingRunsApi = {
async list(): Promise<ProcessingRun[]> {
// TODO: Add a dedicated /processing-runs endpoint to the backend
// For now, return empty array since no user-facing endpoint exists yet
return [];
async list(params?: {
page?: number;
page_size?: number;
account_id?: number;
status?: string;
}): Promise<PaginatedProcessingRuns> {
const response = await api.get<PaginatedProcessingRuns>("/processing-runs", {
params,
});
return response.data;
},
async get(runId: number): Promise<ProcessingRun> {
const response = await api.get<ProcessingRun>(`/processing-runs/${runId}`);
return response.data;
},
async getLogs(
runId: number,
params?: { page?: number; page_size?: number }
): Promise<PaginatedProcessingLogs> {
const response = await api.get<PaginatedProcessingLogs>(
`/processing-runs/${runId}/logs`,
{ params }
);
return response.data;
},
async listForAccount(
accountId: number,
params?: { page?: number; page_size?: number; status?: string }
): Promise<PaginatedProcessingRuns> {
const response = await api.get<PaginatedProcessingRuns>(
`/mail-accounts/${accountId}/processing-runs`,
{ params }
);
return response.data;
},
async listLogsForAccount(
accountId: number,
params?: { page?: number; page_size?: number; level?: string }
): Promise<PaginatedProcessingLogs> {
const response = await api.get<PaginatedProcessingLogs>(
`/mail-accounts/${accountId}/logs`,
{ params }
);
return response.data;
},
};
@@ -336,6 +440,14 @@ export const gmailApi = {
const response = await api.post<GmailDebugEmailResponse>('/providers/gmail/debug-email');
return response.data;
},
/** Update the labels applied to imported Gmail messages. */
async updateImportLabels(importLabelTemplates: string[]): Promise<GmailCredential> {
const response = await api.put<GmailCredential>('/providers/gmail-credential/labels', {
import_label_templates: importLabelTemplates,
});
return response.data;
},
};
// ── SMTP Config API ─────────────────────────────────────────────────────
@@ -483,6 +595,33 @@ export const adminApi = {
async deletePlan(id: number): Promise<void> {
await api.delete(`/admin/plans/${id}`);
},
async listProcessingRuns(params?: {
page?: number;
page_size?: number;
user_id?: number;
account_id?: number;
status?: string;
}): Promise<PaginatedAdminRuns> {
const response = await api.get<PaginatedAdminRuns>('/admin/processing-runs', {
params,
});
return response.data;
},
async listProcessingLogs(params?: {
page?: number;
page_size?: number;
user_id?: number;
account_id?: number;
run_id?: number;
level?: string;
}): Promise<PaginatedAdminLogsResponse> {
const response = await api.get<PaginatedAdminLogsResponse>('/admin/processing-logs', {
params,
});
return response.data;
},
};
// ── Notification Types ──────────────────────────────────────────────────