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
+6 -1
View File
@@ -14,7 +14,7 @@ POP3_ACCOUNT_1_USE_SSL=true
# POP3_ACCOUNT_2_USE_SSL=true # POP3_ACCOUNT_2_USE_SSL=true
# ── Bootstrap Settings (always from env, never from database) ────── # ── Bootstrap Settings (always from env, never from database) ──────
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder # DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/inbox_converge
# SECRET_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'> # SECRET_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
# ENCRYPTION_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'> # ENCRYPTION_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
@@ -41,5 +41,10 @@ MAX_EMAILS_PER_RUN=50
# Throttling (emails per minute) # Throttling (emails per minute)
THROTTLE_EMAILS_PER_MINUTE=10 THROTTLE_EMAILS_PER_MINUTE=10
# Application identity (used by frontend server components at runtime)
APP_NAME=InboxConverge
APP_URL=https://inboxconverge.com
CONTACT_EMAIL=christian@inboxconverge.com
# Logging # Logging
LOG_LEVEL=INFO LOG_LEVEL=INFO
+1 -1
View File
@@ -21,7 +21,7 @@ assignees: ''
<!-- What actually happened --> <!-- What actually happened -->
## Environment ## Environment
- **Component**: [e.g., Backend API, Worker, Docker, pop3_forwarder.py] - **Component**: [e.g., Backend API, Worker, Docker, inbox_converge.py]
- **Version**: [e.g., v1.0.0, main branch] - **Version**: [e.g., v1.0.0, main branch]
- **Deployment**: [e.g., Docker, Kubernetes, local] - **Deployment**: [e.g., Docker, Kubernetes, local]
- **OS**: [e.g., Ubuntu 22.04, macOS, Windows] - **OS**: [e.g., Ubuntu 22.04, macOS, Windows]
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: pop3_forwarder_test POSTGRES_DB: inbox_converge_test
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 10s --health-interval 10s
@@ -113,7 +113,7 @@ jobs:
- name: Run tests with coverage - name: Run tests with coverage
env: env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/pop3_forwarder_test DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/inbox_converge_test
REDIS_URL: redis://localhost:6379/0 REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key-for-ci-cd-at-least-32-chars SECRET_KEY: test-secret-key-for-ci-cd-at-least-32-chars
ENCRYPTION_KEY: test-encryption-key-for-ci-cd-at-least-32-chars ENCRYPTION_KEY: test-encryption-key-for-ci-cd-at-least-32-chars
@@ -173,9 +173,9 @@ jobs:
matrix: matrix:
include: include:
- context: ./backend - context: ./backend
image_name: gmail-puller/backend image_name: inboxconverge/backend
- context: ./frontend - context: ./frontend
image_name: gmail-puller/frontend image_name: inboxconverge/frontend
steps: steps:
- name: Checkout repository - name: Checkout repository
+2 -2
View File
@@ -33,7 +33,7 @@ repos:
- id: black - id: black
language_version: python3.11 language_version: python3.11
args: [--line-length=100] args: [--line-length=100]
files: ^(backend/|pop3_forwarder\.py) files: ^(backend/|inboxconverge\.py)
# Python linting with Ruff (replaces flake8, isort, etc.) # Python linting with Ruff (replaces flake8, isort, etc.)
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
@@ -41,7 +41,7 @@ repos:
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --exit-non-zero-on-fix] args: [--fix, --exit-non-zero-on-fix]
files: ^(backend/|pop3_forwarder\.py) files: ^(backend/|inboxconverge\.py)
# Type checking with mypy # Type checking with mypy
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
+35 -5
View File
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed
- **Project renamed to InboxConverge**: All user-visible strings, Docker container names, database defaults, Docker image paths, monitoring job names, Grafana dashboard titles, and documentation updated from the legacy names (`POP3 to Gmail Forwarder`, `InboxRescue`, `gmail-puller`, `pop3_forwarder`, etc.) to **InboxConverge** / `inboxconverge`.
- **Domain updated to `inboxconverge.com`**: All contact and administrative email addresses now default to `@inboxconverge.com` (e.g. `christian@inboxconverge.com`).
- **Configurable contact details**: Two new environment variables make contact information overridable at deployment time:
- `CONTACT_EMAIL` (default: `christian@inboxconverge.com`) — used by the frontend legal pages (Impressum, Datenschutz) and surfaced in the backend `Settings`.
- `APP_URL` (default: `https://inboxconverge.com`) — the canonical public URL of the deployment.
- `NEXT_PUBLIC_APP_NAME` (default: `InboxConverge`) — the application name shown in frontend legal-page titles; readable by Next.js server components at runtime.
- **Legacy script renamed**: `pop3_forwarder.py``inboxconverge.py`; root `Dockerfile` and `Makefile` updated accordingly.
- **Grafana dashboard file renamed**: `monitoring/grafana/dashboards/inboxrescue.json``inboxconverge.json`.
- **Note on encryption salt**: The internal PBKDF2 salt `b"pop3_forwarder_0"` in `backend/app/core/security.py` is intentionally **not** renamed — changing it would invalidate all existing encrypted credentials stored in the database.
### Added
- **Processing logs & reporting** — users can now view the full history of polling runs and per-email delivery status:
- **`GET /processing-runs`** — paginated list of all processing runs for the authenticated user's mailboxes (filterable by account and status).
- **`GET /processing-runs/{id}`** — details for a single run.
- **`GET /processing-runs/{id}/logs`** — per-email log entries (subject, sender, size, delivery status, error details) for a given run.
- **`GET /mail-accounts/{id}/processing-runs`** — runs scoped to a single mailbox.
- **`GET /mail-accounts/{id}/logs`** — all per-email log entries for a single mailbox.
- **Admin log endpoints** (superuser only):
- **`GET /admin/processing-runs`** — all runs across every user, filterable by user ID, account ID, or status. Account and user email addresses are GDPR-pseudonymised.
- **`GET /admin/processing-logs`** — all per-email log entries system-wide, filterable by user, account, run, or log level. Sender (`From:`) headers are pseudonymised via `mask_from_header()`; subjects are shown as-is (user-owned content).
- **`backend/app/core/gdpr.py`** — GDPR masking utilities: `mask_email()`, `mask_name()`, `mask_from_header()` for pseudonymising PII in admin views.
- **Worker now writes `ProcessingLog` entries per email** — subject, sender, size, delivery outcome and error detail are captured for every email processed by `process_mail_account`.
- **`/logs` page** — user-facing log page with a paginated processing-run table; each row expands inline to show the per-email log for that run (subject, masked sender, size, status).
- **`/admin/logs` page** — admin view with two tabs: *Processing Runs* (expandable, fetches per-email logs on demand) and *Per-Email Logs* (flat table with GDPR-masked sender addresses). Filterable by user ID and status/level.
- **Sidebar navigation** — added *Logs* link (user) and *Activity Logs* link (admin) to `DashboardLayout`.
- **Admin overview** — added *Activity Logs* card to `/admin` page.
- **Dashboard** — "Recent Processing Runs" table now reads from the new `/processing-runs` endpoint; shows account name and a *View all logs* link.
### Added ### Added
- **Apprise alerting**: New `NotificationService` using [Apprise](https://github.com/caronc/apprise) for multi-channel push notifications (Telegram, Slack, Discord, webhooks, and 80+ other services via a single URL scheme). - **Apprise alerting**: New `NotificationService` using [Apprise](https://github.com/caronc/apprise) for multi-channel push notifications (Telegram, Slack, Discord, webhooks, and 80+ other services via a single URL scheme).
- `send_user_notification` — sends to all enabled per-user Apprise channels on processing errors or failures. - `send_user_notification` — sends to all enabled per-user Apprise channels on processing errors or failures.
@@ -22,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `NotificationConfigBase` schema: `name` is now a required field; `apprise_url` is an optional field; `config` (channel-specific JSON) is now optional with a default of `{}` (previously required). Existing clients must be updated to supply `name`. - `NotificationConfigBase` schema: `name` is now a required field; `apprise_url` is an optional field; `config` (channel-specific JSON) is now optional with a default of `{}` (previously required). Existing clients must be updated to supply `name`.
- **Configurable Gmail import labels**: Users can now define which Gmail labels are applied to imported messages from the Settings page. The default setup is opinionated: `{{source_email}}` (rendered to the mailbox address each message came from) plus `imported`, and a reset button restores those defaults instantly.
- **Prometheus metrics** (`/metrics` endpoint on the FastAPI backend, scraped every 15 s): - **Prometheus metrics** (`/metrics` endpoint on the FastAPI backend, scraped every 15 s):
- **HTTP layer** — `http_requests_total` (counter, labelled `method`/`endpoint`/`status_code`) and `http_request_duration_seconds` (histogram). Path segments that are numeric IDs are normalised to `{id}` to avoid label-set explosion. - **HTTP layer** — `http_requests_total` (counter, labelled `method`/`endpoint`/`status_code`) and `http_request_duration_seconds` (histogram). Path segments that are numeric IDs are normalised to `{id}` to avoid label-set explosion.
- **Mail processing** — `mail_processing_runs_total` (counter, by `status`: `completed` / `partial_failure` / `failed`), `mail_processing_emails_total` (counter, by `operation`: `fetched` / `forwarded` / `failed`), `mail_processing_duration_seconds` (histogram), `active_mail_accounts_total` (gauge — set each scheduler cycle). - **Mail processing** — `mail_processing_runs_total` (counter, by `status`: `completed` / `partial_failure` / `failed`), `mail_processing_emails_total` (counter, by `operation`: `fetched` / `forwarded` / `failed`), `mail_processing_duration_seconds` (histogram), `active_mail_accounts_total` (gauge — set each scheduler cycle).
@@ -31,14 +61,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **All metrics** defined as module-level singletons in `backend/app/core/metrics.py` (imported by HTTP middleware, task workers, GmailService, and auth endpoints). - **All metrics** defined as module-level singletons in `backend/app/core/metrics.py` (imported by HTTP middleware, task workers, GmailService, and auth endpoints).
- **Prometheus service** added to `docker-compose.new.yml` (port 9090, 30-day retention, config from `monitoring/prometheus.yml`). - **Prometheus service** added to `docker-compose.new.yml` (port 9090, 30-day retention, config from `monitoring/prometheus.yml`).
- **Grafana service** added to `docker-compose.new.yml` (port 3001, auto-provisioned datasource + pre-built dashboard). Default credentials: `admin` / `admin`. - **Grafana service** added to `docker-compose.new.yml` (port 3001, auto-provisioned datasource + pre-built dashboard). Default credentials: `admin` / `admin`.
- **Pre-built Grafana dashboard** (`monitoring/grafana/dashboards/inboxrescue.json`) with five sections: Mail Processing, Gmail API, Authentication & OAuth, HTTP API, and Celery Workers. Dashboard auto-refreshes every 30 s. - **Pre-built Grafana dashboard** (`monitoring/grafana/dashboards/inboxconverge.json`) with five sections: Mail Processing, Gmail API, Authentication & OAuth, HTTP API, and Celery Workers. Dashboard auto-refreshes every 30 s.
- **Admin interface**: Superusers now have access to a dedicated Admin section in the sidebar with three pages: - **Admin interface**: Superusers now have access to a dedicated Admin section in the sidebar with three pages:
- **Admin Overview** (`/admin`): System-wide stats (total users, mail accounts, processing runs). - **Admin Overview** (`/admin`): System-wide stats (total users, mail accounts, processing runs).
- **Manage Users** (`/admin/users`): Table of all registered users with their subscription tier, status, mail account count, and last login. Admins can edit any user's name, email, plan, active status, and promote/demote admin (superuser) privileges. Users can be deleted (with confirmation). - **Manage Users** (`/admin/users`): Table of all registered users with their subscription tier, status, mail account count, and last login. Admins can edit any user's name, email, plan, active status, and promote/demote admin (superuser) privileges. Users can be deleted (with confirmation).
- **Manage Plans** (`/admin/plans`): Full CRUD for subscription plans—create, edit, and delete plans with fields for tier, name, pricing, max mailboxes, max emails/day, check interval, and support level. - **Manage Plans** (`/admin/plans`): Full CRUD for subscription plans—create, edit, and delete plans with fields for tier, name, pricing, max mailboxes, max emails/day, check interval, and support level.
- **Auto-promotion of admin email**: When the user whose email matches the `ADMIN_EMAIL` environment variable logs in or registers (via email/password or Google OAuth), they are automatically promoted to superuser. Default value is `christianlouis@gmail.com` (configurable via the `ADMIN_EMAIL` env var). - **Auto-promotion of admin email**: When the user whose email matches the `ADMIN_EMAIL` environment variable logs in or registers (via email/password or Google OAuth), they are automatically promoted to superuser. Default value is `christian@inboxconverge.com` (configurable via the `ADMIN_EMAIL` env var).
- **`is_superuser` field in API responses**: `GET /users/me` and all admin user endpoints now include `is_superuser` so the frontend can conditionally show admin UI. - **`is_superuser` field in API responses**: `GET /users/me` and all admin user endpoints now include `is_superuser` so the frontend can conditionally show admin UI.
- **New admin API endpoints** (all require superuser role): - **New admin API endpoints** (all require superuser role):
- `GET /admin/users` List all users with mail account counts. - `GET /admin/users` List all users with mail account counts.
@@ -53,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`DEFAULT_USER_TIER` env var**: Controls the subscription tier assigned to every new user on registration. Defaults to `free`. Set to `enterprise` (or any other tier) for B2B / Google Workspace installations where all employees should start on a zero-rate plan. - **`DEFAULT_USER_TIER` env var**: Controls the subscription tier assigned to every new user on registration. Defaults to `free`. Set to `enterprise` (or any other tier) for B2B / Google Workspace installations where all employees should start on a zero-rate plan.
- **`ALLOWED_DOMAINS` env var**: Comma-separated list of permitted email domains (e.g. `company.com,subsidiary.com`). When set, only addresses from those domains may register or log in. Superusers always bypass this check. Empty (default) = no restriction (normal B2C mode). - **`ALLOWED_DOMAINS` env var**: Comma-separated list of permitted email domains (e.g. `company.com,subsidiary.com`). When set, only addresses from those domains may register or log in. Superusers always bypass this check. Empty (default) = no restriction (normal B2C mode).
- **Dynamic pricing section on landing page**: The home page now fetches `GET /subscriptions/plans` and renders a pricing section only when paid plans exist. In enterprise / all-zero-rate deployments the pricing section is silently hidden — the page just shows features and a "Get started free" CTA. - **Dynamic pricing section on landing page**: The home page now fetches `GET /subscriptions/plans` and renders a pricing section only when paid plans exist. In enterprise / all-zero-rate deployments the pricing section is silently hidden — the page just shows features and a "Get started free" CTA.
- **B2C copy and branding**: App renamed to **InboxRescue** throughout (was "POP3 Forwarder SaaS"). Landing page hero, feature cards, how-it-works, and footer rewritten in a personal, consumer-friendly tone. Pricing updated to €0.99 / €1.99 / €2.99 per month for Good / Better / Best plans. - **B2C copy and branding**: App renamed to **InboxConverge** throughout (was "InboxConverge"). Landing page hero, feature cards, how-it-works, and footer rewritten in a personal, consumer-friendly tone. Pricing updated to €0.99 / €1.99 / €2.99 per month for Good / Better / Best plans.
### Fixed ### Fixed
- Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis". - Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis".
@@ -66,7 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_mail_account` task when computing `duration_seconds`. After a database refresh, `started_at` may be returned as a naive datetime; it is now normalized to UTC before subtraction. - Fixed `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_mail_account` task when computing `duration_seconds`. After a database refresh, `started_at` may be returned as a naive datetime; it is now normalized to UTC before subtraction.
- **Admin user not seeing admin dashboard**: Added startup auto-promotion in `main.py` lifespan handler — on every application start, if the user matching `ADMIN_EMAIL` exists in the database but does not yet have `is_superuser=True`, they are promoted immediately. This fixes accounts created before the auto-promotion-on-login code was deployed (e.g. `christianlouis@gmail.com` was logged in but saw no admin section). - **Admin user not seeing admin dashboard**: Added startup auto-promotion in `main.py` lifespan handler — on every application start, if the user matching `ADMIN_EMAIL` exists in the database but does not yet have `is_superuser=True`, they are promoted immediately. This fixes accounts created before the auto-promotion-on-login code was deployed (e.g. `christian@inboxconverge.com` was logged in but saw no admin section).
- **Blank page on direct navigation to `/admin`, `/admin/users`, `/admin/plans`**: All three admin pages had `if (!user?.is_superuser) return null` before the `<AuthGuard>` was ever rendered. On a direct page load or refresh the Zustand store initialises with `user = null`, so the guard fired immediately and returned an empty render — `AuthGuard` was never mounted, its `checkAuth` effect never ran, and the user data was never fetched. Fixed by removing the early return and moving the superuser guard inside the `<AuthGuard>/<DashboardLayout>` tree, so authentication always runs first. - **Blank page on direct navigation to `/admin`, `/admin/users`, `/admin/plans`**: All three admin pages had `if (!user?.is_superuser) return null` before the `<AuthGuard>` was ever rendered. On a direct page load or refresh the Zustand store initialises with `user = null`, so the guard fired immediately and returned an empty render — `AuthGuard` was never mounted, its `checkAuth` effect never ran, and the user data was never fetched. Fixed by removing the early return and moving the superuser guard inside the `<AuthGuard>/<DashboardLayout>` tree, so authentication always runs first.
### Security ### Security
@@ -226,7 +256,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.1.0] - 2025-12-15 (Legacy Version) ## [0.1.0] - 2025-12-15 (Legacy Version)
### Added ### Added
- Initial release of single-user pop3_forwarder.py script - Initial release of single-user inbox_converge.py script
- Docker support with docker-compose - Docker support with docker-compose
- Multiple POP3 account support - Multiple POP3 account support
- Gmail forwarding via SMTP - Gmail forwarding via SMTP
+5 -5
View File
@@ -1,4 +1,4 @@
# Contributing to POP3 to Gmail Forwarder # Contributing to InboxConverge
Thank you for your interest in contributing! This document provides guidelines for contributing to the project. Thank you for your interest in contributing! This document provides guidelines for contributing to the project.
@@ -10,7 +10,7 @@ Be respectful and inclusive. We welcome contributions from everyone.
### Reporting Bugs ### Reporting Bugs
1. Check if the bug has already been reported in [Issues](https://github.com/christianlouis/pop_puller_to_gmail/issues) 1. Check if the bug has already been reported in [Issues](https://github.com/christianlouis/inboxconverge/issues)
2. If not, create a new issue with: 2. If not, create a new issue with:
- Clear title and description - Clear title and description
- Steps to reproduce - Steps to reproduce
@@ -77,8 +77,8 @@ Be respectful and inclusive. We welcome contributions from everyone.
```bash ```bash
# Clone your fork # Clone your fork
git clone https://github.com/YOUR-USERNAME/pop_puller_to_gmail.git git clone https://github.com/YOUR-USERNAME/inboxconverge.git
cd pop_puller_to_gmail cd inboxconverge
# Install all development dependencies # Install all development dependencies
make install-dev make install-dev
@@ -88,7 +88,7 @@ cp .env.example .env
# Edit .env with test credentials # Edit .env with test credentials
# Run the legacy forwarder script directly # Run the legacy forwarder script directly
python pop3_forwarder.py python inbox_converge.py
# Or start the SaaS backend in dev mode # Or start the SaaS backend in dev mode
make run-dev make run-dev
+5 -5
View File
@@ -8,13 +8,13 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY pop3_forwarder.py . COPY inboxconverge.py .
# Create non-root user for security # Create non-root user for security
RUN useradd -m -u 1000 forwarder && \ RUN useradd -m -u 1000 inboxconverge && \
chown -R forwarder:forwarder /app chown -R inboxconverge:inboxconverge /app
USER forwarder USER inboxconverge
# Run the application # Run the application
CMD ["python", "-u", "pop3_forwarder.py"] CMD ["python", "-u", "inboxconverge.py"]
+3 -3
View File
@@ -120,12 +120,12 @@ run-beat: ## Run Celery beat scheduler
run-flower: ## Run Flower (Celery monitoring) run-flower: ## Run Flower (Celery monitoring)
cd backend && celery -A app.core.celery_app flower --port=5555 cd backend && celery -A app.core.celery_app flower --port=5555
run-legacy: ## Run legacy pop3_forwarder script run-legacy: ## Run legacy inboxconverge script
python pop3_forwarder.py python inboxconverge.py
# Database Shell # Database Shell
shell: ## Open database shell shell: ## Open database shell
docker-compose exec db psql -U postgres -d pop3_forwarder docker-compose exec db psql -U postgres -d inbox_converge
shell-python: ## Open Python shell with app context shell-python: ## Open Python shell with app context
cd backend && python -c "from app.core.database import SessionLocal; db = SessionLocal(); print('Database session available as db')" cd backend && python -c "from app.core.database import SessionLocal; db = SessionLocal(); print('Database session available as db')"
+11 -11
View File
@@ -1,9 +1,9 @@
# POP3 to Gmail Forwarder # InboxConverge
[![CI Tests](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/test.yml/badge.svg)](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/test.yml) [![CI Tests](https://github.com/christianlouis/inboxconverge/actions/workflows/test.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/test.yml)
[![Lint](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/lint.yml/badge.svg)](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/lint.yml) [![Lint](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.yml)
[![Security Scan](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/security.yml/badge.svg)](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/security.yml) [![Security Scan](https://github.com/christianlouis/inboxconverge/actions/workflows/security.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/security.yml)
[![Docker Build](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/docker-build.yml/badge.svg)](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/docker-build.yml) [![Docker Build](https://github.com/christianlouis/inboxconverge/actions/workflows/docker-build.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/docker-build.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://www.docker.com/)
@@ -31,8 +31,8 @@ The repository also includes a multi-tenant SaaS backend built with FastAPI, Pos
```bash ```bash
# Pull and configure # Pull and configure
curl -O https://raw.githubusercontent.com/christianlouis/pop_puller_to_gmail/main/docker-compose.yml curl -O https://raw.githubusercontent.com/christianlouis/inboxconverge/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/christianlouis/pop_puller_to_gmail/main/.env.example curl -o .env https://raw.githubusercontent.com/christianlouis/inboxconverge/main/.env.example
# Edit .env with your credentials # Edit .env with your credentials
nano .env nano .env
@@ -44,8 +44,8 @@ docker-compose up -d
### Building from Source ### Building from Source
```bash ```bash
git clone https://github.com/christianlouis/pop_puller_to_gmail.git git clone https://github.com/christianlouis/inboxconverge.git
cd pop_puller_to_gmail cd inboxconverge
cp .env.example .env # then edit .env cp .env.example .env # then edit .env
docker-compose up -d docker-compose up -d
``` ```
@@ -257,5 +257,5 @@ This project is licensed under the MIT License — see [LICENSE](LICENSE) for de
## Support ## Support
- [Issue Tracker](https://github.com/christianlouis/pop_puller_to_gmail/issues) - [Issue Tracker](https://github.com/christianlouis/inboxconverge/issues)
- [Discussions](https://github.com/christianlouis/pop_puller_to_gmail/discussions) - [Discussions](https://github.com/christianlouis/inboxconverge/discussions)
+1 -1
View File
@@ -15,7 +15,7 @@
If you discover a security vulnerability, please report it responsibly: If you discover a security vulnerability, please report it responsibly:
1. **Email**: Send details to the repository maintainer via the email listed on the [GitHub profile](https://github.com/christianlouis). 1. **Email**: Send details to the repository maintainer via the email listed on the [GitHub profile](https://github.com/christianlouis).
2. **GitHub Private Vulnerability Reporting**: Use [GitHub's security advisory feature](https://github.com/christianlouis/pop_puller_to_gmail/security/advisories/new) to report privately. 2. **GitHub Private Vulnerability Reporting**: Use [GitHub's security advisory feature](https://github.com/christianlouis/inboxconverge/security/advisories/new) to report privately.
### What to Include ### What to Include
+5 -3
View File
@@ -1,5 +1,5 @@
# Database Configuration # Database Configuration
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/pop3_forwarder DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/inbox_converge
# Security # Security
SECRET_KEY=change-this-to-a-secure-random-secret-key-minimum-32-characters SECRET_KEY=change-this-to-a-secure-random-secret-key-minimum-32-characters
@@ -41,12 +41,14 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/0
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Admin Account (created on first startup) # Admin Account (created on first startup)
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=christian@inboxconverge.com
ADMIN_PASSWORD=change-this-secure-password ADMIN_PASSWORD=change-this-secure-password
# Application # Application
APP_NAME=POP3 Forwarder SaaS APP_NAME=InboxConverge
APP_VERSION=2.0.0 APP_VERSION=2.0.0
APP_URL=https://inboxconverge.com
CONTACT_EMAIL=christian@inboxconverge.com
DEBUG=false DEBUG=false
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8000 PORT=8000
+4
View File
@@ -13,6 +13,7 @@ from app.api.v1.endpoints import (
admin, admin,
providers, providers,
app_settings, app_settings,
logs,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -34,3 +35,6 @@ api_router.include_router(
) )
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"]) api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
api_router.include_router(app_settings.router, prefix="/settings", tags=["Settings"]) api_router.include_router(app_settings.router, prefix="/settings", tags=["Settings"])
api_router.include_router(
logs.router, prefix="/processing-runs", tags=["Processing Logs"]
)
+169 -2
View File
@@ -1,15 +1,18 @@
"""Admin endpoints""" """Admin endpoints"""
from typing import List import math
from fastapi import APIRouter, Depends, HTTPException, status from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_current_superuser from app.core.deps import get_current_superuser
from app.core.gdpr import mask_email, mask_from_header
from app.models.database_models import ( from app.models.database_models import (
User, User,
MailAccount, MailAccount,
ProcessingLog,
ProcessingRun, ProcessingRun,
SubscriptionPlan, SubscriptionPlan,
SubscriptionTier, SubscriptionTier,
@@ -27,6 +30,10 @@ from app.models.schemas import (
AdminNotificationConfigResponse, AdminNotificationConfigResponse,
NotificationTestRequest, NotificationTestRequest,
NotificationTestResponse, NotificationTestResponse,
AdminProcessingRunResponse,
AdminProcessingLogResponse,
PaginatedAdminRunsResponse,
PaginatedAdminLogsResponse,
) )
from app.services.notification_service import test_notification from app.services.notification_service import test_notification
@@ -400,3 +407,163 @@ async def test_admin_notification_config(
"""Test an admin notification channel by sending a test message (admin only)""" """Test an admin notification channel by sending a test message (admin only)"""
success, message = await test_notification(request.apprise_url) success, message = await test_notification(request.apprise_url)
return NotificationTestResponse(success=success, message=message) return NotificationTestResponse(success=success, message=message)
# ── Admin Logs ─────────────────────────────────────────────────────────────────
def _admin_paginate(total: int, page: int, page_size: int) -> dict:
pages = max(1, math.ceil(total / page_size)) if total else 1
return {"total": total, "page": page, "page_size": page_size, "pages": pages}
@router.get(
"/processing-runs",
response_model=PaginatedAdminRunsResponse,
summary="List all processing runs across all users (admin only)",
)
async def admin_list_processing_runs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: Optional[int] = Query(None, description="Filter by user ID"),
account_id: Optional[int] = Query(None, description="Filter by mail account ID"),
status_filter: Optional[str] = Query(
None,
alias="status",
description="Filter by run status",
),
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""
Return a paginated list of all processing runs in the system with
GDPR-masked user / account email addresses.
"""
base = (
select(
ProcessingRun,
MailAccount.name.label("account_name"),
MailAccount.email_address.label("account_email"),
MailAccount.user_id.label("uid"),
User.email.label("user_email"),
)
.join(MailAccount, ProcessingRun.mail_account_id == MailAccount.id)
.join(User, MailAccount.user_id == User.id)
)
if user_id is not None:
base = base.where(MailAccount.user_id == user_id)
if account_id is not None:
base = base.where(ProcessingRun.mail_account_id == account_id)
if status_filter:
base = base.where(ProcessingRun.status == status_filter)
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
offset = (page - 1) * page_size
rows = (
await db.execute(
base.order_by(ProcessingRun.started_at.desc())
.offset(offset)
.limit(page_size)
)
).all()
items = [
AdminProcessingRunResponse(
id=row.ProcessingRun.id, # type: ignore[arg-type]
mail_account_id=row.ProcessingRun.mail_account_id, # type: ignore[arg-type]
started_at=row.ProcessingRun.started_at, # type: ignore[arg-type]
completed_at=row.ProcessingRun.completed_at, # type: ignore[arg-type]
duration_seconds=row.ProcessingRun.duration_seconds, # type: ignore[arg-type]
emails_fetched=row.ProcessingRun.emails_fetched, # type: ignore[arg-type]
emails_forwarded=row.ProcessingRun.emails_forwarded, # type: ignore[arg-type]
emails_failed=row.ProcessingRun.emails_failed, # type: ignore[arg-type]
status=row.ProcessingRun.status, # type: ignore[arg-type]
error_message=row.ProcessingRun.error_message, # type: ignore[arg-type]
account_name=row.account_name,
account_email=mask_email(row.account_email) if row.account_email else None,
user_id=row.uid,
user_email=mask_email(row.user_email) if row.user_email else None,
)
for row in rows
]
return PaginatedAdminRunsResponse(
items=items, **_admin_paginate(total, page, page_size) # type: ignore[arg-type]
)
@router.get(
"/processing-logs",
response_model=PaginatedAdminLogsResponse,
summary="List all per-email processing logs across all users (admin only)",
)
async def admin_list_processing_logs(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
user_id: Optional[int] = Query(None, description="Filter by user ID"),
account_id: Optional[int] = Query(None, description="Filter by mail account ID"),
run_id: Optional[int] = Query(None, description="Filter by processing run ID"),
level: Optional[str] = Query(
None, description="Filter by level (INFO, WARNING, ERROR)"
),
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""
Return paginated per-email log entries with GDPR-masked sender addresses.
Subject lines are shown as-is (the user owns their own mail content);
sender addresses are pseudonymised for operator privacy.
"""
base = select(
ProcessingLog,
User.email.label("user_email"),
).join(User, ProcessingLog.user_id == User.id)
if user_id is not None:
base = base.where(ProcessingLog.user_id == user_id)
if account_id is not None:
base = base.where(ProcessingLog.mail_account_id == account_id)
if run_id is not None:
base = base.where(ProcessingLog.processing_run_id == run_id)
if level:
base = base.where(ProcessingLog.level == level.upper())
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
offset = (page - 1) * page_size
rows = (
await db.execute(
base.order_by(ProcessingLog.timestamp.desc())
.offset(offset)
.limit(page_size)
)
).all()
items = [
AdminProcessingLogResponse(
id=row.ProcessingLog.id, # type: ignore[arg-type]
timestamp=row.ProcessingLog.timestamp, # type: ignore[arg-type]
level=row.ProcessingLog.level, # type: ignore[arg-type]
message=row.ProcessingLog.message, # type: ignore[arg-type]
email_subject=row.ProcessingLog.email_subject, # type: ignore[arg-type]
email_from=(
mask_from_header(row.ProcessingLog.email_from)
if row.ProcessingLog.email_from
else None
),
success=row.ProcessingLog.success, # type: ignore[arg-type]
mail_account_id=row.ProcessingLog.mail_account_id, # type: ignore[arg-type]
processing_run_id=row.ProcessingLog.processing_run_id, # type: ignore[arg-type]
email_size_bytes=row.ProcessingLog.email_size_bytes, # type: ignore[arg-type]
error_details=row.ProcessingLog.error_details, # type: ignore[arg-type]
user_id=row.ProcessingLog.user_id, # type: ignore[arg-type]
user_email=mask_email(row.user_email) if row.user_email else None,
)
for row in rows
]
return PaginatedAdminLogsResponse(
items=items, **_admin_paginate(total, page, page_size) # type: ignore[arg-type]
)
+6 -2
View File
@@ -22,6 +22,7 @@ from app.models.database_models import User, SubscriptionTier, GmailCredential
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
from app.services.auth_service import oauth_service from app.services.auth_service import oauth_service
from app.services.gmail_service import GmailService, GMAIL_SCOPES from app.services.gmail_service import GmailService, GMAIL_SCOPES
from app.utils.gmail_labels import build_gmail_credential_scopes
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -286,7 +287,10 @@ async def google_oauth(
if encrypted_refresh: if encrypted_refresh:
existing_cred.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment] existing_cred.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
existing_cred.token_expiry = token_expiry # type: ignore[assignment] existing_cred.token_expiry = token_expiry # type: ignore[assignment]
existing_cred.scopes = scope_list # type: ignore[assignment] existing_cred.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
scope_list,
existing_cred.import_label_templates,
)
existing_cred.is_valid = True # type: ignore[assignment] existing_cred.is_valid = True # type: ignore[assignment]
existing_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] existing_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
else: else:
@@ -296,7 +300,7 @@ async def google_oauth(
encrypted_access_token=encrypted_access, encrypted_access_token=encrypted_access,
encrypted_refresh_token=encrypted_refresh, encrypted_refresh_token=encrypted_refresh,
token_expiry=token_expiry, token_expiry=token_expiry,
scopes=scope_list, scopes=build_gmail_credential_scopes(scope_list),
is_valid=True, is_valid=True,
last_verified_at=datetime.now(timezone.utc), last_verified_at=datetime.now(timezone.utc),
) )
+234
View File
@@ -0,0 +1,234 @@
"""
Processing logs and run history endpoints for users.
Users can view the full history of processing runs and per-email logs
for their own mailboxes. Admin equivalents live in admin.py.
"""
from __future__ import annotations
import math
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import (
MailAccount,
ProcessingLog,
ProcessingRun,
User,
)
from app.models.schemas import (
PaginatedProcessingLogsResponse,
PaginatedProcessingRunsResponse,
ProcessingLogDetailResponse,
ProcessingRunDetailResponse,
)
router = APIRouter()
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _paginate(total: int, page: int, page_size: int) -> dict:
pages = max(1, math.ceil(total / page_size)) if total else 1
return {"total": total, "page": page, "page_size": page_size, "pages": pages}
# ---------------------------------------------------------------------------
# Processing Runs (all accounts belonging to the current user)
# ---------------------------------------------------------------------------
@router.get(
"/processing-runs",
response_model=PaginatedProcessingRunsResponse,
summary="List processing runs for the current user",
)
async def list_processing_runs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
account_id: Optional[int] = Query(None, description="Filter by mail account ID"),
status_filter: Optional[str] = Query(
None,
alias="status",
description="Filter by run status (completed, failed, partial_failure, running)",
),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""
Return a paginated list of processing runs for all mail accounts owned by
the authenticated user, optionally filtered by account or status.
"""
# Base query: join with MailAccount to enforce ownership
base = (
select(ProcessingRun, MailAccount.name, MailAccount.email_address)
.join(MailAccount, ProcessingRun.mail_account_id == MailAccount.id)
.where(MailAccount.user_id == current_user.id) # type: ignore[arg-type]
)
if account_id is not None:
base = base.where(ProcessingRun.mail_account_id == account_id)
if status_filter:
base = base.where(ProcessingRun.status == status_filter)
# Total count
count_q = select(func.count()).select_from(base.subquery())
total = (await db.execute(count_q)).scalar_one()
offset = (page - 1) * page_size
rows = (
await db.execute(
base.order_by(ProcessingRun.started_at.desc())
.offset(offset)
.limit(page_size)
)
).all()
items = [
ProcessingRunDetailResponse(
id=row.ProcessingRun.id, # type: ignore[arg-type]
mail_account_id=row.ProcessingRun.mail_account_id, # type: ignore[arg-type]
started_at=row.ProcessingRun.started_at, # type: ignore[arg-type]
completed_at=row.ProcessingRun.completed_at, # type: ignore[arg-type]
duration_seconds=row.ProcessingRun.duration_seconds, # type: ignore[arg-type]
emails_fetched=row.ProcessingRun.emails_fetched, # type: ignore[arg-type]
emails_forwarded=row.ProcessingRun.emails_forwarded, # type: ignore[arg-type]
emails_failed=row.ProcessingRun.emails_failed, # type: ignore[arg-type]
status=row.ProcessingRun.status, # type: ignore[arg-type]
error_message=row.ProcessingRun.error_message, # type: ignore[arg-type]
account_name=row.name,
account_email=row.email_address,
)
for row in rows
]
return PaginatedProcessingRunsResponse(
items=items, **_paginate(total, page, page_size) # type: ignore[arg-type]
)
@router.get(
"/processing-runs/{run_id}",
response_model=ProcessingRunDetailResponse,
summary="Get a single processing run",
)
async def get_processing_run(
run_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Return details for a single processing run owned by the current user."""
row = (
await db.execute(
select(ProcessingRun, MailAccount.name, MailAccount.email_address)
.join(MailAccount, ProcessingRun.mail_account_id == MailAccount.id)
.where(
ProcessingRun.id == run_id,
MailAccount.user_id == current_user.id, # type: ignore[arg-type]
)
)
).one_or_none()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Processing run not found"
)
return ProcessingRunDetailResponse(
id=row.ProcessingRun.id, # type: ignore[arg-type]
mail_account_id=row.ProcessingRun.mail_account_id, # type: ignore[arg-type]
started_at=row.ProcessingRun.started_at, # type: ignore[arg-type]
completed_at=row.ProcessingRun.completed_at, # type: ignore[arg-type]
duration_seconds=row.ProcessingRun.duration_seconds, # type: ignore[arg-type]
emails_fetched=row.ProcessingRun.emails_fetched, # type: ignore[arg-type]
emails_forwarded=row.ProcessingRun.emails_forwarded, # type: ignore[arg-type]
emails_failed=row.ProcessingRun.emails_failed, # type: ignore[arg-type]
status=row.ProcessingRun.status, # type: ignore[arg-type]
error_message=row.ProcessingRun.error_message, # type: ignore[arg-type]
account_name=row.name,
account_email=row.email_address,
)
@router.get(
"/processing-runs/{run_id}/logs",
response_model=PaginatedProcessingLogsResponse,
summary="Get per-email logs for a processing run",
)
async def get_run_logs(
run_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""
Return the detailed per-email log entries recorded during a specific
processing run. Ownership is verified by joining with MailAccount.
"""
# Verify the run belongs to this user
run_row = (
await db.execute(
select(ProcessingRun)
.join(MailAccount, ProcessingRun.mail_account_id == MailAccount.id)
.where(
ProcessingRun.id == run_id,
MailAccount.user_id == current_user.id, # type: ignore[arg-type]
)
)
).scalar_one_or_none()
if not run_row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Processing run not found"
)
count_q = select(func.count(ProcessingLog.id)).where(
ProcessingLog.processing_run_id == run_id
)
total = (await db.execute(count_q)).scalar_one()
offset = (page - 1) * page_size
logs = (
(
await db.execute(
select(ProcessingLog)
.where(ProcessingLog.processing_run_id == run_id)
.order_by(ProcessingLog.timestamp.asc())
.offset(offset)
.limit(page_size)
)
)
.scalars()
.all()
)
items = [
ProcessingLogDetailResponse(
id=log.id, # type: ignore[arg-type]
timestamp=log.timestamp, # type: ignore[arg-type]
level=log.level, # type: ignore[arg-type]
message=log.message, # type: ignore[arg-type]
email_subject=log.email_subject, # type: ignore[arg-type]
email_from=log.email_from, # type: ignore[arg-type]
success=log.success, # type: ignore[arg-type]
mail_account_id=log.mail_account_id, # type: ignore[arg-type]
processing_run_id=log.processing_run_id, # type: ignore[arg-type]
email_size_bytes=log.email_size_bytes, # type: ignore[arg-type]
error_details=log.error_details, # type: ignore[arg-type]
)
for log in logs
]
return PaginatedProcessingLogsResponse(
items=items, **_paginate(total, page, page_size) # type: ignore[arg-type]
)
+155 -3
View File
@@ -1,9 +1,10 @@
"""Mail account management endpoints""" """Mail account management endpoints"""
from typing import List import math
from fastapi import APIRouter, Depends, HTTPException, status from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc, func
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_current_active_user from app.core.deps import get_current_active_user
@@ -11,6 +12,8 @@ from app.core.security import encrypt_credential
from app.models.database_models import ( from app.models.database_models import (
User, User,
MailAccount, MailAccount,
ProcessingLog,
ProcessingRun,
AccountStatus, AccountStatus,
SubscriptionPlan, SubscriptionPlan,
) )
@@ -22,6 +25,10 @@ from app.models.schemas import (
MailAccountTestResponse, MailAccountTestResponse,
MailAccountAutoDetectRequest, MailAccountAutoDetectRequest,
MailAccountAutoDetectResponse, MailAccountAutoDetectResponse,
PaginatedProcessingRunsResponse,
PaginatedProcessingLogsResponse,
ProcessingRunDetailResponse,
ProcessingLogDetailResponse,
) )
from app.services.mail_processor import MailProcessor, MailServerAutoDetect from app.services.mail_processor import MailProcessor, MailServerAutoDetect
from app.core.config import settings from app.core.config import settings
@@ -274,3 +281,148 @@ async def auto_detect_mail_settings(
return MailAccountAutoDetectResponse( return MailAccountAutoDetectResponse(
success=len(suggestions) > 0, suggestions=suggestions success=len(suggestions) > 0, suggestions=suggestions
) )
# ---------------------------------------------------------------------------
# Per-account processing runs & logs
# ---------------------------------------------------------------------------
@router.get(
"/{account_id}/processing-runs",
response_model=PaginatedProcessingRunsResponse,
summary="List processing runs for a specific mail account",
)
async def list_account_runs(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated processing runs for a mail account owned by the user."""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id,
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
base = select(ProcessingRun).where(ProcessingRun.mail_account_id == account_id)
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
offset = (page - 1) * page_size
runs = (
(
await db.execute(
base.order_by(desc(ProcessingRun.started_at))
.offset(offset)
.limit(page_size)
)
)
.scalars()
.all()
)
pages = max(1, math.ceil(total / page_size)) if total else 1
items = [
ProcessingRunDetailResponse(
id=r.id, # type: ignore[arg-type]
mail_account_id=r.mail_account_id, # type: ignore[arg-type]
started_at=r.started_at, # type: ignore[arg-type]
completed_at=r.completed_at, # type: ignore[arg-type]
duration_seconds=r.duration_seconds, # type: ignore[arg-type]
emails_fetched=r.emails_fetched, # type: ignore[arg-type]
emails_forwarded=r.emails_forwarded, # type: ignore[arg-type]
emails_failed=r.emails_failed, # type: ignore[arg-type]
status=r.status, # type: ignore[arg-type]
error_message=r.error_message, # type: ignore[arg-type]
account_name=account.name, # type: ignore[arg-type]
account_email=account.email_address, # type: ignore[arg-type]
)
for r in runs
]
return PaginatedProcessingRunsResponse(
items=items, total=total, page=page, page_size=page_size, pages=pages
)
@router.get(
"/{account_id}/logs",
response_model=PaginatedProcessingLogsResponse,
summary="List processing logs for a specific mail account",
)
async def list_account_logs(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
level: Optional[str] = Query(
None, description="Filter by log level (INFO, WARNING, ERROR)"
),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated per-email log entries for a mail account owned by the user."""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id,
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
base = select(ProcessingLog).where(
ProcessingLog.mail_account_id == account_id,
ProcessingLog.user_id == current_user.id, # type: ignore[arg-type]
)
if level:
base = base.where(ProcessingLog.level == level.upper())
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
offset = (page - 1) * page_size
logs = (
(
await db.execute(
base.order_by(ProcessingLog.timestamp.desc())
.offset(offset)
.limit(page_size)
)
)
.scalars()
.all()
)
pages = max(1, math.ceil(total / page_size)) if total else 1
items = [
ProcessingLogDetailResponse(
id=log.id, # type: ignore[arg-type]
timestamp=log.timestamp, # type: ignore[arg-type]
level=log.level, # type: ignore[arg-type]
message=log.message, # type: ignore[arg-type]
email_subject=log.email_subject, # type: ignore[arg-type]
email_from=log.email_from, # type: ignore[arg-type]
success=log.success, # type: ignore[arg-type]
mail_account_id=log.mail_account_id, # type: ignore[arg-type]
processing_run_id=log.processing_run_id, # type: ignore[arg-type]
email_size_bytes=log.email_size_bytes, # type: ignore[arg-type]
error_details=log.error_details, # type: ignore[arg-type]
)
for log in logs
]
return PaginatedProcessingLogsResponse(
items=items, total=total, page=page, page_size=page_size, pages=pages
)
+56 -2
View File
@@ -21,12 +21,30 @@ from app.models.schemas import (
GmailCredentialResponse, GmailCredentialResponse,
GmailAuthorizeResponse, GmailAuthorizeResponse,
GmailCallbackRequest, GmailCallbackRequest,
GmailImportLabelsUpdate,
) )
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
from app.utils.gmail_labels import (
MAX_IMPORT_LABELS,
build_gmail_credential_scopes,
extract_granted_scopes,
normalize_import_label_templates,
)
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _validated_import_label_templates(label_templates: List[str]) -> List[str]:
normalized = normalize_import_label_templates(label_templates)
if len(normalized) > MAX_IMPORT_LABELS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"You can configure up to {MAX_IMPORT_LABELS} Gmail import labels.",
)
return normalized
# Gmail API scopes requested during the "Connect Gmail" OAuth flow. # Gmail API scopes requested during the "Connect Gmail" OAuth flow.
# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from # GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from
# gmail_service so the scope list stays in sync with what GmailService uses. # gmail_service so the scope list stays in sync with what GmailService uses.
@@ -223,6 +241,10 @@ async def save_gmail_credential(
existing.gmail_email = credential_in.gmail_email # type: ignore[assignment] existing.gmail_email = credential_in.gmail_email # type: ignore[assignment]
existing.encrypted_access_token = encrypted_access # type: ignore[assignment] existing.encrypted_access_token = encrypted_access # type: ignore[assignment]
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment] existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
existing.scopes = build_gmail_credential_scopes(
existing.granted_scopes,
existing.import_label_templates,
) # type: ignore[assignment]
existing.is_valid = True # type: ignore[assignment] existing.is_valid = True # type: ignore[assignment]
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
await db.commit() await db.commit()
@@ -235,6 +257,7 @@ async def save_gmail_credential(
gmail_email=credential_in.gmail_email, gmail_email=credential_in.gmail_email,
encrypted_access_token=encrypted_access, encrypted_access_token=encrypted_access,
encrypted_refresh_token=encrypted_refresh, encrypted_refresh_token=encrypted_refresh,
scopes=build_gmail_credential_scopes(),
is_valid=True, is_valid=True,
last_verified_at=datetime.now(timezone.utc), last_verified_at=datetime.now(timezone.utc),
) )
@@ -285,6 +308,33 @@ async def delete_gmail_credential(
await db.commit() await db.commit()
@router.put("/gmail-credential/labels", response_model=GmailCredentialResponse)
async def update_gmail_import_labels(
labels_in: GmailImportLabelsUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Update the Gmail labels applied to imported messages."""
result = await db.execute(
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
)
credential = result.scalar_one_or_none()
if not credential:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Gmail credentials found. Connect Gmail first.",
)
credential.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
extract_granted_scopes(credential.scopes),
_validated_import_label_templates(labels_in.import_label_templates),
)
await db.commit()
await db.refresh(credential)
return credential
@router.get("/gmail/authorize-url", response_model=GmailAuthorizeResponse) @router.get("/gmail/authorize-url", response_model=GmailAuthorizeResponse)
async def get_gmail_authorize_url( async def get_gmail_authorize_url(
redirect_uri: str, redirect_uri: str,
@@ -365,6 +415,7 @@ async def send_gmail_debug_email(
try: try:
inject_result = await gmail_service.inject_debug_email( inject_result = await gmail_service.inject_debug_email(
recipient_email=credential.gmail_email, # type: ignore[arg-type] recipient_email=credential.gmail_email, # type: ignore[arg-type]
import_label_templates=credential.import_label_templates,
) )
except GmailInjectionError as exc: except GmailInjectionError as exc:
raise HTTPException( raise HTTPException(
@@ -498,7 +549,10 @@ async def gmail_oauth_callback(
if encrypted_refresh: if encrypted_refresh:
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment] existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
existing.token_expiry = token_expiry # type: ignore[assignment] existing.token_expiry = token_expiry # type: ignore[assignment]
existing.scopes = token_data.get("scope", "").split() # type: ignore[assignment] existing.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
token_data.get("scope", "").split(),
existing.import_label_templates,
)
existing.is_valid = True # type: ignore[assignment] existing.is_valid = True # type: ignore[assignment]
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
await db.commit() await db.commit()
@@ -511,7 +565,7 @@ async def gmail_oauth_callback(
encrypted_access_token=encrypted_access, encrypted_access_token=encrypted_access,
encrypted_refresh_token=encrypted_refresh, encrypted_refresh_token=encrypted_refresh,
token_expiry=token_expiry, token_expiry=token_expiry,
scopes=token_data.get("scope", "").split(), scopes=build_gmail_credential_scopes(token_data.get("scope", "").split()),
is_valid=True, is_valid=True,
last_verified_at=datetime.now(timezone.utc), last_verified_at=datetime.now(timezone.utc),
) )
+5 -3
View File
@@ -27,8 +27,10 @@ class Settings(BaseSettings):
) )
# Application # Application
APP_NAME: str = "InboxRescue" APP_NAME: str = "InboxConverge"
APP_VERSION: str = "2.0.0" APP_VERSION: str = "2.0.0"
APP_URL: str = "https://inboxconverge.com"
CONTACT_EMAIL: str = "christian@inboxconverge.com"
DEBUG: bool = False DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1" API_V1_PREFIX: str = "/api/v1"
@@ -38,7 +40,7 @@ class Settings(BaseSettings):
# Database # Database
DATABASE_URL: str = ( DATABASE_URL: str = (
"postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder" "postgresql+asyncpg://user:password@localhost:5432/inbox_converge"
) )
DATABASE_POOL_SIZE: int = 20 DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10 DATABASE_MAX_OVERFLOW: int = 10
@@ -94,7 +96,7 @@ class Settings(BaseSettings):
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
# Admin # Admin
ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com" ADMIN_EMAIL: Optional[str] = "christian@inboxconverge.com"
ADMIN_PASSWORD: Optional[str] = None ADMIN_PASSWORD: Optional[str] = None
# User defaults & access control # User defaults & access control
+106
View File
@@ -0,0 +1,106 @@
"""
GDPR-compliant data masking utilities.
These helpers are used in admin-facing API responses to pseudonymise
personal data (email addresses, names) so that operators can audit
system behaviour without seeing full end-user PII.
"""
from __future__ import annotations
import re
def mask_email(email: str) -> str:
"""
Partially mask an email address for GDPR-compliant display.
Examples
--------
>>> mask_email("john.doe@example.com")
'jo***@e***.com'
>>> mask_email("ab@x.io")
'ab***@x***.io'
>>> mask_email("a@b.de")
'a***@b***.de'
"""
if not email or "@" not in email:
return "***"
local, _, domain = email.partition("@")
# Local part: keep first 2 chars (or all if shorter), then "***"
visible_local = local[:2] if len(local) >= 2 else local
masked_local = f"{visible_local}***"
# Domain part: keep first char of SLD and the TLD unchanged
domain_parts = domain.rsplit(".", 1)
if len(domain_parts) == 2:
sld, tld = domain_parts
visible_sld = sld[:1] if sld else ""
masked_domain = f"{visible_sld}***.{tld}"
else:
masked_domain = "***"
return f"{masked_local}@{masked_domain}"
def mask_name(name: str) -> str:
"""
Partially mask a display name.
Examples
--------
>>> mask_name("John Doe")
'Jo*** D***'
>>> mask_name("Alice")
'Al***'
"""
if not name:
return "***"
words = name.split()
masked_words = []
for word in words:
visible = word[:2] if len(word) >= 2 else word
masked_words.append(f"{visible}***")
return " ".join(masked_words)
# RFC 5322 address pattern extracts the bare email from strings like
# "John Doe <john@example.com>" or just "john@example.com".
_ADDR_RE = re.compile(r"<([^>]+)>|(\S+@\S+\.\S+)")
def mask_from_header(from_header: str) -> str:
"""
Mask a raw RFC 5322 From header value for GDPR-compliant display.
Examples
--------
>>> mask_from_header("John Doe <john.doe@example.com>")
'Jo*** D*** <jo***@e***.com>'
>>> mask_from_header("john.doe@example.com")
'jo***@e***.com'
"""
if not from_header:
return "***"
# Try to parse "Display Name <email>" form
angle_match = re.search(r"^(.*?)<([^>]+)>", from_header.strip())
if angle_match:
display_name = angle_match.group(1).strip().strip('"')
email_part = angle_match.group(2).strip()
masked_email = mask_email(email_part)
if display_name:
masked_display = mask_name(display_name)
return f"{masked_display} <{masked_email}>"
return masked_email
# Plain email address
bare_match = _ADDR_RE.search(from_header)
if bare_match:
email_part = bare_match.group(1) or bare_match.group(2)
return mask_email(email_part)
# Fallback: mask the whole string
return mask_name(from_header)
+1 -1
View File
@@ -1,5 +1,5 @@
""" """
Prometheus metrics definitions for InboxRescue. Prometheus metrics definitions for InboxConverge.
All application metrics are defined here as module-level singletons so that All application metrics are defined here as module-level singletons so that
every subsystem (HTTP layer, Celery workers, Gmail service, auth) imports every subsystem (HTTP layer, Celery workers, Gmail service, auth) imports
+1 -1
View File
@@ -148,7 +148,7 @@ def create_application() -> FastAPI:
async def root(): async def root():
"""Root endpoint""" """Root endpoint"""
return { return {
"message": "InboxRescue API", "message": "InboxConverge API",
"version": settings.APP_VERSION, "version": settings.APP_VERSION,
"docs": "/api/docs", "docs": "/api/docs",
} }
+18
View File
@@ -17,6 +17,12 @@ from sqlalchemy import (
Index, Index,
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.utils.gmail_labels import (
DEFAULT_IMPORT_LABEL_TEMPLATES,
extract_granted_scopes,
extract_import_label_templates,
)
import enum import enum
from app.core.database import Base from app.core.database import Base
@@ -562,6 +568,18 @@ class GmailCredential(Base):
# Relationships # Relationships
user = relationship("User", backref="gmail_credential") user = relationship("User", backref="gmail_credential")
@property
def granted_scopes(self) -> list[str]:
return extract_granted_scopes(self.scopes)
@property
def import_label_templates(self) -> list[str]:
return extract_import_label_templates(self.scopes)
@property
def default_import_label_templates(self) -> list[str]:
return DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
class AppSetting(Base): class AppSetting(Base):
""" """
+76
View File
@@ -204,6 +204,15 @@ class ProcessingRunResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class ProcessingRunDetailResponse(ProcessingRunResponse):
"""ProcessingRunResponse with optional account metadata."""
account_name: Optional[str] = None
account_email: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# Processing Log Schemas # Processing Log Schemas
class ProcessingLogResponse(BaseModel): class ProcessingLogResponse(BaseModel):
id: int id: int
@@ -217,6 +226,67 @@ class ProcessingLogResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class ProcessingLogDetailResponse(ProcessingLogResponse):
"""ProcessingLogResponse with additional fields."""
mail_account_id: int
processing_run_id: Optional[int] = None
email_size_bytes: Optional[int] = None
error_details: Optional[Dict[str, Any]] = None
model_config = ConfigDict(from_attributes=True)
class PaginatedProcessingRunsResponse(BaseModel):
items: List[ProcessingRunDetailResponse]
total: int
page: int
page_size: int
pages: int
class PaginatedProcessingLogsResponse(BaseModel):
items: List[ProcessingLogDetailResponse]
total: int
page: int
page_size: int
pages: int
class AdminProcessingRunResponse(ProcessingRunDetailResponse):
"""ProcessingRunDetailResponse with user info for admin views."""
user_id: Optional[int] = None
user_email: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class PaginatedAdminRunsResponse(BaseModel):
items: List[AdminProcessingRunResponse]
total: int
page: int
page_size: int
pages: int
class AdminProcessingLogResponse(ProcessingLogDetailResponse):
"""ProcessingLogDetailResponse with user info for admin views."""
user_id: int
user_email: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class PaginatedAdminLogsResponse(BaseModel):
items: List[AdminProcessingLogResponse]
total: int
page: int
page_size: int
pages: int
# Notification Config Schemas # Notification Config Schemas
class NotificationConfigBase(BaseModel): class NotificationConfigBase(BaseModel):
name: str = Field( name: str = Field(
@@ -376,6 +446,8 @@ class GmailCredentialResponse(BaseModel):
user_id: int user_id: int
gmail_email: str gmail_email: str
is_valid: bool is_valid: bool
import_label_templates: List[str] = Field(default_factory=list)
default_import_label_templates: List[str] = Field(default_factory=list)
last_verified_at: Optional[datetime] = None last_verified_at: Optional[datetime] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -430,6 +502,10 @@ class GmailCallbackRequest(BaseModel):
redirect_uri: str redirect_uri: str
class GmailImportLabelsUpdate(BaseModel):
import_label_templates: List[str] = Field(default_factory=list)
# Admin Schemas # Admin Schemas
+22 -4
View File
@@ -25,6 +25,7 @@ from app.core.metrics import (
GMAIL_API_DURATION_SECONDS, GMAIL_API_DURATION_SECONDS,
GMAIL_TOKEN_REFRESHES_TOTAL, GMAIL_TOKEN_REFRESHES_TOTAL,
) )
from app.utils.gmail_labels import render_import_labels
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -294,6 +295,7 @@ class GmailService:
async def inject_debug_email( async def inject_debug_email(
self, self,
recipient_email: str, recipient_email: str,
import_label_templates: Optional[list[str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Inject a debug/test email into the user's Gmail inbox. Inject a debug/test email into the user's Gmail inbox.
@@ -344,11 +346,10 @@ class GmailService:
raw_bytes = msg.as_bytes() raw_bytes = msg.as_bytes()
# Resolve label IDs (create labels if they don't exist yet) label_ids = await self.build_import_label_ids(import_label_templates)
test_label_id = await self.get_or_create_label("test") test_label_id = await self.get_or_create_label("test")
imported_label_id = await self.get_or_create_label("imported") if test_label_id not in label_ids:
label_ids.append(test_label_id)
label_ids = ["INBOX", test_label_id, imported_label_id]
return await self.inject_email( return await self.inject_email(
raw_email=raw_bytes, raw_email=raw_bytes,
@@ -356,6 +357,23 @@ class GmailService:
source_account_name="debug", source_account_name="debug",
) )
async def build_import_label_ids(
self,
import_label_templates: Optional[list[str]] = None,
source_email: Optional[str] = None,
) -> list[str]:
"""Resolve configured import labels into Gmail label IDs."""
label_ids = ["INBOX"]
for label_name in render_import_labels(import_label_templates, source_email):
if label_name.upper() == "INBOX":
continue
label_id = await self.get_or_create_label(label_name)
if label_id not in label_ids:
label_ids.append(label_id)
return label_ids
def get_refreshed_token(self) -> Optional[Dict[str, Any]]: def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
""" """
Return the current access token and expiry if the token was refreshed Return the current access token and expiry if the token was refreshed
+97
View File
@@ -0,0 +1,97 @@
"""Helpers for Gmail import label configuration and rendering."""
from typing import Any, Iterable, Optional
SOURCE_EMAIL_LABEL_TEMPLATE = "{{source_email}}"
DEFAULT_IMPORT_LABEL_TEMPLATES = [SOURCE_EMAIL_LABEL_TEMPLATE, "imported"]
MAX_IMPORT_LABELS = 10
def _normalize_string_list(values: Optional[Iterable[str]]) -> list[str]:
normalized: list[str] = []
seen: set[str] = set()
for value in values or []:
cleaned = value.strip()
if not cleaned:
continue
lowered = cleaned.casefold()
if lowered in seen:
continue
seen.add(lowered)
normalized.append(cleaned)
return normalized
def normalize_import_label_templates(
label_templates: Optional[Iterable[str]],
) -> list[str]:
"""Return a cleaned, de-duplicated label template list."""
normalized = _normalize_string_list(label_templates)
return normalized or DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
def extract_granted_scopes(scopes_data: Any) -> list[str]:
"""Read granted scopes from legacy list or new JSON object storage."""
if isinstance(scopes_data, list):
return _normalize_string_list(
value for value in scopes_data if isinstance(value, str)
)
if isinstance(scopes_data, dict):
granted_scopes = scopes_data.get("granted_scopes", [])
if isinstance(granted_scopes, list):
return _normalize_string_list(
value for value in granted_scopes if isinstance(value, str)
)
return []
def extract_import_label_templates(scopes_data: Any) -> list[str]:
"""Read import label templates from stored Gmail credential metadata."""
if isinstance(scopes_data, dict):
stored_templates = scopes_data.get("import_label_templates", [])
if isinstance(stored_templates, list):
return normalize_import_label_templates(
value for value in stored_templates if isinstance(value, str)
)
return DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
def build_gmail_credential_scopes(
granted_scopes: Optional[Iterable[str]],
import_label_templates: Optional[Iterable[str]] = None,
) -> dict[str, list[str]]:
"""Persist Gmail metadata in the existing JSON column."""
return {
"granted_scopes": _normalize_string_list(granted_scopes),
"import_label_templates": normalize_import_label_templates(
import_label_templates
),
}
def render_import_labels(
import_label_templates: Optional[Iterable[str]],
source_email: Optional[str],
) -> list[str]:
"""Render label templates into actual Gmail label names."""
rendered_labels: list[str] = []
seen: set[str] = set()
resolved_source_email = source_email.strip() if source_email else ""
for template in normalize_import_label_templates(import_label_templates):
rendered = template.replace(SOURCE_EMAIL_LABEL_TEMPLATE, resolved_source_email)
rendered = rendered.strip()
if not rendered:
continue
lowered = rendered.casefold()
if lowered in seen:
continue
seen.add(lowered)
rendered_labels.append(rendered)
return rendered_labels
+1 -1
View File
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
# Create Celery app # Create Celery app
celery_app = Celery( celery_app = Celery(
"pop3_forwarder", "inboxconverge",
broker=settings.CELERY_BROKER_URL, broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND, backend=settings.CELERY_RESULT_BACKEND,
include=["app.workers.tasks"], include=["app.workers.tasks"],
+55 -1
View File
@@ -3,6 +3,7 @@ Celery tasks for background email processing.
""" """
import asyncio import asyncio
import email as email_lib
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from celery import Task from celery import Task
@@ -199,15 +200,46 @@ async def process_mail_account(account_id: int):
f"for account {account.id}; truncating to shorter list" f"for account {account.id}; truncating to shorter list"
) )
# Field length limits matching the DB column definitions
_MAX_SUBJECT_LEN = 500
_MAX_FROM_LEN = 255
for email_data, uid in zip(emails, new_uids): for email_data, uid in zip(emails, new_uids):
# ── Parse email metadata for logging ───────────────────────
email_subject: str | None = None
email_from: str | None = None
try:
msg = email_lib.message_from_bytes(email_data)
raw_subject = msg.get("Subject", "") or ""
email_subject = (
raw_subject[:_MAX_SUBJECT_LEN] if raw_subject else None
)
raw_from = msg.get("From", "") or ""
email_from = raw_from[:_MAX_FROM_LEN] if raw_from else None
except (ValueError, TypeError, UnicodeDecodeError) as exc:
logger.debug(
"Could not parse email headers for account %s: %s",
account.id,
exc,
)
email_size_bytes = len(email_data)
forwarded_ok = False
error_msg: str | None = None
try: try:
if use_gmail_api and gmail_service: if use_gmail_api and gmail_service:
# Inject via Gmail API (preferred) # Inject via Gmail API (preferred)
label_ids = await gmail_service.build_import_label_ids(
import_label_templates=gmail_cred.import_label_templates,
source_email=account.email_address, # type: ignore[arg-type]
)
await gmail_service.inject_email( await gmail_service.inject_email(
raw_email=email_data, raw_email=email_data,
label_ids=["INBOX"], label_ids=label_ids,
source_account_name=account.name, # type: ignore[arg-type] source_account_name=account.name, # type: ignore[arg-type]
) )
forwarded_ok = True
emails_forwarded += 1 emails_forwarded += 1
successfully_forwarded_uids.append(uid) successfully_forwarded_uids.append(uid)
else: else:
@@ -216,6 +248,7 @@ async def process_mail_account(account_id: int):
email_data, account.name, account.forward_to, smtp_config # type: ignore[arg-type] email_data, account.name, account.forward_to, smtp_config # type: ignore[arg-type]
) )
if success: if success:
forwarded_ok = True
emails_forwarded += 1 emails_forwarded += 1
successfully_forwarded_uids.append(uid) successfully_forwarded_uids.append(uid)
else: else:
@@ -253,8 +286,29 @@ async def process_mail_account(account_id: int):
f"Failed to send revocation notification: {notify_exc}" f"Failed to send revocation notification: {notify_exc}"
) )
logger.error(f"Error delivering email: {e}") logger.error(f"Error delivering email: {e}")
error_msg = str(e)
emails_failed += 1 emails_failed += 1
# ── Write per-email ProcessingLog entry ─────────────────────
db.add(
ProcessingLog(
user_id=account.user_id,
mail_account_id=account.id,
processing_run_id=run.id,
level="INFO" if forwarded_ok else "ERROR",
message=(
f"Forwarded: {email_subject or '(no subject)'}"
if forwarded_ok
else f"Failed: {error_msg or 'delivery error'}"
),
email_subject=email_subject,
email_from=email_from,
email_size_bytes=email_size_bytes,
success=forwarded_ok,
error_details={"error": error_msg} if error_msg else None,
)
)
# Persist new message UIDs so they are not processed again # Persist new message UIDs so they are not processed again
for uid in successfully_forwarded_uids: for uid in successfully_forwarded_uids:
if uid not in already_seen_uids: if uid not in already_seen_uids:
+1 -1
View File
@@ -16,7 +16,7 @@ from app.core.security import get_password_hash, create_access_token
# Test database URL (use different database for tests) # Test database URL (use different database for tests)
TEST_DATABASE_URL = settings.DATABASE_URL.replace( TEST_DATABASE_URL = settings.DATABASE_URL.replace(
"/pop3_forwarder", "/pop3_forwarder_test" "/inbox_converge", "/inbox_converge_test"
) )
+1 -1
View File
@@ -89,7 +89,7 @@ class TestApplicationFactory:
async def test_app_title(self, app): async def test_app_title(self, app):
"""Test that app has correct title""" """Test that app has correct title"""
assert app.title == "InboxRescue" assert app.title == "InboxConverge"
async def test_app_version(self, app): async def test_app_version(self, app):
"""Test that app has a version""" """Test that app has a version"""
+50 -1
View File
@@ -3,8 +3,16 @@ Unit tests for Gmail service module.
""" """
import pytest import pytest
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
from app.utils.gmail_labels import (
DEFAULT_IMPORT_LABEL_TEMPLATES,
SOURCE_EMAIL_LABEL_TEMPLATE,
build_gmail_credential_scopes,
extract_granted_scopes,
extract_import_label_templates,
render_import_labels,
)
class TestGmailService: class TestGmailService:
@@ -153,3 +161,44 @@ class TestGmailService:
email = await service.get_email_address() email = await service.get_email_address()
assert email is None assert email is None
def test_gmail_label_metadata_helpers(self):
"""Test Gmail metadata extraction remains backward compatible."""
scopes = build_gmail_credential_scopes(
["scope-a", "scope-b"],
[SOURCE_EMAIL_LABEL_TEMPLATE, "Imported", " imported "],
)
assert extract_granted_scopes(scopes) == ["scope-a", "scope-b"]
assert extract_import_label_templates(scopes) == [
SOURCE_EMAIL_LABEL_TEMPLATE,
"Imported",
]
assert (
extract_import_label_templates(["legacy-scope"])
== DEFAULT_IMPORT_LABEL_TEMPLATES
)
def test_render_import_labels_uses_source_email_template(self):
"""Test that source email templates render to the source mailbox address."""
rendered = render_import_labels(
[SOURCE_EMAIL_LABEL_TEMPLATE, "Imported", ""],
"source@example.com",
)
assert rendered == ["source@example.com", "Imported"]
@pytest.mark.asyncio
async def test_build_import_label_ids_creates_configured_labels(self):
"""Test that configured import labels are created and added alongside INBOX."""
service = GmailService(access_token="test-access-token")
service.get_or_create_label = AsyncMock(
side_effect=["Label-source", "Label-imported"]
) # type: ignore[method-assign]
label_ids = await service.build_import_label_ids(
import_label_templates=[SOURCE_EMAIL_LABEL_TEMPLATE, "imported"],
source_email="source@example.com",
)
assert label_ids == ["INBOX", "Label-source", "Label-imported"]
+12 -9
View File
@@ -4,11 +4,11 @@ services:
# PostgreSQL Database # PostgreSQL Database
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: pop3-postgres container_name: inboxconverge-postgres
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: password POSTGRES_PASSWORD: password
POSTGRES_DB: pop3_forwarder POSTGRES_DB: inbox_converge
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
@@ -22,7 +22,7 @@ services:
# Redis for caching and Celery # Redis for caching and Celery
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: pop3-redis container_name: inboxconverge-redis
ports: ports:
- "6379:6379" - "6379:6379"
volumes: volumes:
@@ -38,7 +38,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-backend container_name: inboxconverge-backend
ports: ports:
- "8000:8000" - "8000:8000"
env_file: env_file:
@@ -58,7 +58,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-celery-worker container_name: inboxconverge-celery-worker
env_file: env_file:
- ./backend/.env - ./backend/.env
depends_on: depends_on:
@@ -75,7 +75,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-celery-beat container_name: inboxconverge-celery-beat
env_file: env_file:
- ./backend/.env - ./backend/.env
depends_on: depends_on:
@@ -92,11 +92,14 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-frontend container_name: inboxconverge-frontend
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- BACKEND_URL=http://backend:8000 - BACKEND_URL=http://backend:8000
- CONTACT_EMAIL=christian@inboxconverge.com
- APP_URL=https://inboxconverge.com
- APP_NAME=InboxConverge
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
@@ -104,7 +107,7 @@ services:
# Prometheus metrics collection # Prometheus metrics collection
prometheus: prometheus:
image: prom/prometheus:v2.51.2 image: prom/prometheus:v2.51.2
container_name: pop3-prometheus container_name: inboxconverge-prometheus
ports: ports:
- "9090:9090" - "9090:9090"
volumes: volumes:
@@ -122,7 +125,7 @@ services:
# Grafana dashboards # Grafana dashboards
grafana: grafana:
image: grafana/grafana:10.4.3 image: grafana/grafana:10.4.3
container_name: pop3-grafana container_name: inboxconverge-grafana
ports: ports:
- "3001:3000" - "3001:3000"
environment: environment:
+3 -3
View File
@@ -1,13 +1,13 @@
version: '3.8' version: '3.8'
services: services:
pop3-forwarder: inboxconverge:
# Option 1: Build from source (default) # Option 1: Build from source (default)
build: . build: .
# Option 2: Use pre-built image from GitHub Container Registry # Option 2: Use pre-built image from GitHub Container Registry
# Uncomment the line below and comment out 'build: .' to use pre-built image # Uncomment the line below and comment out 'build: .' to use pre-built image
# image: ghcr.io/christianlouis/pop_puller_to_gmail:latest # image: ghcr.io/christianlouis/inboxconverge:latest
container_name: pop3-gmail-forwarder container_name: inboxconverge
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
+8 -8
View File
@@ -1,4 +1,4 @@
# POP3 Forwarder SaaS - Multi-Tenant Architecture # InboxConverge - Multi-Tenant Architecture
This document describes the new multi-tenant SaaS architecture for the POP3/IMAP email forwarder. This document describes the new multi-tenant SaaS architecture for the POP3/IMAP email forwarder.
@@ -20,7 +20,7 @@ The project has been transformed from a single-user Docker application into a fu
## 📁 Project Structure ## 📁 Project Structure
``` ```
pop_puller_to_gmail/ inboxconverge/
├── backend/ # FastAPI backend application ├── backend/ # FastAPI backend application
│ ├── app/ │ ├── app/
│ │ ├── api/ # API endpoints │ │ ├── api/ # API endpoints
@@ -50,7 +50,7 @@ pop_puller_to_gmail/
│ └── .env.example # Environment template │ └── .env.example # Environment template
├── frontend/ # React/Next.js frontend (to be implemented) ├── frontend/ # React/Next.js frontend (to be implemented)
├── docker-compose.new.yml # Docker Compose for all services ├── docker-compose.new.yml # Docker Compose for all services
├── pop3_forwarder.py # Legacy single-user script ├── inbox_converge.py # Legacy single-user script
└── README.md # This file └── README.md # This file
``` ```
@@ -68,8 +68,8 @@ pop_puller_to_gmail/
1. **Clone and navigate to repository** 1. **Clone and navigate to repository**
```bash ```bash
git clone https://github.com/christianlouis/pop_puller_to_gmail.git git clone https://github.com/christianlouis/inboxconverge.git
cd pop_puller_to_gmail cd inboxconverge
``` ```
2. **Configure backend environment** 2. **Configure backend environment**
@@ -329,7 +329,7 @@ Coming soon: Kubernetes manifests and Helm charts.
## 🔄 Migration from Legacy Version ## 🔄 Migration from Legacy Version
To migrate from the single-user `pop3_forwarder.py`: To migrate from the single-user `inbox_converge.py`:
1. **Export existing configuration** from `.env` file 1. **Export existing configuration** from `.env` file
2. **Create user account** via API or admin panel 2. **Create user account** via API or admin panel
@@ -404,8 +404,8 @@ MIT License - See [LICENSE](../LICENSE) file
## 🆘 Support ## 🆘 Support
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues - **Issues**: https://github.com/christianlouis/inboxconverge/issues
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions - **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
- **Email**: support@example.com - **Email**: support@example.com
## 🙏 Acknowledgments ## 🙏 Acknowledgments
+1 -1
View File
@@ -1,6 +1,6 @@
# Coding Patterns and Best Practices # Coding Patterns and Best Practices
This document outlines the coding patterns, conventions, and best practices for the POP3 to Gmail Forwarder project. This document outlines the coding patterns, conventions, and best practices for the InboxConverge project.
## Table of Contents ## Table of Contents
- [General Principles](#general-principles) - [General Principles](#general-principles)
+7 -7
View File
@@ -1,6 +1,6 @@
# Deployment Checklist and Next Steps # Deployment Checklist and Next Steps
This document provides a checklist for deploying the multi-tenant POP3 Forwarder with web interface. This document provides a checklist for deploying the multi-tenant InboxConverge with web interface.
## 🚀 Pre-Deployment Checklist ## 🚀 Pre-Deployment Checklist
@@ -48,7 +48,7 @@ This document provides a checklist for deploying the multi-tenant POP3 Forwarder
#### Database #### Database
- [ ] PostgreSQL 15+ instance running - [ ] PostgreSQL 15+ instance running
- [ ] Database created: `pop3_forwarder` - [ ] Database created: `inbox_converge`
- [ ] Connection details configured in backend/.env - [ ] Connection details configured in backend/.env
- [ ] Backups configured - [ ] Backups configured
@@ -89,8 +89,8 @@ sudo certbot --nginx -d yourdomain.com -d api.yourdomain.com
```bash ```bash
# On production server # On production server
cd /opt cd /opt
sudo git clone https://github.com/christianlouis/pop_puller_to_gmail.git sudo git clone https://github.com/christianlouis/inboxconverge.git
cd pop_puller_to_gmail cd inboxconverge
``` ```
### Step 2: Configure Environment ### Step 2: Configure Environment
@@ -195,9 +195,9 @@ curl -X POST https://api.yourdomain.com/api/v1/auth/register \
# Automated daily backup script # Automated daily backup script
cat > /usr/local/bin/backup-pop3-db.sh << 'EOF' cat > /usr/local/bin/backup-pop3-db.sh << 'EOF'
#!/bin/bash #!/bin/bash
BACKUP_DIR=/var/backups/pop3_forwarder BACKUP_DIR=/var/backups/inbox_converge
DATE=$(date +%Y%m%d_%H%M%S) DATE=$(date +%Y%m%d_%H%M%S)
docker exec pop3-postgres pg_dump -U postgres pop3_forwarder | gzip > $BACKUP_DIR/backup_$DATE.sql.gz docker exec inboxconverge-postgres pg_dump -U postgres inbox_converge | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
find $BACKUP_DIR -type f -mtime +30 -delete find $BACKUP_DIR -type f -mtime +30 -delete
EOF EOF
@@ -394,4 +394,4 @@ Use this checklist after deployment:
## 🎉 Congratulations! ## 🎉 Congratulations!
If all checkboxes above are complete, your multi-tenant POP3 Forwarder with web interface is successfully deployed and ready to serve users! If all checkboxes above are complete, your multi-tenant InboxConverge with web interface is successfully deployed and ready to serve users!
+50 -50
View File
@@ -1,6 +1,6 @@
# Deployment Guide # Deployment Guide
This guide walks you through deploying **POP3 to Gmail Forwarder** from scratch — whether you just want a single container pulling emails, a full multi-service SaaS stack with Docker Compose, or a production-grade Kubernetes setup. This guide walks you through deploying **InboxConverge** from scratch — whether you just want a single container pulling emails, a full multi-service SaaS stack with Docker Compose, or a production-grade Kubernetes setup.
--- ---
@@ -52,13 +52,13 @@ You will also need:
## Option 1 — Legacy Single-Container Deployment ## Option 1 — Legacy Single-Container Deployment
The legacy mode runs a single Python script (`pop3_forwarder.py`) that polls POP3 mailboxes and forwards email via SMTP. No database, no web UI — just a container and an `.env` file. The legacy mode runs a single Python script (`inbox_converge.py`) that polls POP3 mailboxes and forwards email via SMTP. No database, no web UI — just a container and an `.env` file.
### 1. Create the environment file ### 1. Create the environment file
```bash ```bash
git clone https://github.com/christianlouis/pop_puller_to_gmail.git git clone https://github.com/christianlouis/inboxconverge.git
cd pop_puller_to_gmail cd inboxconverge
cp .env.example .env cp .env.example .env
``` ```
@@ -97,12 +97,12 @@ The repository ships `docker-compose.yml` for this mode. Here is the content for
version: "3.8" version: "3.8"
services: services:
pop3-forwarder: inbox-converge:
# Build from source # Build from source
build: . build: .
# Or use the pre-built image: # Or use the pre-built image:
# image: ghcr.io/christianlouis/pop_puller_to_gmail:latest # image: ghcr.io/christianlouis/inboxconverge:latest
container_name: pop3-gmail-forwarder container_name: inboxconverge
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
@@ -149,7 +149,7 @@ Save both values — you will need them below.
### 2. Create the backend environment file ### 2. Create the backend environment file
```bash ```bash
cd pop_puller_to_gmail cd inboxconverge
cp backend/.env.example backend/.env cp backend/.env.example backend/.env
``` ```
@@ -157,7 +157,7 @@ Edit `backend/.env`:
```ini ```ini
# ── Database ────────────────────────────────────────────── # ── Database ──────────────────────────────────────────────
DATABASE_URL=postgresql+asyncpg://postgres:change-me@postgres:5432/pop3_forwarder DATABASE_URL=postgresql+asyncpg://postgres:change-me@postgres:5432/inbox_converge
# ── Security (paste the values you generated above) ────── # ── Security (paste the values you generated above) ──────
SECRET_KEY=<your-64-char-hex-secret> SECRET_KEY=<your-64-char-hex-secret>
@@ -198,12 +198,12 @@ services:
# ── PostgreSQL ─────────────────────────────────────────── # ── PostgreSQL ───────────────────────────────────────────
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: pop3-postgres container_name: inboxconverge-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: change-me # must match DATABASE_URL POSTGRES_PASSWORD: change-me # must match DATABASE_URL
POSTGRES_DB: pop3_forwarder POSTGRES_DB: inbox_converge
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -219,7 +219,7 @@ services:
# ── Redis ──────────────────────────────────────────────── # ── Redis ────────────────────────────────────────────────
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: pop3-redis container_name: inboxconverge-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes command: redis-server --appendonly yes
volumes: volumes:
@@ -235,7 +235,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-backend container_name: inboxconverge-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8000:8000"
@@ -260,7 +260,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-celery-worker container_name: inboxconverge-celery-worker
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- ./backend/.env - ./backend/.env
@@ -278,7 +278,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-celery-beat container_name: inboxconverge-celery-beat
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- ./backend/.env - ./backend/.env
@@ -296,7 +296,7 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-frontend container_name: inboxconverge-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
@@ -358,7 +358,7 @@ Below is a set of example Kubernetes manifests to get you started. Adapt namespa
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: pop3-forwarder name: inbox-converge
``` ```
### Secrets ### Secrets
@@ -369,13 +369,13 @@ Store sensitive values in a Kubernetes Secret. In production, consider using an
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
name: pop3-forwarder-secrets name: inbox-converge-secrets
namespace: pop3-forwarder namespace: inbox-converge
type: Opaque type: Opaque
stringData: stringData:
SECRET_KEY: "<your-64-char-hex-secret>" SECRET_KEY: "<your-64-char-hex-secret>"
ENCRYPTION_KEY: "<your-64-char-hex-encryption-key>" ENCRYPTION_KEY: "<your-64-char-hex-encryption-key>"
DATABASE_URL: "postgresql+asyncpg://postgres:change-me@postgres:5432/pop3_forwarder" DATABASE_URL: "postgresql+asyncpg://postgres:change-me@postgres:5432/inbox_converge"
REDIS_URL: "redis://redis:6379/0" REDIS_URL: "redis://redis:6379/0"
CELERY_BROKER_URL: "redis://redis:6379/0" CELERY_BROKER_URL: "redis://redis:6379/0"
CELERY_RESULT_BACKEND: "redis://redis:6379/0" CELERY_RESULT_BACKEND: "redis://redis:6379/0"
@@ -396,7 +396,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: postgres name: postgres
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -416,11 +416,11 @@ spec:
- name: POSTGRES_USER - name: POSTGRES_USER
value: postgres value: postgres
- name: POSTGRES_DB - name: POSTGRES_DB
value: pop3_forwarder value: inbox_converge
- name: POSTGRES_PASSWORD - name: POSTGRES_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: pop3-forwarder-secrets name: inbox-converge-secrets
key: POSTGRES_PASSWORD key: POSTGRES_PASSWORD
volumeMounts: volumeMounts:
- name: pgdata - name: pgdata
@@ -439,7 +439,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: postgres name: postgres
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
selector: selector:
app: postgres app: postgres
@@ -451,7 +451,7 @@ apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: postgres-pvc name: postgres-pvc
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
accessModes: [ReadWriteOnce] accessModes: [ReadWriteOnce]
resources: resources:
@@ -466,7 +466,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: redis name: redis
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -493,7 +493,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: redis name: redis
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
selector: selector:
app: redis app: redis
@@ -509,7 +509,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: backend name: backend
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
replicas: 2 replicas: 2
selector: selector:
@@ -522,22 +522,22 @@ spec:
spec: spec:
initContainers: initContainers:
- name: run-migrations - name: run-migrations
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest image: ghcr.io/christianlouis/inboxconverge-backend:latest
command: ["alembic", "upgrade", "head"] command: ["alembic", "upgrade", "head"]
envFrom: envFrom:
- secretRef: - secretRef:
name: pop3-forwarder-secrets name: inbox-converge-secrets
env: env:
- name: DEBUG - name: DEBUG
value: "false" value: "false"
containers: containers:
- name: backend - name: backend
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest image: ghcr.io/christianlouis/inboxconverge-backend:latest
ports: ports:
- containerPort: 8000 - containerPort: 8000
envFrom: envFrom:
- secretRef: - secretRef:
name: pop3-forwarder-secrets name: inbox-converge-secrets
env: env:
- name: HOST - name: HOST
value: "0.0.0.0" value: "0.0.0.0"
@@ -567,7 +567,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: backend name: backend
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
selector: selector:
app: backend app: backend
@@ -583,7 +583,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: celery-worker name: celery-worker
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
replicas: 2 replicas: 2
selector: selector:
@@ -596,7 +596,7 @@ spec:
spec: spec:
containers: containers:
- name: worker - name: worker
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest image: ghcr.io/christianlouis/inboxconverge-backend:latest
command: command:
- celery - celery
- -A - -A
@@ -606,7 +606,7 @@ spec:
- --concurrency=2 - --concurrency=2
envFrom: envFrom:
- secretRef: - secretRef:
name: pop3-forwarder-secrets name: inbox-converge-secrets
resources: resources:
requests: requests:
cpu: 250m cpu: 250m
@@ -625,7 +625,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: celery-beat name: celery-beat
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
replicas: 1 # Must be exactly 1 replicas: 1 # Must be exactly 1
strategy: strategy:
@@ -640,7 +640,7 @@ spec:
spec: spec:
containers: containers:
- name: beat - name: beat
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest image: ghcr.io/christianlouis/inboxconverge-backend:latest
command: command:
- celery - celery
- -A - -A
@@ -649,7 +649,7 @@ spec:
- --loglevel=info - --loglevel=info
envFrom: envFrom:
- secretRef: - secretRef:
name: pop3-forwarder-secrets name: inbox-converge-secrets
resources: resources:
requests: requests:
cpu: 100m cpu: 100m
@@ -666,7 +666,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: frontend name: frontend
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
replicas: 2 replicas: 2
selector: selector:
@@ -679,7 +679,7 @@ spec:
spec: spec:
containers: containers:
- name: frontend - name: frontend
image: ghcr.io/christianlouis/pop_puller_to_gmail-frontend:latest image: ghcr.io/christianlouis/inboxconverge-frontend:latest
ports: ports:
- containerPort: 3000 - containerPort: 3000
env: env:
@@ -697,7 +697,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: frontend name: frontend
namespace: pop3-forwarder namespace: inbox-converge
spec: spec:
selector: selector:
app: frontend app: frontend
@@ -714,8 +714,8 @@ The Ingress below assumes you have an Ingress controller installed (e.g., [ingre
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: pop3-forwarder-ingress name: inbox-converge-ingress
namespace: pop3-forwarder namespace: inbox-converge
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "10m" nginx.ingress.kubernetes.io/proxy-body-size: "10m"
@@ -725,7 +725,7 @@ spec:
- hosts: - hosts:
- your-domain.com - your-domain.com
- api.your-domain.com - api.your-domain.com
secretName: pop3-forwarder-tls secretName: inbox-converge-tls
rules: rules:
- host: your-domain.com - host: your-domain.com
http: http:
@@ -754,7 +754,7 @@ spec:
If you manage many environments (staging, production, etc.) consider wrapping the manifests above into a Helm chart: If you manage many environments (staging, production, etc.) consider wrapping the manifests above into a Helm chart:
```text ```text
helm/pop3-forwarder/ helm/inbox-converge/
├── Chart.yaml ├── Chart.yaml
├── values.yaml # defaults for all environments ├── values.yaml # defaults for all environments
├── values-staging.yaml ├── values-staging.yaml
@@ -782,8 +782,8 @@ replicaCount:
frontend: 2 frontend: 2
image: image:
backend: ghcr.io/christianlouis/pop_puller_to_gmail-backend backend: ghcr.io/christianlouis/inboxconverge-backend
frontend: ghcr.io/christianlouis/pop_puller_to_gmail-frontend frontend: ghcr.io/christianlouis/inboxconverge-frontend
tag: latest tag: latest
ingress: ingress:
@@ -862,7 +862,7 @@ In production you should place a reverse proxy in front of the backend and front
### Example: nginx ### Example: nginx
```nginx ```nginx
# /etc/nginx/sites-available/pop3-forwarder # /etc/nginx/sites-available/inbox-converge
# Frontend # Frontend
server { server {
@@ -969,7 +969,7 @@ If you prefer Traefik, add it as a service in your Compose file and use labels o
## Upgrading ## Upgrading
```bash ```bash
cd pop_puller_to_gmail cd inboxconverge
# Pull latest code # Pull latest code
git pull origin main git pull origin main
+1 -1
View File
@@ -1,6 +1,6 @@
# Error Codes and Messages # Error Codes and Messages
This document catalogs all error codes used in the POP3 to Gmail Forwarder application. This document catalogs all error codes used in the InboxConverge application.
## Error Code Format ## Error Code Format
+3 -3
View File
@@ -2,7 +2,7 @@
## 🎯 Mission Accomplished ## 🎯 Mission Accomplished
This document summarizes the completion of the web interface and multitenancy features for the POP3 to Gmail Forwarder project. This document summarizes the completion of the web interface and multitenancy features for the InboxConverge project.
## 📦 What Was Delivered ## 📦 What Was Delivered
@@ -320,7 +320,7 @@ These are potential future improvements outside the current task:
### What Was Accomplished ### What Was Accomplished
**Complete implementation of web interface and multitenancy features** **Complete implementation of web interface and multitenancy features**
The POP3 to Gmail Forwarder now has: The InboxConverge now has:
- A modern, responsive web interface - A modern, responsive web interface
- Complete user authentication system - Complete user authentication system
- Full mail account management capabilities - Full mail account management capabilities
@@ -356,7 +356,7 @@ The implementation is **complete and ready for**:
## 👏 Thank You ## 👏 Thank You
This implementation represents a significant milestone in transforming the POP3 Forwarder from a simple script into a production-ready multi-tenant SaaS application. The web interface makes the service accessible to users of all technical levels, while maintaining the robust backend infrastructure. This implementation represents a significant milestone in transforming the InboxConverge from a simple script into a production-ready multi-tenant SaaS application. The web interface makes the service accessible to users of all technical levels, while maintaining the robust backend infrastructure.
**The multitenancy and web interface implementation is now complete and ready for deployment!** 🎉 **The multitenancy and web interface implementation is now complete and ready for deployment!** 🎉
+11 -11
View File
@@ -1,6 +1,6 @@
# Implementation Guide # Implementation Guide
This guide provides step-by-step instructions for setting up and deploying the multi-tenant POP3 Forwarder SaaS application. This guide provides step-by-step instructions for setting up and deploying the multi-tenant InboxConverge application.
## Table of Contents ## Table of Contents
@@ -36,8 +36,8 @@ This guide provides step-by-step instructions for setting up and deploying the m
```bash ```bash
# Clone repository # Clone repository
git clone https://github.com/christianlouis/pop_puller_to_gmail.git git clone https://github.com/christianlouis/inboxconverge.git
cd pop_puller_to_gmail cd inboxconverge
# Create backend environment file # Create backend environment file
cp backend/.env.example backend/.env cp backend/.env.example backend/.env
@@ -49,7 +49,7 @@ Edit `backend/.env` with your settings:
```bash ```bash
# Minimum required for development # Minimum required for development
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/pop3_forwarder DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/inbox_converge
SECRET_KEY=$(openssl rand -hex 32) SECRET_KEY=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32) ENCRYPTION_KEY=$(openssl rand -hex 32)
GOOGLE_CLIENT_ID=your-client-id GOOGLE_CLIENT_ID=your-client-id
@@ -176,7 +176,7 @@ ADMIN_EMAIL=admin@yourdomain.com
Use nginx or Traefik as reverse proxy: Use nginx or Traefik as reverse proxy:
```nginx ```nginx
# /etc/nginx/sites-available/pop3-forwarder # /etc/nginx/sites-available/inbox-converge
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name api.yourdomain.com; server_name api.yourdomain.com;
@@ -202,7 +202,7 @@ cat > /etc/cron.daily/backup-postgres << 'EOF'
#!/bin/bash #!/bin/bash
BACKUP_DIR=/var/backups/postgres BACKUP_DIR=/var/backups/postgres
DATE=$(date +%Y%m%d_%H%M%S) DATE=$(date +%Y%m%d_%H%M%S)
docker exec pop3-postgres pg_dump -U postgres pop3_forwarder | gzip > $BACKUP_DIR/backup_$DATE.sql.gz docker exec inboxconverge-postgres pg_dump -U postgres inbox_converge | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
find $BACKUP_DIR -type f -mtime +7 -delete # Keep 7 days find $BACKUP_DIR -type f -mtime +7 -delete # Keep 7 days
EOF EOF
@@ -348,10 +348,10 @@ docker-compose -f docker-compose.new.yml exec celery-worker celery -A app.worker
```bash ```bash
# Check connections # Check connections
docker exec pop3-postgres psql -U postgres -d pop3_forwarder -c "SELECT count(*) FROM pg_stat_activity;" docker exec inboxconverge-postgres psql -U postgres -d inbox_converge -c "SELECT count(*) FROM pg_stat_activity;"
# Check table sizes # Check table sizes
docker exec pop3-postgres psql -U postgres -d pop3_forwarder -c " docker exec inboxconverge-postgres psql -U postgres -d inbox_converge -c "
SELECT SELECT
schemaname, schemaname,
tablename, tablename,
@@ -376,7 +376,7 @@ docker-compose -f docker-compose.new.yml ps postgres
docker-compose -f docker-compose.new.yml logs postgres docker-compose -f docker-compose.new.yml logs postgres
# Test connection # Test connection
docker exec pop3-postgres psql -U postgres -c "SELECT version();" docker exec inboxconverge-postgres psql -U postgres -c "SELECT version();"
``` ```
#### Celery Worker Not Processing #### Celery Worker Not Processing
@@ -480,8 +480,8 @@ redis:
For additional help: For additional help:
- **Documentation**: See [ARCHITECTURE.md](ARCHITECTURE.md) - **Documentation**: See [ARCHITECTURE.md](ARCHITECTURE.md)
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues - **Issues**: https://github.com/christianlouis/inboxconverge/issues
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions - **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
--- ---
+2 -2
View File
@@ -1,6 +1,6 @@
# Migration Guide: Single-User to Multi-Tenant SaaS # Migration Guide: Single-User to Multi-Tenant SaaS
This guide helps you migrate from the legacy single-user `pop3_forwarder.py` script to the new multi-tenant SaaS application. This guide helps you migrate from the legacy single-user `inbox_converge.py` script to the new multi-tenant SaaS application.
## Overview ## Overview
@@ -230,7 +230,7 @@ docker-compose -f docker-compose.yml down
# Archive old configuration # Archive old configuration
mkdir -p archive mkdir -p archive
mv pop3_forwarder.py archive/ mv inbox_converge.py archive/
mv .env.legacy.backup archive/ mv .env.legacy.backup archive/
mv docker-compose.yml archive/docker-compose.legacy.yml mv docker-compose.yml archive/docker-compose.legacy.yml
+10 -10
View File
@@ -1,6 +1,6 @@
# Quick Start Guide # Quick Start Guide
Get your POP3 to Gmail forwarder running in under 10 minutes! Get your InboxConverge instance running in under 10 minutes!
## Prerequisites ## Prerequisites
@@ -13,8 +13,8 @@ Get your POP3 to Gmail forwarder running in under 10 minutes!
### 1. Clone the Repository ### 1. Clone the Repository
```bash ```bash
git clone https://github.com/christianlouis/pop_puller_to_gmail.git git clone https://github.com/christianlouis/inboxconverge.git
cd pop_puller_to_gmail cd inboxconverge
``` ```
### 2. Generate Gmail App Password ### 2. Generate Gmail App Password
@@ -23,7 +23,7 @@ cd pop_puller_to_gmail
2. Sign in to your Google Account 2. Sign in to your Google Account
3. Select "App passwords" under Security 3. Select "App passwords" under Security
4. Choose "Mail" and "Other (Custom name)" 4. Choose "Mail" and "Other (Custom name)"
5. Enter "POP3 Forwarder" as the name 5. Enter "InboxConverge" as the name
6. Click "Generate" 6. Click "Generate"
7. **Copy the 16-character password** (you'll need this in step 3) 7. **Copy the 16-character password** (you'll need this in step 3)
@@ -66,10 +66,10 @@ docker-compose logs -f
You should see: You should see:
``` ```
pop3-gmail-forwarder | INFO - POP3 to Gmail Forwarder starting... inboxconverge | INFO - InboxConverge starting...
pop3-gmail-forwarder | INFO - Loaded POP3 account: ... inboxconverge | INFO - Loaded POP3 account: ...
pop3-gmail-forwarder | INFO - Configuration validated successfully inboxconverge | INFO - Configuration validated successfully
pop3-gmail-forwarder | INFO - Starting email processing cycle inboxconverge | INFO - Starting email processing cycle
``` ```
### 6. Test the Forwarder ### 6. Test the Forwarder
@@ -192,13 +192,13 @@ Restart after changes: `docker-compose restart`
## Need Help? ## Need Help?
- Open an issue: https://github.com/christianlouis/pop_puller_to_gmail/issues - Open an issue: https://github.com/christianlouis/inboxconverge/issues
- Check existing discussions - Check existing discussions
- Review troubleshooting section in README.md - Review troubleshooting section in README.md
## Success! 🎉 ## Success! 🎉
Your POP3 to Gmail forwarder is now running. Emails will be automatically forwarded every 5 minutes (or your configured interval). Your InboxConverge instance is now running. Emails will be automatically forwarded every 5 minutes (or your configured interval).
**Remember**: **Remember**:
- The forwarder deletes emails from POP3 after successful forwarding - The forwarder deletes emails from POP3 after successful forwarding
+4 -4
View File
@@ -39,8 +39,8 @@ This project has been **completely transformed** from a single-user Docker scrip
```bash ```bash
# Clone repository # Clone repository
git clone https://github.com/christianlouis/pop_puller_to_gmail.git git clone https://github.com/christianlouis/inboxconverge.git
cd pop_puller_to_gmail cd inboxconverge
# Configure environment # Configure environment
cp backend/.env.example backend/.env cp backend/.env.example backend/.env
@@ -330,8 +330,8 @@ MIT License - See [LICENSE](../LICENSE) file for details.
## 🆘 Support ## 🆘 Support
- **Documentation**: See docs in repository - **Documentation**: See docs in repository
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues - **Issues**: https://github.com/christianlouis/inboxconverge/issues
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions - **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
- **Email**: support@example.com (for Enterprise customers) - **Email**: support@example.com (for Enterprise customers)
## 🎉 Acknowledgments ## 🎉 Acknowledgments
+1 -1
View File
@@ -1,7 +1,7 @@
# Roadmap # Roadmap
## Vision ## Vision
Create a robust, scalable, and user-friendly POP3 to Gmail forwarding solution that serves as a complete replacement for Gmail's discontinued POP3 import feature. Create a robust, scalable, and user-friendly InboxConverge email-forwarding solution that serves as a complete replacement for Gmail's discontinued POP3 import feature.
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview ## Overview
Security analysis completed on February 1, 2026 for the Multi-Tenant POP3 Forwarder SaaS application. Security analysis completed on February 1, 2026 for the Multi-Tenant InboxConverge application.
## CodeQL Security Scan ## CodeQL Security Scan
+1 -1
View File
@@ -25,7 +25,7 @@ This guide will help you test the complete multi-tenant web interface with the b
3. Edit the `.env` file and update the following critical values: 3. Edit the `.env` file and update the following critical values:
```bash ```bash
# Database - should point to Docker service # Database - should point to Docker service
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/pop3_forwarder DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/inbox_converge
# Redis - should point to Docker service # Redis - should point to Docker service
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
+15 -3
View File
@@ -2,6 +2,12 @@
Comprehensive task breakdown for repository improvements and production readiness. Comprehensive task breakdown for repository improvements and production readiness.
## ✅ Recently Completed
- [x] Rename entire project to **InboxConverge**: all user-visible strings, Docker container/image names, DB defaults, monitoring, and docs updated.
- [x] Domain updated to `inboxconverge.com`; contact email defaults to `christian@inboxconverge.com`.
- [x] New configurable env vars: `CONTACT_EMAIL`, `APP_URL`, `NEXT_PUBLIC_APP_NAME`.
## 🔴 Critical - Security (In Progress) ## 🔴 Critical - Security (In Progress)
### Completed ✅ ### Completed ✅
@@ -178,7 +184,7 @@ Comprehensive task breakdown for repository improvements and production readines
### Not Started 📋 ### Not Started 📋
- [ ] Integrate Sentry for error tracking - [ ] Integrate Sentry for error tracking
- [ ] Add structured logging with correlation IDs - [x] Add structured logging with correlation IDs (per-email ProcessingLog entries now captured in DB)
- [ ] Add APM (Application Performance Monitoring) - [ ] Add APM (Application Performance Monitoring)
- [ ] Set up uptime monitoring - [ ] Set up uptime monitoring
- [ ] Create runbook for common issues - [ ] Create runbook for common issues
@@ -193,9 +199,11 @@ Comprehensive task breakdown for repository improvements and production readines
- [x] Account enable/disable toggle (UX + backend) - [x] Account enable/disable toggle (UX + backend)
- [x] Per-user SMTP configuration (UX + backend) - [x] Per-user SMTP configuration (UX + backend)
- [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling - [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling
- [x] Configurable Gmail import labels (default `{{source_email}}` + `imported`, editable in Settings with reset-to-default action)
- [x] Unified Google OAuth flow: sign-in requests all Gmail scopes; single `/auth/callback` redirect URI needed in Google Console - [x] Unified Google OAuth flow: sign-in requests all Gmail scopes; single `/auth/callback` redirect URI needed in Google Console
- [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking) - [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking)
- [x] **Debug email**: "Send Debug Email" button in Settings injects a test message (from christian@docuelevate.org, dated today, labelled `test` + `imported`, placed in inbox) to verify end-to-end Gmail API delivery - [x] **Debug email**: "Send Debug Email" button in Settings injects a test message (from christian@docuelevate.org, dated today, labelled `test` + `imported`, placed in inbox) to verify end-to-end Gmail API delivery
- [x] **Logging & reporting**: per-email ProcessingLog capture in worker; user `/logs` page; admin `/admin/logs` page; GDPR masking utilities (`gdpr.py`)
- [ ] Implement GDPR data export endpoint - [ ] Implement GDPR data export endpoint
- [x] Complete notification service integration (Apprise) - [x] Complete notification service integration (Apprise)
- [ ] Add advanced email filtering - [ ] Add advanced email filtering
@@ -232,6 +240,8 @@ because the API client layer is missing.
- [x] `AuthGuard` for protected routes - [x] `AuthGuard` for protected routes
- [x] Fix wizard grey screen (Tailwind v4 `bg-opacity``/75` syntax, modal restructure) - [x] Fix wizard grey screen (Tailwind v4 `bg-opacity``/75` syntax, modal restructure)
- [x] `/auth/gmail-callback` page for Gmail OAuth one-click flow - [x] `/auth/gmail-callback` page for Gmail OAuth one-click flow
- [x] **`/logs` page** — user processing history: paginated runs table with expandable per-email log panel (subject, sender, size, status)
- [x] **Dashboard** — "Recent Processing Runs" now wired to real `/processing-runs` endpoint; shows account name and links to `/logs`
### Not Started 📋 ### Not Started 📋
- [ ] End-to-end testing of frontend against backend API - [ ] End-to-end testing of frontend against backend API
@@ -248,10 +258,11 @@ because the API client layer is missing.
- [x] Admin overview page (`/admin`) with system-wide stats - [x] Admin overview page (`/admin`) with system-wide stats
- [x] User management page (`/admin/users`) — list, edit, delete users; assign plans; promote/demote admin - [x] User management page (`/admin/users`) — list, edit, delete users; assign plans; promote/demote admin
- [x] Plan management page (`/admin/plans`) — full CRUD for subscription plans (mailboxes, emails/day, interval, pricing) - [x] Plan management page (`/admin/plans`) — full CRUD for subscription plans (mailboxes, emails/day, interval, pricing)
- [x] `ADMIN_EMAIL` env var with default `christianlouis@gmail.com`; admin auto-promoted on login and on every application startup (fixes pre-existing accounts) - [x] `ADMIN_EMAIL` env var with default `christian@inboxconverge.com`; admin auto-promoted on login and on every application startup (fixes pre-existing accounts)
- [x] `is_superuser` exposed in `/users/me` response - [x] `is_superuser` exposed in `/users/me` response
- [x] Admin badge (purple shield) shown in top bar for superusers - [x] Admin badge (purple shield) shown in top bar for superusers
- [x] Fix blank page on direct navigation to `/admin*`: moved superuser guard inside `<AuthGuard>` so auth check always runs on fresh load - [x] Fix blank page on direct navigation to `/admin*`: moved superuser guard inside `<AuthGuard>` so auth check always runs on fresh load
- [x] **`/admin/logs` page** — system-wide processing activity: expandable run table + flat per-email log table with GDPR-masked sender addresses; filterable by user ID, status, log level
--- ---
@@ -351,7 +362,8 @@ because the API client layer is missing.
1. **Immediate** (Today): 1. **Immediate** (Today):
- [x] Create `frontend/src/lib/api.ts` (frontend is broken without it) - [x] Create `frontend/src/lib/api.ts` (frontend is broken without it)
- [x] Fix remaining security issues (bare excepts, datetime, redirect_uri) - [x] Fix remaining security issues (bare excepts, datetime, redirect_uri)
- [ ] Add backend endpoint for processing runs (needed by dashboard) - [x] Add backend endpoint for processing runs (needed by dashboard)
- [x] Build logging & reporting: per-email ProcessingLog capture, user `/logs` page, admin `/admin/logs` page, GDPR masking
2. **This Week**: 2. **This Week**:
- [ ] Enable rate limiting - [ ] Enable rate limiting
+2 -2
View File
@@ -1,6 +1,6 @@
# Web Interface Quick Start Guide # Web Interface Quick Start Guide
The POP3 to Gmail Forwarder now includes a modern web interface built with Next.js, making it easy to manage your email forwarding without API calls. The InboxConverge now includes a modern web interface built with Next.js, making it easy to manage your email forwarding without API calls.
## 🌐 Accessing the Web Interface ## 🌐 Accessing the Web Interface
@@ -104,7 +104,7 @@ frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: pop3-frontend container_name: inboxconverge-frontend
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
+1 -1
View File
@@ -126,7 +126,7 @@ elif version == 'v2':
def get_or_create_user_salt(user_id: int) -> bytes: def get_or_create_user_salt(user_id: int) -> bytes:
# Use deterministic salt based on user_id + global salt # Use deterministic salt based on user_id + global salt
# OR store random salt in database per user # OR store random salt in database per user
return hashlib.sha256(f'pop3_forwarder_user_{user_id}'.encode()).digest() return hashlib.sha256(f'inbox_converge_user_{user_id}'.encode()).digest()
``` ```
## Security Best Practices ## Security Best Practices
+1 -1
View File
@@ -64,7 +64,7 @@ async def lifespan(app: FastAPI):
# Shutdown: cleanup # Shutdown: cleanup
app = FastAPI( app = FastAPI(
title="POP3 Forwarder API", title="InboxConverge API",
lifespan=lifespan, lifespan=lifespan,
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
docs_url="/api/docs", docs_url="/api/docs",
+13 -1
View File
@@ -351,7 +351,7 @@ export default function AdminPage() {
</div> </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 <Link
href="/admin/users" href="/admin/users"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group" 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> <p className="text-sm text-gray-500">Create and configure subscription plans</p>
</div> </div>
</Link> </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> </div>
{/* System Alert Channels */} {/* System Alert Channels */}
+11 -7
View File
@@ -4,6 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout'; import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { mailAccountsApi, processingRunsApi } from '@/lib/api'; import { mailAccountsApi, processingRunsApi } from '@/lib/api';
import Link from 'next/link';
import { import {
Mail, Mail,
Send, Send,
@@ -51,19 +52,19 @@ export default function DashboardPage() {
const { data: runs, isLoading: runsLoading } = useQuery({ const { data: runs, isLoading: runsLoading } = useQuery({
queryKey: ['processing-runs'], queryKey: ['processing-runs'],
queryFn: () => processingRunsApi.list(), queryFn: () => processingRunsApi.list({ page: 1, page_size: 10 }),
}); });
const stats = { const stats = {
totalAccounts: accounts?.length || 0, totalAccounts: accounts?.length || 0,
activeAccounts: accounts?.filter((a) => a.is_enabled).length || 0, activeAccounts: accounts?.filter((a) => a.is_enabled).length || 0,
emailsToday: runs emailsToday: runs?.items
?.filter((r) => { ?.filter((r) => {
const today = new Date().toDateString(); const today = new Date().toDateString();
return new Date(r.started_at).toDateString() === today; return new Date(r.started_at).toDateString() === today;
}) })
.reduce((sum, r) => sum + r.emails_forwarded, 0) || 0, .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 ( return (
@@ -100,15 +101,18 @@ export default function DashboardPage() {
{/* Recent Processing Runs */} {/* Recent Processing Runs */}
<div className="bg-white rounded-lg shadow"> <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> <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>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{runsLoading ? ( {runsLoading ? (
<div className="flex items-center justify-center py-12"> <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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div> </div>
) : runs && runs.length > 0 ? ( ) : runs && runs.items && runs.items.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
@@ -133,12 +137,12 @@ export default function DashboardPage() {
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <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); const account = accounts?.find((a) => a.id === run.mail_account_id);
return ( return (
<tr key={run.id}> <tr key={run.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> <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>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center"> <div className="flex items-center">
+5 -3
View File
@@ -1,12 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Datenschutz POP3 Forwarder', title: `Datenschutz ${APP_NAME}`,
}; };
const LAST_UPDATED = 'March 26, 2026'; 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() { export default function DatenschutzPage() {
return ( return (
@@ -82,7 +84,7 @@ export default function DatenschutzPage() {
2. Geltungsbereich dieser Datenschutzerklärung 2. Geltungsbereich dieser Datenschutzerklärung
</h2> </h2>
<p className="text-gray-700"> <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 für alle Nutzer weltweit, einschließlich derjenigen in der
Europäischen Union (EU), dem Europäischen Wirtschaftsraum (EWR), Europäischen Union (EU), dem Europäischen Wirtschaftsraum (EWR),
Deutschland, dem Vereinigten Königreich (UK), der Schweiz, der Deutschland, dem Vereinigten Königreich (UK), der Schweiz, der
+6 -3
View File
@@ -1,8 +1,11 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Metadata } from 'next'; 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 = { export const metadata: Metadata = {
title: 'Impressum POP3 Forwarder', title: `Impressum ${APP_NAME}`,
}; };
export default function ImpressumPage() { export default function ImpressumPage() {
@@ -36,10 +39,10 @@ export default function ImpressumPage() {
Fax: +49 40 97074609<br /> Fax: +49 40 97074609<br />
E-Mail:{' '} E-Mail:{' '}
<a <a
href="mailto:christianlouis@gmail.com" href={`mailto:${CONTACT_EMAIL}`}
className="text-blue-600 hover:text-blue-500" className="text-blue-600 hover:text-blue-500"
> >
christianlouis@gmail.com {CONTACT_EMAIL}
</a> </a>
</p> </p>
</section> </section>
+1 -1
View File
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { 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.", 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 className="max-w-md w-full space-y-8">
<div> <div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900"> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
POP3 to Gmail Forwarder InboxConverge
</h2> </h2>
<p className="mt-2 text-center text-sm text-gray-600"> <p className="mt-2 text-center text-sm text-gray-600">
Sign in to your account 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 justify-between items-center py-4">
<div className="flex items-center"> <div className="flex items-center">
<Mail className="h-8 w-8 text-blue-600 mr-2" /> <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>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link <Link
@@ -136,7 +136,7 @@ export default function Home() {
</h2> </h2>
<p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto"> <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 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, quietly polls them all and drops everything into your Gmail. Set it once,
forget it exists. forget it exists.
</p> </p>
@@ -171,7 +171,7 @@ export default function Home() {
Auto-detects everything Auto-detects everything
</h3> </h3>
<p className="text-gray-600"> <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. settings. No Googling port numbers required.
</p> </p>
</div> </div>
@@ -215,7 +215,7 @@ export default function Home() {
</div> </div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4> <h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
<p className="text-gray-600"> <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. settings in seconds.
</p> </p>
</div> </div>
@@ -225,7 +225,7 @@ export default function Home() {
</div> </div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4> <h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
<p className="text-gray-600"> <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. your inbox using the Gmail API no SMTP relay needed.
</p> </p>
</div> </div>
@@ -270,7 +270,7 @@ export default function Home() {
<footer className="mt-20 border-t border-gray-200 bg-white"> <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"> <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"> <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> </p>
</div> </div>
</footer> </footer>
+129 -2
View File
@@ -16,8 +16,112 @@ import {
AlertTriangle, AlertTriangle,
XCircle, XCircle,
Bug, Bug,
RotateCcw,
Tags,
} from 'lucide-react'; } 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() { export default function SettingsPage() {
return ( return (
<AuthGuard> <AuthGuard>
@@ -106,6 +210,7 @@ function SettingsContent() {
}); });
const [debugEmailResult, setDebugEmailResult] = useState<string | null>(null); const [debugEmailResult, setDebugEmailResult] = useState<string | null>(null);
const [gmailLabelsSaved, setGmailLabelsSaved] = useState(false);
const sendDebugEmailMutation = useMutation({ const sendDebugEmailMutation = useMutation({
mutationFn: gmailApi.sendDebugEmail, mutationFn: gmailApi.sendDebugEmail,
onSuccess: () => { 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({ const saveSmtpMutation = useMutation({
mutationFn: smtpApi.save, mutationFn: smtpApi.save,
onSuccess: () => { onSuccess: () => {
@@ -362,7 +476,9 @@ function SettingsContent() {
{debugEmailResult === 'success' && ( {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"> <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" /> <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> </div>
)} )}
{debugEmailResult === 'error' && ( {debugEmailResult === 'error' && (
@@ -371,6 +487,18 @@ function SettingsContent() {
Failed to inject debug email. Check that Gmail API access is still valid. Failed to inject debug email. Check that Gmail API access is still valid.
</div> </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> </div>
)} )}
@@ -589,4 +717,3 @@ function SettingsContent() {
</div> </div>
); );
} }
+6 -2
View File
@@ -16,6 +16,8 @@ import {
Users, Users,
CreditCard, CreditCard,
Bell Bell
FileText,
Activity
} from 'lucide-react'; } from 'lucide-react';
interface DashboardLayoutProps { interface DashboardLayoutProps {
@@ -37,6 +39,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Mail Accounts', href: '/accounts', icon: Mail }, { name: 'Mail Accounts', href: '/accounts', icon: Mail },
{ name: 'Notifications', href: '/notifications', icon: Bell }, { name: 'Notifications', href: '/notifications', icon: Bell },
{ name: 'Logs', href: '/logs', icon: FileText },
{ name: 'Settings', href: '/settings', icon: Settings }, { name: 'Settings', href: '/settings', icon: Settings },
]; ];
@@ -45,6 +48,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ name: 'Admin Overview', href: '/admin', icon: Shield }, { name: 'Admin Overview', href: '/admin', icon: Shield },
{ name: 'Manage Users', href: '/admin/users', icon: Users }, { name: 'Manage Users', href: '/admin/users', icon: Users },
{ name: 'Manage Plans', href: '/admin/plans', icon: CreditCard }, { 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="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 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"> <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> </div>
<nav className="flex-1 px-2 py-4 space-y-1"> <nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map((item) => { {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-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="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"> <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"> <button onClick={() => setSidebarOpen(false)} className="text-gray-500 hover:text-gray-700">
<X className="h-6 w-6" /> <X className="h-6 w-6" />
</button> </button>
+143 -4
View File
@@ -126,6 +126,64 @@ export interface ProcessingRun {
emails_failed: number; emails_failed: number;
status: string; status: string;
error_message?: string | null; 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 { export interface AutoDetectSuggestion {
@@ -141,6 +199,8 @@ export interface GmailCredential {
user_id: number; user_id: number;
gmail_email: string; gmail_email: string;
is_valid: boolean; is_valid: boolean;
import_label_templates: string[];
default_import_label_templates: string[];
last_verified_at?: string | null; last_verified_at?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -292,10 +352,54 @@ export const mailAccountsApi = {
// ── Processing Runs API ───────────────────────────────────────────────── // ── Processing Runs API ─────────────────────────────────────────────────
export const processingRunsApi = { export const processingRunsApi = {
async list(): Promise<ProcessingRun[]> { async list(params?: {
// TODO: Add a dedicated /processing-runs endpoint to the backend page?: number;
// For now, return empty array since no user-facing endpoint exists yet page_size?: number;
return []; 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'); const response = await api.post<GmailDebugEmailResponse>('/providers/gmail/debug-email');
return response.data; 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 ───────────────────────────────────────────────────── // ── SMTP Config API ─────────────────────────────────────────────────────
@@ -483,6 +595,33 @@ export const adminApi = {
async deletePlan(id: number): Promise<void> { async deletePlan(id: number): Promise<void> {
await api.delete(`/admin/plans/${id}`); 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 ────────────────────────────────────────────────── // ── Notification Types ──────────────────────────────────────────────────
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
POP3 to Gmail Forwarder InboxConverge
Fetches emails from POP3 mailboxes and forwards them to Gmail Fetches emails from POP3 mailboxes and forwards them to Gmail
""" """
@@ -80,7 +80,7 @@ class POP3Account:
class EmailForwarder: class EmailForwarder:
"""Main class for forwarding emails from POP3 to Gmail""" """Main class for forwarding emails from POP3 to Gmail via InboxConverge"""
def __init__(self): def __init__(self):
self.smtp_host = os.getenv('SMTP_HOST', 'smtp.gmail.com') self.smtp_host = os.getenv('SMTP_HOST', 'smtp.gmail.com')
@@ -241,7 +241,7 @@ class EmailForwarder:
payload = json.dumps({ payload = json.dumps({
"From": self.postmark_from, "From": self.postmark_from,
"To": self.postmark_to, "To": self.postmark_to,
"Subject": f"[POP3 Forwarder Alert] {subject}", "Subject": f"[InboxConverge Alert] {subject}",
"TextBody": f"Error occurred at {datetime.now().isoformat()}\n\n{error_message}", "TextBody": f"Error occurred at {datetime.now().isoformat()}\n\n{error_message}",
"MessageStream": "outbound" "MessageStream": "outbound"
}) })
@@ -312,7 +312,7 @@ class EmailForwarder:
def main(): def main():
"""Main entry point""" """Main entry point"""
logger.info("POP3 to Gmail Forwarder starting...") logger.info("InboxConverge starting...")
forwarder = EmailForwarder() forwarder = EmailForwarder()
@@ -51,7 +51,7 @@
} }
] ]
}, },
"description": "InboxRescue system health: mail processing, Gmail API, auth, and HTTP metrics", "description": "InboxConverge system health: mail processing, Gmail API, auth, and HTTP metrics",
"editable": true, "editable": true,
"fiscalYearStartMonth": 0, "fiscalYearStartMonth": 0,
"graphTooltip": 1, "graphTooltip": 1,
@@ -1111,7 +1111,7 @@
], ],
"refresh": "30s", "refresh": "30s",
"schemaVersion": 38, "schemaVersion": 38,
"tags": ["inboxrescue", "monitoring", "email"], "tags": ["inboxconverge", "monitoring", "email"],
"templating": { "templating": {
"list": [] "list": []
}, },
@@ -1121,8 +1121,8 @@
}, },
"timepicker": {}, "timepicker": {},
"timezone": "browser", "timezone": "browser",
"title": "InboxRescue - System Health", "title": "InboxConverge - System Health",
"uid": "inboxrescue-health", "uid": "inboxconverge-health",
"version": 1, "version": 1,
"weekStart": "" "weekStart": ""
} }
@@ -1,7 +1,7 @@
apiVersion: 1 apiVersion: 1
providers: providers:
- name: InboxRescue - name: InboxConverge
orgId: 1 orgId: 1
type: file type: file
disableDeletion: false disableDeletion: false
+1 -1
View File
@@ -3,7 +3,7 @@ global:
evaluation_interval: 15s evaluation_interval: 15s
scrape_configs: scrape_configs:
- job_name: "inboxrescue-backend" - job_name: "inboxconverge-backend"
static_configs: static_configs:
- targets: ["backend:8000"] - targets: ["backend:8000"]
metrics_path: /metrics metrics_path: /metrics