diff --git a/.env.example b/.env.example index af39a01..f1c50e9 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ POP3_ACCOUNT_1_USE_SSL=true # POP3_ACCOUNT_2_USE_SSL=true # ── 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= # ENCRYPTION_KEY= @@ -41,5 +41,10 @@ MAX_EMAILS_PER_RUN=50 # Throttling (emails per minute) 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 LOG_LEVEL=INFO diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d629d20..47ba8c7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -21,7 +21,7 @@ assignees: '' ## 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] - **Deployment**: [e.g., Docker, Kubernetes, local] - **OS**: [e.g., Ubuntu 22.04, macOS, Windows] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 513e46a..73d7dd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: pop3_forwarder_test + POSTGRES_DB: inbox_converge_test options: >- --health-cmd pg_isready --health-interval 10s @@ -113,7 +113,7 @@ jobs: - name: Run tests with coverage 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 SECRET_KEY: test-secret-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: include: - context: ./backend - image_name: gmail-puller/backend + image_name: inboxconverge/backend - context: ./frontend - image_name: gmail-puller/frontend + image_name: inboxconverge/frontend steps: - name: Checkout repository diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 69705af..8448532 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: - id: black language_version: python3.11 args: [--line-length=100] - files: ^(backend/|pop3_forwarder\.py) + files: ^(backend/|inboxconverge\.py) # Python linting with Ruff (replaces flake8, isort, etc.) - repo: https://github.com/astral-sh/ruff-pre-commit @@ -41,7 +41,7 @@ repos: hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - files: ^(backend/|pop3_forwarder\.py) + files: ^(backend/|inboxconverge\.py) # Type checking with mypy - repo: https://github.com/pre-commit/mirrors-mypy diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ccf31..1e340f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 - **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. @@ -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`. +- **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): - **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). @@ -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). - **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`. -- **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 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 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. - **New admin API endpoints** (all require superuser role): - `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. - **`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. -- **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 - 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 `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 `` 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 `/` tree, so authentication always runs first. ### 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) ### Added -- Initial release of single-user pop3_forwarder.py script +- Initial release of single-user inbox_converge.py script - Docker support with docker-compose - Multiple POP3 account support - Gmail forwarding via SMTP diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10c64bc..5126253 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. @@ -10,7 +10,7 @@ Be respectful and inclusive. We welcome contributions from everyone. ### 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: - Clear title and description - Steps to reproduce @@ -77,8 +77,8 @@ Be respectful and inclusive. We welcome contributions from everyone. ```bash # Clone your fork -git clone https://github.com/YOUR-USERNAME/pop_puller_to_gmail.git -cd pop_puller_to_gmail +git clone https://github.com/YOUR-USERNAME/inboxconverge.git +cd inboxconverge # Install all development dependencies make install-dev @@ -88,7 +88,7 @@ cp .env.example .env # Edit .env with test credentials # Run the legacy forwarder script directly -python pop3_forwarder.py +python inbox_converge.py # Or start the SaaS backend in dev mode make run-dev diff --git a/Dockerfile b/Dockerfile index 585497a..dcada4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,13 +8,13 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy application code -COPY pop3_forwarder.py . +COPY inboxconverge.py . # Create non-root user for security -RUN useradd -m -u 1000 forwarder && \ - chown -R forwarder:forwarder /app +RUN useradd -m -u 1000 inboxconverge && \ + chown -R inboxconverge:inboxconverge /app -USER forwarder +USER inboxconverge # Run the application -CMD ["python", "-u", "pop3_forwarder.py"] +CMD ["python", "-u", "inboxconverge.py"] diff --git a/Makefile b/Makefile index 46f012a..6888cf4 100644 --- a/Makefile +++ b/Makefile @@ -120,12 +120,12 @@ run-beat: ## Run Celery beat scheduler run-flower: ## Run Flower (Celery monitoring) cd backend && celery -A app.core.celery_app flower --port=5555 -run-legacy: ## Run legacy pop3_forwarder script - python pop3_forwarder.py +run-legacy: ## Run legacy inboxconverge script + python inboxconverge.py # 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 cd backend && python -c "from app.core.database import SessionLocal; db = SessionLocal(); print('Database session available as db')" diff --git a/README.md b/README.md index 2743127..009d184 100644 --- a/README.md +++ b/README.md @@ -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) -[![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) -[![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) -[![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) +[![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/inboxconverge/actions/workflows/lint.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.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/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) [![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/) @@ -31,8 +31,8 @@ The repository also includes a multi-tenant SaaS backend built with FastAPI, Pos ```bash # Pull and configure -curl -O https://raw.githubusercontent.com/christianlouis/pop_puller_to_gmail/main/docker-compose.yml -curl -o .env https://raw.githubusercontent.com/christianlouis/pop_puller_to_gmail/main/.env.example +curl -O https://raw.githubusercontent.com/christianlouis/inboxconverge/main/docker-compose.yml +curl -o .env https://raw.githubusercontent.com/christianlouis/inboxconverge/main/.env.example # Edit .env with your credentials nano .env @@ -44,8 +44,8 @@ docker-compose up -d ### Building from Source ```bash -git clone https://github.com/christianlouis/pop_puller_to_gmail.git -cd pop_puller_to_gmail +git clone https://github.com/christianlouis/inboxconverge.git +cd inboxconverge cp .env.example .env # then edit .env docker-compose up -d ``` @@ -257,5 +257,5 @@ This project is licensed under the MIT License — see [LICENSE](LICENSE) for de ## Support -- [Issue Tracker](https://github.com/christianlouis/pop_puller_to_gmail/issues) -- [Discussions](https://github.com/christianlouis/pop_puller_to_gmail/discussions) +- [Issue Tracker](https://github.com/christianlouis/inboxconverge/issues) +- [Discussions](https://github.com/christianlouis/inboxconverge/discussions) diff --git a/SECURITY.md b/SECURITY.md index eb88245..a3c8ce3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ 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). -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 diff --git a/backend/.env.example b/backend/.env.example index 926f661..2e109e8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,5 @@ # Database Configuration -DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/pop3_forwarder +DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/inbox_converge # Security 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 # Admin Account (created on first startup) -ADMIN_EMAIL=admin@example.com +ADMIN_EMAIL=christian@inboxconverge.com ADMIN_PASSWORD=change-this-secure-password # Application -APP_NAME=POP3 Forwarder SaaS +APP_NAME=InboxConverge APP_VERSION=2.0.0 +APP_URL=https://inboxconverge.com +CONTACT_EMAIL=christian@inboxconverge.com DEBUG=false HOST=0.0.0.0 PORT=8000 diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 0d17374..fe5eafa 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -13,6 +13,7 @@ from app.api.v1.endpoints import ( admin, providers, app_settings, + logs, ) 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(app_settings.router, prefix="/settings", tags=["Settings"]) +api_router.include_router( + logs.router, prefix="/processing-runs", tags=["Processing Logs"] +) diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index 08a4463..13bb1cd 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -1,15 +1,18 @@ """Admin endpoints""" -from typing import List -from fastapi import APIRouter, Depends, HTTPException, status +import math +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.core.database import get_db from app.core.deps import get_current_superuser +from app.core.gdpr import mask_email, mask_from_header from app.models.database_models import ( User, MailAccount, + ProcessingLog, ProcessingRun, SubscriptionPlan, SubscriptionTier, @@ -27,6 +30,10 @@ from app.models.schemas import ( AdminNotificationConfigResponse, NotificationTestRequest, NotificationTestResponse, + AdminProcessingRunResponse, + AdminProcessingLogResponse, + PaginatedAdminRunsResponse, + PaginatedAdminLogsResponse, ) 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)""" success, message = await test_notification(request.apprise_url) 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] + ) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index bf91b83..e17f427 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -22,6 +22,7 @@ from app.models.database_models import User, SubscriptionTier, GmailCredential from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest from app.services.auth_service import oauth_service from app.services.gmail_service import GmailService, GMAIL_SCOPES +from app.utils.gmail_labels import build_gmail_credential_scopes router = APIRouter() logger = logging.getLogger(__name__) @@ -286,7 +287,10 @@ async def google_oauth( if encrypted_refresh: existing_cred.encrypted_refresh_token = encrypted_refresh # 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.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] else: @@ -296,7 +300,7 @@ async def google_oauth( encrypted_access_token=encrypted_access, encrypted_refresh_token=encrypted_refresh, token_expiry=token_expiry, - scopes=scope_list, + scopes=build_gmail_credential_scopes(scope_list), is_valid=True, last_verified_at=datetime.now(timezone.utc), ) diff --git a/backend/app/api/v1/endpoints/logs.py b/backend/app/api/v1/endpoints/logs.py new file mode 100644 index 0000000..3583c70 --- /dev/null +++ b/backend/app/api/v1/endpoints/logs.py @@ -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] + ) diff --git a/backend/app/api/v1/endpoints/mail_accounts.py b/backend/app/api/v1/endpoints/mail_accounts.py index 8e2d5b1..508709d 100644 --- a/backend/app/api/v1/endpoints/mail_accounts.py +++ b/backend/app/api/v1/endpoints/mail_accounts.py @@ -1,9 +1,10 @@ """Mail account management endpoints""" -from typing import List -from fastapi import APIRouter, Depends, HTTPException, status +import math +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status 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.deps import get_current_active_user @@ -11,6 +12,8 @@ from app.core.security import encrypt_credential from app.models.database_models import ( User, MailAccount, + ProcessingLog, + ProcessingRun, AccountStatus, SubscriptionPlan, ) @@ -22,6 +25,10 @@ from app.models.schemas import ( MailAccountTestResponse, MailAccountAutoDetectRequest, MailAccountAutoDetectResponse, + PaginatedProcessingRunsResponse, + PaginatedProcessingLogsResponse, + ProcessingRunDetailResponse, + ProcessingLogDetailResponse, ) from app.services.mail_processor import MailProcessor, MailServerAutoDetect from app.core.config import settings @@ -274,3 +281,148 @@ async def auto_detect_mail_settings( return MailAccountAutoDetectResponse( 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 + ) diff --git a/backend/app/api/v1/endpoints/providers.py b/backend/app/api/v1/endpoints/providers.py index 59db266..7a9a72a 100644 --- a/backend/app/api/v1/endpoints/providers.py +++ b/backend/app/api/v1/endpoints/providers.py @@ -21,12 +21,30 @@ from app.models.schemas import ( GmailCredentialResponse, GmailAuthorizeResponse, GmailCallbackRequest, + GmailImportLabelsUpdate, ) 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() 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_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from # 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.encrypted_access_token = encrypted_access # 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.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] await db.commit() @@ -235,6 +257,7 @@ async def save_gmail_credential( gmail_email=credential_in.gmail_email, encrypted_access_token=encrypted_access, encrypted_refresh_token=encrypted_refresh, + scopes=build_gmail_credential_scopes(), is_valid=True, last_verified_at=datetime.now(timezone.utc), ) @@ -285,6 +308,33 @@ async def delete_gmail_credential( 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) async def get_gmail_authorize_url( redirect_uri: str, @@ -365,6 +415,7 @@ async def send_gmail_debug_email( try: inject_result = await gmail_service.inject_debug_email( recipient_email=credential.gmail_email, # type: ignore[arg-type] + import_label_templates=credential.import_label_templates, ) except GmailInjectionError as exc: raise HTTPException( @@ -498,7 +549,10 @@ async def gmail_oauth_callback( if encrypted_refresh: existing.encrypted_refresh_token = encrypted_refresh # 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.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] await db.commit() @@ -511,7 +565,7 @@ async def gmail_oauth_callback( encrypted_access_token=encrypted_access, encrypted_refresh_token=encrypted_refresh, token_expiry=token_expiry, - scopes=token_data.get("scope", "").split(), + scopes=build_gmail_credential_scopes(token_data.get("scope", "").split()), is_valid=True, last_verified_at=datetime.now(timezone.utc), ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2752c46..129df41 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -27,8 +27,10 @@ class Settings(BaseSettings): ) # Application - APP_NAME: str = "InboxRescue" + APP_NAME: str = "InboxConverge" APP_VERSION: str = "2.0.0" + APP_URL: str = "https://inboxconverge.com" + CONTACT_EMAIL: str = "christian@inboxconverge.com" DEBUG: bool = False API_V1_PREFIX: str = "/api/v1" @@ -38,7 +40,7 @@ class Settings(BaseSettings): # Database 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_MAX_OVERFLOW: int = 10 @@ -94,7 +96,7 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" # Admin - ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com" + ADMIN_EMAIL: Optional[str] = "christian@inboxconverge.com" ADMIN_PASSWORD: Optional[str] = None # User defaults & access control diff --git a/backend/app/core/gdpr.py b/backend/app/core/gdpr.py new file mode 100644 index 0000000..25733dc --- /dev/null +++ b/backend/app/core/gdpr.py @@ -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 " 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 ") + 'Jo*** D*** ' + >>> mask_from_header("john.doe@example.com") + 'jo***@e***.com' + """ + if not from_header: + return "***" + + # Try to parse "Display Name " 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) diff --git a/backend/app/core/metrics.py b/backend/app/core/metrics.py index 2918b34..07319c9 100644 --- a/backend/app/core/metrics.py +++ b/backend/app/core/metrics.py @@ -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 every subsystem (HTTP layer, Celery workers, Gmail service, auth) imports diff --git a/backend/app/main.py b/backend/app/main.py index cd0e036..5dcfbf8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -148,7 +148,7 @@ def create_application() -> FastAPI: async def root(): """Root endpoint""" return { - "message": "InboxRescue API", + "message": "InboxConverge API", "version": settings.APP_VERSION, "docs": "/api/docs", } diff --git a/backend/app/models/database_models.py b/backend/app/models/database_models.py index 0e04309..192d182 100644 --- a/backend/app/models/database_models.py +++ b/backend/app/models/database_models.py @@ -17,6 +17,12 @@ from sqlalchemy import ( Index, ) from sqlalchemy.orm import relationship + +from app.utils.gmail_labels import ( + DEFAULT_IMPORT_LABEL_TEMPLATES, + extract_granted_scopes, + extract_import_label_templates, +) import enum from app.core.database import Base @@ -562,6 +568,18 @@ class GmailCredential(Base): # Relationships 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): """ diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 0aace87..609b32b 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -204,6 +204,15 @@ class ProcessingRunResponse(BaseModel): 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 class ProcessingLogResponse(BaseModel): id: int @@ -217,6 +226,67 @@ class ProcessingLogResponse(BaseModel): 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 class NotificationConfigBase(BaseModel): name: str = Field( @@ -376,6 +446,8 @@ class GmailCredentialResponse(BaseModel): user_id: int gmail_email: str 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 created_at: datetime updated_at: datetime @@ -430,6 +502,10 @@ class GmailCallbackRequest(BaseModel): redirect_uri: str +class GmailImportLabelsUpdate(BaseModel): + import_label_templates: List[str] = Field(default_factory=list) + + # Admin Schemas diff --git a/backend/app/services/gmail_service.py b/backend/app/services/gmail_service.py index ea5b93e..48a4149 100644 --- a/backend/app/services/gmail_service.py +++ b/backend/app/services/gmail_service.py @@ -25,6 +25,7 @@ from app.core.metrics import ( GMAIL_API_DURATION_SECONDS, GMAIL_TOKEN_REFRESHES_TOTAL, ) +from app.utils.gmail_labels import render_import_labels logger = logging.getLogger(__name__) @@ -294,6 +295,7 @@ class GmailService: async def inject_debug_email( self, recipient_email: str, + import_label_templates: Optional[list[str]] = None, ) -> Dict[str, Any]: """ Inject a debug/test email into the user's Gmail inbox. @@ -344,11 +346,10 @@ class GmailService: 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") - imported_label_id = await self.get_or_create_label("imported") - - label_ids = ["INBOX", test_label_id, imported_label_id] + if test_label_id not in label_ids: + label_ids.append(test_label_id) return await self.inject_email( raw_email=raw_bytes, @@ -356,6 +357,23 @@ class GmailService: 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]]: """ Return the current access token and expiry if the token was refreshed diff --git a/backend/app/utils/gmail_labels.py b/backend/app/utils/gmail_labels.py new file mode 100644 index 0000000..25d8848 --- /dev/null +++ b/backend/app/utils/gmail_labels.py @@ -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 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 7672ff5..82f7ec1 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) # Create Celery app celery_app = Celery( - "pop3_forwarder", + "inboxconverge", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND, include=["app.workers.tasks"], diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index ccb42f3..1db3cb6 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -3,6 +3,7 @@ Celery tasks for background email processing. """ import asyncio +import email as email_lib import time from datetime import datetime, timedelta, timezone 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" ) + # 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): + # ── 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: if use_gmail_api and gmail_service: # 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( raw_email=email_data, - label_ids=["INBOX"], + label_ids=label_ids, source_account_name=account.name, # type: ignore[arg-type] ) + forwarded_ok = True emails_forwarded += 1 successfully_forwarded_uids.append(uid) 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] ) if success: + forwarded_ok = True emails_forwarded += 1 successfully_forwarded_uids.append(uid) else: @@ -253,8 +286,29 @@ async def process_mail_account(account_id: int): f"Failed to send revocation notification: {notify_exc}" ) logger.error(f"Error delivering email: {e}") + error_msg = str(e) 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 for uid in successfully_forwarded_uids: if uid not in already_seen_uids: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c287ae0..d958483 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 = settings.DATABASE_URL.replace( - "/pop3_forwarder", "/pop3_forwarder_test" + "/inbox_converge", "/inbox_converge_test" ) diff --git a/backend/tests/unit/test_app.py b/backend/tests/unit/test_app.py index ac8f017..81b36a7 100644 --- a/backend/tests/unit/test_app.py +++ b/backend/tests/unit/test_app.py @@ -89,7 +89,7 @@ class TestApplicationFactory: async def test_app_title(self, app): """Test that app has correct title""" - assert app.title == "InboxRescue" + assert app.title == "InboxConverge" async def test_app_version(self, app): """Test that app has a version""" diff --git a/backend/tests/unit/test_gmail_service.py b/backend/tests/unit/test_gmail_service.py index 09810e9..4289104 100644 --- a/backend/tests/unit/test_gmail_service.py +++ b/backend/tests/unit/test_gmail_service.py @@ -3,8 +3,16 @@ Unit tests for Gmail service module. """ 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.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: @@ -153,3 +161,44 @@ class TestGmailService: email = await service.get_email_address() 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"] diff --git a/docker-compose.new.yml b/docker-compose.new.yml index 297ba11..31eddea 100644 --- a/docker-compose.new.yml +++ b/docker-compose.new.yml @@ -4,11 +4,11 @@ services: # PostgreSQL Database postgres: image: postgres:15-alpine - container_name: pop3-postgres + container_name: inboxconverge-postgres environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: password - POSTGRES_DB: pop3_forwarder + POSTGRES_DB: inbox_converge ports: - "5432:5432" volumes: @@ -22,7 +22,7 @@ services: # Redis for caching and Celery redis: image: redis:7-alpine - container_name: pop3-redis + container_name: inboxconverge-redis ports: - "6379:6379" volumes: @@ -38,7 +38,7 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: pop3-backend + container_name: inboxconverge-backend ports: - "8000:8000" env_file: @@ -58,7 +58,7 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: pop3-celery-worker + container_name: inboxconverge-celery-worker env_file: - ./backend/.env depends_on: @@ -75,7 +75,7 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: pop3-celery-beat + container_name: inboxconverge-celery-beat env_file: - ./backend/.env depends_on: @@ -92,11 +92,14 @@ services: build: context: ./frontend dockerfile: Dockerfile - container_name: pop3-frontend + container_name: inboxconverge-frontend ports: - "3000:3000" environment: - BACKEND_URL=http://backend:8000 + - CONTACT_EMAIL=christian@inboxconverge.com + - APP_URL=https://inboxconverge.com + - APP_NAME=InboxConverge depends_on: - backend restart: unless-stopped @@ -104,7 +107,7 @@ services: # Prometheus metrics collection prometheus: image: prom/prometheus:v2.51.2 - container_name: pop3-prometheus + container_name: inboxconverge-prometheus ports: - "9090:9090" volumes: @@ -122,7 +125,7 @@ services: # Grafana dashboards grafana: image: grafana/grafana:10.4.3 - container_name: pop3-grafana + container_name: inboxconverge-grafana ports: - "3001:3000" environment: diff --git a/docker-compose.yml b/docker-compose.yml index 9174f50..ab43a03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,13 @@ version: '3.8' services: - pop3-forwarder: + inboxconverge: # Option 1: Build from source (default) build: . # Option 2: Use pre-built image from GitHub Container Registry # Uncomment the line below and comment out 'build: .' to use pre-built image - # image: ghcr.io/christianlouis/pop_puller_to_gmail:latest - container_name: pop3-gmail-forwarder + # image: ghcr.io/christianlouis/inboxconverge:latest + container_name: inboxconverge restart: unless-stopped env_file: - .env diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4bf0f10..4c59016 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. @@ -20,7 +20,7 @@ The project has been transformed from a single-user Docker application into a fu ## 📁 Project Structure ``` -pop_puller_to_gmail/ +inboxconverge/ ├── backend/ # FastAPI backend application │ ├── app/ │ │ ├── api/ # API endpoints @@ -50,7 +50,7 @@ pop_puller_to_gmail/ │ └── .env.example # Environment template ├── frontend/ # React/Next.js frontend (to be implemented) ├── 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 ``` @@ -68,8 +68,8 @@ pop_puller_to_gmail/ 1. **Clone and navigate to repository** ```bash - git clone https://github.com/christianlouis/pop_puller_to_gmail.git - cd pop_puller_to_gmail + git clone https://github.com/christianlouis/inboxconverge.git + cd inboxconverge ``` 2. **Configure backend environment** @@ -329,7 +329,7 @@ Coming soon: Kubernetes manifests and Helm charts. ## 🔄 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 2. **Create user account** via API or admin panel @@ -404,8 +404,8 @@ MIT License - See [LICENSE](../LICENSE) file ## 🆘 Support -- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues -- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions +- **Issues**: https://github.com/christianlouis/inboxconverge/issues +- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions - **Email**: support@example.com ## 🙏 Acknowledgments diff --git a/docs/CODING_PATTERNS.md b/docs/CODING_PATTERNS.md index 79b91b1..89abd31 100644 --- a/docs/CODING_PATTERNS.md +++ b/docs/CODING_PATTERNS.md @@ -1,6 +1,6 @@ # 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 - [General Principles](#general-principles) diff --git a/docs/DEPLOYMENT_CHECKLIST.md b/docs/DEPLOYMENT_CHECKLIST.md index 74a5a0e..69f8157 100644 --- a/docs/DEPLOYMENT_CHECKLIST.md +++ b/docs/DEPLOYMENT_CHECKLIST.md @@ -1,6 +1,6 @@ # 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 @@ -48,7 +48,7 @@ This document provides a checklist for deploying the multi-tenant POP3 Forwarder #### Database - [ ] PostgreSQL 15+ instance running -- [ ] Database created: `pop3_forwarder` +- [ ] Database created: `inbox_converge` - [ ] Connection details configured in backend/.env - [ ] Backups configured @@ -89,8 +89,8 @@ sudo certbot --nginx -d yourdomain.com -d api.yourdomain.com ```bash # On production server cd /opt -sudo git clone https://github.com/christianlouis/pop_puller_to_gmail.git -cd pop_puller_to_gmail +sudo git clone https://github.com/christianlouis/inboxconverge.git +cd inboxconverge ``` ### Step 2: Configure Environment @@ -195,9 +195,9 @@ curl -X POST https://api.yourdomain.com/api/v1/auth/register \ # Automated daily backup script cat > /usr/local/bin/backup-pop3-db.sh << 'EOF' #!/bin/bash -BACKUP_DIR=/var/backups/pop3_forwarder +BACKUP_DIR=/var/backups/inbox_converge 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 EOF @@ -394,4 +394,4 @@ Use this checklist after deployment: ## 🎉 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! diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/DEPLOYMENT_GUIDE.md index cefeba1..c0521bd 100644 --- a/docs/DEPLOYMENT_GUIDE.md +++ b/docs/DEPLOYMENT_GUIDE.md @@ -1,6 +1,6 @@ # 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 -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 ```bash -git clone https://github.com/christianlouis/pop_puller_to_gmail.git -cd pop_puller_to_gmail +git clone https://github.com/christianlouis/inboxconverge.git +cd inboxconverge 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" services: - pop3-forwarder: + inbox-converge: # Build from source build: . # Or use the pre-built image: - # image: ghcr.io/christianlouis/pop_puller_to_gmail:latest - container_name: pop3-gmail-forwarder + # image: ghcr.io/christianlouis/inboxconverge:latest + container_name: inboxconverge restart: unless-stopped env_file: - .env @@ -149,7 +149,7 @@ Save both values — you will need them below. ### 2. Create the backend environment file ```bash -cd pop_puller_to_gmail +cd inboxconverge cp backend/.env.example backend/.env ``` @@ -157,7 +157,7 @@ Edit `backend/.env`: ```ini # ── 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) ────── SECRET_KEY= @@ -198,12 +198,12 @@ services: # ── PostgreSQL ─────────────────────────────────────────── postgres: image: postgres:15-alpine - container_name: pop3-postgres + container_name: inboxconverge-postgres restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: change-me # must match DATABASE_URL - POSTGRES_DB: pop3_forwarder + POSTGRES_DB: inbox_converge volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -219,7 +219,7 @@ services: # ── Redis ──────────────────────────────────────────────── redis: image: redis:7-alpine - container_name: pop3-redis + container_name: inboxconverge-redis restart: unless-stopped command: redis-server --appendonly yes volumes: @@ -235,7 +235,7 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: pop3-backend + container_name: inboxconverge-backend restart: unless-stopped ports: - "8000:8000" @@ -260,7 +260,7 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: pop3-celery-worker + container_name: inboxconverge-celery-worker restart: unless-stopped env_file: - ./backend/.env @@ -278,7 +278,7 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: pop3-celery-beat + container_name: inboxconverge-celery-beat restart: unless-stopped env_file: - ./backend/.env @@ -296,7 +296,7 @@ services: build: context: ./frontend dockerfile: Dockerfile - container_name: pop3-frontend + container_name: inboxconverge-frontend restart: unless-stopped ports: - "3000:3000" @@ -358,7 +358,7 @@ Below is a set of example Kubernetes manifests to get you started. Adapt namespa apiVersion: v1 kind: Namespace metadata: - name: pop3-forwarder + name: inbox-converge ``` ### Secrets @@ -369,13 +369,13 @@ Store sensitive values in a Kubernetes Secret. In production, consider using an apiVersion: v1 kind: Secret metadata: - name: pop3-forwarder-secrets - namespace: pop3-forwarder + name: inbox-converge-secrets + namespace: inbox-converge type: Opaque stringData: SECRET_KEY: "" 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" CELERY_BROKER_URL: "redis://redis:6379/0" CELERY_RESULT_BACKEND: "redis://redis:6379/0" @@ -396,7 +396,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: postgres - namespace: pop3-forwarder + namespace: inbox-converge spec: replicas: 1 selector: @@ -416,11 +416,11 @@ spec: - name: POSTGRES_USER value: postgres - name: POSTGRES_DB - value: pop3_forwarder + value: inbox_converge - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: - name: pop3-forwarder-secrets + name: inbox-converge-secrets key: POSTGRES_PASSWORD volumeMounts: - name: pgdata @@ -439,7 +439,7 @@ apiVersion: v1 kind: Service metadata: name: postgres - namespace: pop3-forwarder + namespace: inbox-converge spec: selector: app: postgres @@ -451,7 +451,7 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: name: postgres-pvc - namespace: pop3-forwarder + namespace: inbox-converge spec: accessModes: [ReadWriteOnce] resources: @@ -466,7 +466,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: redis - namespace: pop3-forwarder + namespace: inbox-converge spec: replicas: 1 selector: @@ -493,7 +493,7 @@ apiVersion: v1 kind: Service metadata: name: redis - namespace: pop3-forwarder + namespace: inbox-converge spec: selector: app: redis @@ -509,7 +509,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: backend - namespace: pop3-forwarder + namespace: inbox-converge spec: replicas: 2 selector: @@ -522,22 +522,22 @@ spec: spec: initContainers: - name: run-migrations - image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest + image: ghcr.io/christianlouis/inboxconverge-backend:latest command: ["alembic", "upgrade", "head"] envFrom: - secretRef: - name: pop3-forwarder-secrets + name: inbox-converge-secrets env: - name: DEBUG value: "false" containers: - name: backend - image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest + image: ghcr.io/christianlouis/inboxconverge-backend:latest ports: - containerPort: 8000 envFrom: - secretRef: - name: pop3-forwarder-secrets + name: inbox-converge-secrets env: - name: HOST value: "0.0.0.0" @@ -567,7 +567,7 @@ apiVersion: v1 kind: Service metadata: name: backend - namespace: pop3-forwarder + namespace: inbox-converge spec: selector: app: backend @@ -583,7 +583,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: celery-worker - namespace: pop3-forwarder + namespace: inbox-converge spec: replicas: 2 selector: @@ -596,7 +596,7 @@ spec: spec: containers: - name: worker - image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest + image: ghcr.io/christianlouis/inboxconverge-backend:latest command: - celery - -A @@ -606,7 +606,7 @@ spec: - --concurrency=2 envFrom: - secretRef: - name: pop3-forwarder-secrets + name: inbox-converge-secrets resources: requests: cpu: 250m @@ -625,7 +625,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: celery-beat - namespace: pop3-forwarder + namespace: inbox-converge spec: replicas: 1 # Must be exactly 1 strategy: @@ -640,7 +640,7 @@ spec: spec: containers: - name: beat - image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest + image: ghcr.io/christianlouis/inboxconverge-backend:latest command: - celery - -A @@ -649,7 +649,7 @@ spec: - --loglevel=info envFrom: - secretRef: - name: pop3-forwarder-secrets + name: inbox-converge-secrets resources: requests: cpu: 100m @@ -666,7 +666,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: frontend - namespace: pop3-forwarder + namespace: inbox-converge spec: replicas: 2 selector: @@ -679,7 +679,7 @@ spec: spec: containers: - name: frontend - image: ghcr.io/christianlouis/pop_puller_to_gmail-frontend:latest + image: ghcr.io/christianlouis/inboxconverge-frontend:latest ports: - containerPort: 3000 env: @@ -697,7 +697,7 @@ apiVersion: v1 kind: Service metadata: name: frontend - namespace: pop3-forwarder + namespace: inbox-converge spec: selector: app: frontend @@ -714,8 +714,8 @@ The Ingress below assumes you have an Ingress controller installed (e.g., [ingre apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: pop3-forwarder-ingress - namespace: pop3-forwarder + name: inbox-converge-ingress + namespace: inbox-converge annotations: cert-manager.io/cluster-issuer: letsencrypt-prod nginx.ingress.kubernetes.io/proxy-body-size: "10m" @@ -725,7 +725,7 @@ spec: - hosts: - your-domain.com - api.your-domain.com - secretName: pop3-forwarder-tls + secretName: inbox-converge-tls rules: - host: your-domain.com http: @@ -754,7 +754,7 @@ spec: If you manage many environments (staging, production, etc.) consider wrapping the manifests above into a Helm chart: ```text -helm/pop3-forwarder/ +helm/inbox-converge/ ├── Chart.yaml ├── values.yaml # defaults for all environments ├── values-staging.yaml @@ -782,8 +782,8 @@ replicaCount: frontend: 2 image: - backend: ghcr.io/christianlouis/pop_puller_to_gmail-backend - frontend: ghcr.io/christianlouis/pop_puller_to_gmail-frontend + backend: ghcr.io/christianlouis/inboxconverge-backend + frontend: ghcr.io/christianlouis/inboxconverge-frontend tag: latest ingress: @@ -862,7 +862,7 @@ In production you should place a reverse proxy in front of the backend and front ### Example: nginx ```nginx -# /etc/nginx/sites-available/pop3-forwarder +# /etc/nginx/sites-available/inbox-converge # Frontend server { @@ -969,7 +969,7 @@ If you prefer Traefik, add it as a service in your Compose file and use labels o ## Upgrading ```bash -cd pop_puller_to_gmail +cd inboxconverge # Pull latest code git pull origin main diff --git a/docs/ERRORS.md b/docs/ERRORS.md index d7439f4..5a019dd 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -1,6 +1,6 @@ # 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 diff --git a/docs/IMPLEMENTATION_COMPLETE.md b/docs/IMPLEMENTATION_COMPLETE.md index 1ce6292..f023493 100644 --- a/docs/IMPLEMENTATION_COMPLETE.md +++ b/docs/IMPLEMENTATION_COMPLETE.md @@ -2,7 +2,7 @@ ## 🎯 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 @@ -320,7 +320,7 @@ These are potential future improvements outside the current task: ### What Was Accomplished ✅ **Complete implementation of web interface and multitenancy features** -The POP3 to Gmail Forwarder now has: +The InboxConverge now has: - A modern, responsive web interface - Complete user authentication system - Full mail account management capabilities @@ -356,7 +356,7 @@ The implementation is **complete and ready for**: ## 👏 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!** 🎉 diff --git a/docs/IMPLEMENTATION_GUIDE.md b/docs/IMPLEMENTATION_GUIDE.md index bd22b1e..b30c3a1 100644 --- a/docs/IMPLEMENTATION_GUIDE.md +++ b/docs/IMPLEMENTATION_GUIDE.md @@ -1,6 +1,6 @@ # 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 @@ -36,8 +36,8 @@ This guide provides step-by-step instructions for setting up and deploying the m ```bash # Clone repository -git clone https://github.com/christianlouis/pop_puller_to_gmail.git -cd pop_puller_to_gmail +git clone https://github.com/christianlouis/inboxconverge.git +cd inboxconverge # Create backend environment file cp backend/.env.example backend/.env @@ -49,7 +49,7 @@ Edit `backend/.env` with your settings: ```bash # 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) ENCRYPTION_KEY=$(openssl rand -hex 32) GOOGLE_CLIENT_ID=your-client-id @@ -176,7 +176,7 @@ ADMIN_EMAIL=admin@yourdomain.com Use nginx or Traefik as reverse proxy: ```nginx -# /etc/nginx/sites-available/pop3-forwarder +# /etc/nginx/sites-available/inbox-converge server { listen 443 ssl http2; server_name api.yourdomain.com; @@ -202,7 +202,7 @@ cat > /etc/cron.daily/backup-postgres << 'EOF' #!/bin/bash BACKUP_DIR=/var/backups/postgres 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 EOF @@ -348,10 +348,10 @@ docker-compose -f docker-compose.new.yml exec celery-worker celery -A app.worker ```bash # 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 -docker exec pop3-postgres psql -U postgres -d pop3_forwarder -c " +docker exec inboxconverge-postgres psql -U postgres -d inbox_converge -c " SELECT schemaname, tablename, @@ -376,7 +376,7 @@ docker-compose -f docker-compose.new.yml ps postgres docker-compose -f docker-compose.new.yml logs postgres # 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 @@ -480,8 +480,8 @@ redis: For additional help: - **Documentation**: See [ARCHITECTURE.md](ARCHITECTURE.md) -- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues -- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions +- **Issues**: https://github.com/christianlouis/inboxconverge/issues +- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions --- diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index d1654a2..f80d06b 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -1,6 +1,6 @@ # 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 @@ -230,7 +230,7 @@ docker-compose -f docker-compose.yml down # Archive old configuration mkdir -p archive -mv pop3_forwarder.py archive/ +mv inbox_converge.py archive/ mv .env.legacy.backup archive/ mv docker-compose.yml archive/docker-compose.legacy.yml diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index b2f900d..68e17d9 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -1,6 +1,6 @@ # Quick Start Guide -Get your POP3 to Gmail forwarder running in under 10 minutes! +Get your InboxConverge instance running in under 10 minutes! ## Prerequisites @@ -13,8 +13,8 @@ Get your POP3 to Gmail forwarder running in under 10 minutes! ### 1. Clone the Repository ```bash -git clone https://github.com/christianlouis/pop_puller_to_gmail.git -cd pop_puller_to_gmail +git clone https://github.com/christianlouis/inboxconverge.git +cd inboxconverge ``` ### 2. Generate Gmail App Password @@ -23,7 +23,7 @@ cd pop_puller_to_gmail 2. Sign in to your Google Account 3. Select "App passwords" under Security 4. Choose "Mail" and "Other (Custom name)" -5. Enter "POP3 Forwarder" as the name +5. Enter "InboxConverge" as the name 6. Click "Generate" 7. **Copy the 16-character password** (you'll need this in step 3) @@ -66,10 +66,10 @@ docker-compose logs -f You should see: ``` -pop3-gmail-forwarder | INFO - POP3 to Gmail Forwarder starting... -pop3-gmail-forwarder | INFO - Loaded POP3 account: ... -pop3-gmail-forwarder | INFO - Configuration validated successfully -pop3-gmail-forwarder | INFO - Starting email processing cycle +inboxconverge | INFO - InboxConverge starting... +inboxconverge | INFO - Loaded POP3 account: ... +inboxconverge | INFO - Configuration validated successfully +inboxconverge | INFO - Starting email processing cycle ``` ### 6. Test the Forwarder @@ -192,13 +192,13 @@ Restart after changes: `docker-compose restart` ## 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 - Review troubleshooting section in README.md ## 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**: - The forwarder deletes emails from POP3 after successful forwarding diff --git a/docs/README_SAAS.md b/docs/README_SAAS.md index 36d37ff..bcadd04 100644 --- a/docs/README_SAAS.md +++ b/docs/README_SAAS.md @@ -39,8 +39,8 @@ This project has been **completely transformed** from a single-user Docker scrip ```bash # Clone repository -git clone https://github.com/christianlouis/pop_puller_to_gmail.git -cd pop_puller_to_gmail +git clone https://github.com/christianlouis/inboxconverge.git +cd inboxconverge # Configure environment cp backend/.env.example backend/.env @@ -330,8 +330,8 @@ MIT License - See [LICENSE](../LICENSE) file for details. ## 🆘 Support - **Documentation**: See docs in repository -- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues -- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions +- **Issues**: https://github.com/christianlouis/inboxconverge/issues +- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions - **Email**: support@example.com (for Enterprise customers) ## 🎉 Acknowledgments diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index da39ac5..f382326 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,7 +1,7 @@ # Roadmap ## 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. --- diff --git a/docs/SECURITY_REPORT.md b/docs/SECURITY_REPORT.md index 1b788f8..bd42486 100644 --- a/docs/SECURITY_REPORT.md +++ b/docs/SECURITY_REPORT.md @@ -2,7 +2,7 @@ ## 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 diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index d647c17..365ff10 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -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: ```bash # 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_URL=redis://redis:6379/0 diff --git a/docs/TODO.md b/docs/TODO.md index 939bab0..d41cfdb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,6 +2,12 @@ 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) ### Completed ✅ @@ -178,7 +184,7 @@ Comprehensive task breakdown for repository improvements and production readines ### Not Started 📋 - [ ] 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) - [ ] Set up uptime monitoring - [ ] 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] Per-user SMTP configuration (UX + backend) - [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] 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] **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 - [x] Complete notification service integration (Apprise) - [ ] Add advanced email filtering @@ -232,6 +240,8 @@ because the API client layer is missing. - [x] `AuthGuard` for protected routes - [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] **`/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 📋 - [ ] 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] 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] `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] Admin badge (purple shield) shown in top bar for superusers - [x] Fix blank page on direct navigation to `/admin*`: moved superuser guard inside `` 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): - [x] Create `frontend/src/lib/api.ts` (frontend is broken without it) - [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**: - [ ] Enable rate limiting diff --git a/docs/WEB_INTERFACE_GUIDE.md b/docs/WEB_INTERFACE_GUIDE.md index ff82984..8950854 100644 --- a/docs/WEB_INTERFACE_GUIDE.md +++ b/docs/WEB_INTERFACE_GUIDE.md @@ -1,6 +1,6 @@ # 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 @@ -104,7 +104,7 @@ frontend: build: context: ./frontend dockerfile: Dockerfile - container_name: pop3-frontend + container_name: inboxconverge-frontend ports: - "3000:3000" environment: diff --git a/docs/adr/002-fernet-encryption.md b/docs/adr/002-fernet-encryption.md index 07be933..e8158fd 100644 --- a/docs/adr/002-fernet-encryption.md +++ b/docs/adr/002-fernet-encryption.md @@ -126,7 +126,7 @@ elif version == 'v2': def get_or_create_user_salt(user_id: int) -> bytes: # Use deterministic salt based on user_id + global salt # 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 diff --git a/docs/adr/003-fastapi-web-framework.md b/docs/adr/003-fastapi-web-framework.md index d8e9ce6..2b201cd 100644 --- a/docs/adr/003-fastapi-web-framework.md +++ b/docs/adr/003-fastapi-web-framework.md @@ -64,7 +64,7 @@ async def lifespan(app: FastAPI): # Shutdown: cleanup app = FastAPI( - title="POP3 Forwarder API", + title="InboxConverge API", lifespan=lifespan, openapi_url="/api/openapi.json", docs_url="/api/docs", diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 2330ef3..667364b 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -351,7 +351,7 @@ export default function AdminPage() { )} -
+
Create and configure subscription plans

+ +
+ +
+
+

Activity Logs

+

Processing runs and per-email logs across all users

+
+
{/* System Alert Channels */} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 31036c0..07b77a4 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -4,6 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard'; import { DashboardLayout } from '@/components/DashboardLayout'; import { useQuery } from '@tanstack/react-query'; import { mailAccountsApi, processingRunsApi } from '@/lib/api'; +import Link from 'next/link'; import { Mail, Send, @@ -51,19 +52,19 @@ export default function DashboardPage() { const { data: runs, isLoading: runsLoading } = useQuery({ queryKey: ['processing-runs'], - queryFn: () => processingRunsApi.list(), + queryFn: () => processingRunsApi.list({ page: 1, page_size: 10 }), }); const stats = { totalAccounts: accounts?.length || 0, activeAccounts: accounts?.filter((a) => a.is_enabled).length || 0, - emailsToday: runs + emailsToday: runs?.items ?.filter((r) => { const today = new Date().toDateString(); return new Date(r.started_at).toDateString() === today; }) .reduce((sum, r) => sum + r.emails_forwarded, 0) || 0, - errors: runs?.filter((r) => r.emails_failed > 0).length || 0, + errors: runs?.items?.filter((r) => r.emails_failed > 0).length || 0, }; return ( @@ -100,15 +101,18 @@ export default function DashboardPage() { {/* Recent Processing Runs */}
-
+

Recent Processing Runs

+ + View all logs → +
{runsLoading ? (
- ) : runs && runs.length > 0 ? ( + ) : runs && runs.items && runs.items.length > 0 ? ( @@ -133,12 +137,12 @@ export default function DashboardPage() { - {runs.slice(0, 10).map((run) => { + {runs.items.map((run) => { const account = accounts?.find((a) => a.id === run.mail_account_id); return (
- {account?.name || `Account ${run.mail_account_id}`} + {run.account_name || account?.name || `Account ${run.mail_account_id}`}
diff --git a/frontend/src/app/datenschutz/page.tsx b/frontend/src/app/datenschutz/page.tsx index b02206e..270956a 100644 --- a/frontend/src/app/datenschutz/page.tsx +++ b/frontend/src/app/datenschutz/page.tsx @@ -1,12 +1,14 @@ import Link from 'next/link'; import type { Metadata } from 'next'; +const APP_NAME = process.env.APP_NAME ?? 'InboxConverge'; + export const metadata: Metadata = { - title: 'Datenschutz – POP3 Forwarder', + title: `Datenschutz – ${APP_NAME}`, }; const LAST_UPDATED = 'March 26, 2026'; -const CONTACT_EMAIL = 'christianlouis@gmail.com'; +const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com'; export default function DatenschutzPage() { return ( @@ -82,7 +84,7 @@ export default function DatenschutzPage() { 2. Geltungsbereich dieser Datenschutzerklärung

- Dieser Hinweis gilt für die POP3 Forwarder-Webanwendung. Er gilt + Dieser Hinweis gilt für die {APP_NAME}-Webanwendung. Er gilt für alle Nutzer weltweit, einschließlich derjenigen in der Europäischen Union (EU), dem Europäischen Wirtschaftsraum (EWR), Deutschland, dem Vereinigten Königreich (UK), der Schweiz, der diff --git a/frontend/src/app/impressum/page.tsx b/frontend/src/app/impressum/page.tsx index 0859416..6ed9f3c 100644 --- a/frontend/src/app/impressum/page.tsx +++ b/frontend/src/app/impressum/page.tsx @@ -1,8 +1,11 @@ import Link from 'next/link'; import type { Metadata } from 'next'; +const APP_NAME = process.env.APP_NAME ?? 'InboxConverge'; +const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com'; + export const metadata: Metadata = { - title: 'Impressum – POP3 Forwarder', + title: `Impressum – ${APP_NAME}`, }; export default function ImpressumPage() { @@ -36,10 +39,10 @@ export default function ImpressumPage() { Fax: +49 40 97074609
E-Mail:{' '} - christianlouis@gmail.com + {CONTACT_EMAIL}

diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index b97ef9c..927f1d3 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -14,7 +14,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "InboxRescue — your old inboxes, delivered to Gmail", + title: "InboxConverge — your old inboxes, delivered to Gmail", description: "Poll your legacy POP3 and IMAP mailboxes and have everything land quietly in Gmail. Set it once, forget it exists.", }; diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index cc73535..cdb6fb4 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -46,7 +46,7 @@ export default function LoginPage() {

- POP3 to Gmail Forwarder + InboxConverge

Sign in to your account diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 76ff207..dfeab1a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -106,7 +106,7 @@ export default function Home() {

-

InboxRescue

+

InboxConverge

You know the ones — that GMX account from 2009, the old ISP address your - bank still sends to, the Hotmail you gave out in school. InboxRescue + bank still sends to, the Hotmail you gave out in school. InboxConverge quietly polls them all and drops everything into your Gmail. Set it once, forget it exists.

@@ -171,7 +171,7 @@ export default function Home() { Auto-detects everything

- 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.

@@ -215,7 +215,7 @@ export default function Home() {

Add your old inbox

- Paste the email address — InboxRescue auto-detects the POP3/IMAP + Paste the email address — InboxConverge auto-detects the POP3/IMAP settings in seconds.

@@ -225,7 +225,7 @@ export default function Home() {

Connect Gmail

- 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.

@@ -270,7 +270,7 @@ export default function Home() {

- © {new Date().getFullYear()} InboxRescue — made for people, not enterprises. + © {new Date().getFullYear()} InboxConverge — made for people, not enterprises.

diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index a12f6a0..6253e9f 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -16,8 +16,112 @@ import { AlertTriangle, XCircle, Bug, + RotateCcw, + Tags, } from 'lucide-react'; +const DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES = ['{{source_email}}', 'imported']; + +function parseImportLabelTemplates(input: string): string[] { + return input + .split('\n') + .map((value) => value.trim()) + .filter((value, index, values) => value.length > 0 && values.indexOf(value) === index); +} + +function GmailImportLabelsForm({ + gmailCredential, + onSave, + isSaving, +}: { + gmailCredential: { + gmail_email: string; + import_label_templates: string[]; + default_import_label_templates: string[]; + }; + onSave: (labels: string[]) => void; + isSaving: boolean; +}) { + const [labelsInput, setLabelsInput] = useState( + gmailCredential.import_label_templates.join('\n') + ); + + const defaultTemplates = + gmailCredential.default_import_label_templates.length > 0 + ? gmailCredential.default_import_label_templates + : DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES; + const parsedLabels = parseImportLabelTemplates(labelsInput); + const isDefaultSelection = + parsedLabels.length === defaultTemplates.length && + parsedLabels.every((value, index) => value === defaultTemplates[index]); + + return ( +
+
+ +

Import labels

+
+

+ One label is created per line. We recommend keeping{' '} + + {'{{source_email}}'} + {' '} + so each imported message is tagged with the mailbox it came from, plus a + catch-all label like imported. +

+

+ Example: a mail pulled from billing@example.com will be + labeled as billing@example.com when{' '} + + {'{{source_email}}'} + {' '} + is present. +

+