Merge branch 'main' into copilot/add-apprise-alerting-capabilities
This commit is contained in:
+6
-1
@@ -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=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
|
||||
# ENCRYPTION_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
|
||||
|
||||
@@ -41,5 +41,10 @@ MAX_EMAILS_PER_RUN=50
|
||||
# Throttling (emails per minute)
|
||||
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
|
||||
|
||||
@@ -21,7 +21,7 @@ assignees: ''
|
||||
<!-- What actually happened -->
|
||||
|
||||
## 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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
-5
@@ -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 `<AuthGuard>` was ever rendered. On a direct page load or refresh the Zustand store initialises with `user = null`, so the guard fired immediately and returned an empty render — `AuthGuard` was never mounted, its `checkAuth` effect never ran, and the user data was never fetched. Fixed by removing the early return and moving the superuser guard inside the `<AuthGuard>/<DashboardLayout>` tree, so authentication always runs first.
|
||||
|
||||
### Security
|
||||
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+5
-5
@@ -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"]
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# POP3 to Gmail Forwarder
|
||||
# InboxConverge
|
||||
|
||||
[](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/test.yml)
|
||||
[](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/lint.yml)
|
||||
[](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/security.yml)
|
||||
[](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/docker-build.yml)
|
||||
[](https://github.com/christianlouis/inboxconverge/actions/workflows/test.yml)
|
||||
[](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.yml)
|
||||
[](https://github.com/christianlouis/inboxconverge/actions/workflows/security.yml)
|
||||
[](https://github.com/christianlouis/inboxconverge/actions/workflows/docker-build.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
GDPR-compliant data masking utilities.
|
||||
|
||||
These helpers are used in admin-facing API responses to pseudonymise
|
||||
personal data (email addresses, names) so that operators can audit
|
||||
system behaviour without seeing full end-user PII.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def mask_email(email: str) -> str:
|
||||
"""
|
||||
Partially mask an email address for GDPR-compliant display.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> mask_email("john.doe@example.com")
|
||||
'jo***@e***.com'
|
||||
>>> mask_email("ab@x.io")
|
||||
'ab***@x***.io'
|
||||
>>> mask_email("a@b.de")
|
||||
'a***@b***.de'
|
||||
"""
|
||||
if not email or "@" not in email:
|
||||
return "***"
|
||||
|
||||
local, _, domain = email.partition("@")
|
||||
|
||||
# Local part: keep first 2 chars (or all if shorter), then "***"
|
||||
visible_local = local[:2] if len(local) >= 2 else local
|
||||
masked_local = f"{visible_local}***"
|
||||
|
||||
# Domain part: keep first char of SLD and the TLD unchanged
|
||||
domain_parts = domain.rsplit(".", 1)
|
||||
if len(domain_parts) == 2:
|
||||
sld, tld = domain_parts
|
||||
visible_sld = sld[:1] if sld else ""
|
||||
masked_domain = f"{visible_sld}***.{tld}"
|
||||
else:
|
||||
masked_domain = "***"
|
||||
|
||||
return f"{masked_local}@{masked_domain}"
|
||||
|
||||
|
||||
def mask_name(name: str) -> str:
|
||||
"""
|
||||
Partially mask a display name.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> mask_name("John Doe")
|
||||
'Jo*** D***'
|
||||
>>> mask_name("Alice")
|
||||
'Al***'
|
||||
"""
|
||||
if not name:
|
||||
return "***"
|
||||
words = name.split()
|
||||
masked_words = []
|
||||
for word in words:
|
||||
visible = word[:2] if len(word) >= 2 else word
|
||||
masked_words.append(f"{visible}***")
|
||||
return " ".join(masked_words)
|
||||
|
||||
|
||||
# RFC 5322 address pattern – extracts the bare email from strings like
|
||||
# "John Doe <john@example.com>" or just "john@example.com".
|
||||
_ADDR_RE = re.compile(r"<([^>]+)>|(\S+@\S+\.\S+)")
|
||||
|
||||
|
||||
def mask_from_header(from_header: str) -> str:
|
||||
"""
|
||||
Mask a raw RFC 5322 From header value for GDPR-compliant display.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> mask_from_header("John Doe <john.doe@example.com>")
|
||||
'Jo*** D*** <jo***@e***.com>'
|
||||
>>> mask_from_header("john.doe@example.com")
|
||||
'jo***@e***.com'
|
||||
"""
|
||||
if not from_header:
|
||||
return "***"
|
||||
|
||||
# Try to parse "Display Name <email>" form
|
||||
angle_match = re.search(r"^(.*?)<([^>]+)>", from_header.strip())
|
||||
if angle_match:
|
||||
display_name = angle_match.group(1).strip().strip('"')
|
||||
email_part = angle_match.group(2).strip()
|
||||
masked_email = mask_email(email_part)
|
||||
if display_name:
|
||||
masked_display = mask_name(display_name)
|
||||
return f"{masked_display} <{masked_email}>"
|
||||
return masked_email
|
||||
|
||||
# Plain email address
|
||||
bare_match = _ADDR_RE.search(from_header)
|
||||
if bare_match:
|
||||
email_part = bare_match.group(1) or bare_match.group(2)
|
||||
return mask_email(email_part)
|
||||
|
||||
# Fallback: mask the whole string
|
||||
return mask_name(from_header)
|
||||
@@ -1,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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+12
-9
@@ -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:
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!
|
||||
|
||||
+50
-50
@@ -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=<your-64-char-hex-secret>
|
||||
@@ -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: "<your-64-char-hex-secret>"
|
||||
ENCRYPTION_KEY: "<your-64-char-hex-encryption-key>"
|
||||
DATABASE_URL: "postgresql+asyncpg://postgres:change-me@postgres:5432/pop3_forwarder"
|
||||
DATABASE_URL: "postgresql+asyncpg://postgres:change-me@postgres:5432/inbox_converge"
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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!** 🎉
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+10
-10
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-3
@@ -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 `<AuthGuard>` so auth check always runs on fresh load
|
||||
- [x] **`/admin/logs` page** — system-wide processing activity: expandable run table + flat per-email log table with GDPR-masked sender addresses; filterable by user ID, status, log level
|
||||
|
||||
---
|
||||
|
||||
@@ -351,7 +362,8 @@ because the API client layer is missing.
|
||||
1. **Immediate** (Today):
|
||||
- [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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -351,7 +351,7 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
|
||||
@@ -376,6 +376,18 @@ export default function AdminPage() {
|
||||
<p className="text-sm text-gray-500">Create and configure subscription plans</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/logs"
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
|
||||
>
|
||||
<div className="p-3 rounded-full bg-green-100 group-hover:bg-green-200 transition-colors">
|
||||
<Activity className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">Activity Logs</p>
|
||||
<p className="text-sm text-gray-500">Processing runs and per-email logs across all users</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* System Alert Channels */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { mailAccountsApi, processingRunsApi } from '@/lib/api';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
@@ -51,19 +52,19 @@ export default function DashboardPage() {
|
||||
|
||||
const { data: runs, isLoading: runsLoading } = useQuery({
|
||||
queryKey: ['processing-runs'],
|
||||
queryFn: () => processingRunsApi.list(),
|
||||
queryFn: () => processingRunsApi.list({ page: 1, page_size: 10 }),
|
||||
});
|
||||
|
||||
const stats = {
|
||||
totalAccounts: accounts?.length || 0,
|
||||
activeAccounts: accounts?.filter((a) => a.is_enabled).length || 0,
|
||||
emailsToday: runs
|
||||
emailsToday: runs?.items
|
||||
?.filter((r) => {
|
||||
const today = new Date().toDateString();
|
||||
return new Date(r.started_at).toDateString() === today;
|
||||
})
|
||||
.reduce((sum, r) => sum + r.emails_forwarded, 0) || 0,
|
||||
errors: runs?.filter((r) => r.emails_failed > 0).length || 0,
|
||||
errors: runs?.items?.filter((r) => r.emails_failed > 0).length || 0,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -100,15 +101,18 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Recent Processing Runs */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Recent Processing Runs</h3>
|
||||
<Link href="/logs" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
|
||||
View all logs →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{runsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : runs && runs.length > 0 ? (
|
||||
) : runs && runs.items && runs.items.length > 0 ? (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
@@ -133,12 +137,12 @@ export default function DashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{runs.slice(0, 10).map((run) => {
|
||||
{runs.items.map((run) => {
|
||||
const account = accounts?.find((a) => a.id === run.mail_account_id);
|
||||
return (
|
||||
<tr key={run.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{account?.name || `Account ${run.mail_account_id}`}
|
||||
{run.account_name || account?.name || `Account ${run.mail_account_id}`}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Datenschutz – POP3 Forwarder',
|
||||
title: `Datenschutz – ${APP_NAME}`,
|
||||
};
|
||||
|
||||
const LAST_UPDATED = 'March 26, 2026';
|
||||
const CONTACT_EMAIL = 'christianlouis@gmail.com';
|
||||
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
|
||||
|
||||
export default function DatenschutzPage() {
|
||||
return (
|
||||
@@ -82,7 +84,7 @@ export default function DatenschutzPage() {
|
||||
2. Geltungsbereich dieser Datenschutzerklärung
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Dieser Hinweis gilt für die POP3 Forwarder-Webanwendung. Er gilt
|
||||
Dieser Hinweis gilt für die {APP_NAME}-Webanwendung. Er gilt
|
||||
für alle Nutzer weltweit, einschließlich derjenigen in der
|
||||
Europäischen Union (EU), dem Europäischen Wirtschaftsraum (EWR),
|
||||
Deutschland, dem Vereinigten Königreich (UK), der Schweiz, der
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
|
||||
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Impressum – POP3 Forwarder',
|
||||
title: `Impressum – ${APP_NAME}`,
|
||||
};
|
||||
|
||||
export default function ImpressumPage() {
|
||||
@@ -36,10 +39,10 @@ export default function ImpressumPage() {
|
||||
Fax: +49 40 97074609<br />
|
||||
E-Mail:{' '}
|
||||
<a
|
||||
href="mailto:christianlouis@gmail.com"
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
christianlouis@gmail.com
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function LoginPage() {
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
POP3 to Gmail Forwarder
|
||||
InboxConverge
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function Home() {
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<div className="flex items-center">
|
||||
<Mail className="h-8 w-8 text-blue-600 mr-2" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">InboxRescue</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">InboxConverge</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
@@ -136,7 +136,7 @@ export default function Home() {
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto">
|
||||
You know the ones — that GMX account from 2009, the old ISP address your
|
||||
bank still sends to, the Hotmail you gave out in school. InboxRescue
|
||||
bank still sends to, the Hotmail you gave out in school. InboxConverge
|
||||
quietly polls them all and drops everything into your Gmail. Set it once,
|
||||
forget it exists.
|
||||
</p>
|
||||
@@ -171,7 +171,7 @@ export default function Home() {
|
||||
Auto-detects everything
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Type your old email address and InboxRescue figures out the server
|
||||
Type your old email address and InboxConverge figures out the server
|
||||
settings. No Googling port numbers required.
|
||||
</p>
|
||||
</div>
|
||||
@@ -215,7 +215,7 @@ export default function Home() {
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
|
||||
<p className="text-gray-600">
|
||||
Paste the email address — InboxRescue auto-detects the POP3/IMAP
|
||||
Paste the email address — InboxConverge auto-detects the POP3/IMAP
|
||||
settings in seconds.
|
||||
</p>
|
||||
</div>
|
||||
@@ -225,7 +225,7 @@ export default function Home() {
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
|
||||
<p className="text-gray-600">
|
||||
Sign in with Google once. InboxRescue delivers mail directly into
|
||||
Sign in with Google once. InboxConverge delivers mail directly into
|
||||
your inbox using the Gmail API — no SMTP relay needed.
|
||||
</p>
|
||||
</div>
|
||||
@@ -270,7 +270,7 @@ export default function Home() {
|
||||
<footer className="mt-20 border-t border-gray-200 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
© {new Date().getFullYear()} InboxRescue — made for people, not enterprises.
|
||||
© {new Date().getFullYear()} InboxConverge — made for people, not enterprises.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -16,8 +16,112 @@ import {
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Bug,
|
||||
RotateCcw,
|
||||
Tags,
|
||||
} from 'lucide-react';
|
||||
|
||||
const DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES = ['{{source_email}}', 'imported'];
|
||||
|
||||
function parseImportLabelTemplates(input: string): string[] {
|
||||
return input
|
||||
.split('\n')
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, values) => value.length > 0 && values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function GmailImportLabelsForm({
|
||||
gmailCredential,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: {
|
||||
gmailCredential: {
|
||||
gmail_email: string;
|
||||
import_label_templates: string[];
|
||||
default_import_label_templates: string[];
|
||||
};
|
||||
onSave: (labels: string[]) => void;
|
||||
isSaving: boolean;
|
||||
}) {
|
||||
const [labelsInput, setLabelsInput] = useState(
|
||||
gmailCredential.import_label_templates.join('\n')
|
||||
);
|
||||
|
||||
const defaultTemplates =
|
||||
gmailCredential.default_import_label_templates.length > 0
|
||||
? gmailCredential.default_import_label_templates
|
||||
: DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES;
|
||||
const parsedLabels = parseImportLabelTemplates(labelsInput);
|
||||
const isDefaultSelection =
|
||||
parsedLabels.length === defaultTemplates.length &&
|
||||
parsedLabels.every((value, index) => value === defaultTemplates[index]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Tags className="h-4 w-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Import labels</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
One label is created per line. We recommend keeping{' '}
|
||||
<code className="rounded bg-white px-1 py-0.5 text-xs text-gray-700">
|
||||
{'{{source_email}}'}
|
||||
</code>{' '}
|
||||
so each imported message is tagged with the mailbox it came from, plus a
|
||||
catch-all label like <strong>imported</strong>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Example: a mail pulled from <strong>billing@example.com</strong> will be
|
||||
labeled as <strong>billing@example.com</strong> when{' '}
|
||||
<code className="rounded bg-white px-1 py-0.5 text-xs text-gray-700">
|
||||
{'{{source_email}}'}
|
||||
</code>{' '}
|
||||
is present.
|
||||
</p>
|
||||
<textarea
|
||||
value={labelsInput}
|
||||
onChange={(e) => setLabelsInput(e.target.value)}
|
||||
rows={4}
|
||||
className="mt-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`{{source_email}}\nimported`}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<span>Suggested defaults:</span>
|
||||
{defaultTemplates.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-full border border-gray-200 bg-white px-2 py-1 text-gray-700"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(parsedLabels)}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Save label setup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLabelsInput(defaultTemplates.join('\n'))}
|
||||
disabled={isSaving || isDefaultSelection}
|
||||
className="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm text-gray-700 ring-1 ring-gray-300 transition-colors hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset defaults
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
Connected Gmail target: <strong>{gmailCredential.gmail_email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
@@ -106,6 +210,7 @@ function SettingsContent() {
|
||||
});
|
||||
|
||||
const [debugEmailResult, setDebugEmailResult] = useState<string | null>(null);
|
||||
const [gmailLabelsSaved, setGmailLabelsSaved] = useState(false);
|
||||
const sendDebugEmailMutation = useMutation({
|
||||
mutationFn: gmailApi.sendDebugEmail,
|
||||
onSuccess: () => {
|
||||
@@ -118,6 +223,15 @@ function SettingsContent() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateGmailLabelsMutation = useMutation({
|
||||
mutationFn: gmailApi.updateImportLabels,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gmail-credential'] });
|
||||
setGmailLabelsSaved(true);
|
||||
setTimeout(() => setGmailLabelsSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const saveSmtpMutation = useMutation({
|
||||
mutationFn: smtpApi.save,
|
||||
onSuccess: () => {
|
||||
@@ -362,7 +476,9 @@ function SettingsContent() {
|
||||
{debugEmailResult === 'success' && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
Debug email injected successfully. Check your Gmail inbox — it should be labelled <strong className="mx-1">test</strong> and <strong className="mx-1">imported</strong>.
|
||||
Debug email injected successfully. Check your Gmail inbox for the
|
||||
configured import labels plus a <strong className="mx-1">test</strong>{' '}
|
||||
label.
|
||||
</div>
|
||||
)}
|
||||
{debugEmailResult === 'error' && (
|
||||
@@ -371,6 +487,18 @@ function SettingsContent() {
|
||||
Failed to inject debug email. Check that Gmail API access is still valid.
|
||||
</div>
|
||||
)}
|
||||
<GmailImportLabelsForm
|
||||
key={gmailCredential.updated_at}
|
||||
gmailCredential={gmailCredential}
|
||||
isSaving={updateGmailLabelsMutation.isPending}
|
||||
onSave={(labels) => updateGmailLabelsMutation.mutate(labels)}
|
||||
/>
|
||||
{gmailLabelsSaved && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
Gmail import labels saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -589,4 +717,3 @@ function SettingsContent() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Users,
|
||||
CreditCard,
|
||||
Bell
|
||||
FileText,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
@@ -37,6 +39,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Mail Accounts', href: '/accounts', icon: Mail },
|
||||
{ name: 'Notifications', href: '/notifications', icon: Bell },
|
||||
{ name: 'Logs', href: '/logs', icon: FileText },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
@@ -45,6 +48,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{ name: 'Admin Overview', href: '/admin', icon: Shield },
|
||||
{ name: 'Manage Users', href: '/admin/users', icon: Users },
|
||||
{ name: 'Manage Plans', href: '/admin/plans', icon: CreditCard },
|
||||
{ name: 'Activity Logs', href: '/admin/logs', icon: Activity },
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -56,7 +60,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
|
||||
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-gray-900">InboxRescue</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900">InboxConverge</h1>
|
||||
</div>
|
||||
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
@@ -119,7 +123,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
<div className="fixed inset-0 bg-gray-600/75" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white">
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-gray-900">InboxRescue</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900">InboxConverge</h1>
|
||||
<button onClick={() => setSidebarOpen(false)} className="text-gray-500 hover:text-gray-700">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
+143
-4
@@ -126,6 +126,64 @@ export interface ProcessingRun {
|
||||
emails_failed: number;
|
||||
status: string;
|
||||
error_message?: string | null;
|
||||
account_name?: string | null;
|
||||
account_email?: string | null;
|
||||
}
|
||||
|
||||
export interface ProcessingLog {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
email_subject?: string | null;
|
||||
email_from?: string | null;
|
||||
success: boolean;
|
||||
mail_account_id: number;
|
||||
processing_run_id?: number | null;
|
||||
email_size_bytes?: number | null;
|
||||
error_details?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PaginatedProcessingRuns {
|
||||
items: ProcessingRun[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface PaginatedProcessingLogs {
|
||||
items: ProcessingLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface AdminProcessingRun extends ProcessingRun {
|
||||
user_id?: number | null;
|
||||
user_email?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProcessingLog extends ProcessingLog {
|
||||
user_id: number;
|
||||
user_email?: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedAdminRuns {
|
||||
items: AdminProcessingRun[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface PaginatedAdminLogsResponse {
|
||||
items: AdminProcessingLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface AutoDetectSuggestion {
|
||||
@@ -141,6 +199,8 @@ export interface GmailCredential {
|
||||
user_id: number;
|
||||
gmail_email: string;
|
||||
is_valid: boolean;
|
||||
import_label_templates: string[];
|
||||
default_import_label_templates: string[];
|
||||
last_verified_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -292,10 +352,54 @@ export const mailAccountsApi = {
|
||||
// ── Processing Runs API ─────────────────────────────────────────────────
|
||||
|
||||
export const processingRunsApi = {
|
||||
async list(): Promise<ProcessingRun[]> {
|
||||
// TODO: Add a dedicated /processing-runs endpoint to the backend
|
||||
// For now, return empty array since no user-facing endpoint exists yet
|
||||
return [];
|
||||
async list(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
account_id?: number;
|
||||
status?: string;
|
||||
}): Promise<PaginatedProcessingRuns> {
|
||||
const response = await api.get<PaginatedProcessingRuns>("/processing-runs", {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async get(runId: number): Promise<ProcessingRun> {
|
||||
const response = await api.get<ProcessingRun>(`/processing-runs/${runId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getLogs(
|
||||
runId: number,
|
||||
params?: { page?: number; page_size?: number }
|
||||
): Promise<PaginatedProcessingLogs> {
|
||||
const response = await api.get<PaginatedProcessingLogs>(
|
||||
`/processing-runs/${runId}/logs`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async listForAccount(
|
||||
accountId: number,
|
||||
params?: { page?: number; page_size?: number; status?: string }
|
||||
): Promise<PaginatedProcessingRuns> {
|
||||
const response = await api.get<PaginatedProcessingRuns>(
|
||||
`/mail-accounts/${accountId}/processing-runs`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async listLogsForAccount(
|
||||
accountId: number,
|
||||
params?: { page?: number; page_size?: number; level?: string }
|
||||
): Promise<PaginatedProcessingLogs> {
|
||||
const response = await api.get<PaginatedProcessingLogs>(
|
||||
`/mail-accounts/${accountId}/logs`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -336,6 +440,14 @@ export const gmailApi = {
|
||||
const response = await api.post<GmailDebugEmailResponse>('/providers/gmail/debug-email');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Update the labels applied to imported Gmail messages. */
|
||||
async updateImportLabels(importLabelTemplates: string[]): Promise<GmailCredential> {
|
||||
const response = await api.put<GmailCredential>('/providers/gmail-credential/labels', {
|
||||
import_label_templates: importLabelTemplates,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ── SMTP Config API ─────────────────────────────────────────────────────
|
||||
@@ -483,6 +595,33 @@ export const adminApi = {
|
||||
async deletePlan(id: number): Promise<void> {
|
||||
await api.delete(`/admin/plans/${id}`);
|
||||
},
|
||||
|
||||
async listProcessingRuns(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
user_id?: number;
|
||||
account_id?: number;
|
||||
status?: string;
|
||||
}): Promise<PaginatedAdminRuns> {
|
||||
const response = await api.get<PaginatedAdminRuns>('/admin/processing-runs', {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async listProcessingLogs(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
user_id?: number;
|
||||
account_id?: number;
|
||||
run_id?: number;
|
||||
level?: string;
|
||||
}): Promise<PaginatedAdminLogsResponse> {
|
||||
const response = await api.get<PaginatedAdminLogsResponse>('/admin/processing-logs', {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Notification Types ──────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
POP3 to Gmail Forwarder
|
||||
InboxConverge
|
||||
Fetches emails from POP3 mailboxes and forwards them to Gmail
|
||||
"""
|
||||
|
||||
@@ -80,7 +80,7 @@ class POP3Account:
|
||||
|
||||
|
||||
class EmailForwarder:
|
||||
"""Main class for forwarding emails from POP3 to Gmail"""
|
||||
"""Main class for forwarding emails from POP3 to Gmail via InboxConverge"""
|
||||
|
||||
def __init__(self):
|
||||
self.smtp_host = os.getenv('SMTP_HOST', 'smtp.gmail.com')
|
||||
@@ -241,7 +241,7 @@ class EmailForwarder:
|
||||
payload = json.dumps({
|
||||
"From": self.postmark_from,
|
||||
"To": self.postmark_to,
|
||||
"Subject": f"[POP3 Forwarder Alert] {subject}",
|
||||
"Subject": f"[InboxConverge Alert] {subject}",
|
||||
"TextBody": f"Error occurred at {datetime.now().isoformat()}\n\n{error_message}",
|
||||
"MessageStream": "outbound"
|
||||
})
|
||||
@@ -312,7 +312,7 @@ class EmailForwarder:
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
logger.info("POP3 to Gmail Forwarder starting...")
|
||||
logger.info("InboxConverge starting...")
|
||||
|
||||
forwarder = EmailForwarder()
|
||||
|
||||
+4
-4
@@ -51,7 +51,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "InboxRescue system health: mail processing, Gmail API, auth, and HTTP metrics",
|
||||
"description": "InboxConverge system health: mail processing, Gmail API, auth, and HTTP metrics",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
@@ -1111,7 +1111,7 @@
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["inboxrescue", "monitoring", "email"],
|
||||
"tags": ["inboxconverge", "monitoring", "email"],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
@@ -1121,8 +1121,8 @@
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "InboxRescue - System Health",
|
||||
"uid": "inboxrescue-health",
|
||||
"title": "InboxConverge - System Health",
|
||||
"uid": "inboxconverge-health",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: InboxRescue
|
||||
- name: InboxConverge
|
||||
orgId: 1
|
||||
type: file
|
||||
disableDeletion: false
|
||||
|
||||
@@ -3,7 +3,7 @@ global:
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "inboxrescue-backend"
|
||||
- job_name: "inboxconverge-backend"
|
||||
static_configs:
|
||||
- targets: ["backend:8000"]
|
||||
metrics_path: /metrics
|
||||
|
||||
Reference in New Issue
Block a user