Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e7139645b | |||
| 9c12555a81 | |||
| 06efeed7be | |||
| 88e8abae4e | |||
| 5ea44d1822 | |||
| 815629910b | |||
| b6999baf2c | |||
| ddad290e82 | |||
| 3cd7a781f6 | |||
| 2c1f7125e4 | |||
| e2c5c8580b | |||
| aa754bf217 | |||
| f847625442 | |||
| 5423f2bb94 | |||
| e52f00bc16 | |||
| da9d822afa | |||
| 769907a65b | |||
| 983ce75264 | |||
| eb47594e53 | |||
| 63839c3886 | |||
| 7dd0b5f600 | |||
| 187bf16866 | |||
| c0e2dd22cb | |||
| 8b6436d1c6 | |||
| 0bf943d24f | |||
| 0568caeb8f | |||
| cd85fba347 | |||
| dd9a80a8c5 | |||
| 029c272a4e | |||
| 337423bf68 | |||
| 63bb458815 | |||
| 703bbb42d3 | |||
| c813df8a03 | |||
| 0a0836e993 | |||
| b866627937 | |||
| 3db6c9fb11 | |||
| d098126542 | |||
| 0645bc0462 | |||
| 2638f415aa | |||
| 3a11e07f09 | |||
| 85fdf0a600 | |||
| 8152635238 | |||
| 61d525cb82 | |||
| 90665ea100 | |||
| 9712e8a3b6 | |||
| 191fee9be4 | |||
| 5ff5cb54be | |||
| d6235852c2 | |||
| da05016143 | |||
| fd2adb08cd | |||
| 1377ca266c | |||
| 99bfff541f | |||
| 0720901265 | |||
| db3ce61467 | |||
| 129d8a1bdd | |||
| 466e380cb2 | |||
| eae2f59c53 | |||
| 5bddbb42f5 | |||
| 8763881b6c | |||
| 062739a9a1 | |||
| 6a4b991f29 | |||
| 6b9e12017e | |||
| 9017505b0f | |||
| 56df261eae | |||
| aabccbe96d | |||
| 40c23c43fe | |||
| c092dd9ac6 | |||
| c406cb09d3 | |||
| 15f1108732 | |||
| 19a1b3d066 | |||
| 3c8bb4bfb6 | |||
| feeb6fc99c | |||
| 9e0d143f8d | |||
| 472f0bf57e | |||
| 9073f43aed | |||
| 454452a0b6 | |||
| 50fe46a46a | |||
| 96dda16a66 | |||
| 9eab576bf1 | |||
| 4779c17434 | |||
| a0d863d5ae | |||
| 0be9493a63 | |||
| bf1b465971 | |||
| c1fd12f5ba | |||
| d92affabec | |||
| f7caa05c8f | |||
| 6afd7c871e | |||
| 57db094d44 | |||
| 105d6dcd6e | |||
| fb3311cb7f | |||
| 2792c0f6fe | |||
| f080375f94 | |||
| 0698eca628 | |||
| 312fd9976f | |||
| 15af7fde61 | |||
| aa2ea611b1 | |||
| 31ca3a285f | |||
| 9638e031ea | |||
| 35ddcdf9df | |||
| fc7e09d4d6 | |||
| 441e8b54e9 | |||
| 66f94e1ecb | |||
| 350ef6288d | |||
| 5419627613 | |||
| c09019d333 | |||
| dd4532f660 | |||
| ccd74c34b6 | |||
| ae98a54859 | |||
| d0719eff76 | |||
| f1970e0e0f | |||
| 526824de05 | |||
| f821cadbbf | |||
| 88fac1d8dd | |||
| 1bb3f71230 | |||
| 1135ee81e2 | |||
| b1dd322ec1 | |||
| 1cf8dfbb60 | |||
| 4a3d368a40 | |||
| eed61c0597 | |||
| d3e0ca4e33 | |||
| b76f241b98 | |||
| 189e2f729d | |||
| 4b15b63b56 | |||
| 9a7faf49b6 | |||
| 4844377342 | |||
| ad3cb81709 | |||
| bb34920c94 | |||
| 880dfebf60 | |||
| d256f62afc | |||
| 2fd018afa1 | |||
| 2168a1413c | |||
| 207b119f4c | |||
| f8a9f3ce53 | |||
| 8673b898a1 | |||
| aa0a7526c0 | |||
| 5f3f6e47e7 | |||
| ab2598c48b | |||
| 351a197173 | |||
| f3ae8cc662 | |||
| 8a9176c7e5 | |||
| a2ddaed54c | |||
| 67f5f0b18e | |||
| 762a971081 | |||
| 78cb104c73 | |||
| fd7f797d12 | |||
| ccd94eb9cd | |||
| 999a5112f3 | |||
| 6b285f4fb9 | |||
| e5f359bbd9 | |||
| 82829661da | |||
| 69de187aab | |||
| 6d10022833 | |||
| 2fa57e7fbe | |||
| b96b34954b | |||
| 412e7bc6f5 | |||
| fd229d1b51 | |||
| 896329ccb7 | |||
| 69716517aa | |||
| a0f2d9bf22 | |||
| b824095bf5 | |||
| 64b8e788ce | |||
| 0bc08b9825 | |||
| 9314c38775 | |||
| 56c62f8e6c | |||
| 5711a56569 | |||
| 2708a010bf | |||
| 5f24e7f1b6 | |||
| fd45c838fc | |||
| 5d76df7270 | |||
| 92d67369e9 | |||
| bbd2febfc1 | |||
| 5bf4cbc2e9 | |||
| a6c95bef4d | |||
| eba086fff0 | |||
| 276ef1f8b2 | |||
| 3f9aa69ff9 | |||
| 74fa460660 | |||
| 3dcb700e68 | |||
| 3332344ca0 | |||
| c9a8f48f7a | |||
| ca87abce0a | |||
| d85bd6cfd1 | |||
| dd91110ff1 | |||
| b1abae6e08 | |||
| 744618fefe | |||
| f1a55b4f74 | |||
| d4aa353383 | |||
| 24b1be84c3 | |||
| 53334402aa | |||
| 7971d3f76a | |||
| 979b7b6cec | |||
| 37b0af2eb7 | |||
| c7a1a2b884 | |||
| 30d24c96f2 | |||
| 1353253ad0 | |||
| 739852720a | |||
| c404afa539 | |||
| aa3f188b6f | |||
| 8faa6deb78 | |||
| 8cf0d101bf | |||
| dda768ff73 | |||
| 5c40415590 | |||
| 20170f2f2d | |||
| 53c1b48d33 | |||
| 08f7543fdf | |||
| 75f6e5f083 | |||
| fb9b3893c4 | |||
| a0e8f07328 | |||
| 6bea660d16 | |||
| 43db7f4f66 | |||
| d494460701 | |||
| 68063b20b8 | |||
| b46fabd8cd | |||
| 960ab2a96f | |||
| 372632ce6f |
@@ -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]
|
||||
|
||||
@@ -14,6 +14,8 @@ on:
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
PRIVATE_REGISTRY: registry.cklnet.com
|
||||
K8S_STATE_REPO: christianlouis/k8s-cluster-state
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: Lint ──────────────────────────────────────────────────────
|
||||
@@ -77,7 +79,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 +115,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
|
||||
@@ -121,12 +123,37 @@ jobs:
|
||||
cd backend
|
||||
pytest tests/ -v --cov=app --cov-report=xml --cov-report=term
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run frontend tests with coverage
|
||||
run: npm run test:ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Upload backend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./backend/coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./backend/coverage.xml
|
||||
flags: backend
|
||||
name: backend-coverage
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./frontend/coverage/lcov.info
|
||||
flags: frontend
|
||||
name: frontend-coverage
|
||||
fail_ci_if_error: false
|
||||
|
||||
# ── Phase 3: Security ──────────────────────────────────────────────────
|
||||
@@ -148,7 +175,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install bandit safety
|
||||
pip install bandit pip-audit
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run Bandit security scan
|
||||
@@ -156,7 +183,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check dependencies for known vulnerabilities
|
||||
run: safety check --json
|
||||
run: pip-audit -r backend/requirements.txt
|
||||
continue-on-error: true
|
||||
|
||||
# ── Phase 4: Build ─────────────────────────────────────────────────────
|
||||
@@ -173,9 +200,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
|
||||
@@ -224,3 +251,86 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
||||
|
||||
# ── Phase 5: GitOps – update preprod k8s manifest ─────────────────────────
|
||||
update-k8s-manifest:
|
||||
name: Update Preprod K8s Manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Compute image tags
|
||||
id: tag
|
||||
run: |
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
echo "backend_image=${{ env.PRIVATE_REGISTRY }}/inboxconverge/backend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=${{ env.PRIVATE_REGISTRY }}/inboxconverge/frontend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if GH_PAT is configured and has repo access
|
||||
id: pat-check
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
if [ -z "$GH_PAT" ]; then
|
||||
echo "::warning::GH_PAT secret is not configured. Skipping k8s manifest update."
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--oauth2-bearer "$GH_PAT" \
|
||||
"https://api.github.com/repos/${{ env.K8S_STATE_REPO }}")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::GH_PAT does not have access to ${{ env.K8S_STATE_REPO }} (HTTP $HTTP_CODE). Skipping k8s manifest update."
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout k8s-cluster-state
|
||||
if: steps.pat-check.outputs.available == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.K8S_STATE_REPO }}
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
path: k8s-cluster-state
|
||||
ref: main
|
||||
|
||||
- name: Update backend image tag in preprod manifest
|
||||
if: steps.pat-check.outputs.available == 'true'
|
||||
uses: mikefarah/yq@v4.44.6
|
||||
env:
|
||||
IMAGE: ${{ steps.tag.outputs.backend_image }}
|
||||
with:
|
||||
cmd: |
|
||||
yq -i '(.. | select(tag == "!!str") | select(test("^registry\\.cklnet\\.com/inboxconverge/backend:"))) = strenv(IMAGE)' \
|
||||
k8s-cluster-state/apps/gmail-puller/preprod/gmail-puller-stack.yaml
|
||||
|
||||
- name: Update frontend image tag in preprod manifest
|
||||
if: steps.pat-check.outputs.available == 'true'
|
||||
uses: mikefarah/yq@v4.44.6
|
||||
env:
|
||||
IMAGE: ${{ steps.tag.outputs.frontend_image }}
|
||||
with:
|
||||
cmd: |
|
||||
yq -i '(.. | select(tag == "!!str") | select(test("^registry\\.cklnet\\.com/inboxconverge/frontend:"))) = strenv(IMAGE)' \
|
||||
k8s-cluster-state/apps/gmail-puller/preprod/gmail-puller-stack.yaml
|
||||
|
||||
- name: Commit and push manifest update
|
||||
if: steps.pat-check.outputs.available == 'true'
|
||||
run: |
|
||||
cd k8s-cluster-state
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add apps/gmail-puller/preprod/gmail-puller-stack.yaml
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore(preprod): update inboxconverge images to ${{ steps.tag.outputs.short_sha }}"
|
||||
git push
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Semantic Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Semantic Release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'christianlouis/InboxConverge'
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install python-semantic-release
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Run Semantic Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
semantic-release version --print
|
||||
semantic-release version
|
||||
semantic-release publish
|
||||
|
||||
- name: Update changelog if no new version was released
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Detect whether `semantic-release version` just created a version commit.
|
||||
# PSR's version commits have a bare version number as the subject (e.g. "0.2.2").
|
||||
# If the latest commit matches that pattern the changelog was already updated.
|
||||
LAST_COMMIT_MSG=$(git log -1 --format="%s")
|
||||
if echo "$LAST_COMMIT_MSG" | grep -qP "^\d+\.\d+\.\d+$"; then
|
||||
echo "semantic-release created version commit '$LAST_COMMIT_MSG' - CHANGELOG.md already updated"
|
||||
else
|
||||
echo "No new version was released; CHANGELOG.md unchanged"
|
||||
fi
|
||||
@@ -38,7 +38,6 @@ env/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -55,7 +54,6 @@ htmlcov/
|
||||
|
||||
# Backend specific
|
||||
backend/.env
|
||||
backend/alembic/versions/*_*.py
|
||||
backend/*.db
|
||||
backend/*.sqlite
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Safety policy configuration
|
||||
# See: https://docs.safetycli.com/safety-docs/safety-policy-file
|
||||
|
||||
version: "3.0"
|
||||
|
||||
security:
|
||||
ignore-cvss-severity-below: 0
|
||||
ignore-cvss-unknown-severity: false
|
||||
ignore-vulnerabilities:
|
||||
# ecdsa CVE: side-channel / Minerva attack (CVE-64396, CVE-64459)
|
||||
# The ecdsa maintainers have explicitly stated that these vulnerabilities
|
||||
# cannot be fixed in pure Python. ecdsa is a transitive dependency of
|
||||
# python-jose[cryptography], which is used only for JWT token
|
||||
# verification/signing – a context where precise timing side-channels
|
||||
# are not exploitable by remote attackers. The 'cryptography' package
|
||||
# (already installed) handles all sensitive key operations.
|
||||
64396:
|
||||
reason: "ecdsa side-channel (Minerva) — unfixable in pure Python per maintainers; not exploitable in our JWT-signing context"
|
||||
expires: "2027-01-01"
|
||||
64459:
|
||||
reason: "ecdsa side-channel attack — unfixable in pure Python per maintainers; not exploitable in our JWT-signing context"
|
||||
expires: "2027-01-01"
|
||||
@@ -5,118 +5,500 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
<!-- version list -->
|
||||
|
||||
## v0.6.0 (2026-03-29)
|
||||
|
||||
### Chores
|
||||
|
||||
- Initial plan for frontend test coverage
|
||||
([`7dd0b5f`](https://github.com/christianlouis/InboxConverge/commit/7dd0b5f6005b4d4c2db4cc0939fa6b4921ceff94))
|
||||
|
||||
### Features
|
||||
|
||||
- Add frontend test coverage for date-utils, api interceptors, AuthGuard, QueryProvider,
|
||||
DashboardLayout
|
||||
([`63839c3`](https://github.com/christianlouis/InboxConverge/commit/63839c3886445527258d988ad65f869cf46387a5))
|
||||
|
||||
- Add NotificationWizard + ProviderWizard tests, fix lint, update docs
|
||||
([`769907a`](https://github.com/christianlouis/InboxConverge/commit/769907a65b956048a77e5714b998a45426d08bf4))
|
||||
|
||||
|
||||
## v0.5.1 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Convert jest.config.js to jest.config.mjs (ES module syntax)
|
||||
([`0a0836e`](https://github.com/christianlouis/InboxConverge/commit/0a0836e993721a5702582c78d5b86bd52da96e44))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Fixed three ESLint errors that caused CI to fail: removed unused `_setUser` store binding and unused `useAuthStore` import from `login/page.tsx`; replaced unused `_err` catch binding with a bare `catch {}` in `login/page.tsx`; removed a `useEffect` in `settings/page.tsx` that called `setProfileForm` synchronously (flagged by `react-hooks/set-state-in-effect`) — the effect was redundant because `useState` already initialises the form from the auth store's `user` object, which is the same value passed as `initialData` to `useQuery`.
|
||||
- Fixed wizard to create new mail accounts showing a big grey screen: `bg-opacity-75` was removed in Tailwind CSS v4; replaced with the `/75` opacity modifier syntax (`bg-gray-500/75`) in `AddMailAccountModal` and `DashboardLayout` mobile overlay. Restructured the modal from the deprecated `inline-block align-bottom` centering trick to a proper flexbox layout with `relative z-10` on the modal content.
|
||||
- Fixed mail account creation always failing with a backend validation error: `email_address` and `forward_to` are required fields in the backend schema but were missing from the `AddMailAccountModal` form. Added both fields to the form — `email_address` is auto-synced from the username input, and `forward_to` (destination Gmail address) is a new explicit field pre-populated from the logged-in user's email. Also added `delivery_method` selector and `delete_after_forward` checkbox.
|
||||
- Implemented the Settings page (was a placeholder showing "coming soon"): now includes a Profile section to update name and email via `PUT /users/me`, an Account Information section showing subscription tier/status and member-since date, and a Security section.
|
||||
- Fixed `sqlalchemy.exc.DBAPIError` raised by asyncpg when inserting timezone-aware `datetime.now(timezone.utc)` values into timezone-naive `DateTime` (TIMESTAMP WITHOUT TIME ZONE) columns: changed all `DateTime` column definitions in `database_models.py` to `DateTime(timezone=True)` (TIMESTAMP WITH TIME ZONE) and replaced all `default=datetime.utcnow` callable references with `default=lambda: datetime.now(timezone.utc)` for consistent, timezone-aware timestamps throughout the ORM.
|
||||
- Fixed `ProgrammingError` (`cached statement plan is invalid`) raised by the asyncpg dialect during startup: SQLAlchemy's asyncpg wrapper maintains an LRU prepared-statement cache per connection (default size 100). When `Base.metadata.create_all()` executes `CREATE TYPE … AS ENUM` DDL inside a transaction, PostgreSQL invalidates the cached plans for that connection. The next enum-type existence check then fails because the dialect tries to reuse the now-stale prepared statement. Fix: set `prepared_statement_cache_size=0` in `connect_args` on `create_async_engine` to disable the cache entirely, which is the documented SQLAlchemy recommendation for DDL-at-startup scenarios.
|
||||
- Fixed `UndefinedTableError` on first boot: the lifespan startup event now calls `Base.metadata.create_all()` via the async engine before attempting to seed default settings, so all tables are created automatically when the database is empty (e.g., fresh PostgreSQL container with no Alembic migrations run yet).
|
||||
- Fixed frontend API calls being hardcoded to `http://localhost:8000` in production: `NEXT_PUBLIC_API_URL` is baked into the JavaScript bundle at Next.js build time, so it can never be overridden at container runtime. Replaced the `NEXT_PUBLIC_API_URL` mechanism with a Next.js Route Handler proxy at `/api/v1/[...path]` that reads `process.env.BACKEND_URL` at server startup and proxies all `/api/v1/*` requests to the real backend. The frontend Axios client now uses a relative base URL (`/api/v1`), which also eliminates the CORS issue since the browser only ever talks to the same-origin Next.js server. Update `BACKEND_URL=http://backend:8000` in `docker-compose.new.yml` (or your deployment env) to point the proxy at your backend.
|
||||
- Fixed infinite spinning wheel on the home page: `authStore` no longer initialises `isLoading` as `true` unconditionally — it is now `false` when no access token exists in `localStorage`, so unauthenticated users see the landing page immediately instead of an endless spinner
|
||||
- Home page now performs an auth check when a token is present in `localStorage`, redirecting authenticated users to the dashboard and clearing stale tokens on failure
|
||||
- Wrapped `useSearchParams()` in a `Suspense` boundary in `frontend/src/app/auth/callback/page.tsx` to fix the Next.js build error: "useSearchParams() should be wrapped in a suspense boundary at page /auth/callback"
|
||||
- Fixed TypeScript build error in `frontend/src/app/accounts/page.tsx`: replaced non-existent `account.username` with `account.email_address`, `account.last_checked_at` with `account.last_check_at`, and `account.last_error` with `account.last_error_message` (the backend intentionally excludes `username` from API responses for security)
|
||||
- Fixed TypeScript error in `frontend/src/app/auth/callback/page.tsx`: `TokenResponse` doesn't include `user`; now fetches user via `userApi.getCurrentUser()` after OAuth token exchange
|
||||
- Fixed TypeScript errors in `frontend/src/app/dashboard/page.tsx`: replaced non-existent `errors_count` with `emails_failed` on `ProcessingRun`
|
||||
- Fixed TypeScript errors in `frontend/src/components/AddMailAccountModal.tsx`: removed invalid `account.username` access, added missing required fields to initial form state, and fixed autoDetect suggestions access
|
||||
- Made `email_address`, `use_tls`, `forward_to` optional in the `MailAccountCreate` TypeScript interface to align with form usage
|
||||
- Added typed suggestion fields to `autoDetect` return type in `api.ts`
|
||||
- Excluded test files (`*.test.ts`, `*.spec.ts`) from TypeScript compilation in `tsconfig.json`
|
||||
- Upgraded Node.js base image in `frontend/Dockerfile` from `node:18-alpine` to `node:20-alpine` to satisfy the Node.js >= 20.9.0 requirement for Next.js and fix Docker build failures
|
||||
- Removed `actions/attest-build-provenance` step and associated `id-token: write` / `attestations: write` permissions from the CI `build` job — this action is not available for private user-owned repositories and caused every build to fail
|
||||
- Downgraded `eslint` from `^10` to `^9` in the frontend to resolve `TypeError: contextOrFilename.getFilename is not a function` caused by ESLint 10 removing the `getFilename()` API used by `eslint-plugin-react` bundled in `eslint-config-next`
|
||||
- Upgraded `sqlalchemy` from `2.0.25` to `2.0.48` to fix `AssertionError: Class ... directly inherits TypingOnly but has additional attributes` on Python 3.14 (`__static_attributes__`, `__firstlineno__`)
|
||||
### Changed
|
||||
|
||||
- **README rewrite**: Replaced README with a focused, marketing-oriented page targeting users affected by Google's 2026 shutdown of Gmail POP import ("Check mail from other accounts") and Gmailify. Fixed broken CI badge links (pointed to non-existent workflow files; now correctly references `ci.yml`). Removed verbose technical implementation sections in favour of a clear problem statement, comparison table, 5-minute quick-start, and concise delivery-method summary.
|
||||
|
||||
### Added
|
||||
- **Architecture Decision Records ADR-003 through ADR-010**: Added eight new ADRs covering FastAPI web framework (ADR-003), PostgreSQL database (ADR-004), Celery task retry strategy (ADR-005), key management in production (ADR-006), JWT authentication (ADR-007), Next.js frontend (ADR-008), Gmail API email delivery (ADR-009), and hybrid configuration model (ADR-010)
|
||||
- `userApi.updateProfile()` method in `frontend/src/lib/api.ts` for updating user profile via `PUT /users/me`
|
||||
- **Account enable/disable toggle**: `PATCH /mail-accounts/{id}/toggle` backend endpoint and a Power-icon toggle button on each account card in the UI. Disabled accounts are visually dimmed. Re-enabling an account that was in ERROR state resets its status to ACTIVE so the scheduler picks it up again.
|
||||
- **`is_enabled` checkbox in edit modal**: The Add/Edit mail account form now includes an "Enabled" checkbox so the flag can be set when creating or editing an account.
|
||||
- **Message deduplication tracking** (`DownloadedMessageId` table): Both POP3 and IMAP fetch paths now track downloaded message UIDs so the same message is never delivered twice, even when `delete_after_forward=False`.
|
||||
- IMAP: messages are marked `\Seen` after fetching so they don't appear in future `UNSEEN` searches. DB UIDs provide a secondary guard.
|
||||
- POP3: UIDL-based deduplication; messages are skipped if their UID is already in the DB.
|
||||
- Old UID records are pruned by `cleanup_old_logs` after `days_to_keep` days.
|
||||
- **Gmail API "one-click" OAuth grant flow**: New `GET /providers/gmail/authorize-url` and `POST /providers/gmail/callback` endpoints. The flow requests `gmail.insert + gmail.labels` scopes with `access_type=offline` so a long-lived refresh token is issued. A new `/auth/gmail-callback` frontend page handles the redirect from Google, exchanges the code, and redirects the user back to Settings.
|
||||
- **Gmail token auto-refresh and persistence**: `GmailService` now records whether the `google-auth` library refreshed the access token during a Celery run. If it did, the Celery task writes the new access token and expiry back to `GmailCredential`, eliminating an unnecessary extra refresh call on the next run. A `401/403` or `invalid_grant` error during delivery marks `GmailCredential.is_valid = False` so the user is prompted to re-authorise.
|
||||
- **Per-user SMTP relay configuration** (`UserSmtpConfig` table): New `GET/PUT/DELETE /users/smtp-config` endpoints let each user store their own SMTP relay (host, port, username, password, TLS flag). The Celery task checks for per-user SMTP first; falls back to the global `AppSetting` SMTP config if none is set.
|
||||
- **Settings page – Gmail & SMTP sections**: The Settings page now shows a "Gmail API Delivery" card with connection status, "Connect Gmail" / "Re-authorise" / "Disconnect" buttons, and a token-lifetime explanation. An "SMTP Fallback" card lets users save their own SMTP relay credentials.
|
||||
- **Celery scheduling fix**: `process_all_enabled_accounts` previously only polled accounts with `status IN [ACTIVE, TESTING]`, causing ERROR-status accounts to be silently skipped forever. It now polls all `is_enabled = True` accounts regardless of status, so transient errors are retried automatically.
|
||||
- **Backend URL logged at startup**: The Next.js server now logs the resolved `BACKEND_URL` (e.g. `[proxy] BACKEND_URL = http://backend:8000`) via `src/instrumentation.ts` when the server starts, making it easy to diagnose `ECONNREFUSED` proxy errors. The per-request error log now also includes the full target URL.
|
||||
- **Dual-registry Docker deployment**: CI now builds separate backend and frontend images and pushes to both GHCR (`ghcr.io`) and private registry (`registry.cklnet.com`) using a matrix strategy
|
||||
- **Database-backed configuration**: `AppSetting` model and `ConfigService` for hybrid config (DB-first, env-var fallback)
|
||||
- Admin API endpoints for managing settings (`GET/PUT/DELETE /api/v1/settings`)
|
||||
- Default settings seeded into database on first startup (SMTP, processing, Gmail API, notifications)
|
||||
- Unit tests for `ConfigService` (24 tests covering resolution order, CRUD, SMTP helper, defaults)
|
||||
- Gmail API delivery documentation with comparison table (Gmail API vs SMTP forwarding)
|
||||
- GitHub issue templates (bug report, feature request, test needed)
|
||||
- Pull request template with comprehensive checklist
|
||||
- `docs/CODING_PATTERNS.md` with development best practices
|
||||
- `docs/ERRORS.md` documenting all error codes
|
||||
- `docs/adr/` directory with Architecture Decision Records
|
||||
- `Makefile` with common development tasks
|
||||
- `.pre-commit-config.yaml` for code quality enforcement
|
||||
- `CHANGELOG.md` for version tracking
|
||||
- Security validation for SECRET_KEY and ENCRYPTION_KEY on startup
|
||||
- CSRF protection middleware
|
||||
- Security headers middleware (X-Frame-Options, CSP, HSTS)
|
||||
- Rate limiting per user/tier
|
||||
- Comprehensive test infrastructure setup
|
||||
- CI/CD pipeline for testing and security scanning
|
||||
- Dependabot configuration for automated dependency updates (pip, npm, GitHub Actions, Docker)
|
||||
- Copilot instructions requiring TODO.md and CHANGELOG.md updates
|
||||
- Unit tests for security middleware (SecurityHeadersMiddleware, CSRFProtectionMiddleware)
|
||||
- Unit tests for JWT token lifecycle (access tokens, refresh tokens, decode, edge cases)
|
||||
- Unit tests for credential encryption edge cases (empty, long, unicode, special chars)
|
||||
- Unit tests for FastAPI application factory and core endpoints (root, health, OpenAPI)
|
||||
- Unit tests for Pydantic schema validation (users, mail accounts, notifications, subscriptions)
|
||||
- Unit tests for JWT `sub` claim string encoding and token type verification
|
||||
- Created `frontend/src/lib/api.ts` — API client module (fixes frontend compilation blocker)
|
||||
- Reached 57% test coverage (up from 54%)
|
||||
|
||||
### Changed
|
||||
- Configuration system now supports database-backed settings in addition to environment variables
|
||||
- Celery tasks (`tasks.py`) use `ConfigService` for SMTP config instead of raw `os.getenv()` calls
|
||||
- README updated with hybrid configuration docs, Gmail API vs SMTP comparison, and Apprise notifications
|
||||
- Architecture docs updated to reflect Gmail API service, hybrid config, and new API endpoints
|
||||
- Reorganized documentation into `docs/` directory
|
||||
- Improved error handling with specific exception types
|
||||
- Updated datetime usage to timezone-aware
|
||||
- Enhanced logging with structured context
|
||||
- Bumped Docker Python base image from 3.11-slim to 3.14-slim
|
||||
- Bumped CI Python version from 3.11 to 3.14
|
||||
- Bumped CI Node.js version from 18 to 20
|
||||
- Bumped GitHub Actions: `actions/setup-python` v5 → v6, `actions/setup-node` v4 → v6, `docker/setup-buildx-action` v3 → v4, `codecov/codecov-action` v3 → v5
|
||||
- Bumped backend dependencies: pydantic 2.5.3 → 2.12.5, pydantic-settings 2.1.0 → 2.13.1, psycopg2-binary 2.9.9 → 2.9.11, asyncpg 0.29.0 → 0.31.0, stripe 7.11.0 → 14.4.1, aioimaplib 1.0.1 → 2.0.1, google-auth-httplib2 0.2.0 → 0.3.0, celery 5.3.6 → 5.6.2, redis 5.0.1 → 7.3.0, tenacity 8.2.3 → 9.1.4
|
||||
- Bumped frontend dependencies: react 19.2.3 → 19.2.4, @tanstack/react-query ^5.90.20 → ^5.95.0, axios ^1.13.5 → ^1.13.6, zustand ^5.0.11 → ^5.0.12, eslint ^9 → ^10, eslint-config-next 16.1.6 → 16.2.1
|
||||
- Synced `frontend/package.json` `eslint-config-next` to `16.2.1` to match `package-lock.json` (resolves `npm ci` EUSAGE failure)
|
||||
- **Celery tasks test coverage**: Added 40 unit tests for `backend/app/workers/tasks.py`, raising coverage from 9.22% to 96%. Tests cover `_as_utc` helper, `process_mail_account` (Gmail API and SMTP forwarding, credential revocation, empty-email detection, error handling), `process_all_enabled_accounts` (stale-run cleanup, interval checking), and `cleanup_old_logs` (data retention, stale-run recovery).
|
||||
|
||||
### Removed
|
||||
- Removed CodeQL analysis from CI pipeline (was blocking builds)
|
||||
- **Admin endpoint test coverage**: Added 87 unit tests for `admin.py` covering all 17 endpoints (stats, user CRUD, plan CRUD, notification config CRUD, notification testing, processing runs/logs with GDPR masking and pagination). Coverage improved from 24% to 100%.
|
||||
- **Domain-based logo fallback for mail accounts**: `ProviderLogoBanner` now shows provider logos even for accounts that have no `provider_name` set, by extracting the domain from the email address and matching it against a new `DOMAIN_ICON_MAP`. Covers Gmail, GMX, WEB.DE, Yahoo Mail, AOL, T-Online, Outlook/Hotmail, IONOS, Freenet, iCloud, Posteo, and Proton Mail.
|
||||
- **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components, utility functions, and API interceptors. Installed `@testing-library/react`, `@testing-library/jest-dom`, and `@testing-library/user-event`. New suites: `date-utils.test.ts` (30 tests), `api.test.ts` (9 tests), `AuthGuard.test.tsx` (6 tests), `QueryProvider.test.tsx` (2 tests), `DashboardLayout.test.tsx` (14 tests), `NotificationWizard.test.tsx` (32 tests), `ProviderWizard.test.tsx` (20 tests). Total frontend: 119 tests across 8 suites.
|
||||
- **Improved `mail_processor.py` test coverage**: Added 46 unit tests for POP3 connection testing, POP3 email fetching, IMAP edge cases (stale UID store failure, delete failure, MailFetchError re-raise, star-prefix line filtering), email forwarding (STARTTLS/SSL/multipart), and routing methods. Statement coverage increased from ~42% to 98%.
|
||||
|
||||
### Fixed
|
||||
- JWT `sub` claim now encoded as string per JWT spec (python-jose rejects integer subjects)
|
||||
- `TokenPayload` schema `sub` field type changed from `int` to `str` for consistency
|
||||
- Replaced deprecated `datetime.utcnow()` with `datetime.now(timezone.utc)` throughout backend
|
||||
- Replaced deprecated FastAPI `@app.on_event()` handlers with modern `lifespan` context manager
|
||||
- Replaced deprecated Pydantic `class Config` with `model_config = ConfigDict(...)` in all schemas
|
||||
- Replaced deprecated Pydantic `.dict()` with `.model_dump()` in mail account updates
|
||||
- Removed overly broad `except (GmailInjectionError, Exception)` in task error handler
|
||||
- Bare exception handlers replaced with specific types
|
||||
- Open redirect vulnerability in OAuth redirect_uri
|
||||
- Default encryption keys security issue
|
||||
|
||||
- **Test fixtures**: Fixed `conftest.py` admin user fixture using non-existent `is_admin`/`is_verified` fields (should be `is_superuser`), and JWT `sub` claim using email instead of user ID.
|
||||
- Convert `frontend/jest.config.js` to `jest.config.mjs` using ES module `import`/`export` syntax to resolve ESLint `@typescript-eslint/no-require-imports` error.
|
||||
- Suppress noisy `ignored untagged response` INFO log lines from `aioimaplib` in Celery workers by setting the `aioimaplib` logger to WARNING level in `celery_app.py`.
|
||||
- Eliminate `file_cache is only supported with oauth2client<4.0.0` warnings by passing `cache_discovery=False` to `googleapiclient.discovery.build()` in `gmail_service.py`.
|
||||
|
||||
## v0.5.0 (2026-03-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **ci**: Add Codecov integration with frontend Jest coverage and CODECOV_TOKEN
|
||||
([`0645bc0`](https://github.com/christianlouis/InboxConverge/commit/0645bc046227332ebe1fdbf05538500587515ebf))
|
||||
|
||||
|
||||
## v0.4.5 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: Replace safety scan with pip-audit to fix CI EOF error
|
||||
([`8152635`](https://github.com/christianlouis/InboxConverge/commit/8152635238ab4167dc3c63b31b4d54d85ace76a2))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Codecov integration: frontend Jest test coverage (lcov) and backend pytest-cov (XML) are now uploaded to Codecov with `CODECOV_TOKEN` authentication and separate `frontend`/`backend` flags.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: replaced `safety scan --json` (requires interactive login in Safety CLI v3) with `pip-audit` to fix EOF error in the security scan job
|
||||
|
||||
## v0.4.4 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Show proper error message instead of [object Object] in mail account wizard
|
||||
([`191fee9`](https://github.com/christianlouis/InboxConverge/commit/191fee9be4a2fd77d76ee089d09717db85deeca3))
|
||||
|
||||
|
||||
## v0.4.3 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Apply parseUTC to dashboard, unify date helpers in date-utils.ts
|
||||
([`db3ce61`](https://github.com/christianlouis/InboxConverge/commit/db3ce6146797e9e3a54c434b54215d131dd5ce65))
|
||||
|
||||
- Drop empty emails, clear stale errors on success, harden IMAP extraction
|
||||
([`0720901`](https://github.com/christianlouis/InboxConverge/commit/0720901265c53d05609787166dfecbcc6b0d2b1b))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- All dependencies updated to patched versions
|
||||
- Security headers added to all API responses
|
||||
- Input validation improved for all endpoints
|
||||
- Credential handling audited and improved
|
||||
|
||||
- Upgraded `fastapi` from `0.109.1` to `0.135.2`, pulling in `starlette>=1.0.0` and fixing 4 Denial-of-Service vulnerabilities present in `starlette<=0.35.1`.
|
||||
- Updated CI security scan command from deprecated `safety check` to `safety scan`.
|
||||
- Added `.safety-policy.yml` to document and suppress the two unfixable `ecdsa` side-channel CVEs (64396, 64459) that the upstream maintainers have acknowledged cannot be resolved in pure Python.
|
||||
### Fixed
|
||||
|
||||
- **Add mail account wizard showed `[object Object]`** — FastAPI validation
|
||||
errors return `detail` as an array of objects, not a plain string. The
|
||||
frontend error handler now inspects the type of `detail`: if it is a string
|
||||
it is used directly; if it is an array the individual `msg` fields are joined;
|
||||
otherwise the generic `Error.message` or a fallback string is shown. The fix
|
||||
is applied to both the Save and Test-Connection error paths.
|
||||
|
||||
- **IMAP: fix all IMAP emails appearing empty** — `aioimaplib` stores RFC822
|
||||
literal data as `bytearray`, not `bytes`. The FETCH extraction loop was
|
||||
checking `isinstance(line, bytes)` which returns `False` for `bytearray`,
|
||||
causing every email body to be silently skipped and the message to appear
|
||||
empty. The check now accepts both types (`isinstance(line, (bytes,
|
||||
bytearray))`) and converts the result to plain `bytes` before returning,
|
||||
so the rest of the pipeline is unaffected. This affected every IMAP
|
||||
account (T-Online, GMX, and others).
|
||||
- **IMAP: fix T-Online BYE "Too many invalid IMAP commands"** — `UID STORE` flag
|
||||
arguments now use RFC 3501–required parentheses: `+FLAGS (\Seen)` and
|
||||
`+FLAGS (\Deleted)`. Strict servers such as T-Online reject the bare
|
||||
`+FLAGS \Seen` syntax as a `BAD` command and forcefully drop the connection.
|
||||
- **IMAP: fix `aioimaplib` crash on `UID SEARCH`** — `aioimaplib`'s `.uid()`
|
||||
wrapper explicitly blocks `"search"`, raising
|
||||
`command UID only possible with COPY, FETCH, EXPUNGE (w/UIDPLUS) or STORE`.
|
||||
The initial UNSEEN discovery now uses a plain `SEARCH UNSEEN` to obtain
|
||||
sequence numbers, followed by a lightweight `FETCH (UID)` to resolve them
|
||||
to stable UIDs; all subsequent operations (`FETCH`, `STORE`) continue to
|
||||
use the UID form.
|
||||
- **IMAP: filter whitespace-only RFC822 responses** — the FETCH extraction
|
||||
loop now checks `email_data.strip()` so that trivially-empty byte payloads
|
||||
(e.g. `\r\n`) returned by servers like T-Online are rejected at the
|
||||
extraction layer rather than being forwarded as empty emails.
|
||||
- **Tasks: drop completely empty emails with a warning** — after parsing
|
||||
each fetched email, the processing loop now checks whether the message has
|
||||
no subject, no From header, and no body. Such emails are silently dropped
|
||||
(logged as `WARNING`, written to the processing log, UID recorded as seen
|
||||
so the message is not retried). This handles cases where T-Online or
|
||||
other servers emit genuinely empty RFC822 responses that may indicate a
|
||||
server-side bug.
|
||||
- **Status page: clear stale IMAP errors after a successful run** — on a
|
||||
successful processing run (`emails_failed == 0`), the account's
|
||||
`last_error_message` and `last_error_at` fields are now cleared.
|
||||
Previously a transient IMAP error would remain visible on the dashboard
|
||||
even after subsequent pulls completed without problems.
|
||||
- **Dashboard: fix UTC hour-shift on "Last check" timestamps** — the dashboard
|
||||
page was using `new Date(iso)` which treats timezone-naive ISO strings from
|
||||
the backend as local time, shifting relative labels (e.g. "1h ago" instead
|
||||
of "Just now") for users outside UTC. The dashboard now uses the same
|
||||
`parseUTC()` helper that the logs page already applied. The helper functions
|
||||
`formatRelative`, `formatDate`, and `formatDuration` have been consolidated
|
||||
into `src/lib/date-utils.ts` and are imported by all pages.
|
||||
|
||||
## v0.4.2 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- CI pipeline Node.js 20 deprecation, Codecov input, img lint errors
|
||||
([`40c23c4`](https://github.com/christianlouis/InboxConverge/commit/40c23c43fe2e0c08eae8e714ce7782d0b1b12c29))
|
||||
|
||||
- Suppress bandit B104 false positive for 0.0.0.0 binding in config.py
|
||||
([`c092dd9`](https://github.com/christianlouis/InboxConverge/commit/c092dd9ac62f17574345167f5a4a96ba7c15d65b))
|
||||
|
||||
### Code Style
|
||||
|
||||
- Apply black formatting to test_mail_processor_imap.py
|
||||
([`3c8bb4b`](https://github.com/christianlouis/InboxConverge/commit/3c8bb4bfb6df209a1e90f628ab44b8af9f3601fd))
|
||||
|
||||
|
||||
## v0.4.1 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use UID-based IMAP commands and batch STORE operations to fix T-Online BYE errors
|
||||
([`454452a`](https://github.com/christianlouis/InboxConverge/commit/454452a0b61cdc6176443be9a030effc4e1c9ba3))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
|
||||
- Suppress bandit B104 false positive for `HOST = "0.0.0.0"` in `config.py`; binding to all interfaces is intentional for containerised deployments.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: add `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` env to `ci.yml` to fix Node.js 20 deprecation warnings for `actions/checkout` and `actions/github-script`.
|
||||
- CI: fix Codecov upload step — change invalid `file:` input to `files:` for `codecov/codecov-action@v5`.
|
||||
- Frontend: replace `<img>` with `<Image />` from `next/image` in `ProviderWizard.tsx` to fix `no-img-element` ESLint errors.
|
||||
|
||||
- Fix timezone display in Mailbox Activity / Admin Logs pages: timestamps from the server were parsed as local time when no timezone indicator was present, causing relative times ("1h ago") and absolute dates to be shifted by the client's UTC offset.
|
||||
- Worker tasks: use a fresh DB session for `send_user_notification` calls and move notifications after `db.commit()` to prevent the post-rollback `greenlet_spawn` SQLAlchemy error.
|
||||
- Worker tasks: ensure `last_check_at` and error status are always committed before notifications, fixing accounts being endlessly re-queued after IMAP auth failures.
|
||||
- Style: apply black formatting to `backend/tests/unit/test_mail_processor_imap.py` to fix CI black check failure.
|
||||
## v0.4.0 (2026-03-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **dashboard**: Replace per-run table with per-account Mailbox Status view
|
||||
([`6afd7c8`](https://github.com/christianlouis/InboxConverge/commit/6afd7c871e7c694a93f119aa66f1b798c9b5b2bd))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Dashboard "Recent Processing Runs" table replaced with a per-account **Mailbox Status** view: each account now shows its last-check status (OK / Error / Pending), relative last-check time, any error message, and lifetime processed/failed counters. The noisy per-run table is gone; full activity history remains available on the Logs page.
|
||||
- Stats cards updated: "Emails Forwarded Today" → "Emails Processed" (all-time total from account records); "Errors" → "Accounts with Errors" (count of accounts currently showing an error).
|
||||
- **IMAP: switched to UID-based commands** (`UID SEARCH`, `UID FETCH`, `UID STORE`) instead of volatile sequence-number-based commands. Sequence numbers shift whenever other messages are expunged, causing the wrong messages to be targeted and triggering "Too many invalid IMAP commands" errors on strict servers like T-Online. UIDs remain stable for the lifetime of a mailbox.
|
||||
### Fixed
|
||||
- Provider logos now appear on the Mail Accounts page: `provider_name` is correctly saved when creating accounts via the provider wizard and propagated through backend/frontend schemas.
|
||||
- Fetch-emails button now shows a text label ("Fetch"), a descriptive tooltip, a "Fetching…" loading state, and a brief green "Queued!" confirmation after the action completes.
|
||||
- **IMAP: eliminated redundant per-message `STORE +FLAGS \Seen`** — RFC822 FETCH implicitly marks a message as `\Seen` on IMAP servers, making the extra round-trip unnecessary. This reduces the total command count by *N* per polling cycle.
|
||||
- **IMAP: batch `STORE +FLAGS \Deleted`** — when `delete_after_forward` is enabled, all successfully fetched UIDs are now marked for deletion in a single `UID STORE uid1,uid2,…` command instead of one command per message.
|
||||
- **IMAP: batch re-marking of stale UIDs** — UIDs already tracked in the database that still appear as UNSEEN on the server (e.g. because a previous STORE failed) are now re-marked `\Seen` with a single batch command instead of one STORE per message.
|
||||
- **IMAP: graceful BYE handling** — the IMAP client is now stored in a variable so the `finally` block always attempts a clean `logout()`. If the server has already sent `BYE` and closed the connection the logout failure is swallowed silently, preventing it from masking the original error.
|
||||
|
||||
## v0.3.2 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve 21 mypy type errors across backend modules
|
||||
([`0698eca`](https://github.com/christianlouis/InboxConverge/commit/0698eca62835d498135137176f4df7d0572cd033))
|
||||
|
||||
|
||||
## v0.3.1 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Test connection always showed success; add per-account test endpoint
|
||||
([`441e8b5`](https://github.com/christianlouis/InboxConverge/commit/441e8b54e958686fe52414393d715a18dfcda77c))
|
||||
- Fixed 21 mypy type errors across `notification_service.py`, `mail_processor.py`, `auth.py`, `tasks.py`, `providers.py`, `mail_accounts.py`, and `main.py`
|
||||
|
||||
|
||||
## v0.3.0 (2026-03-28)
|
||||
|
||||
### Features
|
||||
|
||||
- Mailbox-centric activity view, reduce log noise from empty polling cycles
|
||||
([`dd4532f`](https://github.com/christianlouis/InboxConverge/commit/dd4532f6604e2e8c1fa27fd3da05cd5e62733842))
|
||||
|
||||
|
||||
## v0.2.2 (2026-03-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Test Connection always showed "success"**: frontend never checked the `success` field
|
||||
returned by the backend — any HTTP 200 response (including `{success: false}`) was
|
||||
displayed as "Connection successful!". Now the actual `success` value is checked and
|
||||
authentication failures are shown as errors with the server's message.
|
||||
- **Test Connection in edit mode required password re-entry**: added backend endpoint
|
||||
`POST /mail-accounts/{account_id}/test` that decrypts and uses the stored credentials
|
||||
so existing accounts can be tested without re-typing the password.
|
||||
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve semantic-release CHANGELOG.md not updating properly
|
||||
([`d3e0ca4`](https://github.com/christianlouis/InboxConverge/commit/d3e0ca4e33779372b53884e25cf0d5cc3478848c))
|
||||
|
||||
### Chores
|
||||
|
||||
- **deps**: Bump cryptography
|
||||
([`2fd018a`](https://github.com/christianlouis/InboxConverge/commit/2fd018afa109a2fa9d087da8590323c9a9d520ed))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Pull Now**: Added a "Pull Now" button (⟳) to each mail account card on the Accounts page. Clicking it immediately queues a Celery `process_mail_account` task for that account via the new `POST /mail-accounts/{id}/pull-now` backend endpoint. The button shows a spinner while the request is in flight and is disabled for inactive accounts.
|
||||
- **Provider logos**: Provider logos (Gmail, GMX, WEB.DE, Outlook, Yahoo, AOL, T-Online, IONOS, Freenet, Posteo, iCloud, Proton Mail) are displayed as a full-width banner at the top of each account card. Using `next/image` with `fill` + `object-contain` ensures every logo – from square icons to very wide wordmarks (up to 6:1 aspect ratio) – renders correctly without distortion.
|
||||
- **Proton Mail**: Added Proton Mail as a provider preset (backend + ProviderWizard). Supports IMAP and POP3 via Proton Mail Bridge (default ports 127.0.0.1:1143 / 127.0.0.1:1144). Domains: `proton.me`, `protonmail.com`, `protonmail.ch`, `pm.me`.
|
||||
|
||||
### Changed
|
||||
- **Mailbox Activity view**: The user-facing "Logs" page has been redesigned to a mailbox-centric
|
||||
layout (renamed "Mailbox Activity"). Each mail account is shown as a card with its last check
|
||||
status and error (if any). Only runs that actually fetched emails are shown in the pull history,
|
||||
eliminating noise from empty polling cycles. This mirrors Gmail's external POP pull UI.
|
||||
- **Processing runs filter**: Added `has_emails` query parameter to `GET /processing-runs` and
|
||||
`GET /mail-accounts/{id}/processing-runs`. When `has_emails=true`, only runs with
|
||||
`emails_fetched > 0` are returned, allowing clients to suppress empty polling noise.
|
||||
|
||||
### Fixed
|
||||
- **Processing log durations**: Runs that were killed by SIGKILL or failed before updating their
|
||||
own status (e.g. missing SMTP credentials) now always record a correct `completed_at` and
|
||||
`duration_seconds`. The error handler no longer accesses an expired SQLAlchemy ORM attribute
|
||||
(`run.started_at`) after a session rollback, which previously caused the handler to crash and
|
||||
left runs stuck in the `running` state indefinitely.
|
||||
- **Celery datetime crash**: Fixed `TypeError: can't subtract offset-naive and offset-aware
|
||||
datetimes` in `process_all_enabled_accounts` by wrapping `account.last_check_at` with the
|
||||
existing `_as_utc()` helper before comparing against `datetime.now(timezone.utc)`. This
|
||||
crash silently prevented every mail account from being processed on every scheduled run.
|
||||
- **Per-account polling interval**: Changed the Celery beat schedule for
|
||||
`process_all_enabled_accounts` from every 5 minutes (`*/5`) to every minute (`*`). The
|
||||
per-account `check_interval_minutes` field already gates whether an account actually gets
|
||||
processed, so accounts configured with a 1-minute interval are now polled as expected instead
|
||||
of being limited to 5-minute effective intervals.
|
||||
- **Processing log early-exit path**: When a mail account has no delivery method configured
|
||||
(SMTP credentials missing and Gmail API not set up), the processing run now correctly sets
|
||||
`completed_at`, `duration_seconds`, and `account.last_check_at`, preventing the account from
|
||||
being re-queued on every scheduler tick and generating a flood of failed runs.
|
||||
- **Stale-run detection moved to scheduler**: `process_all_enabled_accounts` (runs every 5 min)
|
||||
now marks orphaned `running` runs as `failed` immediately. Previously this only happened in the
|
||||
daily `cleanup_old_logs` task, meaning stale runs could show huge durations (hours/days).
|
||||
- **Duration display rounding bug**: `formatDuration` in the frontend Processing Logs page now
|
||||
uses `Math.floor` instead of `Math.round` for the seconds component, eliminating the "60s"
|
||||
artefact that appeared for durations very close to a whole minute boundary.
|
||||
|
||||
### Changed
|
||||
- **Decoupled Gmail permissions from Google Sign-In**: The "Sign in with Google" OAuth flow now only requests basic profile scopes (`openid`, `email`, `profile`) instead of also requesting Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`). Users can grant Gmail access separately via the "Connect Gmail" button in Settings. This results in a simpler, permission-free login experience.
|
||||
|
||||
|
||||
## [0.2.1] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
- **`SyntaxWarning` at startup**: Fixed invalid escape sequence `\S` in a docstring in `mail_processor.py` (changed to `\\S`). In Python 3.12+ this emits a `SyntaxWarning` and will become a `SyntaxError` in a future Python version.
|
||||
- **Processing runs stuck in "running" state**: Fixed three related bugs in `tasks.py` that caused `ProcessingRun` records to remain in the `running` state indefinitely:
|
||||
1. The exception handler now calls `await db.rollback()` before attempting to write the `failed` status, ensuring the SQLAlchemy session is in a clean state even when the original exception occurred during a DB flush/commit.
|
||||
2. The error-handler `await db.commit()` is now wrapped in its own `try/except` so a commit failure inside the handler no longer propagates silently and leaves the run as `running`.
|
||||
3. `account.last_check_at` is now updated in the error path, throttling re-dispatch by `process_all_enabled_accounts` and preventing a cascade of new `running` runs on every scheduler tick.
|
||||
- **Stale "running" run cleanup**: `cleanup_old_logs` now marks any `ProcessingRun` that has been in the `running` state for longer than 35 minutes (Celery hard time-limit is 30 min) as `failed` with an explanatory message. This recovers runs left behind by OOM kills, container restarts, or other SIGKILL events.
|
||||
|
||||
## [0.2.0] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- **Automatic release numbers** (`pyproject.toml`): Added `version_toml = ["pyproject.toml:project.version"]` to `[tool.semantic_release]` so that `python-semantic-release` now writes the computed version back into the `project.version` field of `pyproject.toml` on every release. The field is initialised to `0.0.0` and will be bumped automatically from that point forward.
|
||||
- **Release badge** (`README.md`): Added a dynamic "GitHub Release" shield that always shows the latest published release tag.
|
||||
- **Releases section** (`README.md`): Added a "Releases" section explaining the Semantic Versioning / Conventional Commits workflow and the version-bump rules.
|
||||
|
||||
### Fixed
|
||||
- **CI `update-k8s-manifest` job**: Fixed image tag computation and `yq` update patterns to target `registry.cklnet.com` (private registry) instead of `ghcr.io`. The k8s manifest uses private registry image references, so the previous GHCR-based patterns never matched and no tag updates were applied.
|
||||
- **CI `update-k8s-manifest` job**: Enhanced the PAT validation step to verify the token actually has read access to the `k8s-cluster-state` repository (via a GitHub API probe) before attempting checkout, preventing a 403 "Write access to repository not granted" failure when the PAT exists but lacks the necessary repository access.
|
||||
|
||||
## [0.1.2] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
- **CI `update-k8s-manifest` job**: Added a `Check if GH_PAT is configured` step that emits a warning and skips the GitOps steps when the `GH_PAT` secret is absent or empty, preventing a 403 "Write access to repository not granted" failure that blocked the pipeline when the secret was not set.
|
||||
|
||||
## [0.1.1] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
- **CI `update-k8s-manifest` job**: Fixed checkout of `k8s-cluster-state` repo by adding `ref: main` to the `actions/checkout` step, preventing a "Not Found" 404 error caused by the action's API call to determine the default branch. Also corrected the image tag format from `main-<sha>` to `sha-<sha>` to match the tags actually generated by `docker/metadata-action@v5` with `type=sha`.
|
||||
- **`ProgrammingError` on `notification_configs`**: Added Alembic migration `0001` that runs `ALTER TABLE notification_configs ADD COLUMN IF NOT EXISTS` for the `name` and `apprise_url` columns introduced by the Apprise PR. SQLAlchemy's `create_all` does not ALTER existing tables, so existing deployments were missing these columns and crashing at runtime. The migration is idempotent (`IF NOT EXISTS`) so it is safe for fresh installs too. `app/main.py` lifespan now runs `alembic upgrade head` after `create_all`.
|
||||
- **`/logs` page 404**: Created missing Next.js page at `src/app/logs/page.tsx`. The user-facing "Logs" sidebar link was pointing to `/logs` but no page existed. The new page lists all processing runs with expandable per-email log details and pagination.
|
||||
- **`/admin/logs` page 404**: Created missing Next.js page at `src/app/admin/logs/page.tsx`. The admin "Activity Logs" sidebar link was pointing to `/admin/logs` but no page existed. The new page shows all processing runs across all users with status filtering and pagination.
|
||||
- **Black formatting**: `backend/app/api/v1/endpoints/admin.py` was not formatted correctly; reformatted to pass `black --check`.
|
||||
|
||||
## [0.1.0] - 2026-03-26
|
||||
|
||||
### Added
|
||||
- **Semantic Release** (`release.yml`): Automated versioning and GitHub Release creation on every push to `main` using `python-semantic-release`. Reads conventional-commit prefixes (`feat:`, `fix:`, etc.) to determine the next version and updates `CHANGELOG.md`.
|
||||
- **`pyproject.toml`**: Project metadata and `[tool.semantic_release]` configuration for `python-semantic-release`.
|
||||
- **GitOps auto-deployment** (step in `ci.yml`): After a successful Docker build on `main`, a new `update-k8s-manifest` job checks out `christianlouis/k8s-cluster-state` (using the `GH_PAT` secret) and updates the backend and frontend image tags in `apps/gmail-puller/preprod/gmail-puller-stack.yaml` to the new `main-<sha>` image, then commits and pushes.
|
||||
- **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.
|
||||
- **Apprise alerting**: New `NotificationService` using [Apprise](https://github.com/caronc/apprise) for multi-channel push notifications (Telegram, Slack, Discord, webhooks, and 80+ other services via a single URL scheme).
|
||||
- `send_user_notification` — sends to all enabled per-user Apprise channels on processing errors or failures.
|
||||
- `send_admin_notification` — sends to all enabled admin-wide channels for system events.
|
||||
- `test_notification` — validates an Apprise URL by dispatching a test message.
|
||||
- **`NotificationConfig` model**: Added `name` (friendly label) and `apprise_url` (nullable Apprise URL) columns.
|
||||
- **`AdminNotificationConfig` model**: New table (`admin_notification_configs`) for system-wide admin alert channels with `name`, `apprise_url`, `is_enabled`, `notify_on_errors`, `notify_on_system_events`, and `description` fields.
|
||||
- **Notifications API** (`/api/v1/notifications`): Full CRUD endpoints (GET/POST/PUT/DELETE) plus a `/test` endpoint for user notification configs.
|
||||
- **Admin Notifications API** (`/api/v1/admin/notifications`): Full CRUD + `/test` endpoints for admin notification configs, superuser-only.
|
||||
- **Task integration**: `process_mail_account` now calls `send_user_notification` on Gmail credential revocation, per-email forwarding failures, and unhandled processing exceptions.
|
||||
- **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).
|
||||
- **Gmail API** — `gmail_api_requests_total` (counter, by `operation` and `status`), `gmail_api_duration_seconds` (histogram, by `operation`), `gmail_token_refreshes_total` (counter), `gmail_credentials_invalidated_total` (counter).
|
||||
- **Authentication / OAuth** — `auth_logins_total` (counter, `method` × `status`), `auth_registrations_total` (counter, `method` × `status`), `oauth_callbacks_total` (counter, `provider` × `status`).
|
||||
- **Celery tasks** — `celery_tasks_total` (counter, `task_name` × `status`) and `celery_task_duration_seconds` (histogram, by `task_name`).
|
||||
- **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/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 `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.
|
||||
- `GET /admin/users/{id}` – Get a single user's details.
|
||||
- `PUT /admin/users/{id}` – Update user details, plan, active status, and superuser flag.
|
||||
- `DELETE /admin/users/{id}` – Delete a user.
|
||||
- `GET /admin/plans` – List all subscription plans (including zero-price / inactive).
|
||||
- `POST /admin/plans` – Create a new subscription plan.
|
||||
- `PUT /admin/plans/{id}` – Update a subscription plan.
|
||||
- `DELETE /admin/plans/{id}` – Delete a subscription plan.
|
||||
- **Admin badge in top bar**: Admin users see a purple shield icon and an "Admin" badge next to their email in the top navigation bar.
|
||||
- **`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 **InboxConverge** throughout. 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.
|
||||
- **Impressum & Datenschutz pages**: Added `/impressum` (legal notice per § 5 TMG) and `/datenschutz` (comprehensive privacy policy covering GDPR/DSGVO, CCPA, LGPD, and other international regulations) as public pages. Footer links to both pages were added to the dashboard layout and the login page.
|
||||
- **Gmail Debug Email**: New "Send Debug Email" button in the Gmail API settings section. When clicked, it injects a test email into the user's Gmail inbox via the Gmail API. The message includes the current date in the subject line and is automatically labelled with `test` and `imported`. Useful for verifying end-to-end Gmail API delivery without requiring a full mail-account polling cycle.
|
||||
- **Unified Google OAuth flow**: Google Sign-In now requests all Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`) in the same consent screen, so users no longer need a separate "Connect Gmail" step after signing in with Google. Gmail credentials are stored automatically on successful sign-in.
|
||||
- **Architecture Decision Records ADR-003 through ADR-010**: Added eight new ADRs covering FastAPI web framework (ADR-003), PostgreSQL database (ADR-004), Celery task retry strategy (ADR-005), key management in production (ADR-006), JWT authentication (ADR-007), Next.js frontend (ADR-008), Gmail API email delivery (ADR-009), and hybrid configuration model (ADR-010).
|
||||
- **Account enable/disable toggle**: `PATCH /mail-accounts/{id}/toggle` backend endpoint and a Power-icon toggle button on each account card in the UI. Disabled accounts are visually dimmed. Re-enabling an account that was in ERROR state resets its status to ACTIVE so the scheduler picks it up again.
|
||||
- **Message deduplication tracking** (`DownloadedMessageId` table): Both POP3 and IMAP fetch paths now track downloaded message UIDs so the same message is never delivered twice, even when `delete_after_forward=False`.
|
||||
- **Gmail API "one-click" OAuth grant flow**: New `GET /providers/gmail/authorize-url` and `POST /providers/gmail/callback` endpoints with offline access and long-lived refresh tokens.
|
||||
- **Gmail token auto-refresh and persistence**: `GmailService` now records whether the `google-auth` library refreshed the access token during a Celery run and persists any new access token back to `GmailCredential`, eliminating unnecessary extra refresh calls.
|
||||
- **Per-user SMTP relay configuration** (`UserSmtpConfig` table): New `GET/PUT/DELETE /users/smtp-config` endpoints let each user store their own SMTP relay. The Celery task checks for per-user SMTP first; falls back to the global `AppSetting` SMTP config if none is set.
|
||||
- **Settings page** — Gmail & SMTP sections: Shows a "Gmail API Delivery" card with connection status and connect/re-authorise/disconnect buttons, plus an "SMTP Fallback" card for per-user SMTP relay credentials.
|
||||
- **Celery scheduling fix**: `process_all_enabled_accounts` now polls all `is_enabled = True` accounts regardless of status, so transient errors are retried automatically.
|
||||
- **Backend URL logged at startup**: The Next.js server now logs the resolved `BACKEND_URL` via `src/instrumentation.ts` when the server starts, making it easy to diagnose `ECONNREFUSED` proxy errors.
|
||||
- **Dual-registry Docker deployment**: CI now builds separate backend and frontend images and pushes to both GHCR (`ghcr.io`) and private registry (`registry.cklnet.com`) using a matrix strategy.
|
||||
- **Database-backed configuration**: `AppSetting` model and `ConfigService` for hybrid config (DB-first, env-var fallback). Admin API endpoints for managing settings (`GET/PUT/DELETE /api/v1/settings`). Default settings seeded into database on first startup (SMTP, processing, Gmail API, notifications).
|
||||
- Unit tests for `ConfigService` (24 tests covering resolution order, CRUD, SMTP helper, defaults).
|
||||
- Security validation for `SECRET_KEY` and `ENCRYPTION_KEY` on startup.
|
||||
- CSRF protection middleware and security headers middleware (X-Frame-Options, CSP, HSTS).
|
||||
- Rate limiting per user/tier.
|
||||
- Comprehensive test infrastructure setup, CI/CD pipeline, and Dependabot configuration for automated dependency updates.
|
||||
|
||||
### 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.
|
||||
- `NotificationConfigBase` schema: `name` field now has a default of `"My Notification"` (previously required); `apprise_url` is optional; `config` (channel-specific JSON) is optional with a default of `{}`.
|
||||
- Configuration system now supports database-backed settings in addition to environment variables.
|
||||
- Celery tasks (`tasks.py`) use `ConfigService` for SMTP config instead of raw `os.getenv()` calls.
|
||||
- Bumped Docker Python base image from `3.11-slim` to `3.14-slim` and CI Python version from 3.11 to 3.14.
|
||||
- Bumped CI Node.js version from 18 to 20.
|
||||
- Bumped GitHub Actions: `actions/setup-python` v5 → v6, `actions/setup-node` v4 → v6, `docker/setup-buildx-action` v3 → v4, `codecov/codecov-action` v3 → v5.
|
||||
- Bumped backend dependencies: pydantic 2.5.3 → 2.12.5, pydantic-settings 2.1.0 → 2.9.1, asyncpg 0.29.0 → 0.31.0, stripe 7.11.0 → 14.4.1, celery 5.3.6 → 5.6.2, redis 5.0.1 → 7.3.0.
|
||||
- Bumped frontend dependencies: react 19.2.3 → 19.2.4, axios ^1.13.5 → ^1.13.6, eslint-config-next 16.1.6 → 16.2.1.
|
||||
|
||||
### Fixed
|
||||
- **ESLint parse error in `DashboardLayout.tsx`**: Missing comma after `Bell` in the `lucide-react` named import caused a TypeScript parse error (`',' expected` at line 19). Added the missing comma.
|
||||
- **`/processing-runs` endpoint 404s**: Routes in `logs.py` had a redundant `/processing-runs` path segment (the router was already mounted at `/processing-runs` in `api.py`). All three user-facing log endpoints now return correct results.
|
||||
- **`NotificationConfigCreate` schema test failure**: `NotificationConfigBase.name` was a required field (`...`) but the unit test and the database column both use a default of `"My Notification"`. Changed the Pydantic field to `default="My Notification"` to match the DB default and allow callers to omit the field.
|
||||
- Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis".
|
||||
- **Mailbox limit always hit at 1**: The `subscription_plans` table was never seeded, so the limit check fell back to the env-var default. Fixed by seeding four default `SubscriptionPlan` rows at startup (Free, Good, Better, Best) and rewriting the limit check to look up the user's active plan from the DB first.
|
||||
- **Zero-price plans hidden from public marketing**: `GET /subscriptions/plans` now only returns plans with `price_monthly > 0`.
|
||||
- **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 so users matching `ADMIN_EMAIL` are promoted to superuser on every application start.
|
||||
- **Blank page on direct navigation to `/admin`, `/admin/users`, `/admin/plans`**: Moved superuser guard inside the `<AuthGuard>/<DashboardLayout>` tree so authentication always runs first.
|
||||
- **Mailbox edit form**: Multiple fixes including username field pre-population, silent credential overwrite prevention, and all connection fields made editable.
|
||||
- **Wizard grey screen**: `bg-opacity-75` replaced with `/75` opacity modifier syntax for Tailwind CSS v4 compatibility.
|
||||
- **Mail account creation always failing**: Added missing `email_address` and `forward_to` required fields to the `AddMailAccountModal` form.
|
||||
- **Settings page**: Implemented the Settings page (was a placeholder showing "coming soon").
|
||||
- **`sqlalchemy.exc.DBAPIError`**: Fixed timezone-naive vs timezone-aware datetime mismatch by changing all `DateTime` columns to `DateTime(timezone=True)`.
|
||||
- **`ProgrammingError` (cached statement plan is invalid)**: Disabled asyncpg prepared statement cache (`prepared_statement_cache_size=0`) to fix DDL-at-startup scenarios.
|
||||
- **`UndefinedTableError` on first boot**: Lifespan startup event now calls `Base.metadata.create_all()` before attempting to seed default settings.
|
||||
- **Frontend API calls hardcoded to `localhost:8000`**: Replaced `NEXT_PUBLIC_API_URL` mechanism with a Next.js Route Handler proxy at `/api/v1/[...path]` that reads `BACKEND_URL` at server startup.
|
||||
- **Infinite spinning wheel on home page**: `authStore` no longer initialises `isLoading` as `true` unconditionally — it is now `false` when no access token exists in `localStorage`.
|
||||
- **`useSearchParams()` Suspense boundary**: Wrapped `useSearchParams()` in a `Suspense` boundary in `frontend/src/app/auth/callback/page.tsx`.
|
||||
- Various TypeScript build errors in accounts page, auth callback, and dashboard pages.
|
||||
- **Node.js base image**: Upgraded from `node:18-alpine` to `node:20-alpine` to satisfy Next.js requirements.
|
||||
- **Build attestation step removed**: `actions/attest-build-provenance` is not available for private user-owned repositories; removed it to fix CI.
|
||||
- **ESLint downgraded**: Downgraded ESLint from `^10` to `^9` to fix `TypeError: contextOrFilename.getFilename is not a function`.
|
||||
- **SQLAlchemy upgraded**: Upgraded from `2.0.25` to `2.0.48` to fix `AssertionError` on Python 3.14.
|
||||
- JWT `sub` claim now encoded as string per JWT spec.
|
||||
- Replaced deprecated `datetime.utcnow()` with `datetime.now(timezone.utc)` throughout backend.
|
||||
- Replaced deprecated FastAPI `@app.on_event()` handlers with modern `lifespan` context manager.
|
||||
- Replaced deprecated Pydantic `class Config` with `model_config = ConfigDict(...)` in all schemas.
|
||||
- Open redirect vulnerability in OAuth `redirect_uri` fixed.
|
||||
|
||||
### Removed
|
||||
- Removed CodeQL analysis from CI pipeline (was blocking builds).
|
||||
|
||||
### Security
|
||||
- Upgraded `python-jose` from 3.3.0 to 3.5.0 to fix CVE: algorithm confusion vulnerability with OpenSSH ECDSA keys.
|
||||
- Security headers added to all API responses.
|
||||
- CSRF protection middleware added.
|
||||
- Input validation improved for all endpoints.
|
||||
- Credential handling audited and improved.
|
||||
- Restricted overly permissive CORS `allow_methods` in backend.
|
||||
|
||||
## [1.0.0] - 2026-02-01
|
||||
|
||||
> **Note**: This was a pre-rewrite baseline version. The current v0.x series begins from v0.1.0 (2026-03-26).
|
||||
|
||||
### Added
|
||||
- Multi-tenant SaaS backend with FastAPI
|
||||
- JWT and OAuth2 (Google Sign-In) authentication
|
||||
@@ -128,15 +510,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Apprise for multi-channel notifications
|
||||
- Docker and docker-compose support
|
||||
- Comprehensive API documentation with OpenAPI
|
||||
- Extensive documentation (README, ARCHITECTURE, SECURITY_REPORT, etc.)
|
||||
|
||||
### Changed
|
||||
- Upgraded from single-user script to multi-tenant platform
|
||||
|
||||
## [0.1.0] - 2025-12-15 (Legacy Version)
|
||||
## [0.0.1] - 2025-12-15
|
||||
|
||||
> **Note**: Legacy single-user script release (previously labelled `[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
|
||||
@@ -144,49 +527,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Error notifications via Postmarkapp
|
||||
- Environment-based configuration
|
||||
- Basic logging
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
- **[Unreleased]** - Current development (agentic coding improvements, security hardening)
|
||||
- **[1.0.0]** - Multi-tenant SaaS platform (2026-02-01)
|
||||
- **[0.1.0]** - Legacy single-user script (2025-12-15)
|
||||
|
||||
---
|
||||
|
||||
## How to Update This Changelog
|
||||
|
||||
### Categories
|
||||
|
||||
Use these standard categories:
|
||||
- **Added** - New features
|
||||
- **Changed** - Changes in existing functionality
|
||||
- **Deprecated** - Soon-to-be removed features
|
||||
- **Removed** - Removed features
|
||||
- **Fixed** - Bug fixes
|
||||
- **Security** - Vulnerability fixes
|
||||
|
||||
### Format
|
||||
|
||||
```markdown
|
||||
## [Version] - YYYY-MM-DD
|
||||
|
||||
### Added
|
||||
- New feature description (#issue-number)
|
||||
|
||||
### Fixed
|
||||
- Bug fix description (#issue-number)
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Add unreleased changes to `[Unreleased]` section
|
||||
2. When releasing, move unreleased changes to new version section
|
||||
3. Add version number, date, and comparison link
|
||||
4. Create git tag: `git tag -a v1.0.0 -m "Release v1.0.0"`
|
||||
|
||||
---
|
||||
|
||||
**Maintained by**: Development Team
|
||||
**Last Updated**: 2026-03-23
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,261 +1,106 @@
|
||||
# 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/ci.yml)
|
||||
[](https://github.com/christianlouis/InboxConverge/releases/latest)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://www.docker.com/)
|
||||
|
||||
A Docker-based solution that automatically fetches emails from POP3 mailboxes and forwards them to Gmail, replacing Google's discontinued POP3 import feature.
|
||||
**Google is removing "Check mail from other accounts" (POP) and Gmailify in 2026. InboxConverge is the self-hosted replacement.**
|
||||
|
||||
## Features
|
||||
Gmail's built-in POP fetcher and Gmailify are being shut down imminently. Google's suggested alternatives — asking your old provider to configure outbound forwarding, or reading mail in the Gmail mobile app over IMAP — don't replicate the seamless, automatic consolidation you had. InboxConverge does: it polls your POP3/IMAP mailboxes on a schedule and injects new messages directly into your Gmail inbox, exactly like the old feature, running on your own infrastructure.
|
||||
|
||||
- **Multiple POP3 Accounts** — support for unlimited POP3 mailboxes
|
||||
- **Dual Delivery** — inject emails via **Gmail API** (preferred) or forward via **SMTP**
|
||||
- **Hybrid Configuration** — configure via environment variables, `.env` files, **or** the database
|
||||
- **Smart Throttling** — configurable rate limiting to stay within Gmail quotas
|
||||
- **Error Reporting** — multi-channel notifications (Apprise: email, Telegram, Slack, Discord, webhooks)
|
||||
- **Scheduled Polling** — configurable check intervals (default: every 5 minutes)
|
||||
- **Docker Ready** — fully containerized with Docker Compose support
|
||||
- **Secure** — runs as non-root user, SSL/TLS connections, encrypted credential storage
|
||||
> **Google's own announcement:** *"Gmail no longer supports fetching email from third-party accounts via POP. The 'Check mail from other accounts' option is no longer available in Gmail."*
|
||||
>
|
||||
> InboxConverge puts that option back — permanently, on your own terms.
|
||||
|
||||
### SaaS Platform (in development)
|
||||
## Who this is for
|
||||
|
||||
The repository also includes a multi-tenant SaaS backend built with FastAPI, PostgreSQL, Redis, and Celery. It adds multi-user support, OAuth2 authentication, POP3/IMAP protocol support, encrypted credential storage, and background job processing. See the [SaaS README](docs/README_SAAS.md) for details.
|
||||
- You used Gmail's "Check mail from other accounts" and it's going away
|
||||
- You have one or more external mailboxes (work, old ISP, custom domain) that you want consolidated into Gmail automatically
|
||||
- You don't want to rely on your old provider supporting outbound forwarding
|
||||
- You want email delivered cleanly into Gmail without headers being mangled or spam filters misfiring
|
||||
|
||||
## Quick Start
|
||||
## What you get
|
||||
|
||||
### Using a Pre-built Docker Image (Recommended)
|
||||
| | Google POP Import (shutting down 2026) | InboxConverge |
|
||||
|---|---|---|
|
||||
| Still works? | ❌ Shutting down 2026 | ✅ |
|
||||
| Multiple source accounts | Limited | ✅ Unlimited |
|
||||
| Automatic, scheduled polling | ✅ | ✅ Every 5 min (configurable) |
|
||||
| Original headers preserved | ❌ | ✅ Via Gmail API injection |
|
||||
| Counts against sending quota | ❌ N/A | ✅ No (Gmail API) / ⚠️ Yes (SMTP) |
|
||||
| Alerts when something breaks | ❌ | ✅ Email, Slack, Telegram, Discord… |
|
||||
| Self-hosted, no third-party dependency | ❌ | ✅ Docker, runs anywhere |
|
||||
| Open source | ❌ | ✅ MIT |
|
||||
|
||||
## Get started in 5 minutes
|
||||
|
||||
```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
|
||||
# 1. Grab the config files
|
||||
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
|
||||
# 2. Fill in your credentials
|
||||
nano .env
|
||||
|
||||
# Start
|
||||
# 3. Launch
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Building from Source
|
||||
Minimum `.env` to get going:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
|
||||
cd pop_puller_to_gmail
|
||||
cp .env.example .env # then edit .env
|
||||
docker-compose up -d
|
||||
```dotenv
|
||||
# Source mailbox — add _2_, _3_, … for additional accounts
|
||||
POP3_ACCOUNT_1_HOST=pop.yourprovider.com
|
||||
POP3_ACCOUNT_1_USER=you@yourprovider.com
|
||||
POP3_ACCOUNT_1_PASSWORD=your-pop3-password
|
||||
|
||||
# Destination — Gmail App Password (quickest way to start)
|
||||
SMTP_USER=you@gmail.com
|
||||
SMTP_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||
|
||||
# Required internal secrets — run each command below and paste the output
|
||||
SECRET_KEY= # python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
ENCRYPTION_KEY= # python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/inboxconverge
|
||||
```
|
||||
|
||||
See the [Quick Start Guide](docs/QUICKSTART.md) for detailed instructions.
|
||||
See the [Quick Start Guide](docs/QUICKSTART.md) for a full walkthrough, including the recommended Gmail API setup which preserves headers and avoids sending-quota limits.
|
||||
|
||||
## Configuration
|
||||
## Two ways to deliver mail into Gmail
|
||||
|
||||
### Hybrid Configuration (Environment + Database)
|
||||
**Gmail API injection (recommended)** — messages land in your inbox with original `From`, `Reply-To`, and `Message-ID` intact, don't count against your sending quota, and bypass the spam-filter penalties that forwarded mail often triggers. Requires a one-time Google OAuth2 authorisation.
|
||||
|
||||
The application supports a **hybrid configuration model**:
|
||||
**SMTP with App Password (zero-setup fallback)** — works immediately with a [Gmail App Password](https://myaccount.google.com/apppasswords). Forwarded messages may be re-wrapped and count toward your 500-message/day free-tier limit. Good for getting started quickly; upgrade to the API later.
|
||||
|
||||
| Source | Priority | Use For |
|
||||
|--------|----------|---------|
|
||||
| **Database** (`app_settings` table) | Highest | SMTP, processing, Gmail API, notifications |
|
||||
| **Environment variables / `.env`** | Fallback | All settings; required for bootstrap settings |
|
||||
| **Built-in defaults** | Lowest | Sensible defaults for all non-bootstrap settings |
|
||||
## Key settings
|
||||
|
||||
**Bootstrap settings** (`DATABASE_URL`, `SECRET_KEY`, `ENCRYPTION_KEY`) always come from environment variables because the database connection depends on them.
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `CHECK_INTERVAL_MINUTES` | `5` | How often mailboxes are polled |
|
||||
| `MAX_EMAILS_PER_RUN` | `50` | Maximum messages fetched per account per run |
|
||||
| `THROTTLE_EMAILS_PER_MINUTE` | `10` | Rate cap toward Gmail |
|
||||
|
||||
All other settings (SMTP, processing intervals, Gmail API, etc.) can be managed via the admin API at `/api/v1/settings` and are stored in the PostgreSQL database. When a database setting exists, it takes priority over the corresponding environment variable.
|
||||
|
||||
### POP3 Accounts
|
||||
|
||||
Add multiple POP3 accounts by incrementing the account number in your `.env`:
|
||||
|
||||
```bash
|
||||
POP3_ACCOUNT_1_HOST=pop.provider1.com
|
||||
POP3_ACCOUNT_1_USER=user1@provider1.com
|
||||
POP3_ACCOUNT_1_PASSWORD=password1
|
||||
|
||||
POP3_ACCOUNT_2_HOST=pop.provider2.com
|
||||
POP3_ACCOUNT_2_USER=user2@provider2.com
|
||||
POP3_ACCOUNT_2_PASSWORD=password2
|
||||
```
|
||||
|
||||
### Email Delivery Methods
|
||||
|
||||
The forwarder supports two delivery methods for getting emails into Gmail:
|
||||
|
||||
#### Gmail API Injection (Preferred)
|
||||
|
||||
Emails are injected directly into your Gmail account using Google's `users.messages.insert()` API. This is the **recommended method** because it:
|
||||
|
||||
- Preserves original email headers and metadata exactly as-is
|
||||
- Does not modify `From`, `Reply-To`, or `Message-ID` headers
|
||||
- Applies Gmail labels (e.g., `INBOX`) on injection
|
||||
- Does not count against Gmail's SMTP sending quotas
|
||||
- Does not require an SMTP App Password
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Configure Google OAuth2 credentials (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`)
|
||||
2. Authenticate via the SaaS web UI or API (`POST /api/v1/providers/gmail-credential`)
|
||||
3. Set `delivery_method` to `gmail_api` when creating mail accounts
|
||||
|
||||
**Required OAuth2 Scopes:**
|
||||
- `https://www.googleapis.com/auth/gmail.insert`
|
||||
- `https://www.googleapis.com/auth/gmail.labels`
|
||||
|
||||
#### SMTP Forwarding (Fallback)
|
||||
|
||||
Emails are forwarded to Gmail via SMTP. This is the legacy method and is used as a fallback when Gmail API credentials are not available.
|
||||
|
||||
**Limitations vs Gmail API:**
|
||||
- Modifies email headers (adds `Received`, may rewrite `From`)
|
||||
- Counts against Gmail's SMTP sending quota (500/day for free accounts)
|
||||
- Requires a Gmail App Password (see below)
|
||||
- May trigger spam filters for forwarded mail
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Go to your [Google Account Security](https://myaccount.google.com/security)
|
||||
2. Under "Signing in to Google," select **App Passwords**
|
||||
3. Generate a new app password for "Mail"
|
||||
4. Set `SMTP_PASSWORD` in your environment or database settings
|
||||
|
||||
### Environment Variables
|
||||
|
||||
> **Note:** All settings marked ★ can also be managed via the database
|
||||
> through the admin API (`/api/v1/settings`). Database values take precedence.
|
||||
|
||||
#### Bootstrap Settings (env only)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `DATABASE_URL` | Yes | `postgresql+asyncpg://...` | PostgreSQL connection string |
|
||||
| `SECRET_KEY` | Yes | — | JWT signing key (min 32 chars) |
|
||||
| `ENCRYPTION_KEY` | Yes | — | Credential encryption key (min 32 chars) |
|
||||
|
||||
#### POP3/IMAP Accounts (env only — or via API)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `POP3_ACCOUNT_N_HOST` | Yes | — | POP3 server hostname |
|
||||
| `POP3_ACCOUNT_N_PORT` | No | `995` | POP3 server port |
|
||||
| `POP3_ACCOUNT_N_USER` | Yes | — | POP3 username |
|
||||
| `POP3_ACCOUNT_N_PASSWORD` | Yes | — | POP3 password |
|
||||
| `POP3_ACCOUNT_N_USE_SSL` | No | `true` | Use SSL/TLS |
|
||||
|
||||
#### SMTP Settings (★ database-configurable)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `SMTP_HOST` | No | `smtp.gmail.com` | SMTP server |
|
||||
| `SMTP_PORT` | No | `587` | SMTP port |
|
||||
| `SMTP_USER` | For SMTP | — | SMTP username |
|
||||
| `SMTP_PASSWORD` | For SMTP | — | SMTP password (App Password) |
|
||||
| `SMTP_USE_TLS` | No | `true` | Use STARTTLS |
|
||||
|
||||
#### Gmail API Settings (★ database-configurable)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | For Gmail API | — | Google OAuth2 client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | For Gmail API | — | Google OAuth2 client secret |
|
||||
| `GMAIL_API_ENABLED` | No | `true` | Enable Gmail API delivery |
|
||||
|
||||
#### Processing Settings (★ database-configurable)
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `CHECK_INTERVAL_MINUTES` | No | `5` | Polling interval |
|
||||
| `MAX_EMAILS_PER_RUN` | No | `50` | Max emails per account per run |
|
||||
| `THROTTLE_EMAILS_PER_MINUTE` | No | `10` | Rate limit |
|
||||
| `LOG_LEVEL` | No | `INFO` | Logging level |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ POP3 Server 1 │
|
||||
└────────┬────────┘
|
||||
│ (Fetch emails)
|
||||
▼
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ POP3 Server 2 │─────▶│ Forwarder │─────▶│ Gmail API │
|
||||
└─────────────────┘ │ Container │ │ (Preferred) │
|
||||
│ │ │ └──────────────────┘
|
||||
┌────────▼────────┐ │ Config from: │ ┌──────────────────┐
|
||||
│ POP3 Server N │ │ • Database │─────▶│ Gmail SMTP │
|
||||
└─────────────────┘ │ • Environment │ │ (Fallback) │
|
||||
└──────┬───────────┘ └──────────────────┘
|
||||
│ (Notifications)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Apprise │
|
||||
│ (Email, Slack, │
|
||||
│ Telegram ...) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
1. **Polling** — checks POP3/IMAP mailboxes at the configured interval
|
||||
2. **Fetching** — retrieves new emails from each account
|
||||
3. **Delivery** — injects into Gmail via API (preferred) or forwards via SMTP (fallback)
|
||||
4. **Cleanup** — deletes from source after successful delivery
|
||||
5. **Throttling** — respects rate limits to avoid quota issues
|
||||
6. **Notifications** — sends alerts via Apprise (email, Telegram, Slack, Discord, webhooks)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
make install-dev
|
||||
|
||||
# Run linting & formatting
|
||||
make lint
|
||||
make format
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Start backend in dev mode
|
||||
make run-dev
|
||||
```
|
||||
|
||||
See the [Testing Guide](docs/TESTING_GUIDE.md) for the full test workflow.
|
||||
All settings except the three bootstrap secrets can be changed at runtime in the admin web UI — no restart needed.
|
||||
|
||||
## Documentation
|
||||
|
||||
Detailed documentation lives in the [`docs/`](docs/) directory:
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Architecture](docs/ARCHITECTURE.md) | System design and component overview |
|
||||
| [Quick Start](docs/QUICKSTART.md) | Step-by-step setup guide |
|
||||
| [Migration Guide](docs/MIGRATION_GUIDE.md) | Upgrading from v1 to v2 |
|
||||
| [Deployment Checklist](docs/DEPLOYMENT_CHECKLIST.md) | Production deployment guide |
|
||||
| [Roadmap](docs/ROADMAP.md) | Planned features and milestones |
|
||||
| [Testing Guide](docs/TESTING_GUIDE.md) | How to run and write tests |
|
||||
| [Coding Patterns](docs/CODING_PATTERNS.md) | Code style and conventions |
|
||||
| [SaaS README](docs/README_SAAS.md) | Multi-tenant SaaS platform details |
|
||||
| | |
|
||||
|---|---|
|
||||
| [Quick Start](docs/QUICKSTART.md) | Step-by-step setup, Gmail API & SMTP |
|
||||
| [Deployment Checklist](docs/DEPLOYMENT_CHECKLIST.md) | Production hardening guide |
|
||||
| [Architecture](docs/ARCHITECTURE.md) | How the pieces fit together |
|
||||
| [Migration Guide](docs/MIGRATION_GUIDE.md) | Upgrading from an older version |
|
||||
| [Roadmap](docs/ROADMAP.md) | What's coming next |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on:
|
||||
|
||||
- Reporting bugs and suggesting features
|
||||
- Development setup and code style
|
||||
- Pull request process
|
||||
Bug reports, feature requests, and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Security
|
||||
|
||||
To report a vulnerability, please see [SECURITY.md](SECURITY.md). **Do not open public issues for security concerns.**
|
||||
Please report vulnerabilities privately via [SECURITY.md](SECURITY.md) rather than opening a public issue.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Support
|
||||
|
||||
- [Issue Tracker](https://github.com/christianlouis/pop_puller_to_gmail/issues)
|
||||
- [Discussions](https://github.com/christianlouis/pop_puller_to_gmail/discussions)
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Inject build metadata
|
||||
ARG BUILD_DATE=""
|
||||
ENV BUILD_DATE=${BUILD_DATE}
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add name and apprise_url columns to notification_configs
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-03-26
|
||||
|
||||
These two columns were added to the NotificationConfig ORM model in the
|
||||
Apprise alerting feature PR. SQLAlchemy's create_all() does not ALTER
|
||||
existing tables, so deployments that had notification_configs created before
|
||||
this change are missing the columns and raise a ProgrammingError at runtime.
|
||||
|
||||
Using ADD COLUMN IF NOT EXISTS makes this migration idempotent – it is safe
|
||||
to run against both fresh installs (where create_all already created the
|
||||
columns) and existing deployments (where the columns are absent).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE notification_configs "
|
||||
"ADD COLUMN IF NOT EXISTS name VARCHAR(255) NOT NULL DEFAULT 'My Notification'"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE notification_configs " "ADD COLUMN IF NOT EXISTS apprise_url TEXT"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_configs DROP COLUMN IF EXISTS apprise_url")
|
||||
op.execute("ALTER TABLE notification_configs DROP COLUMN IF EXISTS name")
|
||||
@@ -13,6 +13,8 @@ from app.api.v1.endpoints import (
|
||||
admin,
|
||||
providers,
|
||||
app_settings,
|
||||
logs,
|
||||
version,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -34,3 +36,7 @@ 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"]
|
||||
)
|
||||
api_router.include_router(version.router, prefix="/version", tags=["Version"])
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
"""Admin endpoints"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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.models.database_models import User, MailAccount, ProcessingRun
|
||||
from app.core.gdpr import mask_email, mask_from_header
|
||||
from app.models.database_models import (
|
||||
User,
|
||||
MailAccount,
|
||||
ProcessingLog,
|
||||
ProcessingRun,
|
||||
SubscriptionPlan,
|
||||
SubscriptionTier,
|
||||
AdminNotificationConfig,
|
||||
)
|
||||
from app.models.schemas import (
|
||||
AdminUserListResponse,
|
||||
AdminUserUpdate,
|
||||
UserDetailResponse,
|
||||
SubscriptionPlanResponse,
|
||||
SubscriptionPlanCreate,
|
||||
SubscriptionPlanUpdate,
|
||||
AdminNotificationConfigCreate,
|
||||
AdminNotificationConfigUpdate,
|
||||
AdminNotificationConfigResponse,
|
||||
NotificationTestRequest,
|
||||
NotificationTestResponse,
|
||||
AdminProcessingRunResponse,
|
||||
AdminProcessingLogResponse,
|
||||
PaginatedAdminRunsResponse,
|
||||
PaginatedAdminLogsResponse,
|
||||
)
|
||||
from app.services.notification_service import test_notification
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -35,3 +64,508 @@ async def get_admin_stats(
|
||||
"total_mail_accounts": total_accounts,
|
||||
"total_processing_runs": total_runs,
|
||||
}
|
||||
|
||||
|
||||
# ── User management ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[AdminUserListResponse])
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all users with their mail account counts (admin only)"""
|
||||
result = await db.execute(
|
||||
select(User).order_by(User.created_at.desc()).offset(skip).limit(limit)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
# Fetch mail account counts per user in one query
|
||||
counts_result = await db.execute(
|
||||
select(MailAccount.user_id, func.count(MailAccount.id).label("cnt")).group_by(
|
||||
MailAccount.user_id
|
||||
)
|
||||
)
|
||||
counts = {row.user_id: row.cnt for row in counts_result}
|
||||
|
||||
response = []
|
||||
for u in users:
|
||||
response.append(
|
||||
AdminUserListResponse(
|
||||
id=u.id, # type: ignore[arg-type]
|
||||
email=u.email, # type: ignore[arg-type]
|
||||
full_name=u.full_name, # type: ignore[arg-type]
|
||||
is_active=u.is_active, # type: ignore[arg-type]
|
||||
is_superuser=u.is_superuser, # type: ignore[arg-type]
|
||||
subscription_tier=u.subscription_tier, # type: ignore[arg-type]
|
||||
subscription_status=u.subscription_status, # type: ignore[arg-type]
|
||||
google_id=u.google_id, # type: ignore[arg-type]
|
||||
oauth_provider=u.oauth_provider, # type: ignore[arg-type]
|
||||
last_login_at=u.last_login_at, # type: ignore[arg-type]
|
||||
created_at=u.created_at, # type: ignore[arg-type]
|
||||
mail_account_count=counts.get(u.id, 0), # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a specific user's details (admin only)"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/users/{user_id}", response_model=UserDetailResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_update: AdminUserUpdate,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a user's details, plan, or admin status (admin only)"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
if user_update.full_name is not None:
|
||||
user.full_name = user_update.full_name # type: ignore[assignment]
|
||||
if user_update.email is not None:
|
||||
user.email = user_update.email # type: ignore[assignment]
|
||||
if user_update.is_active is not None:
|
||||
user.is_active = user_update.is_active # type: ignore[assignment]
|
||||
if user_update.is_superuser is not None:
|
||||
user.is_superuser = user_update.is_superuser # type: ignore[assignment]
|
||||
if user_update.subscription_tier is not None:
|
||||
user.subscription_tier = SubscriptionTier(user_update.subscription_tier.value) # type: ignore[assignment]
|
||||
if user_update.subscription_status is not None:
|
||||
user.subscription_status = user_update.subscription_status # type: ignore[assignment]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a user and all their data (admin only)"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete your own account via admin endpoint",
|
||||
)
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Plan management ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
||||
async def list_all_plans(
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all subscription plans including inactive ones (admin only)"""
|
||||
result = await db.execute(select(SubscriptionPlan).order_by(SubscriptionPlan.tier))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/plans",
|
||||
response_model=SubscriptionPlanResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_plan(
|
||||
plan_in: SubscriptionPlanCreate,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new subscription plan (admin only)"""
|
||||
existing = await db.execute(
|
||||
select(SubscriptionPlan).where(
|
||||
SubscriptionPlan.tier == SubscriptionTier(plan_in.tier.value)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"A plan for tier '{plan_in.tier.value}' already exists",
|
||||
)
|
||||
|
||||
plan = SubscriptionPlan(
|
||||
tier=SubscriptionTier(plan_in.tier.value),
|
||||
name=plan_in.name,
|
||||
description=plan_in.description,
|
||||
price_monthly=plan_in.price_monthly,
|
||||
price_yearly=plan_in.price_yearly,
|
||||
max_mail_accounts=plan_in.max_mail_accounts,
|
||||
max_emails_per_day=plan_in.max_emails_per_day,
|
||||
check_interval_minutes=plan_in.check_interval_minutes,
|
||||
support_level=plan_in.support_level,
|
||||
features=plan_in.features,
|
||||
is_active=plan_in.is_active,
|
||||
)
|
||||
db.add(plan)
|
||||
await db.commit()
|
||||
await db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
@router.put("/plans/{plan_id}", response_model=SubscriptionPlanResponse)
|
||||
async def update_plan(
|
||||
plan_id: int,
|
||||
plan_update: SubscriptionPlanUpdate,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a subscription plan's limits or pricing (admin only)"""
|
||||
result = await db.execute(
|
||||
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id)
|
||||
)
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
|
||||
)
|
||||
|
||||
if plan_update.name is not None:
|
||||
plan.name = plan_update.name # type: ignore[assignment]
|
||||
if plan_update.description is not None:
|
||||
plan.description = plan_update.description # type: ignore[assignment]
|
||||
if plan_update.price_monthly is not None:
|
||||
plan.price_monthly = plan_update.price_monthly # type: ignore[assignment]
|
||||
if plan_update.price_yearly is not None:
|
||||
plan.price_yearly = plan_update.price_yearly # type: ignore[assignment]
|
||||
if plan_update.max_mail_accounts is not None:
|
||||
plan.max_mail_accounts = plan_update.max_mail_accounts # type: ignore[assignment]
|
||||
if plan_update.max_emails_per_day is not None:
|
||||
plan.max_emails_per_day = plan_update.max_emails_per_day # type: ignore[assignment]
|
||||
if plan_update.check_interval_minutes is not None:
|
||||
plan.check_interval_minutes = plan_update.check_interval_minutes # type: ignore[assignment]
|
||||
if plan_update.support_level is not None:
|
||||
plan.support_level = plan_update.support_level # type: ignore[assignment]
|
||||
if plan_update.features is not None:
|
||||
plan.features = plan_update.features # type: ignore[assignment]
|
||||
if plan_update.is_active is not None:
|
||||
plan.is_active = plan_update.is_active # type: ignore[assignment]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_plan(
|
||||
plan_id: int,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a subscription plan (admin only)"""
|
||||
result = await db.execute(
|
||||
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id)
|
||||
)
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
|
||||
)
|
||||
await db.delete(plan)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Admin notification config management ──────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=List[AdminNotificationConfigResponse])
|
||||
async def list_admin_notification_configs(
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all admin notification configurations (admin only)"""
|
||||
result = await db.execute(select(AdminNotificationConfig))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications",
|
||||
response_model=AdminNotificationConfigResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_admin_notification_config(
|
||||
config_in: AdminNotificationConfigCreate,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new admin notification configuration (admin only)"""
|
||||
config = AdminNotificationConfig(**config_in.dict())
|
||||
db.add(config)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
|
||||
)
|
||||
async def get_admin_notification_config(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a specific admin notification configuration (admin only)"""
|
||||
result = await db.execute(
|
||||
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Admin notification config not found",
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@router.put(
|
||||
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
|
||||
)
|
||||
async def update_admin_notification_config(
|
||||
config_id: int,
|
||||
config_in: AdminNotificationConfigUpdate,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an admin notification configuration (admin only)"""
|
||||
result = await db.execute(
|
||||
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Admin notification config not found",
|
||||
)
|
||||
|
||||
update_data = config_in.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(config, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@router.delete("/notifications/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_admin_notification_config(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete an admin notification configuration (admin only)"""
|
||||
result = await db.execute(
|
||||
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Admin notification config not found",
|
||||
)
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/notifications/test", response_model=NotificationTestResponse)
|
||||
async def test_admin_notification_config(
|
||||
request: NotificationTestRequest,
|
||||
current_user: User = Depends(get_current_superuser),
|
||||
):
|
||||
"""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]
|
||||
)
|
||||
|
||||
@@ -7,10 +7,17 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote as urlquote
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, get_password_hash
|
||||
from app.core.metrics import (
|
||||
AUTH_LOGINS_TOTAL,
|
||||
AUTH_REGISTRATIONS_TOTAL,
|
||||
OAUTH_CALLBACKS_TOTAL,
|
||||
)
|
||||
from app.models.database_models import User, SubscriptionTier
|
||||
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
|
||||
from app.services.auth_service import oauth_service
|
||||
@@ -18,6 +25,57 @@ from app.services.auth_service import oauth_service
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Scopes requested during Google Sign-In – only basic profile information.
|
||||
# Gmail API access is granted separately via the "Connect Gmail" flow in
|
||||
# Settings (/providers/gmail/authorize-url).
|
||||
GOOGLE_LOGIN_SCOPES = [
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
]
|
||||
|
||||
|
||||
def _domain_of(email: str) -> str:
|
||||
"""Return the lowercased domain part of an email address."""
|
||||
return email.split("@")[-1].lower()
|
||||
|
||||
|
||||
def _check_domain_allowed(email: str) -> None:
|
||||
"""
|
||||
Raise 403 if ALLOWED_DOMAINS is configured and the email's domain is not
|
||||
in the list. Always passes when ALLOWED_DOMAINS is empty (no restriction).
|
||||
"""
|
||||
if not settings.ALLOWED_DOMAINS:
|
||||
return
|
||||
domain = _domain_of(email)
|
||||
if domain not in settings.ALLOWED_DOMAINS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
f"Registrations are restricted to approved domains. "
|
||||
f"'{domain}' is not authorised."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _default_tier() -> SubscriptionTier:
|
||||
"""Return the SubscriptionTier that should be assigned to every new user."""
|
||||
try:
|
||||
return SubscriptionTier(settings.DEFAULT_USER_TIER)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"DEFAULT_USER_TIER '%s' is not a valid tier; falling back to FREE.",
|
||||
settings.DEFAULT_USER_TIER,
|
||||
)
|
||||
return SubscriptionTier.FREE
|
||||
|
||||
|
||||
def _is_admin_email(email: str) -> bool:
|
||||
return (
|
||||
settings.ADMIN_EMAIL is not None
|
||||
and email.lower() == settings.ADMIN_EMAIL.lower()
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
||||
@@ -34,6 +92,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||
)
|
||||
|
||||
# Domain restriction check (before creating the account)
|
||||
_check_domain_allowed(user_in.email)
|
||||
|
||||
# Create new user
|
||||
user = User(
|
||||
email=user_in.email,
|
||||
@@ -41,14 +102,16 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
hashed_password=(
|
||||
get_password_hash(user_in.password) if user_in.password else None
|
||||
),
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_tier=_default_tier(),
|
||||
is_active=True,
|
||||
is_superuser=_is_admin_email(user_in.email),
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
AUTH_REGISTRATIONS_TOTAL.labels(method="password", status="success").inc()
|
||||
logger.info(f"New user registered: {user.email}")
|
||||
|
||||
return user
|
||||
@@ -65,6 +128,7 @@ async def login(
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.hashed_password:
|
||||
AUTH_LOGINS_TOTAL.labels(method="password", status="failure").inc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
@@ -73,6 +137,7 @@ async def login(
|
||||
|
||||
# Verify password
|
||||
if not verify_password(form_data.password, user.hashed_password): # type: ignore[arg-type]
|
||||
AUTH_LOGINS_TOTAL.labels(method="password", status="failure").inc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
@@ -81,17 +146,29 @@ async def login(
|
||||
|
||||
# Check if user is active
|
||||
if not user.is_active:
|
||||
AUTH_LOGINS_TOTAL.labels(method="password", status="failure").inc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
|
||||
)
|
||||
|
||||
# Domain restriction — superusers always bypass
|
||||
if not user.is_superuser:
|
||||
_check_domain_allowed(str(user.email))
|
||||
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
# Auto-promote to superuser if this is the configured admin email
|
||||
if not user.is_superuser and _is_admin_email(str(user.email)):
|
||||
user.is_superuser = True # type: ignore[assignment]
|
||||
logger.info(f"Auto-promoted admin user: {user.email}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Create tokens
|
||||
tokens = oauth_service.create_tokens_for_user(user)
|
||||
|
||||
AUTH_LOGINS_TOTAL.labels(method="password", status="success").inc()
|
||||
logger.info(f"User logged in: {user.email}")
|
||||
|
||||
return tokens
|
||||
@@ -112,6 +189,7 @@ async def google_oauth(
|
||||
)
|
||||
|
||||
if not user_info.get("verified_email"):
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="error").inc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email not verified with Google",
|
||||
@@ -135,21 +213,36 @@ async def google_oauth(
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
# Domain restriction — superusers always bypass
|
||||
if not user.is_superuser:
|
||||
_check_domain_allowed(email)
|
||||
|
||||
# Auto-promote to superuser if this is the configured admin email
|
||||
if not user.is_superuser and _is_admin_email(email):
|
||||
user.is_superuser = True # type: ignore[assignment]
|
||||
logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}")
|
||||
|
||||
logger.info(f"Existing user logged in with Google: {user.email}")
|
||||
AUTH_LOGINS_TOTAL.labels(method="google", status="success").inc()
|
||||
else:
|
||||
# Domain restriction check before creating the account
|
||||
_check_domain_allowed(email)
|
||||
|
||||
# Create new user
|
||||
user = User(
|
||||
email=email,
|
||||
full_name=user_info.get("full_name"),
|
||||
google_id=google_id,
|
||||
oauth_provider="google",
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_tier=_default_tier(),
|
||||
is_active=True,
|
||||
last_login_at=datetime.now(timezone.utc),
|
||||
is_superuser=_is_admin_email(email),
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
logger.info(f"New user registered with Google: {user.email}")
|
||||
AUTH_REGISTRATIONS_TOTAL.labels(method="google", status="success").inc()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
@@ -157,21 +250,21 @@ async def google_oauth(
|
||||
# Create tokens
|
||||
tokens = oauth_service.create_tokens_for_user(user)
|
||||
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="success").inc()
|
||||
return tokens
|
||||
|
||||
|
||||
@router.get("/google/authorize-url")
|
||||
async def get_google_authorize_url(redirect_uri: str):
|
||||
"""Get Google OAuth2 authorization URL"""
|
||||
from app.core.config import settings
|
||||
|
||||
"""Get Google OAuth2 authorization URL for sign-in (profile scopes only)."""
|
||||
scope = urlquote(" ".join(GOOGLE_LOGIN_SCOPES))
|
||||
auth_url = (
|
||||
f"https://accounts.google.com/o/oauth2/v2/auth?"
|
||||
f"client_id={settings.GOOGLE_CLIENT_ID}&"
|
||||
f"response_type=code&"
|
||||
f"scope=openid%20email%20profile&"
|
||||
f"redirect_uri={redirect_uri}&"
|
||||
f"access_type=offline"
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
f"?client_id={settings.GOOGLE_CLIENT_ID}"
|
||||
"&response_type=code"
|
||||
f"&scope={scope}"
|
||||
f"&redirect_uri={redirect_uri}"
|
||||
"&prompt=select_account"
|
||||
)
|
||||
|
||||
return {"authorization_url": auth_url}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
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(
|
||||
"",
|
||||
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)",
|
||||
),
|
||||
has_emails: Optional[bool] = Query(
|
||||
None,
|
||||
description="When true, only return runs that fetched at least one email",
|
||||
),
|
||||
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, status, or whether
|
||||
any emails were fetched (has_emails=true reduces log noise by hiding empty
|
||||
polling cycles).
|
||||
"""
|
||||
# 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)
|
||||
if has_emails is True:
|
||||
base = base.where(ProcessingRun.emails_fetched > 0)
|
||||
|
||||
# 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(
|
||||
"/{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(
|
||||
"/{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,14 +1,23 @@
|
||||
"""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
|
||||
from app.core.security import encrypt_credential
|
||||
from app.models.database_models import User, MailAccount, AccountStatus
|
||||
from app.core.security import encrypt_credential, decrypt_credential
|
||||
from app.models.database_models import (
|
||||
User,
|
||||
MailAccount,
|
||||
ProcessingLog,
|
||||
ProcessingRun,
|
||||
AccountStatus,
|
||||
SubscriptionPlan,
|
||||
)
|
||||
from app.workers.tasks import process_mail_account as process_mail_account_task
|
||||
from app.models.schemas import (
|
||||
MailAccountCreate,
|
||||
MailAccountResponse,
|
||||
@@ -17,6 +26,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
|
||||
@@ -34,26 +47,41 @@ async def create_mail_account(
|
||||
):
|
||||
"""Create a new mail account"""
|
||||
|
||||
# Check subscription limits
|
||||
result = await db.execute(
|
||||
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
||||
)
|
||||
existing_accounts = result.scalars().all()
|
||||
|
||||
tier_limits = {
|
||||
"free": settings.TIER_FREE_MAX_ACCOUNTS,
|
||||
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
|
||||
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
|
||||
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
||||
}
|
||||
|
||||
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
|
||||
|
||||
if len(existing_accounts) >= max_accounts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail="Account limit reached. Upgrade your subscription to add more accounts.",
|
||||
# Superusers are not subject to subscription limits
|
||||
if not current_user.is_superuser:
|
||||
# Count existing accounts for this user
|
||||
result = await db.execute(
|
||||
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
||||
)
|
||||
existing_accounts = result.scalars().all()
|
||||
|
||||
# Try to look up the limit from the active SubscriptionPlan in the DB first
|
||||
# so that admin-managed plan limits take effect immediately.
|
||||
plan_result = await db.execute(
|
||||
select(SubscriptionPlan).where(
|
||||
SubscriptionPlan.tier == current_user.subscription_tier,
|
||||
SubscriptionPlan.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
plan = plan_result.scalar_one_or_none()
|
||||
|
||||
if plan is not None:
|
||||
max_accounts = plan.max_mail_accounts
|
||||
else:
|
||||
# Fall back to env-var / config values when no plan row exists
|
||||
tier_limits = {
|
||||
"free": settings.TIER_FREE_MAX_ACCOUNTS,
|
||||
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
|
||||
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
|
||||
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
||||
}
|
||||
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1) # type: ignore[assignment]
|
||||
|
||||
if len(existing_accounts) >= max_accounts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail="Account limit reached. Upgrade your subscription to add more accounts.",
|
||||
)
|
||||
|
||||
# Encrypt password
|
||||
encrypted_password = encrypt_credential(account_in.password)
|
||||
@@ -76,6 +104,7 @@ async def create_mail_account(
|
||||
check_interval_minutes=account_in.check_interval_minutes,
|
||||
max_emails_per_check=account_in.max_emails_per_check,
|
||||
delete_after_forward=account_in.delete_after_forward,
|
||||
provider_name=account_in.provider_name,
|
||||
)
|
||||
|
||||
db.add(account)
|
||||
@@ -146,9 +175,9 @@ async def update_mail_account(
|
||||
update_data = account_update.model_dump(exclude_unset=True)
|
||||
|
||||
if "password" in update_data:
|
||||
update_data["encrypted_password"] = encrypt_credential(
|
||||
update_data.pop("password")
|
||||
)
|
||||
password = update_data.pop("password")
|
||||
if password: # Only update when a non-empty password is provided
|
||||
update_data["encrypted_password"] = encrypt_credential(password)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(account, field, value)
|
||||
@@ -214,6 +243,36 @@ async def toggle_mail_account(
|
||||
return account
|
||||
|
||||
|
||||
@router.post("/{account_id}/pull-now", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def pull_now(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Immediately queue a pull for the given mail account"""
|
||||
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"
|
||||
)
|
||||
|
||||
if not account.is_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Account is disabled. Enable it before pulling.",
|
||||
)
|
||||
|
||||
process_mail_account_task.delay(account_id)
|
||||
|
||||
return {"message": "Pull queued successfully"}
|
||||
|
||||
|
||||
@router.post("/test", response_model=MailAccountTestResponse)
|
||||
async def test_mail_connection(
|
||||
test_request: MailAccountTestRequest,
|
||||
@@ -242,6 +301,39 @@ async def test_mail_connection(
|
||||
return MailAccountTestResponse(success=success, message=message)
|
||||
|
||||
|
||||
@router.post("/{account_id}/test", response_model=MailAccountTestResponse)
|
||||
async def test_existing_mail_connection(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Test connection for an existing mail account using its stored credentials"""
|
||||
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"
|
||||
)
|
||||
|
||||
try:
|
||||
password = decrypt_credential(str(account.encrypted_password))
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to decrypt stored credentials",
|
||||
)
|
||||
|
||||
processor = MailProcessor(account, password)
|
||||
success, message = await processor.test_connection()
|
||||
|
||||
return MailAccountTestResponse(success=success, message=message)
|
||||
|
||||
|
||||
@router.post("/auto-detect", response_model=MailAccountAutoDetectResponse)
|
||||
async def auto_detect_mail_settings(
|
||||
detect_request: MailAccountAutoDetectRequest,
|
||||
@@ -254,3 +346,154 @@ 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),
|
||||
has_emails: Optional[bool] = Query(
|
||||
None,
|
||||
description="When true, only return runs that fetched at least one email",
|
||||
),
|
||||
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)
|
||||
if has_emails is True:
|
||||
base = base.where(ProcessingRun.emails_fetched > 0)
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Notification configuration endpoints"""
|
||||
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -10,8 +10,12 @@ from app.core.deps import get_current_active_user
|
||||
from app.models.database_models import User, NotificationConfig
|
||||
from app.models.schemas import (
|
||||
NotificationConfigCreate,
|
||||
NotificationConfigUpdate,
|
||||
NotificationConfigResponse,
|
||||
NotificationTestRequest,
|
||||
NotificationTestResponse,
|
||||
)
|
||||
from app.services.notification_service import test_notification
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -24,7 +28,7 @@ async def create_notification_config(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create notification configuration"""
|
||||
"""Create a new notification configuration"""
|
||||
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
|
||||
db.add(config)
|
||||
await db.commit()
|
||||
@@ -37,8 +41,94 @@ async def list_notification_configs(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all notification configurations"""
|
||||
"""List all notification configurations for the current user"""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig).where(NotificationConfig.user_id == current_user.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{config_id}", response_model=NotificationConfigResponse)
|
||||
async def get_notification_config(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a specific notification configuration"""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig).where(
|
||||
NotificationConfig.id == config_id,
|
||||
NotificationConfig.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification config not found",
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/{config_id}", response_model=NotificationConfigResponse)
|
||||
async def update_notification_config(
|
||||
config_id: int,
|
||||
config_in: NotificationConfigUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a notification configuration"""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig).where(
|
||||
NotificationConfig.id == config_id,
|
||||
NotificationConfig.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification config not found",
|
||||
)
|
||||
|
||||
update_data = config_in.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(config, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_notification_config(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a notification configuration"""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig).where(
|
||||
NotificationConfig.id == config_id,
|
||||
NotificationConfig.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification config not found",
|
||||
)
|
||||
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/test", response_model=NotificationTestResponse)
|
||||
async def test_notification_config(
|
||||
request: NotificationTestRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""Test a notification channel by sending a test message"""
|
||||
success, message = await test_notification(request.apprise_url)
|
||||
return NotificationTestResponse(success=success, message=message)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from urllib.parse import quote as urlquote
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -10,7 +11,7 @@ import logging
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.core.security import encrypt_credential
|
||||
from app.core.security import encrypt_credential, decrypt_credential
|
||||
from app.core.config import settings
|
||||
from app.models.database_models import User, GmailCredential
|
||||
from app.models.schemas import (
|
||||
@@ -20,18 +21,37 @@ 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,
|
||||
)
|
||||
from app.services.gmail_service import GmailService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gmail API scopes needed for email injection
|
||||
|
||||
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.
|
||||
GMAIL_API_SCOPES = [
|
||||
"openid",
|
||||
"email",
|
||||
"https://www.googleapis.com/auth/gmail.insert",
|
||||
"https://www.googleapis.com/auth/gmail.labels",
|
||||
*GMAIL_SCOPES,
|
||||
]
|
||||
|
||||
# Provider presets with server configurations
|
||||
@@ -135,6 +155,15 @@ PROVIDER_PRESETS: List[ProviderPreset] = [
|
||||
pop3_ssl={"host": "pop.mail.de", "port": 995},
|
||||
notes="Use your mail.de email credentials.",
|
||||
),
|
||||
ProviderPreset(
|
||||
id="protonmail",
|
||||
name="Proton Mail",
|
||||
icon="protonmail",
|
||||
domains=["proton.me", "protonmail.com", "protonmail.ch", "pm.me"],
|
||||
imap_ssl={"host": "127.0.0.1", "port": 1143},
|
||||
pop3_ssl={"host": "127.0.0.1", "port": 1144},
|
||||
notes="Requires Proton Mail Bridge running locally. Use your Bridge password (not your Proton account password). Default Bridge ports: IMAP 127.0.0.1:1143, POP3 127.0.0.1:1144.",
|
||||
),
|
||||
ProviderPreset(
|
||||
id="icloud",
|
||||
name="iCloud Mail",
|
||||
@@ -221,6 +250,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()
|
||||
@@ -233,6 +266,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(granted_scopes=[]),
|
||||
is_valid=True,
|
||||
last_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -283,6 +317,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,
|
||||
@@ -302,7 +363,7 @@ async def get_gmail_authorize_url(
|
||||
detail="Google OAuth2 is not configured on this server.",
|
||||
)
|
||||
|
||||
scope = " ".join(GMAIL_API_SCOPES)
|
||||
scope = urlquote(" ".join(GMAIL_API_SCOPES))
|
||||
url = (
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
f"?client_id={settings.GOOGLE_CLIENT_ID}"
|
||||
@@ -311,10 +372,84 @@ async def get_gmail_authorize_url(
|
||||
f"&redirect_uri={redirect_uri}"
|
||||
"&access_type=offline"
|
||||
"&prompt=consent"
|
||||
"&include_granted_scopes=true"
|
||||
"&state=gmail_connect"
|
||||
)
|
||||
return GmailAuthorizeResponse(authorization_url=url)
|
||||
|
||||
|
||||
@router.post("/gmail/debug-email", status_code=status.HTTP_200_OK)
|
||||
async def send_gmail_debug_email(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Inject a debug/test email into the current user's Gmail inbox.
|
||||
|
||||
The message appears to have been sent by christian@docuelevate.org,
|
||||
carries today's date in the subject, and is tagged with the custom
|
||||
labels "test" and "imported" as well as placed in the inbox.
|
||||
|
||||
Useful for verifying that Gmail API delivery is working end-to-end
|
||||
without requiring an active mail-account polling cycle.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(GmailCredential).where(
|
||||
GmailCredential.user_id == current_user.id,
|
||||
GmailCredential.is_valid == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
credential = result.scalar_one_or_none()
|
||||
|
||||
if not credential:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No valid Gmail credentials found. Connect Gmail first.",
|
||||
)
|
||||
|
||||
access_token = decrypt_credential(credential.encrypted_access_token) # type: ignore[arg-type]
|
||||
refresh_token = (
|
||||
decrypt_credential(credential.encrypted_refresh_token) # type: ignore[arg-type]
|
||||
if credential.encrypted_refresh_token
|
||||
else None
|
||||
)
|
||||
|
||||
gmail_service = GmailService(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
client_id=settings.GOOGLE_CLIENT_ID,
|
||||
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||
)
|
||||
|
||||
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(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Gmail injection failed: {exc}",
|
||||
)
|
||||
|
||||
# Persist refreshed token if the google-auth library renewed it
|
||||
refreshed = gmail_service.get_refreshed_token()
|
||||
if refreshed:
|
||||
credential.encrypted_access_token = encrypt_credential( # type: ignore[assignment]
|
||||
refreshed["access_token"]
|
||||
)
|
||||
if refreshed.get("expiry"):
|
||||
credential.token_expiry = refreshed["expiry"] # type: ignore[assignment]
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": "Debug email injected successfully",
|
||||
"message_id": inject_result.get("message_id"),
|
||||
"thread_id": inject_result.get("thread_id"),
|
||||
"label_ids": inject_result.get("label_ids", []),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/gmail/callback",
|
||||
response_model=GmailCredentialResponse,
|
||||
@@ -423,7 +558,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()
|
||||
@@ -436,7 +574,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),
|
||||
)
|
||||
|
||||
@@ -15,9 +15,18 @@ router = APIRouter()
|
||||
|
||||
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
||||
async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
||||
"""List all available subscription plans"""
|
||||
"""
|
||||
List subscription plans shown in marketing / pricing pages.
|
||||
|
||||
Zero-price plans (price_monthly == 0) are intentionally excluded so that
|
||||
enterprise / white-label deployments that assign a free plan to all users
|
||||
don't surface that plan in the public pricing UI.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True) # noqa: E712
|
||||
select(SubscriptionPlan).where(
|
||||
SubscriptionPlan.is_active.is_(True),
|
||||
SubscriptionPlan.price_monthly > 0,
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -25,10 +34,22 @@ async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/current")
|
||||
async def get_current_subscription(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get current user's subscription details"""
|
||||
"""Get current user's subscription details including plan limits"""
|
||||
plan_result = await db.execute(
|
||||
select(SubscriptionPlan).where(
|
||||
SubscriptionPlan.tier == current_user.subscription_tier,
|
||||
SubscriptionPlan.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
plan = plan_result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"tier": current_user.subscription_tier,
|
||||
"status": current_user.subscription_status,
|
||||
"expires_at": current_user.subscription_expires_at,
|
||||
"max_mail_accounts": plan.max_mail_accounts if plan else None,
|
||||
"max_emails_per_day": plan.max_emails_per_day if plan else None,
|
||||
"check_interval_minutes": plan.check_interval_minutes if plan else None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Version endpoint – returns the application version and build date.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_version():
|
||||
"""Return application version and build metadata."""
|
||||
return {
|
||||
"version": settings.APP_VERSION,
|
||||
"build_date": settings.BUILD_DATE or None,
|
||||
}
|
||||
@@ -27,18 +27,23 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "POP3 Forwarder SaaS"
|
||||
APP_NAME: str = "InboxConverge"
|
||||
APP_VERSION: str = "2.0.0"
|
||||
BUILD_DATE: str = ""
|
||||
APP_URL: str = "https://inboxconverge.com"
|
||||
CONTACT_EMAIL: str = "christian@inboxconverge.com"
|
||||
DEBUG: bool = False
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Server
|
||||
HOST: str = "0.0.0.0"
|
||||
HOST: str = (
|
||||
"0.0.0.0" # nosec B104 – intentional: containerised service binds all interfaces
|
||||
)
|
||||
PORT: int = 8000
|
||||
|
||||
# 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,9 +99,18 @@ class Settings(BaseSettings):
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# Admin
|
||||
ADMIN_EMAIL: Optional[str] = None
|
||||
ADMIN_EMAIL: Optional[str] = "christian@inboxconverge.com"
|
||||
ADMIN_PASSWORD: Optional[str] = None
|
||||
|
||||
# User defaults & access control
|
||||
# Tier assigned to every new user on registration: free | basic | pro | enterprise
|
||||
DEFAULT_USER_TIER: str = "free"
|
||||
# Comma-separated list of allowed email domains (empty = no restriction).
|
||||
# When set, only addresses from these domains may register or log in.
|
||||
# Useful for B2B / Google Workspace installations.
|
||||
# Example: "company.com,subsidiary.com"
|
||||
ALLOWED_DOMAINS: List[str] = []
|
||||
|
||||
# Mail Server Presets
|
||||
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json"
|
||||
|
||||
@@ -108,6 +122,23 @@ class Settings(BaseSettings):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
return v
|
||||
|
||||
@field_validator("ALLOWED_DOMAINS", mode="before")
|
||||
@classmethod
|
||||
def assemble_allowed_domains(cls, v: str | List[str]) -> List[str]:
|
||||
"""Parse allowed domains from a comma-separated environment variable"""
|
||||
if isinstance(v, str):
|
||||
return [d.strip().lower() for d in v.split(",") if d.strip()]
|
||||
return [d.lower() for d in v if d]
|
||||
|
||||
@field_validator("DEFAULT_USER_TIER")
|
||||
@classmethod
|
||||
def validate_default_user_tier(cls, v: str) -> str:
|
||||
"""Ensure DEFAULT_USER_TIER is one of the known tier values"""
|
||||
valid = {"free", "basic", "pro", "enterprise"}
|
||||
if v.lower() not in valid:
|
||||
raise ValueError(f"DEFAULT_USER_TIER must be one of {valid}, got '{v}'")
|
||||
return v.lower()
|
||||
|
||||
@field_validator("SECRET_KEY")
|
||||
@classmethod
|
||||
def validate_secret_key(cls, v: str) -> str:
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
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
|
||||
the same registry objects.
|
||||
"""
|
||||
|
||||
from prometheus_client import Counter, Histogram, Gauge
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP layer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HTTP_REQUESTS_TOTAL = Counter(
|
||||
"http_requests_total",
|
||||
"Total HTTP requests received",
|
||||
["method", "endpoint", "status_code"],
|
||||
)
|
||||
|
||||
HTTP_REQUEST_DURATION_SECONDS = Histogram(
|
||||
"http_request_duration_seconds",
|
||||
"HTTP request duration in seconds",
|
||||
["method", "endpoint"],
|
||||
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mail processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MAIL_PROCESSING_RUNS_TOTAL = Counter(
|
||||
"mail_processing_runs_total",
|
||||
"Total mail-account processing runs by final status",
|
||||
["status"], # completed | partial_failure | failed
|
||||
)
|
||||
|
||||
MAIL_PROCESSING_EMAILS_TOTAL = Counter(
|
||||
"mail_processing_emails_total",
|
||||
"Total emails encountered during processing runs",
|
||||
["operation"], # fetched | forwarded | failed
|
||||
)
|
||||
|
||||
MAIL_PROCESSING_DURATION_SECONDS = Histogram(
|
||||
"mail_processing_duration_seconds",
|
||||
"Duration of a single mail-account processing run in seconds",
|
||||
buckets=(1, 5, 10, 30, 60, 120, 300, 600),
|
||||
)
|
||||
|
||||
ACTIVE_MAIL_ACCOUNTS = Gauge(
|
||||
"active_mail_accounts_total",
|
||||
"Number of enabled mail accounts queued for this scheduler cycle",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gmail API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GMAIL_API_REQUESTS_TOTAL = Counter(
|
||||
"gmail_api_requests_total",
|
||||
"Total Gmail API requests by operation and outcome",
|
||||
[
|
||||
"operation",
|
||||
"status",
|
||||
], # operation: inject|verify|get_label|get_profile status: success|error
|
||||
)
|
||||
|
||||
GMAIL_API_DURATION_SECONDS = Histogram(
|
||||
"gmail_api_duration_seconds",
|
||||
"Gmail API call duration in seconds",
|
||||
["operation"],
|
||||
buckets=(0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0),
|
||||
)
|
||||
|
||||
GMAIL_TOKEN_REFRESHES_TOTAL = Counter(
|
||||
"gmail_token_refreshes_total",
|
||||
"Total number of OAuth access-token refreshes performed by GmailService",
|
||||
)
|
||||
|
||||
GMAIL_CREDENTIALS_INVALIDATED_TOTAL = Counter(
|
||||
"gmail_credentials_invalidated_total",
|
||||
"Total times a user's Gmail credentials were marked invalid (revoked token)",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication / OAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUTH_LOGINS_TOTAL = Counter(
|
||||
"auth_logins_total",
|
||||
"Total login attempts by method and outcome",
|
||||
["method", "status"], # method: password|google status: success|failure
|
||||
)
|
||||
|
||||
AUTH_REGISTRATIONS_TOTAL = Counter(
|
||||
"auth_registrations_total",
|
||||
"Total registration attempts by method and outcome",
|
||||
["method", "status"], # method: password|google status: success|failure
|
||||
)
|
||||
|
||||
OAUTH_CALLBACKS_TOTAL = Counter(
|
||||
"oauth_callbacks_total",
|
||||
"Total OAuth2 callback events by provider and outcome",
|
||||
["provider", "status"], # provider: google status: success|error
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Celery tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CELERY_TASKS_TOTAL = Counter(
|
||||
"celery_tasks_total",
|
||||
"Total Celery task executions by task name and status",
|
||||
["task_name", "status"], # status: success|failure
|
||||
)
|
||||
|
||||
CELERY_TASK_DURATION_SECONDS = Histogram(
|
||||
"celery_task_duration_seconds",
|
||||
"Celery task execution duration in seconds",
|
||||
["task_name"],
|
||||
buckets=(1, 5, 10, 30, 60, 120, 300, 600, 1800),
|
||||
)
|
||||
@@ -2,16 +2,30 @@
|
||||
Main FastAPI application.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
|
||||
import logging
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from alembic import command as alembic_command
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.middleware import SecurityHeadersMiddleware, CSRFProtectionMiddleware
|
||||
from app.core.metrics import HTTP_REQUESTS_TOTAL, HTTP_REQUEST_DURATION_SECONDS
|
||||
from app.api.v1.api import api_router
|
||||
|
||||
# Absolute path to alembic.ini – one level above this file's directory
|
||||
# (backend/app/main.py → backend/alembic.ini)
|
||||
_ALEMBIC_INI = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "alembic.ini")
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL.upper()),
|
||||
@@ -22,7 +36,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Application lifespan handler for startup and shutdown events."""
|
||||
# Startup
|
||||
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
||||
@@ -44,6 +58,25 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Apply schema migrations for columns added to existing tables.
|
||||
# Alembic's ADD COLUMN IF NOT EXISTS migrations are idempotent – safe for
|
||||
# both fresh installs (create_all already added the columns) and existing
|
||||
# deployments (where the columns may be absent).
|
||||
try:
|
||||
|
||||
def _run_alembic_upgrade() -> None:
|
||||
alembic_cfg = AlembicConfig(_ALEMBIC_INI)
|
||||
alembic_command.upgrade(alembic_cfg, "head")
|
||||
|
||||
await asyncio.to_thread(_run_alembic_upgrade)
|
||||
logger.info("Database schema migrations applied")
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Could not apply schema migrations: %s — some columns may be missing",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Seed default database-backed settings (no-op if they already exist)
|
||||
try:
|
||||
from app.core.database import async_session_maker
|
||||
@@ -54,6 +87,34 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception as exc:
|
||||
logger.warning("Could not seed default settings: %s", exc, exc_info=True)
|
||||
|
||||
# Ensure the configured ADMIN_EMAIL user has is_superuser=True.
|
||||
# This runs on every startup so that existing accounts created before the
|
||||
# auto-promotion login logic existed are also promoted correctly.
|
||||
if settings.ADMIN_EMAIL:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
from app.core.database import async_session_maker
|
||||
from app.models.database_models import User
|
||||
|
||||
async with async_session_maker() as db:
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
func.lower(User.email) == settings.ADMIN_EMAIL.lower()
|
||||
)
|
||||
)
|
||||
admin_user = result.scalar_one_or_none()
|
||||
if admin_user and not admin_user.is_superuser:
|
||||
admin_user.is_superuser = True # type: ignore[assignment]
|
||||
await db.commit()
|
||||
logger.info(
|
||||
"Auto-promoted admin user to superuser on startup: %s",
|
||||
admin_user.email,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not auto-promote admin user on startup: %s", exc, exc_info=True
|
||||
)
|
||||
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down application")
|
||||
@@ -65,7 +126,7 @@ def create_application() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management",
|
||||
description="Poll your legacy POP3/IMAP inboxes and deliver everything to Gmail. For real people, not enterprises.",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
@@ -88,11 +149,35 @@ def create_application() -> FastAPI:
|
||||
# Include API router
|
||||
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
@app.middleware("http")
|
||||
async def prometheus_middleware(request: Request, call_next):
|
||||
"""Record per-request Prometheus metrics."""
|
||||
# Normalize the path so high-cardinality IDs don't explode label sets.
|
||||
path = request.url.path
|
||||
# Strip numeric path segments (e.g. /api/v1/accounts/42 → /api/v1/accounts/{id})
|
||||
normalized = re.sub(r"/\d+", "/{id}", path)
|
||||
|
||||
start = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
duration = time.perf_counter() - start
|
||||
|
||||
HTTP_REQUESTS_TOTAL.labels(
|
||||
method=request.method,
|
||||
endpoint=normalized,
|
||||
status_code=str(response.status_code),
|
||||
).inc()
|
||||
HTTP_REQUEST_DURATION_SECONDS.labels(
|
||||
method=request.method,
|
||||
endpoint=normalized,
|
||||
).observe(duration)
|
||||
|
||||
return response
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"message": "POP3 Forwarder SaaS API",
|
||||
"message": "InboxConverge API",
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/api/docs",
|
||||
}
|
||||
@@ -102,6 +187,12 @@ def create_application() -> FastAPI:
|
||||
"""Health check endpoint for container orchestration"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
@app.get("/metrics", include_in_schema=False)
|
||||
async def metrics():
|
||||
"""Prometheus metrics endpoint."""
|
||||
data = generate_latest()
|
||||
return Response(content=data, media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -289,6 +295,11 @@ class NotificationConfig(Base):
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
name = Column(String(255), nullable=False, default="My Notification")
|
||||
apprise_url = Column(
|
||||
Text, nullable=True
|
||||
) # The Apprise URL e.g. tgram://token/chatid
|
||||
|
||||
# Channel details
|
||||
channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
@@ -557,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):
|
||||
"""
|
||||
@@ -590,3 +613,29 @@ class AppSetting(Base):
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class AdminNotificationConfig(Base):
|
||||
"""System-wide admin notification channels"""
|
||||
|
||||
__tablename__ = "admin_notification_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
apprise_url = Column(Text, nullable=False)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
notify_on_errors = Column(Boolean, default=True)
|
||||
notify_on_system_events = Column(Boolean, default=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -74,6 +74,7 @@ class UserDetailResponse(UserResponse):
|
||||
stripe_customer_id: Optional[str] = None
|
||||
subscription_expires_at: Optional[datetime] = None
|
||||
last_login_at: Optional[datetime] = None
|
||||
is_superuser: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -112,6 +113,7 @@ class MailAccountBase(BaseModel):
|
||||
check_interval_minutes: int = Field(default=5, gt=0, le=1440)
|
||||
max_emails_per_check: int = Field(default=50, gt=0, le=1000)
|
||||
delete_after_forward: bool = True
|
||||
provider_name: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class MailAccountCreate(MailAccountBase):
|
||||
@@ -120,6 +122,13 @@ class MailAccountCreate(MailAccountBase):
|
||||
|
||||
class MailAccountUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
email_address: Optional[EmailStr] = None
|
||||
protocol: Optional[MailProtocol] = None
|
||||
host: Optional[str] = Field(None, max_length=255)
|
||||
port: Optional[int] = Field(None, gt=0, lt=65536)
|
||||
use_ssl: Optional[bool] = None
|
||||
use_tls: Optional[bool] = None
|
||||
username: Optional[str] = Field(None, max_length=255)
|
||||
password: Optional[str] = None
|
||||
forward_to: Optional[EmailStr] = None
|
||||
delivery_method: Optional[DeliveryMethod] = None
|
||||
@@ -127,6 +136,7 @@ class MailAccountUpdate(BaseModel):
|
||||
check_interval_minutes: Optional[int] = Field(None, gt=0, le=1440)
|
||||
max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000)
|
||||
delete_after_forward: Optional[bool] = None
|
||||
provider_name: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class MailAccountResponse(MailAccountBase):
|
||||
@@ -145,9 +155,8 @@ class MailAccountResponse(MailAccountBase):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Don't expose password or username in responses
|
||||
# Don't expose password in responses; username is safe to return
|
||||
password: str = Field(exclude=True, default="")
|
||||
username: str = Field(exclude=True, default="")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -197,6 +206,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
|
||||
@@ -210,11 +228,81 @@ 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(
|
||||
default="My Notification",
|
||||
max_length=255,
|
||||
description="Friendly name for this notification channel",
|
||||
)
|
||||
channel: NotificationChannel
|
||||
apprise_url: Optional[str] = Field(None, description="Apprise notification URL")
|
||||
is_enabled: bool = True
|
||||
config: Dict[str, Any]
|
||||
config: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Legacy channel-specific configuration (deprecated in favour of apprise_url)",
|
||||
)
|
||||
notify_on_errors: bool = True
|
||||
notify_on_success: bool = False
|
||||
notify_threshold: int = Field(default=3, gt=0, le=100)
|
||||
@@ -225,6 +313,9 @@ class NotificationConfigCreate(NotificationConfigBase):
|
||||
|
||||
|
||||
class NotificationConfigUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
channel: Optional[NotificationChannel] = None
|
||||
apprise_url: Optional[str] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
notify_on_errors: Optional[bool] = None
|
||||
@@ -241,6 +332,46 @@ class NotificationConfigResponse(NotificationConfigBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NotificationTestRequest(BaseModel):
|
||||
apprise_url: str = Field(..., description="Apprise URL to test")
|
||||
|
||||
|
||||
class NotificationTestResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# Admin Notification Config Schemas
|
||||
class AdminNotificationConfigBase(BaseModel):
|
||||
name: str = Field(..., max_length=255)
|
||||
apprise_url: str = Field(..., description="Apprise notification URL")
|
||||
is_enabled: bool = True
|
||||
notify_on_errors: bool = True
|
||||
notify_on_system_events: bool = True
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class AdminNotificationConfigCreate(AdminNotificationConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
class AdminNotificationConfigUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
apprise_url: Optional[str] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
notify_on_errors: Optional[bool] = None
|
||||
notify_on_system_events: Optional[bool] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class AdminNotificationConfigResponse(AdminNotificationConfigBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# Subscription Schemas
|
||||
class SubscriptionPlanResponse(BaseModel):
|
||||
id: int
|
||||
@@ -319,6 +450,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
|
||||
@@ -371,3 +504,63 @@ class GmailAuthorizeResponse(BaseModel):
|
||||
class GmailCallbackRequest(BaseModel):
|
||||
code: str
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
class GmailImportLabelsUpdate(BaseModel):
|
||||
import_label_templates: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# Admin Schemas
|
||||
|
||||
|
||||
class AdminUserListResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
subscription_tier: SubscriptionTier
|
||||
subscription_status: str
|
||||
google_id: Optional[str] = None
|
||||
oauth_provider: Optional[str] = None
|
||||
last_login_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
mail_account_count: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_superuser: Optional[bool] = None
|
||||
subscription_tier: Optional[SubscriptionTier] = None
|
||||
subscription_status: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionPlanCreate(BaseModel):
|
||||
tier: SubscriptionTier
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price_monthly: float = 0.0
|
||||
price_yearly: Optional[float] = None
|
||||
max_mail_accounts: int = 1
|
||||
max_emails_per_day: int = 1000
|
||||
check_interval_minutes: int = 5
|
||||
support_level: str = "community"
|
||||
features: Optional[Dict[str, Any]] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class SubscriptionPlanUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
price_monthly: Optional[float] = None
|
||||
price_yearly: Optional[float] = None
|
||||
max_mail_accounts: Optional[int] = None
|
||||
max_emails_per_day: Optional[int] = None
|
||||
check_interval_minutes: Optional[int] = None
|
||||
support_level: Optional[str] = None
|
||||
features: Optional[Dict[str, Any]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@@ -25,12 +25,13 @@ class OAuthService:
|
||||
def _register_google(self):
|
||||
"""Register Google OAuth2 provider"""
|
||||
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
|
||||
scope = "openid email profile"
|
||||
self.oauth.register(
|
||||
name="google",
|
||||
client_id=settings.GOOGLE_CLIENT_ID,
|
||||
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
client_kwargs={"scope": scope},
|
||||
)
|
||||
|
||||
async def get_google_user_info(
|
||||
@@ -99,6 +100,10 @@ class OAuthService:
|
||||
"google_id": user_info.get("id"),
|
||||
"picture": user_info.get("picture"),
|
||||
"verified_email": user_info.get("verified_email", False),
|
||||
"access_token": access_token,
|
||||
"refresh_token": token_data.get("refresh_token"),
|
||||
"expires_in": token_data.get("expires_in"),
|
||||
"scope": token_data.get("scope", ""),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database_models import AppSetting
|
||||
from app.models.database_models import AppSetting, SubscriptionPlan, SubscriptionTier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -338,4 +338,87 @@ class ConfigService:
|
||||
if created:
|
||||
await db.commit()
|
||||
logger.info("Seeded %d default settings into the database", created)
|
||||
|
||||
# Seed default subscription plans
|
||||
await ConfigService.seed_default_plans(db)
|
||||
|
||||
return created
|
||||
|
||||
@staticmethod
|
||||
async def seed_default_plans(db: AsyncSession) -> int:
|
||||
"""
|
||||
Populate the database with default subscription plans (skip existing tiers).
|
||||
|
||||
Limits mirror the env-var defaults so behaviour is unchanged on first boot
|
||||
but can be overridden by admins via the plan management UI.
|
||||
|
||||
Returns the number of plans created.
|
||||
"""
|
||||
from app.core.config import settings # local import to avoid circular deps
|
||||
|
||||
default_plans = [
|
||||
{
|
||||
"tier": SubscriptionTier.FREE,
|
||||
"name": "Free",
|
||||
"description": "Dip your toes in. One old inbox pulled into Gmail, checked every 30 minutes. Free forever, no card needed.",
|
||||
"price_monthly": 0.0,
|
||||
"price_yearly": 0.0,
|
||||
"max_mail_accounts": settings.TIER_FREE_MAX_ACCOUNTS,
|
||||
"max_emails_per_day": 100,
|
||||
"check_interval_minutes": 30,
|
||||
"support_level": "community",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tier": SubscriptionTier.BASIC,
|
||||
"name": "Good",
|
||||
"description": "Got a handful of dusty inboxes you just can't let go of? This one's for you. Less than a coffee per month.",
|
||||
"price_monthly": 0.99,
|
||||
"price_yearly": 9.90,
|
||||
"max_mail_accounts": settings.TIER_BASIC_MAX_ACCOUNTS,
|
||||
"max_emails_per_day": 1000,
|
||||
"check_interval_minutes": 15,
|
||||
"support_level": "email",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tier": SubscriptionTier.PRO,
|
||||
"name": "Better",
|
||||
"description": "You're clearly the type who keeps every email address you've ever had. Respect. Checked every 5 minutes.",
|
||||
"price_monthly": 1.99,
|
||||
"price_yearly": 19.90,
|
||||
"max_mail_accounts": settings.TIER_PRO_MAX_ACCOUNTS,
|
||||
"max_emails_per_day": 10000,
|
||||
"check_interval_minutes": 5,
|
||||
"support_level": "email",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tier": SubscriptionTier.ENTERPRISE,
|
||||
"name": "Best",
|
||||
"description": "Every old inbox you've ever had, all landing neatly in Gmail, checked every minute. The full works.",
|
||||
"price_monthly": 2.99,
|
||||
"price_yearly": 29.90,
|
||||
"max_mail_accounts": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
||||
"max_emails_per_day": 100000,
|
||||
"check_interval_minutes": 1,
|
||||
"support_level": "email",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
created = 0
|
||||
for plan_data in default_plans:
|
||||
result = await db.execute(
|
||||
select(SubscriptionPlan).where(
|
||||
SubscriptionPlan.tier == plan_data["tier"]
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
db.add(SubscriptionPlan(**plan_data))
|
||||
created += 1
|
||||
|
||||
if created:
|
||||
await db.commit()
|
||||
logger.info("Seeded %d default subscription plans into the database", created)
|
||||
return created
|
||||
|
||||
@@ -9,18 +9,31 @@ This is preferred over SMTP forwarding as it doesn't modify the email.
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import textwrap
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import format_datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from app.core.metrics import (
|
||||
GMAIL_API_REQUESTS_TOTAL,
|
||||
GMAIL_API_DURATION_SECONDS,
|
||||
GMAIL_TOKEN_REFRESHES_TOTAL,
|
||||
)
|
||||
from app.utils.gmail_labels import render_import_labels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gmail API scopes needed for email injection
|
||||
GMAIL_SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.insert",
|
||||
"https://www.googleapis.com/auth/gmail.labels",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
]
|
||||
|
||||
|
||||
@@ -71,7 +84,9 @@ class GmailService:
|
||||
def service(self):
|
||||
"""Lazy-initialize the Gmail API service."""
|
||||
if self._service is None:
|
||||
self._service = build("gmail", "v1", credentials=self.credentials)
|
||||
self._service = build(
|
||||
"gmail", "v1", credentials=self.credentials, cache_discovery=False
|
||||
)
|
||||
return self._service
|
||||
|
||||
async def inject_email(
|
||||
@@ -111,6 +126,7 @@ class GmailService:
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
_start = time.perf_counter()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
@@ -120,6 +136,10 @@ class GmailService:
|
||||
.execute(),
|
||||
)
|
||||
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="success").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="inject").observe(_dur)
|
||||
|
||||
logger.info(
|
||||
f"Injected email into Gmail: id={result.get('id')}"
|
||||
f"{f' from {source_account_name}' if source_account_name else ''}"
|
||||
@@ -132,6 +152,9 @@ class GmailService:
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="error").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="inject").observe(_dur)
|
||||
error_msg = (
|
||||
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
|
||||
)
|
||||
@@ -139,6 +162,9 @@ class GmailService:
|
||||
# Surface 401 so callers can mark credentials as invalid
|
||||
raise GmailInjectionError(error_msg)
|
||||
except Exception as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="error").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="inject").observe(_dur)
|
||||
error_msg = f"Failed to inject email into Gmail: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise GmailInjectionError(error_msg)
|
||||
@@ -152,15 +178,22 @@ class GmailService:
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
_start = time.perf_counter()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users().getProfile(userId="me").execute(),
|
||||
)
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="verify", status="success").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="verify").observe(_dur)
|
||||
email = result.get("emailAddress", "unknown")
|
||||
logger.info(f"Gmail API access verified for: {email}")
|
||||
return True
|
||||
except Exception as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="verify", status="error").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="verify").observe(_dur)
|
||||
logger.error(f"Gmail API access verification failed: {e}")
|
||||
return False
|
||||
|
||||
@@ -173,16 +206,176 @@ class GmailService:
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
_start = time.perf_counter()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users().getProfile(userId="me").execute(),
|
||||
)
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(
|
||||
operation="get_profile", status="success"
|
||||
).inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="get_profile").observe(_dur)
|
||||
return result.get("emailAddress")
|
||||
except Exception as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(
|
||||
operation="get_profile", status="error"
|
||||
).inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="get_profile").observe(_dur)
|
||||
logger.error(f"Failed to get Gmail email address: {e}")
|
||||
return None
|
||||
|
||||
async def get_or_create_label(self, name: str) -> str:
|
||||
"""
|
||||
Return the Gmail label ID for a label with the given name.
|
||||
|
||||
Lists the user's existing labels and returns the ID of the first
|
||||
match (case-insensitive). If no matching label is found, a new
|
||||
label is created and its ID is returned.
|
||||
|
||||
Args:
|
||||
name: Human-readable label name (e.g. "test", "imported").
|
||||
|
||||
Returns:
|
||||
Gmail label ID string (e.g. "Label_1234567890").
|
||||
|
||||
Raises:
|
||||
GmailInjectionError: If the Gmail API call fails.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
_start = time.perf_counter()
|
||||
try:
|
||||
labels_resp = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users().labels().list(userId="me").execute(),
|
||||
)
|
||||
for label in labels_resp.get("labels", []):
|
||||
if label.get("name", "").lower() == name.lower():
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(
|
||||
operation="get_label", status="success"
|
||||
).inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(
|
||||
_dur
|
||||
)
|
||||
return label["id"]
|
||||
|
||||
# Label not found – create it
|
||||
created = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users()
|
||||
.labels()
|
||||
.create(userId="me", body={"name": name})
|
||||
.execute(),
|
||||
)
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(
|
||||
operation="get_label", status="success"
|
||||
).inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(_dur)
|
||||
logger.info(f"Created Gmail label '{name}' with id={created['id']}")
|
||||
return created["id"]
|
||||
|
||||
except HttpError as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="get_label", status="error").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(_dur)
|
||||
error_msg = f"Gmail API error while managing label '{name}': {e.reason if hasattr(e, 'reason') else str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise GmailInjectionError(error_msg)
|
||||
except Exception as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="get_label", status="error").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(_dur)
|
||||
error_msg = f"Failed to get/create Gmail label '{name}': {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise GmailInjectionError(error_msg)
|
||||
|
||||
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.
|
||||
|
||||
The message is made to appear as if it was sent by
|
||||
christian@docuelevate.org on the current date. It is placed in
|
||||
the inbox and tagged with the custom labels "test" and "imported"
|
||||
so it is easy to identify and clean up.
|
||||
|
||||
Args:
|
||||
recipient_email: The Gmail address to deliver the message to
|
||||
(the authenticated user's address).
|
||||
|
||||
Returns:
|
||||
Dict with message_id, thread_id, and label_ids.
|
||||
|
||||
Raises:
|
||||
GmailInjectionError: If injection or label management fails.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
date_str = now.strftime("%d %B %Y") # e.g. "25 March 2026"
|
||||
|
||||
subject = f"Test Import – {date_str}"
|
||||
|
||||
body = textwrap.dedent(f"""\
|
||||
Hi there,
|
||||
|
||||
This is an automated test message injected via the Gmail API to
|
||||
confirm that the import pipeline is working correctly.
|
||||
|
||||
Date: {date_str}
|
||||
Source: DocuElevate Integration Test
|
||||
|
||||
If you can see this message in your inbox it means that Gmail API
|
||||
delivery is functioning as expected. Feel free to delete it.
|
||||
|
||||
Best regards,
|
||||
Christian Krakau-Louis
|
||||
DocuElevate
|
||||
""")
|
||||
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["From"] = "Christian Krakau-Louis <christian@docuelevate.org>"
|
||||
msg["To"] = recipient_email
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = format_datetime(now)
|
||||
msg["Message-ID"] = f"<debug-{now.strftime('%Y%m%d%H%M%S')}@docuelevate.org>"
|
||||
|
||||
raw_bytes = msg.as_bytes()
|
||||
|
||||
label_ids = await self.build_import_label_ids(import_label_templates)
|
||||
test_label_id = await self.get_or_create_label("test")
|
||||
if test_label_id not in label_ids:
|
||||
label_ids.append(test_label_id)
|
||||
|
||||
return await self.inject_email(
|
||||
raw_email=raw_bytes,
|
||||
label_ids=label_ids,
|
||||
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
|
||||
@@ -198,6 +391,7 @@ class GmailService:
|
||||
"""
|
||||
current_token = self.credentials.token
|
||||
if current_token and current_token != self._initial_access_token:
|
||||
GMAIL_TOKEN_REFRESHES_TOTAL.inc()
|
||||
return {
|
||||
"access_token": current_token,
|
||||
"expiry": self.credentials.expiry,
|
||||
|
||||
@@ -5,6 +5,7 @@ Supports both POP3 and IMAP protocols with secure connections.
|
||||
|
||||
import asyncio
|
||||
import poplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from email import parser
|
||||
@@ -183,18 +184,18 @@ class MailProcessor:
|
||||
if self.account.protocol == MailProtocol.POP3_SSL:
|
||||
context = ssl.create_default_context()
|
||||
pop_conn = poplib.POP3_SSL(
|
||||
self.account.host,
|
||||
self.account.port,
|
||||
str(self.account.host),
|
||||
int(self.account.port),
|
||||
context=context,
|
||||
timeout=30,
|
||||
)
|
||||
else:
|
||||
pop_conn = poplib.POP3(
|
||||
self.account.host, self.account.port, timeout=30
|
||||
pop_conn = poplib.POP3( # type: ignore[assignment]
|
||||
str(self.account.host), int(self.account.port), timeout=30
|
||||
)
|
||||
|
||||
# Authenticate
|
||||
pop_conn.user(self.account.username)
|
||||
pop_conn.user(str(self.account.username))
|
||||
pop_conn.pass_(self.password)
|
||||
|
||||
# Retrieve UIDL map: {msg_number: uid_string}
|
||||
@@ -263,10 +264,31 @@ class MailProcessor:
|
||||
async def _fetch_imap_emails(
|
||||
self, max_count: int, already_seen_uids: Set[str]
|
||||
) -> Tuple[List[bytes], List[str]]:
|
||||
"""Fetch emails via IMAP, marking each message \Seen to prevent re-fetch."""
|
||||
"""Fetch emails via IMAP using UID-based commands.
|
||||
|
||||
UIDs are stable identifiers that do not change when other messages are
|
||||
expunged, unlike IMAP sequence numbers. Sequence-number-based FETCH /
|
||||
STORE commands can target the wrong message mid-session and cause
|
||||
servers to report "Too many invalid IMAP commands" (as seen with
|
||||
t-online). This implementation:
|
||||
|
||||
* Uses a plain ``SEARCH UNSEEN`` to retrieve sequence numbers, then a
|
||||
lightweight ``FETCH (UID)`` to resolve them to stable UIDs.
|
||||
(``aioimaplib``'s ``.uid()`` wrapper does not support ``SEARCH``.)
|
||||
* Uses ``UID FETCH`` and ``UID STORE`` for all subsequent operations.
|
||||
* Re-marks already-processed UIDs as \\Seen in a single batch command
|
||||
instead of one STORE per message.
|
||||
* Relies on the implicit \\Seen flag set by RFC822 FETCH so no
|
||||
additional per-message STORE is needed for newly fetched mail.
|
||||
* Batches the \\Deleted STORE into a single command when
|
||||
``delete_after_forward`` is enabled.
|
||||
* Uses RFC 3501–compliant parenthesised flag syntax, e.g.
|
||||
``+FLAGS (\\Seen)``, required by strict servers such as T-Online.
|
||||
"""
|
||||
emails: List[bytes] = []
|
||||
new_uids: List[str] = []
|
||||
|
||||
imap_client = None
|
||||
try:
|
||||
# Create IMAP client
|
||||
if self.account.protocol == MailProtocol.IMAP_SSL:
|
||||
@@ -282,70 +304,152 @@ class MailProcessor:
|
||||
await imap_client.login(self.account.username, self.password)
|
||||
await imap_client.select("INBOX")
|
||||
|
||||
# Search for unseen messages only
|
||||
# Step 1: Standard sequence-based SEARCH for UNSEEN messages.
|
||||
# aioimaplib's .uid() wrapper explicitly blocks "search", so we
|
||||
# use the plain SEARCH command and resolve to UIDs in step 2.
|
||||
response = await imap_client.search("UNSEEN")
|
||||
message_ids = response.lines[0].split()
|
||||
if (
|
||||
response.result != "OK"
|
||||
or not response.lines
|
||||
or not response.lines[0].strip()
|
||||
):
|
||||
logger.info(f"Found 0 unread messages for account {self.account.id}")
|
||||
# logout is handled by the finally block below
|
||||
return emails, new_uids
|
||||
|
||||
# Limit to max_count
|
||||
message_ids = message_ids[:max_count]
|
||||
seq_nums: List[bytes] = response.lines[0].split()
|
||||
if not seq_nums:
|
||||
logger.info(f"Found 0 unread messages for account {self.account.id}")
|
||||
return emails, new_uids
|
||||
|
||||
# Limit to max_count before doing any further work.
|
||||
seq_nums = seq_nums[:max_count]
|
||||
|
||||
# Step 2: Resolve sequence numbers to stable UIDs with a
|
||||
# lightweight FETCH (UID) so all subsequent commands are UID-based.
|
||||
seq_string = b",".join(seq_nums).decode()
|
||||
uid_resp = await imap_client.fetch(seq_string, "(UID)")
|
||||
|
||||
# Step 3: Parse UIDs from response lines.
|
||||
# Each line looks like: b'1 (UID 42)'
|
||||
all_unseen_uids: List[bytes] = []
|
||||
for line in uid_resp.lines:
|
||||
if isinstance(line, bytes):
|
||||
m = re.search(rb"\bUID\s+(\d+)", line)
|
||||
if m:
|
||||
all_unseen_uids.append(m.group(1))
|
||||
|
||||
logger.info(
|
||||
f"Found {len(message_ids)} unread messages for account {self.account.id}"
|
||||
f"Found {len(all_unseen_uids)} unread messages for account "
|
||||
f"{self.account.id}"
|
||||
)
|
||||
|
||||
# Fetch each message
|
||||
for msg_id in message_ids:
|
||||
uid_str = msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id)
|
||||
|
||||
# Skip messages already tracked in our DB
|
||||
# Step 4: Split into stale UIDs (already in our DB but still
|
||||
# UNSEEN on the server — e.g. a previous STORE failed) and
|
||||
# genuinely new UIDs.
|
||||
stale_uid_bytes: List[bytes] = []
|
||||
uids_to_fetch: List[bytes] = []
|
||||
for uid in all_unseen_uids:
|
||||
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
if uid_str in already_seen_uids:
|
||||
logger.debug(
|
||||
f"Skipping already-processed IMAP message {uid_str} "
|
||||
f"for account {self.account.id}"
|
||||
)
|
||||
# Still mark as Seen so it doesn't show up in UNSEEN searches
|
||||
await imap_client.store(msg_id, "+FLAGS", "\\Seen")
|
||||
continue
|
||||
stale_uid_bytes.append(uid)
|
||||
else:
|
||||
uids_to_fetch.append(uid)
|
||||
|
||||
# Re-mark stale UIDs as \Seen in a single batch STORE command so
|
||||
# they stop appearing in UNSEEN searches without consuming one
|
||||
# round-trip per message.
|
||||
# RFC 3501 requires parentheses around flag names: +FLAGS (\Seen).
|
||||
if stale_uid_bytes:
|
||||
uid_set = b",".join(stale_uid_bytes).decode()
|
||||
try:
|
||||
response = await imap_client.fetch(msg_id, "(RFC822)")
|
||||
await imap_client.uid("store", uid_set, "+FLAGS", "(\\Seen)")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to re-mark stale messages as \\Seen for account "
|
||||
f"{self.account.id}: {e}"
|
||||
)
|
||||
|
||||
# Extract email data from response
|
||||
email_data = None
|
||||
for line in response.lines:
|
||||
if isinstance(line, bytes) and b"RFC822" in line:
|
||||
# Find the email content
|
||||
start_idx = line.find(b"{")
|
||||
if start_idx != -1:
|
||||
# Email data is in the next parts
|
||||
continue
|
||||
elif isinstance(line, bytes) and not line.startswith(b"*"):
|
||||
email_data = line
|
||||
break
|
||||
# Fetch each new message. RFC822 FETCH implicitly sets \Seen on
|
||||
# the server so no extra STORE per message is required.
|
||||
fetched_uids: List[bytes] = []
|
||||
for uid in uids_to_fetch:
|
||||
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
try:
|
||||
fetch_response = await imap_client.uid("fetch", uid_str, "(RFC822)")
|
||||
|
||||
if email_data:
|
||||
# Extract raw email bytes from the FETCH response lines.
|
||||
# A UID FETCH response looks like:
|
||||
# [b'<seq> (UID <uid> RFC822 {<size>}', <email_bytes>, b')']
|
||||
# Skip the header line (contains "RFC822") and grab the
|
||||
# first substantive bytes value.
|
||||
#
|
||||
# aioimaplib stores IMAP literal data (the actual email
|
||||
# content) as bytearray, not bytes. We must accept both
|
||||
# types; bytes() converts bytearray so the rest of the
|
||||
# pipeline always receives plain bytes.
|
||||
email_data: Optional[bytes] = None
|
||||
for line in fetch_response.lines:
|
||||
if not isinstance(line, (bytes, bytearray)):
|
||||
continue
|
||||
if b"RFC822" in line:
|
||||
continue
|
||||
if line.startswith(b"*"):
|
||||
continue
|
||||
if line in (b")", b""):
|
||||
continue
|
||||
email_data = bytes(line)
|
||||
break
|
||||
|
||||
if email_data and email_data.strip():
|
||||
emails.append(email_data)
|
||||
new_uids.append(uid_str)
|
||||
|
||||
# Always mark as Seen after fetching so the message is
|
||||
# not picked up again on the next UNSEEN search.
|
||||
await imap_client.store(msg_id, "+FLAGS", "\\Seen")
|
||||
|
||||
if self.account.delete_after_forward:
|
||||
await imap_client.store(msg_id, "+FLAGS", "\\Deleted")
|
||||
fetched_uids.append(uid)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No email data extracted for UID {uid_str} on "
|
||||
f"account {self.account.id} "
|
||||
f"(raw bytes: {len(email_data) if email_data else 0}); "
|
||||
"this may indicate a server-side error producing empty "
|
||||
"IMAP RFC822 responses"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching message {msg_id}: {e}")
|
||||
logger.error(
|
||||
f"Error fetching message UID {uid_str} for account "
|
||||
f"{self.account.id}: {e}"
|
||||
)
|
||||
|
||||
# Expunge deleted messages
|
||||
if self.account.delete_after_forward:
|
||||
await imap_client.expunge()
|
||||
|
||||
await imap_client.logout()
|
||||
# Batch-mark fetched messages for deletion if configured (one STORE
|
||||
# command covers all UIDs instead of N individual commands).
|
||||
# RFC 3501 requires parentheses around flag names: +FLAGS (\Deleted).
|
||||
if self.account.delete_after_forward and fetched_uids:
|
||||
uid_set = b",".join(fetched_uids).decode()
|
||||
try:
|
||||
await imap_client.uid("store", uid_set, "+FLAGS", "(\\Deleted)")
|
||||
await imap_client.expunge()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to delete messages for account "
|
||||
f"{self.account.id}: {e}"
|
||||
)
|
||||
|
||||
except MailFetchError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching IMAP emails: {e}")
|
||||
logger.error(
|
||||
f"Error fetching IMAP emails for account {self.account.id}: {e}"
|
||||
)
|
||||
raise MailFetchError(f"IMAP fetch error: {str(e)}")
|
||||
finally:
|
||||
# Always attempt a clean logout. If the server already sent BYE
|
||||
# the logout call will fail silently rather than masking the real
|
||||
# error.
|
||||
if imap_client is not None:
|
||||
try:
|
||||
await imap_client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return emails, new_uids
|
||||
|
||||
@@ -597,6 +701,26 @@ class MailServerAutoDetect:
|
||||
"pop3_ssl": {"host": "pop.mail.de", "port": 995},
|
||||
"imap_ssl": {"host": "imap.mail.de", "port": 993},
|
||||
},
|
||||
"proton.me": {
|
||||
"name": "Proton Mail",
|
||||
"imap_ssl": {"host": "127.0.0.1", "port": 1143},
|
||||
"pop3_ssl": {"host": "127.0.0.1", "port": 1144},
|
||||
},
|
||||
"protonmail.com": {
|
||||
"name": "Proton Mail",
|
||||
"imap_ssl": {"host": "127.0.0.1", "port": 1143},
|
||||
"pop3_ssl": {"host": "127.0.0.1", "port": 1144},
|
||||
},
|
||||
"protonmail.ch": {
|
||||
"name": "Proton Mail",
|
||||
"imap_ssl": {"host": "127.0.0.1", "port": 1143},
|
||||
"pop3_ssl": {"host": "127.0.0.1", "port": 1144},
|
||||
},
|
||||
"pm.me": {
|
||||
"name": "Proton Mail",
|
||||
"imap_ssl": {"host": "127.0.0.1", "port": 1143},
|
||||
"pop3_ssl": {"host": "127.0.0.1", "port": 1144},
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Notification service using Apprise for multi-channel alerting.
|
||||
|
||||
Supports:
|
||||
- User-specific notifications (per-user Apprise URLs)
|
||||
- Admin-wide system notifications (system-level alerts)
|
||||
- Test notifications to verify configuration
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import apprise
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.database_models import NotificationConfig, AdminNotificationConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_user_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
body: str,
|
||||
notify_on_error: bool = True,
|
||||
) -> int:
|
||||
"""
|
||||
Send a notification to all enabled notification channels for a given user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: The user to notify
|
||||
title: Notification title/subject
|
||||
body: Notification body text
|
||||
notify_on_error: If True, only sends to channels with notify_on_errors=True
|
||||
If False, only sends to channels with notify_on_success=True
|
||||
|
||||
Returns:
|
||||
Number of channels notified successfully
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(NotificationConfig).where(
|
||||
NotificationConfig.user_id == user_id,
|
||||
NotificationConfig.is_enabled == True, # noqa: E712
|
||||
NotificationConfig.apprise_url.isnot(None),
|
||||
)
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
|
||||
if not configs:
|
||||
return 0
|
||||
|
||||
sent = 0
|
||||
for config in configs:
|
||||
if notify_on_error and not config.notify_on_errors:
|
||||
continue
|
||||
if not notify_on_error and not config.notify_on_success:
|
||||
continue
|
||||
|
||||
try:
|
||||
success = await _send_apprise(str(config.apprise_url or ""), title, body)
|
||||
if success:
|
||||
sent += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to send notification via channel %s (user %s): %s",
|
||||
config.id,
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
async def send_admin_notification(
|
||||
db: AsyncSession,
|
||||
title: str,
|
||||
body: str,
|
||||
) -> int:
|
||||
"""
|
||||
Send a notification to all enabled admin notification channels.
|
||||
|
||||
Returns:
|
||||
Number of channels notified successfully
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AdminNotificationConfig).where(
|
||||
AdminNotificationConfig.is_enabled == True, # noqa: E712
|
||||
AdminNotificationConfig.notify_on_errors == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
configs = result.scalars().all()
|
||||
|
||||
if not configs:
|
||||
return 0
|
||||
|
||||
sent = 0
|
||||
for config in configs:
|
||||
try:
|
||||
success = await _send_apprise(str(config.apprise_url or ""), title, body)
|
||||
if success:
|
||||
sent += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to send admin notification via channel %s: %s",
|
||||
config.id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
async def test_notification(apprise_url: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Send a test notification to the given Apprise URL.
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
try:
|
||||
success = await _send_apprise(
|
||||
apprise_url,
|
||||
title="InboxRescue: Test Notification",
|
||||
body="This is a test notification from InboxRescue. Your notification channel is configured correctly!",
|
||||
)
|
||||
if success:
|
||||
return True, "Test notification sent successfully"
|
||||
return False, "Notification delivery failed (check your Apprise URL)"
|
||||
except Exception as exc:
|
||||
return False, f"Error sending test notification: {exc}"
|
||||
|
||||
|
||||
async def _send_apprise(url: str, title: str, body: str) -> bool:
|
||||
"""Internal helper – create an Apprise instance, load the URL, and notify."""
|
||||
ap = apprise.Apprise()
|
||||
if not ap.add(url):
|
||||
logger.warning("Apprise could not parse URL: %s", url[:60])
|
||||
return False
|
||||
|
||||
result = await ap.async_notify(title=title, body=body)
|
||||
return bool(result)
|
||||
@@ -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
|
||||
@@ -10,9 +10,12 @@ from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Suppress noisy INFO-level "ignored untagged response" messages from aioimaplib
|
||||
logging.getLogger("aioimaplib").setLevel(logging.WARNING)
|
||||
|
||||
# Create Celery app
|
||||
celery_app = Celery(
|
||||
"pop3_forwarder",
|
||||
"inboxconverge",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
include=["app.workers.tasks"],
|
||||
@@ -36,7 +39,9 @@ celery_app.conf.update(
|
||||
celery_app.conf.beat_schedule = {
|
||||
"process-all-mail-accounts": {
|
||||
"task": "app.workers.tasks.process_all_enabled_accounts",
|
||||
"schedule": crontab(minute="*/5"), # Every 5 minutes
|
||||
"schedule": crontab(
|
||||
minute="*"
|
||||
), # Every minute (per-account interval gates actual work)
|
||||
},
|
||||
"cleanup-old-logs": {
|
||||
"task": "app.workers.tasks.cleanup_old_logs",
|
||||
|
||||
@@ -3,13 +3,24 @@ 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
|
||||
import logging
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
from app.core.database import async_session_maker
|
||||
from app.core.database import async_session_maker, engine
|
||||
from app.core.security import decrypt_credential, encrypt_credential
|
||||
from app.core.metrics import (
|
||||
MAIL_PROCESSING_RUNS_TOTAL,
|
||||
MAIL_PROCESSING_EMAILS_TOTAL,
|
||||
MAIL_PROCESSING_DURATION_SECONDS,
|
||||
ACTIVE_MAIL_ACCOUNTS,
|
||||
GMAIL_CREDENTIALS_INVALIDATED_TOTAL,
|
||||
CELERY_TASKS_TOTAL,
|
||||
CELERY_TASK_DURATION_SECONDS,
|
||||
)
|
||||
from app.models.database_models import (
|
||||
MailAccount,
|
||||
ProcessingRun,
|
||||
@@ -23,19 +34,39 @@ from app.models.database_models import (
|
||||
from app.services.mail_processor import MailProcessor
|
||||
from app.services.gmail_service import GmailService
|
||||
from app.services.config_service import ConfigService
|
||||
from app.services.notification_service import send_user_notification
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
"""Return *dt* with UTC tzinfo, attaching it if the datetime is naive."""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
class AsyncTask(Task):
|
||||
"""Base task class that handles async operations"""
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
"""Run async task in event loop"""
|
||||
# Use asyncio.run() for better event loop management
|
||||
return asyncio.run(self.run(*args, **kwargs))
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
return await self.run(*args, **kwargs)
|
||||
finally:
|
||||
# Dispose the connection pool before the event loop closes.
|
||||
# Each asyncio.run() creates a fresh event loop; if pooled
|
||||
# asyncpg connections are still open when the loop is torn
|
||||
# down, asyncpg raises "Exception terminating connection".
|
||||
# Disposing the engine here closes those connections cleanly
|
||||
# inside the same loop, before asyncio.run() shuts it down.
|
||||
await engine.dispose()
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
@celery_app.task(base=AsyncTask, name="app.workers.tasks.process_mail_account")
|
||||
@@ -46,6 +77,7 @@ async def process_mail_account(account_id: int):
|
||||
Args:
|
||||
account_id: ID of mail account to process
|
||||
"""
|
||||
_task_start = time.monotonic()
|
||||
async with async_session_maker() as db:
|
||||
try:
|
||||
# Get account
|
||||
@@ -58,10 +90,15 @@ async def process_mail_account(account_id: int):
|
||||
logger.warning(f"Account {account_id} not found or disabled")
|
||||
return
|
||||
|
||||
# Capture start time in a local variable so the error handler can
|
||||
# compute duration_seconds without touching the (expired) ORM
|
||||
# attribute after a session rollback.
|
||||
_run_started_at = datetime.now(timezone.utc)
|
||||
|
||||
# Create processing run
|
||||
run = ProcessingRun(
|
||||
mail_account_id=account.id,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
started_at=_run_started_at,
|
||||
status="running",
|
||||
)
|
||||
db.add(run)
|
||||
@@ -89,6 +126,7 @@ async def process_mail_account(account_id: int):
|
||||
)
|
||||
|
||||
run.emails_fetched = len(emails) # type: ignore[assignment]
|
||||
MAIL_PROCESSING_EMAILS_TOTAL.labels(operation="fetched").inc(len(emails))
|
||||
|
||||
# Forward emails
|
||||
emails_forwarded = 0
|
||||
@@ -99,6 +137,7 @@ async def process_mail_account(account_id: int):
|
||||
|
||||
gmail_service = None
|
||||
smtp_config = None
|
||||
gmail_cred = None
|
||||
|
||||
if use_gmail_api:
|
||||
# Get user's Gmail credentials
|
||||
@@ -156,10 +195,16 @@ async def process_mail_account(account_id: int):
|
||||
)
|
||||
run.status = "failed" # type: ignore[assignment]
|
||||
run.error_message = "No delivery method configured (SMTP credentials missing and Gmail API not set up)" # type: ignore[assignment]
|
||||
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
run.duration_seconds = ( # type: ignore[assignment]
|
||||
run.completed_at - _run_started_at
|
||||
).total_seconds()
|
||||
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
successfully_forwarded_uids: list[str] = []
|
||||
skipped_empty_uids: list[str] = []
|
||||
|
||||
if len(emails) != len(new_uids):
|
||||
logger.error(
|
||||
@@ -167,15 +212,95 @@ 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:
|
||||
if use_gmail_api and gmail_service:
|
||||
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
|
||||
|
||||
# ── Detect completely empty emails ───────────────────────────
|
||||
# T-Online (and potentially other servers) may return empty
|
||||
# RFC822 responses. An email with no subject, no sender and no
|
||||
# body provides no value and should not be forwarded. We log a
|
||||
# warning (it may indicate a server-side bug) and skip the
|
||||
# message while still recording its UID so it is not retried.
|
||||
if not email_subject and not email_from:
|
||||
body_has_content = False
|
||||
try:
|
||||
if msg.is_multipart(): # type: ignore[possibly-undefined]
|
||||
for part in msg.walk():
|
||||
payload = part.get_payload(decode=True)
|
||||
if isinstance(payload, bytes) and payload.strip():
|
||||
body_has_content = True
|
||||
break
|
||||
else:
|
||||
payload = msg.get_payload(decode=True) # type: ignore[possibly-undefined]
|
||||
body_has_content = isinstance(payload, bytes) and bool(
|
||||
payload.strip()
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not body_has_content:
|
||||
logger.warning(
|
||||
"Dropping completely empty email (uid=%s, account=%s, "
|
||||
"size=%d bytes) — no subject, sender, or body; "
|
||||
"this may indicate a server-side error.",
|
||||
uid,
|
||||
account.id,
|
||||
email_size_bytes,
|
||||
)
|
||||
skipped_empty_uids.append(uid)
|
||||
db.add(
|
||||
ProcessingLog(
|
||||
user_id=account.user_id,
|
||||
mail_account_id=account.id,
|
||||
processing_run_id=run.id,
|
||||
level="WARNING",
|
||||
message="Dropped empty email (no subject, sender, or body)",
|
||||
email_subject=None,
|
||||
email_from=None,
|
||||
email_size_bytes=email_size_bytes,
|
||||
success=False,
|
||||
error_details={"reason": "empty_email"},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if use_gmail_api and gmail_service and gmail_cred:
|
||||
# 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:
|
||||
@@ -184,6 +309,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:
|
||||
@@ -203,13 +329,48 @@ async def process_mail_account(account_id: int):
|
||||
)
|
||||
):
|
||||
gmail_cred.is_valid = False # type: ignore[assignment]
|
||||
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
|
||||
logger.warning(
|
||||
f"Gmail credentials revoked for user {account.user_id}. "
|
||||
"User must re-authorise."
|
||||
)
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
await send_user_notification(
|
||||
db=notif_db,
|
||||
user_id=int(account.user_id),
|
||||
title="InboxRescue: Gmail Authorization Expired",
|
||||
body=f"Your Gmail credentials for account '{account.name}' have been revoked. Please re-authorize Gmail access in Settings.",
|
||||
notify_on_error=True,
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
logger.warning(
|
||||
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:
|
||||
@@ -220,6 +381,17 @@ async def process_mail_account(account_id: int):
|
||||
)
|
||||
)
|
||||
|
||||
# Also persist UIDs of dropped empty emails so they are not
|
||||
# re-fetched and re-evaluated on the next run.
|
||||
for uid in skipped_empty_uids:
|
||||
if uid not in already_seen_uids:
|
||||
db.add(
|
||||
DownloadedMessageId(
|
||||
mail_account_id=account.id,
|
||||
message_uid=uid,
|
||||
)
|
||||
)
|
||||
|
||||
# If Gmail API was used, persist any refreshed access token back to
|
||||
# the DB so the next run doesn't need an extra token-refresh call.
|
||||
if use_gmail_api and gmail_service and gmail_cred:
|
||||
@@ -237,7 +409,9 @@ async def process_mail_account(account_id: int):
|
||||
run.emails_forwarded = emails_forwarded # type: ignore[assignment]
|
||||
run.emails_failed = emails_failed # type: ignore[assignment]
|
||||
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
run.duration_seconds = (run.completed_at - run.started_at).total_seconds()
|
||||
run.duration_seconds = (
|
||||
run.completed_at - _as_utc(run.started_at) # type: ignore[arg-type]
|
||||
).total_seconds()
|
||||
run.status = "completed" if emails_failed == 0 else "partial_failure" # type: ignore[assignment]
|
||||
|
||||
# Update account
|
||||
@@ -248,6 +422,8 @@ async def process_mail_account(account_id: int):
|
||||
if emails_failed == 0:
|
||||
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
||||
account.last_error_message = None # type: ignore[assignment]
|
||||
account.last_error_at = None # type: ignore[assignment]
|
||||
else:
|
||||
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
@@ -255,6 +431,38 @@ async def process_mail_account(account_id: int):
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send failure notification after the commit so the status is
|
||||
# persisted even if the notification fails. Use a fresh session
|
||||
# to avoid interfering with the (now-committed) main transaction.
|
||||
if emails_failed > 0:
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
await send_user_notification(
|
||||
db=notif_db,
|
||||
user_id=int(account.user_id),
|
||||
title="InboxRescue: Mail Forwarding Failures",
|
||||
body=f"Mail account '{account.name}': {emails_failed} email(s) failed to forward.",
|
||||
notify_on_error=True,
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
logger.warning(f"Failed to send notification: {notify_exc}")
|
||||
|
||||
# Record Prometheus metrics for this completed run
|
||||
_run_status = "completed" if emails_failed == 0 else "partial_failure"
|
||||
MAIL_PROCESSING_RUNS_TOTAL.labels(status=_run_status).inc()
|
||||
MAIL_PROCESSING_EMAILS_TOTAL.labels(operation="forwarded").inc(
|
||||
emails_forwarded
|
||||
)
|
||||
MAIL_PROCESSING_EMAILS_TOTAL.labels(operation="failed").inc(emails_failed)
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
MAIL_PROCESSING_DURATION_SECONDS.observe(_task_duration)
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="process_mail_account", status="success"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(
|
||||
task_name="process_mail_account"
|
||||
).observe(_task_duration)
|
||||
|
||||
logger.info(
|
||||
f"Processed account {account.id}: "
|
||||
f"{emails_forwarded} forwarded, {emails_failed} failed"
|
||||
@@ -263,13 +471,31 @@ async def process_mail_account(account_id: int):
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing account {account_id}: {e}")
|
||||
|
||||
# Mark run as failed
|
||||
# Record failure metric
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
MAIL_PROCESSING_RUNS_TOTAL.labels(status="failed").inc()
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="process_mail_account", status="failure"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(
|
||||
task_name="process_mail_account"
|
||||
).observe(_task_duration)
|
||||
|
||||
# Mark run as failed – roll back any pending/broken transaction first
|
||||
# so the session is in a clean state before we write the failure status.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rb_exc:
|
||||
logger.warning(f"Rollback failed during error handler: {rb_exc}")
|
||||
|
||||
if "run" in locals():
|
||||
run.status = "failed" # type: ignore[assignment]
|
||||
run.error_message = str(e) # type: ignore[assignment]
|
||||
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
run.duration_seconds = (
|
||||
run.completed_at - run.started_at
|
||||
# Use the locally-captured start time to avoid accessing an
|
||||
# expired ORM attribute after the session rollback above.
|
||||
run.duration_seconds = ( # type: ignore[assignment]
|
||||
run.completed_at - _run_started_at
|
||||
).total_seconds()
|
||||
|
||||
# Update account error status
|
||||
@@ -277,8 +503,35 @@ async def process_mail_account(account_id: int):
|
||||
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
account.last_error_message = str(e) # type: ignore[assignment]
|
||||
# Always update last_check_at so process_all_enabled_accounts
|
||||
# throttles re-dispatch instead of queuing a new task every cycle.
|
||||
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception as commit_exc:
|
||||
logger.error(
|
||||
f"Failed to persist failed status for run of account "
|
||||
f"{account_id}: {commit_exc}"
|
||||
)
|
||||
|
||||
# Send error notification after the commit (and outside the run/account
|
||||
# guards) so the status is always persisted first. Use a fresh session
|
||||
# to avoid the post-rollback session's broken greenlet context causing
|
||||
# the notification query itself to fail with "greenlet_spawn has not
|
||||
# been called".
|
||||
if "account" in locals() and account is not None:
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
await send_user_notification(
|
||||
db=notif_db,
|
||||
user_id=int(account.user_id),
|
||||
title="InboxRescue: Mail Processing Error",
|
||||
body=f"Error processing mail account '{account.name}': {e}",
|
||||
notify_on_error=True,
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
logger.warning(f"Failed to send error notification: {notify_exc}")
|
||||
|
||||
|
||||
@celery_app.task(base=AsyncTask, name="app.workers.tasks.process_all_enabled_accounts")
|
||||
@@ -287,8 +540,35 @@ async def process_all_enabled_accounts():
|
||||
Process all enabled mail accounts.
|
||||
This task is scheduled to run periodically.
|
||||
"""
|
||||
_task_start = time.monotonic()
|
||||
async with async_session_maker() as db:
|
||||
try:
|
||||
# Mark stale "running" runs as failed. A run is considered stale
|
||||
# when it has been in the "running" state longer than the Celery
|
||||
# hard time limit (30 min) plus a small buffer – this recovers from
|
||||
# worker crashes or SIGKILL events faster than waiting for the
|
||||
# daily cleanup_old_logs task.
|
||||
stale_threshold = datetime.now(timezone.utc) - timedelta(minutes=35)
|
||||
stale_result = await db.execute(
|
||||
select(ProcessingRun).where(
|
||||
ProcessingRun.status == "running",
|
||||
ProcessingRun.started_at < stale_threshold,
|
||||
)
|
||||
)
|
||||
stale_runs = stale_result.scalars().all()
|
||||
for stale_run in stale_runs:
|
||||
stale_run.status = "failed" # type: ignore[assignment]
|
||||
stale_run.error_message = ( # type: ignore[assignment]
|
||||
"Run timed out or worker was killed before completion"
|
||||
)
|
||||
stale_run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
stale_run.duration_seconds = ( # type: ignore[assignment]
|
||||
stale_run.completed_at - _as_utc(stale_run.started_at) # type: ignore[arg-type]
|
||||
).total_seconds()
|
||||
if stale_runs:
|
||||
await db.commit()
|
||||
logger.info(f"Marked {len(stale_runs)} stale processing runs as failed")
|
||||
|
||||
# Fetch all enabled accounts regardless of operational status so
|
||||
# that accounts in ERROR state are retried automatically.
|
||||
result = await db.execute(
|
||||
@@ -299,13 +579,14 @@ async def process_all_enabled_accounts():
|
||||
accounts = result.scalars().all()
|
||||
|
||||
logger.info(f"Processing {len(accounts)} enabled mail accounts")
|
||||
ACTIVE_MAIL_ACCOUNTS.set(len(accounts))
|
||||
|
||||
# Process each account
|
||||
for account in accounts:
|
||||
# Check if it's time to check this account
|
||||
if account.last_check_at:
|
||||
time_since_last_check = (
|
||||
datetime.now(timezone.utc) - account.last_check_at
|
||||
time_since_last_check = datetime.now(timezone.utc) - _as_utc(
|
||||
account.last_check_at
|
||||
)
|
||||
if time_since_last_check.total_seconds() < (
|
||||
account.check_interval_minutes * 60
|
||||
@@ -316,22 +597,64 @@ async def process_all_enabled_accounts():
|
||||
# Queue processing task
|
||||
process_mail_account.delay(account.id)
|
||||
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="process_all_enabled_accounts", status="success"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(
|
||||
task_name="process_all_enabled_accounts"
|
||||
).observe(_task_duration)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing accounts: {e}")
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="process_all_enabled_accounts", status="failure"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(
|
||||
task_name="process_all_enabled_accounts"
|
||||
).observe(_task_duration)
|
||||
|
||||
|
||||
@celery_app.task(base=AsyncTask, name="app.workers.tasks.cleanup_old_logs")
|
||||
async def cleanup_old_logs(days_to_keep: int = 30):
|
||||
"""
|
||||
Clean up old processing logs, runs, and downloaded message ID records.
|
||||
Also marks stale "running" runs (older than the Celery task time-limit)
|
||||
as "failed" to recover from worker crashes or SIGKILL events.
|
||||
|
||||
Args:
|
||||
days_to_keep: Number of days of data to retain
|
||||
"""
|
||||
_task_start = time.monotonic()
|
||||
async with async_session_maker() as db:
|
||||
try:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
|
||||
|
||||
# Mark stale "running" runs as "failed".
|
||||
# A run is considered stale when it has been in the "running" state
|
||||
# for longer than the Celery hard time limit (30 min) plus a small
|
||||
# buffer – meaning the worker was likely killed before it could write
|
||||
# the final status (OOM kill, container restart, SIGKILL, etc.).
|
||||
stale_threshold = datetime.now(timezone.utc) - timedelta(minutes=35)
|
||||
stale_result = await db.execute(
|
||||
select(ProcessingRun).where(
|
||||
ProcessingRun.status == "running",
|
||||
ProcessingRun.started_at < stale_threshold,
|
||||
)
|
||||
)
|
||||
stale_runs = stale_result.scalars().all()
|
||||
for stale_run in stale_runs:
|
||||
stale_run.status = "failed" # type: ignore[assignment]
|
||||
stale_run.error_message = "Run timed out or worker was killed before completion" # type: ignore[assignment]
|
||||
stale_run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
stale_run.duration_seconds = ( # type: ignore[assignment]
|
||||
stale_run.completed_at - _as_utc(stale_run.started_at) # type: ignore[arg-type]
|
||||
).total_seconds()
|
||||
|
||||
if stale_runs:
|
||||
logger.info(f"Marked {len(stale_runs)} stale processing runs as failed")
|
||||
|
||||
# Delete old processing runs
|
||||
result = await db.execute(
|
||||
select(ProcessingRun).where(ProcessingRun.started_at < cutoff_date)
|
||||
@@ -365,5 +688,20 @@ async def cleanup_old_logs(days_to_keep: int = 30):
|
||||
f"{len(old_logs)} old logs"
|
||||
)
|
||||
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="cleanup_old_logs", status="success"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(task_name="cleanup_old_logs").observe(
|
||||
_task_duration
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up logs: {e}")
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="cleanup_old_logs", status="failure"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(task_name="cleanup_old_logs").observe(
|
||||
_task_duration
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Core Framework
|
||||
fastapi==0.109.1 # Updated: Fixed ReDoS vulnerability (was 0.109.0)
|
||||
fastapi==0.135.2 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.13.1
|
||||
@@ -11,7 +11,8 @@ psycopg2-binary==2.9.11
|
||||
asyncpg==0.31.0
|
||||
|
||||
# Authentication
|
||||
python-jose[cryptography]==3.3.0
|
||||
|
||||
python-jose[cryptography]==3.5.0 # Updated: Fixed algorithm confusion with OpenSSH ECDSA keys (was 3.3.0)
|
||||
bcrypt==4.3.0
|
||||
python-multipart==0.0.22 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
|
||||
authlib==1.6.9 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
|
||||
@@ -37,7 +38,7 @@ celery==5.6.2
|
||||
redis==7.3.0
|
||||
|
||||
# Security & Encryption
|
||||
cryptography==46.0.5 # Updated: Fixed NULL pointer dereference (was 42.0.0)
|
||||
cryptography==46.0.6 # Updated: Fixed NULL pointer dereference (was 42.0.0)
|
||||
|
||||
# Notifications
|
||||
apprise==1.7.1
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ async def test_user(db_session: AsyncSession) -> User:
|
||||
email="test@example.com",
|
||||
hashed_password=get_password_hash("testpassword123"),
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
@@ -97,8 +96,7 @@ async def test_admin_user(db_session: AsyncSession) -> User:
|
||||
email="admin@example.com",
|
||||
hashed_password=get_password_hash("adminpassword123"),
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
is_admin=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
@@ -109,14 +107,14 @@ async def test_admin_user(db_session: AsyncSession) -> User:
|
||||
@pytest.fixture
|
||||
def auth_headers(test_user: User) -> dict:
|
||||
"""Generate authentication headers for test user"""
|
||||
access_token = create_access_token(data={"sub": test_user.email})
|
||||
access_token = create_access_token(data={"sub": str(test_user.id)})
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_auth_headers(test_admin_user: User) -> dict:
|
||||
"""Generate authentication headers for admin user"""
|
||||
access_token = create_access_token(data={"sub": test_admin_user.email})
|
||||
access_token = create_access_token(data={"sub": str(test_admin_user.id)})
|
||||
return {"Authorization": f"Bearer {access_token}"}
|
||||
|
||||
|
||||
@@ -131,8 +129,7 @@ def user_factory(db_session: AsyncSession):
|
||||
email: str = None,
|
||||
password: str = "testpassword123",
|
||||
is_active: bool = True,
|
||||
is_verified: bool = True,
|
||||
is_admin: bool = False,
|
||||
is_superuser: bool = False,
|
||||
) -> User:
|
||||
if email is None:
|
||||
import uuid
|
||||
@@ -143,8 +140,7 @@ def user_factory(db_session: AsyncSession):
|
||||
email=email,
|
||||
hashed_password=get_password_hash(password),
|
||||
is_active=is_active,
|
||||
is_verified=is_verified,
|
||||
is_admin=is_admin,
|
||||
is_superuser=is_superuser,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -89,7 +89,7 @@ class TestApplicationFactory:
|
||||
|
||||
async def test_app_title(self, app):
|
||||
"""Test that app has correct title"""
|
||||
assert app.title == "POP3 Forwarder SaaS"
|
||||
assert app.title == "InboxConverge"
|
||||
|
||||
async def test_app_version(self, app):
|
||||
"""Test that app has a version"""
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Unit tests for empty-email detection in the task processing pipeline.
|
||||
|
||||
Covers two layers:
|
||||
1. tasks._empty_email_check_logic – the inline body-inspection that
|
||||
decides whether a parsed email should be dropped.
|
||||
2. The "clear last_error_message on success" contract – verifying that a
|
||||
successful run nulls out any previously stored error so the status page
|
||||
no longer shows stale IMAP errors.
|
||||
"""
|
||||
|
||||
import email as email_lib
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse(raw: bytes):
|
||||
"""Parse raw bytes into an email.Message object (same call used in tasks.py)."""
|
||||
return email_lib.message_from_bytes(raw)
|
||||
|
||||
|
||||
def _body_has_content(msg) -> bool:
|
||||
"""
|
||||
Mirror of the body-detection logic in tasks.py's email processing loop.
|
||||
|
||||
Returns True if the message has at least one non-empty / non-whitespace
|
||||
text or binary payload.
|
||||
"""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
payload = part.get_payload(decode=True)
|
||||
if isinstance(payload, bytes) and payload.strip():
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
return isinstance(payload, bytes) and bool(payload.strip())
|
||||
|
||||
|
||||
def _is_empty_email(raw: bytes) -> bool:
|
||||
"""
|
||||
Replicates the complete empty-email gate from tasks.py:
|
||||
- no subject AND no from AND no body → True (should be dropped)
|
||||
"""
|
||||
msg = _parse(raw)
|
||||
email_subject = (msg.get("Subject", "") or "").strip() or None
|
||||
email_from = (msg.get("From", "") or "").strip() or None
|
||||
|
||||
if email_subject or email_from:
|
||||
return False # Has at least a header → not empty
|
||||
|
||||
return not _body_has_content(msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body-detection tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBodyHasContent:
|
||||
def test_plain_text_body(self):
|
||||
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\nHello world"
|
||||
msg = _parse(raw)
|
||||
assert _body_has_content(msg) is True
|
||||
|
||||
def test_empty_body(self):
|
||||
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n"
|
||||
msg = _parse(raw)
|
||||
assert _body_has_content(msg) is False
|
||||
|
||||
def test_crlf_only_body(self):
|
||||
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n\r\n"
|
||||
msg = _parse(raw)
|
||||
assert _body_has_content(msg) is False
|
||||
|
||||
def test_whitespace_only_body(self):
|
||||
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n \t "
|
||||
msg = _parse(raw)
|
||||
assert _body_has_content(msg) is False
|
||||
|
||||
def test_multipart_with_content(self):
|
||||
raw = (
|
||||
b"MIME-Version: 1.0\r\n"
|
||||
b"Content-Type: multipart/mixed; boundary=X\r\n\r\n"
|
||||
b"--X\r\n"
|
||||
b"Content-Type: text/plain\r\n\r\n"
|
||||
b"Hello from multipart\r\n"
|
||||
b"--X--\r\n"
|
||||
)
|
||||
msg = _parse(raw)
|
||||
assert _body_has_content(msg) is True
|
||||
|
||||
def test_multipart_all_empty_parts(self):
|
||||
raw = (
|
||||
b"MIME-Version: 1.0\r\n"
|
||||
b"Content-Type: multipart/mixed; boundary=X\r\n\r\n"
|
||||
b"--X\r\n"
|
||||
b"Content-Type: text/plain\r\n\r\n"
|
||||
b"\r\n"
|
||||
b"--X--\r\n"
|
||||
)
|
||||
msg = _parse(raw)
|
||||
assert _body_has_content(msg) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Complete empty-email gate tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsEmptyEmail:
|
||||
def test_completely_empty_raw_bytes(self):
|
||||
assert _is_empty_email(b"") is True
|
||||
|
||||
def test_only_headers_no_body(self):
|
||||
raw = b"\r\n"
|
||||
assert _is_empty_email(raw) is True
|
||||
|
||||
def test_no_subject_no_from_no_body(self):
|
||||
raw = b"Date: Mon, 1 Jan 2024 00:00:00 +0000\r\n\r\n"
|
||||
assert _is_empty_email(raw) is True
|
||||
|
||||
def test_has_subject_no_from_no_body(self):
|
||||
"""Subject alone is enough to keep the email."""
|
||||
raw = b"Subject: Alert\r\n\r\n"
|
||||
assert _is_empty_email(raw) is False
|
||||
|
||||
def test_has_from_no_subject_no_body(self):
|
||||
"""From alone is enough to keep the email."""
|
||||
raw = b"From: sender@example.com\r\n\r\n"
|
||||
assert _is_empty_email(raw) is False
|
||||
|
||||
def test_no_headers_but_body_has_content(self):
|
||||
"""Body content alone is enough to keep the email."""
|
||||
raw = b"\r\nThis is the body."
|
||||
assert _is_empty_email(raw) is False
|
||||
|
||||
def test_normal_email_is_not_empty(self):
|
||||
raw = (
|
||||
b"From: sender@example.com\r\n"
|
||||
b"Subject: Hello\r\n\r\n"
|
||||
b"Some body text.\r\n"
|
||||
)
|
||||
assert _is_empty_email(raw) is False
|
||||
|
||||
def test_crlf_only_is_empty(self):
|
||||
assert _is_empty_email(b"\r\n\r\n") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status-clearing contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLastErrorMessageClearOnSuccess:
|
||||
"""
|
||||
The `last_error_message` field must be cleared when a run completes with
|
||||
zero forwarding failures, so the status page does not show stale errors.
|
||||
|
||||
This test exercises the same branch logic used in tasks.py without
|
||||
needing a full Celery / DB setup.
|
||||
"""
|
||||
|
||||
def _simulate_account_status_update(
|
||||
self, emails_failed: int, current_error_message: str | None
|
||||
) -> dict:
|
||||
"""
|
||||
Simulate the account-status block from tasks.py:
|
||||
|
||||
if emails_failed == 0:
|
||||
account.status = ACTIVE
|
||||
account.last_error_message = None
|
||||
account.last_error_at = None
|
||||
else:
|
||||
account.status = ERROR
|
||||
account.last_error_at = <now>
|
||||
account.last_error_message = f"{emails_failed} emails failed to forward"
|
||||
|
||||
Returns a dict with the resulting field values.
|
||||
"""
|
||||
from app.models.database_models import AccountStatus
|
||||
|
||||
state = {
|
||||
"last_error_message": current_error_message,
|
||||
"last_error_at": "2024-01-01",
|
||||
}
|
||||
|
||||
if emails_failed == 0:
|
||||
state["status"] = AccountStatus.ACTIVE
|
||||
state["last_error_message"] = None
|
||||
state["last_error_at"] = None
|
||||
else:
|
||||
state["status"] = AccountStatus.ERROR
|
||||
state["last_error_message"] = f"{emails_failed} emails failed to forward"
|
||||
state["last_error_at"] = "2024-01-02"
|
||||
|
||||
return state
|
||||
|
||||
def test_error_cleared_on_zero_failures(self):
|
||||
"""A previously stored error is wiped when all emails forward OK."""
|
||||
result = self._simulate_account_status_update(
|
||||
emails_failed=0,
|
||||
current_error_message="IMAP fetch error: UID only possible with …",
|
||||
)
|
||||
assert result["last_error_message"] is None
|
||||
assert result["last_error_at"] is None
|
||||
|
||||
def test_error_set_when_failures_exist(self):
|
||||
"""When emails fail, the error message is updated, not cleared."""
|
||||
result = self._simulate_account_status_update(
|
||||
emails_failed=3,
|
||||
current_error_message=None,
|
||||
)
|
||||
assert result["last_error_message"] == "3 emails failed to forward"
|
||||
assert result["last_error_at"] is not None
|
||||
|
||||
def test_no_error_remains_none_on_success(self):
|
||||
"""A clean account stays clean after a successful run."""
|
||||
result = self._simulate_account_status_update(
|
||||
emails_failed=0,
|
||||
current_error_message=None,
|
||||
)
|
||||
assert result["last_error_message"] is None
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
"""
|
||||
Unit tests for the IMAP fetch logic in MailProcessor.
|
||||
|
||||
These tests verify that _fetch_imap_emails:
|
||||
- Uses a plain SEARCH UNSEEN (not uid("search")) to get sequence numbers
|
||||
- Resolves sequence numbers to UIDs via a lightweight FETCH (UID)
|
||||
- Uses UID FETCH / UID STORE for all subsequent operations
|
||||
- Batches stale-UID re-marking into a single STORE command
|
||||
- Does NOT issue a per-message STORE for Seen (RFC822 sets it implicitly)
|
||||
- Batches Deleted STORE into a single command when delete_after_forward=True
|
||||
- Uses RFC 3501 parenthesised flag syntax, e.g. +FLAGS (\\Seen)
|
||||
- Handles an empty UNSEEN result without error
|
||||
- Handles individual message fetch failures gracefully
|
||||
- Always attempts logout in the finally block
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
from app.services.mail_processor import MailProcessor, MailFetchError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to build lightweight fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_account(
|
||||
protocol="imap_ssl",
|
||||
host="imap.example.com",
|
||||
port=993,
|
||||
username="user@example.com",
|
||||
delete_after_forward=False,
|
||||
account_id=1,
|
||||
):
|
||||
"""Return a minimal MailAccount-like mock."""
|
||||
from app.models.database_models import MailProtocol
|
||||
|
||||
account = MagicMock()
|
||||
account.id = account_id
|
||||
account.host = host
|
||||
account.port = port
|
||||
account.username = username
|
||||
account.delete_after_forward = delete_after_forward
|
||||
account.protocol = (
|
||||
MailProtocol.IMAP_SSL if protocol == "imap_ssl" else MailProtocol.IMAP
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
def _make_imap_response(result="OK", lines=None):
|
||||
"""Return an object that looks like an aioimaplib ImapResponse."""
|
||||
resp = MagicMock()
|
||||
resp.result = result
|
||||
resp.lines = lines if lines is not None else [b""]
|
||||
return resp
|
||||
|
||||
|
||||
def _make_fetch_response(uid_str: str, email_bytes: bytes):
|
||||
"""
|
||||
Simulate the lines that aioimaplib returns for a UID FETCH (RFC822) call.
|
||||
|
||||
The typical structure is:
|
||||
[b'<seq> (UID <uid> RFC822 {<size>}', <email_data>, b')']
|
||||
"""
|
||||
header = f"1 (UID {uid_str} RFC822 {{12345}}".encode()
|
||||
return _make_imap_response(result="OK", lines=[header, email_bytes, b")"])
|
||||
|
||||
|
||||
def _make_uid_list_response(seq_uid_pairs):
|
||||
"""
|
||||
Simulate the response from fetch(seq_string, "(UID)").
|
||||
|
||||
seq_uid_pairs is a list of (seq_num_str, uid_str) tuples. Each entry
|
||||
produces a line like b'1 (UID 42)' which the production code parses with
|
||||
a regex.
|
||||
"""
|
||||
lines = [f"{seq} (UID {uid})".encode() for seq, uid in seq_uid_pairs]
|
||||
return _make_imap_response(result="OK", lines=lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchImapEmailsUidCommands:
|
||||
"""Verify UID-based command usage in _fetch_imap_emails."""
|
||||
|
||||
@pytest.fixture
|
||||
def processor(self):
|
||||
from app.services.mail_processor import MailProcessor
|
||||
|
||||
account = _make_account()
|
||||
return MailProcessor(account=account, decrypted_password="secret")
|
||||
|
||||
@pytest.fixture
|
||||
def mock_imap(self):
|
||||
"""A mock aioimaplib client whose async methods are AsyncMock."""
|
||||
client = MagicMock()
|
||||
client.wait_hello_from_server = AsyncMock()
|
||||
client.login = AsyncMock()
|
||||
client.select = AsyncMock()
|
||||
client.search = AsyncMock()
|
||||
client.fetch = AsyncMock()
|
||||
client.uid = AsyncMock()
|
||||
client.logout = AsyncMock()
|
||||
return client
|
||||
|
||||
async def test_uses_plain_search_not_uid_search(self, processor, mock_imap):
|
||||
"""SEARCH must be issued as a plain SEARCH UNSEEN, not via uid()."""
|
||||
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b""])
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
await processor._fetch_imap_emails(10, set())
|
||||
|
||||
# search("UNSEEN") must be called
|
||||
mock_imap.search.assert_awaited_once_with("UNSEEN")
|
||||
# uid("search", ...) must NOT be called
|
||||
search_uid_calls = [
|
||||
c for c in mock_imap.uid.call_args_list if c.args[0] == "search"
|
||||
]
|
||||
assert search_uid_calls == []
|
||||
|
||||
async def test_no_messages_returns_empty(self, processor, mock_imap):
|
||||
"""Empty UNSEEN result should return empty lists without error."""
|
||||
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b""])
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
assert emails == []
|
||||
assert uids == []
|
||||
|
||||
async def test_fetches_new_message_via_uid_fetch(self, processor, mock_imap):
|
||||
"""New messages should be fetched with UID FETCH, not plain FETCH."""
|
||||
raw_email = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"42"])
|
||||
mock_imap.fetch.return_value = _make_uid_list_response([("42", "42")])
|
||||
|
||||
def uid_side_effect(command, *args):
|
||||
if command == "fetch":
|
||||
return _make_fetch_response(args[0], raw_email)
|
||||
return _make_imap_response()
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
assert emails == [raw_email]
|
||||
assert new_uids == ["42"]
|
||||
|
||||
# The fetch call should use UID FETCH
|
||||
fetch_call = [c for c in mock_imap.uid.call_args_list if c.args[0] == "fetch"]
|
||||
assert len(fetch_call) == 1
|
||||
assert fetch_call[0] == call("fetch", "42", "(RFC822)")
|
||||
|
||||
async def test_no_per_message_store_for_seen(self, processor, mock_imap):
|
||||
"""RFC822 implicitly marks \\Seen; no extra STORE per message is needed."""
|
||||
raw_email = b"From: a@b.com\r\n\r\nHello"
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(
|
||||
result="OK", lines=[b"10 11 12"]
|
||||
)
|
||||
mock_imap.fetch.return_value = _make_uid_list_response(
|
||||
[("10", "10"), ("11", "11"), ("12", "12")]
|
||||
)
|
||||
|
||||
def uid_side_effect(command, *args):
|
||||
if command == "fetch":
|
||||
uid_str = args[0]
|
||||
return _make_fetch_response(uid_str, raw_email)
|
||||
return _make_imap_response()
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
# No STORE command should have been called (delete_after_forward=False)
|
||||
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
|
||||
assert store_calls == [], "Expected no STORE commands for \\Seen"
|
||||
|
||||
assert len(emails) == 3
|
||||
assert new_uids == ["10", "11", "12"]
|
||||
|
||||
async def test_stale_uids_batched_in_single_store(self, processor, mock_imap):
|
||||
"""UIDs already in already_seen_uids must be re-marked in one batch STORE."""
|
||||
# Two messages: one stale (already in DB), one new
|
||||
raw_email = b"From: x@y.com\r\n\r\nNew mail"
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"5 6"])
|
||||
mock_imap.fetch.return_value = _make_uid_list_response([("5", "5"), ("6", "6")])
|
||||
|
||||
def uid_side_effect(command, *args):
|
||||
if command == "fetch":
|
||||
return _make_fetch_response(args[0], raw_email)
|
||||
return _make_imap_response()
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(
|
||||
10, already_seen_uids={"5"} # UID 5 is stale
|
||||
)
|
||||
|
||||
# Only UID 6 is new
|
||||
assert new_uids == ["6"]
|
||||
assert len(emails) == 1
|
||||
|
||||
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
|
||||
# Exactly one STORE for the stale UID with RFC 3501 parenthesised syntax
|
||||
assert len(store_calls) == 1
|
||||
assert store_calls[0] == call("store", "5", "+FLAGS", "(\\Seen)")
|
||||
|
||||
async def test_multiple_stale_uids_batched_together(self, processor, mock_imap):
|
||||
"""Multiple stale UIDs should be sent as a comma-separated set."""
|
||||
mock_imap.search.return_value = _make_imap_response(
|
||||
result="OK", lines=[b"1 2 3 4"]
|
||||
)
|
||||
mock_imap.fetch.return_value = _make_uid_list_response(
|
||||
[("1", "1"), ("2", "2"), ("3", "3"), ("4", "4")]
|
||||
)
|
||||
|
||||
def uid_side_effect(command, *args):
|
||||
if command == "fetch":
|
||||
return _make_fetch_response(args[0], b"email data")
|
||||
return _make_imap_response()
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
await processor._fetch_imap_emails(10, already_seen_uids={"1", "2"})
|
||||
|
||||
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
|
||||
assert len(store_calls) == 1
|
||||
# The UID set string should contain both stale UIDs (order may vary)
|
||||
uid_set_arg = store_calls[0].args[1]
|
||||
parts = set(uid_set_arg.split(","))
|
||||
assert parts == {"1", "2"}
|
||||
# RFC 3501 parenthesised flag syntax
|
||||
assert store_calls[0].args[3] == "(\\Seen)"
|
||||
|
||||
async def test_delete_after_forward_uses_single_batch_store(self):
|
||||
"""delete_after_forward=True must issue one UID STORE \\Deleted command."""
|
||||
from app.services.mail_processor import MailProcessor
|
||||
|
||||
account = _make_account(delete_after_forward=True)
|
||||
processor = MailProcessor(account=account, decrypted_password="s")
|
||||
|
||||
mock_imap = MagicMock()
|
||||
mock_imap.wait_hello_from_server = AsyncMock()
|
||||
mock_imap.login = AsyncMock()
|
||||
mock_imap.select = AsyncMock()
|
||||
mock_imap.expunge = AsyncMock()
|
||||
mock_imap.logout = AsyncMock()
|
||||
mock_imap.search = AsyncMock(
|
||||
return_value=_make_imap_response(result="OK", lines=[b"7 8"])
|
||||
)
|
||||
mock_imap.fetch = AsyncMock(
|
||||
return_value=_make_uid_list_response([("7", "7"), ("8", "8")])
|
||||
)
|
||||
|
||||
def uid_side_effect(command, *args):
|
||||
if command == "fetch":
|
||||
return _make_fetch_response(args[0], b"raw email")
|
||||
return _make_imap_response()
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
assert new_uids == ["7", "8"]
|
||||
|
||||
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
|
||||
# Exactly one STORE for deletion covering both UIDs
|
||||
assert len(store_calls) == 1
|
||||
uid_set_arg = store_calls[0].args[1]
|
||||
parts = set(uid_set_arg.split(","))
|
||||
assert parts == {"7", "8"}
|
||||
assert store_calls[0].args[2] == "+FLAGS"
|
||||
# RFC 3501 parenthesised flag syntax
|
||||
assert store_calls[0].args[3] == "(\\Deleted)"
|
||||
# expunge must also be called
|
||||
mock_imap.expunge.assert_awaited_once()
|
||||
|
||||
async def test_individual_fetch_failure_does_not_abort(self, processor, mock_imap):
|
||||
"""A single message fetch error should be logged but not stop processing."""
|
||||
raw_email = b"From: ok@example.com\r\n\r\nOK"
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(
|
||||
result="OK", lines=[b"20 21"]
|
||||
)
|
||||
mock_imap.fetch.return_value = _make_uid_list_response(
|
||||
[("20", "20"), ("21", "21")]
|
||||
)
|
||||
|
||||
call_count = {"count": 0}
|
||||
|
||||
def uid_side_effect(command, *args):
|
||||
if command == "fetch":
|
||||
call_count["count"] += 1
|
||||
if call_count["count"] == 1:
|
||||
raise Exception("Connection dropped by server")
|
||||
return _make_fetch_response(args[0], raw_email)
|
||||
return _make_imap_response()
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
# Second message should still be processed
|
||||
assert len(emails) == 1
|
||||
assert new_uids == ["21"]
|
||||
|
||||
async def test_logout_called_even_on_connection_error(self, processor, mock_imap):
|
||||
"""logout() must be attempted even when the connection drops mid-session."""
|
||||
mock_imap.search = AsyncMock(side_effect=Exception("BYE server gone"))
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
from app.services.mail_processor import MailFetchError
|
||||
|
||||
with pytest.raises(MailFetchError):
|
||||
await processor._fetch_imap_emails(10, set())
|
||||
|
||||
mock_imap.logout.assert_awaited_once()
|
||||
|
||||
async def test_logout_failure_does_not_mask_error(self, processor, mock_imap):
|
||||
"""If logout itself raises, the original MailFetchError should propagate."""
|
||||
mock_imap.search = AsyncMock(side_effect=Exception("server gone"))
|
||||
mock_imap.logout = AsyncMock(side_effect=Exception("logout also failed"))
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
from app.services.mail_processor import MailFetchError
|
||||
|
||||
with pytest.raises(MailFetchError):
|
||||
await processor._fetch_imap_emails(10, set())
|
||||
|
||||
async def test_respects_max_count_limit(self, processor, mock_imap):
|
||||
"""Only max_count messages should be processed."""
|
||||
raw_email = b"From: a@b.com\r\n\r\nHi"
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(
|
||||
result="OK", lines=[b"1 2 3 4 5"]
|
||||
)
|
||||
# After max_count=3, only seq 1,2,3 are resolved to UIDs
|
||||
mock_imap.fetch.return_value = _make_uid_list_response(
|
||||
[("1", "1"), ("2", "2"), ("3", "3")]
|
||||
)
|
||||
|
||||
def uid_side_effect(command, *args):
|
||||
if command == "fetch":
|
||||
return _make_fetch_response(args[0], raw_email)
|
||||
return _make_imap_response()
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(3, set())
|
||||
|
||||
assert len(emails) == 3
|
||||
assert new_uids == ["1", "2", "3"]
|
||||
# fetch for UIDs should have been called with the first 3 seq numbers
|
||||
mock_imap.fetch.assert_awaited_once_with("1,2,3", "(UID)")
|
||||
|
||||
async def test_plain_imap_uses_imap4_not_ssl(self):
|
||||
"""Non-SSL IMAP accounts must use IMAP4, not IMAP4_SSL."""
|
||||
from app.services.mail_processor import MailProcessor
|
||||
|
||||
account = _make_account(protocol="imap")
|
||||
processor = MailProcessor(account=account, decrypted_password="pw")
|
||||
|
||||
mock_imap = MagicMock()
|
||||
mock_imap.wait_hello_from_server = AsyncMock()
|
||||
mock_imap.login = AsyncMock()
|
||||
mock_imap.select = AsyncMock()
|
||||
mock_imap.logout = AsyncMock()
|
||||
mock_imap.search = AsyncMock(
|
||||
return_value=_make_imap_response(result="OK", lines=[b""])
|
||||
)
|
||||
mock_imap.uid = AsyncMock(
|
||||
return_value=_make_imap_response(result="OK", lines=[b""])
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4",
|
||||
return_value=mock_imap,
|
||||
) as mock_cls:
|
||||
await processor._fetch_imap_emails(10, set())
|
||||
mock_cls.assert_called_once()
|
||||
|
||||
async def test_whitespace_only_rfc822_body_is_skipped(self, processor, mock_imap):
|
||||
"""A UID FETCH that returns only whitespace bytes must not be added to results.
|
||||
|
||||
T-Online (and potentially other servers) can return RFC822 responses
|
||||
whose body is just CR LF or other whitespace. The extraction loop
|
||||
must treat these as absent email data, not as a valid message.
|
||||
"""
|
||||
# Simulate a server that returns b"\r\n" instead of real email bytes
|
||||
whitespace_response = _make_imap_response(
|
||||
result="OK",
|
||||
lines=[b"1 (UID 99 RFC822 {2}", b"\r\n", b")"],
|
||||
)
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"99"])
|
||||
mock_imap.fetch.return_value = _make_uid_list_response([("99", "99")])
|
||||
|
||||
mock_imap.uid = AsyncMock(
|
||||
side_effect=lambda cmd, *args: (
|
||||
whitespace_response if cmd == "fetch" else _make_imap_response()
|
||||
)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
# Whitespace-only response must not produce a message
|
||||
assert emails == []
|
||||
assert new_uids == []
|
||||
|
||||
async def test_empty_bytes_rfc822_body_is_skipped(self, processor, mock_imap):
|
||||
"""A UID FETCH that returns b'' as the body must not be added to results."""
|
||||
empty_response = _make_imap_response(
|
||||
result="OK",
|
||||
lines=[b"1 (UID 77 RFC822 {0}", b"", b")"],
|
||||
)
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"77"])
|
||||
mock_imap.fetch.return_value = _make_uid_list_response([("77", "77")])
|
||||
|
||||
mock_imap.uid = AsyncMock(
|
||||
side_effect=lambda cmd, *args: (
|
||||
empty_response if cmd == "fetch" else _make_imap_response()
|
||||
)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
assert emails == []
|
||||
assert new_uids == []
|
||||
|
||||
async def test_bytearray_literal_data_is_accepted(self, processor, mock_imap):
|
||||
"""aioimaplib stores IMAP literal data as bytearray, not bytes.
|
||||
|
||||
This is the root cause of emails appearing empty on IMAP servers such
|
||||
as T-Online and GMX: the extraction loop previously skipped bytearray
|
||||
objects because ``isinstance(bytearray(...), bytes)`` is False.
|
||||
|
||||
The fix accepts both bytes and bytearray and converts to bytes so the
|
||||
rest of the pipeline receives plain bytes as expected.
|
||||
"""
|
||||
raw_email = b"From: user@t-online.de\r\nSubject: Real email\r\n\r\nBody"
|
||||
# aioimaplib returns the RFC822 literal as bytearray in response.lines
|
||||
bytearray_response = _make_imap_response(
|
||||
result="OK",
|
||||
lines=[
|
||||
b"1 (UID 55 RFC822 {%d}" % len(raw_email),
|
||||
bytearray(raw_email),
|
||||
b")",
|
||||
],
|
||||
)
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"55"])
|
||||
mock_imap.fetch.return_value = _make_uid_list_response([("55", "55")])
|
||||
|
||||
mock_imap.uid = AsyncMock(
|
||||
side_effect=lambda cmd, *args: (
|
||||
bytearray_response if cmd == "fetch" else _make_imap_response()
|
||||
)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
# The email must be extracted and returned as plain bytes
|
||||
assert new_uids == ["55"]
|
||||
assert len(emails) == 1
|
||||
assert emails[0] == raw_email
|
||||
assert isinstance(emails[0], bytes)
|
||||
|
||||
async def test_bytearray_whitespace_literal_is_skipped(self, processor, mock_imap):
|
||||
"""A bytearray literal that is whitespace-only must still be rejected."""
|
||||
bytearray_ws_response = _make_imap_response(
|
||||
result="OK",
|
||||
lines=[b"1 (UID 56 RFC822 {2}", bytearray(b"\r\n"), b")"],
|
||||
)
|
||||
|
||||
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"56"])
|
||||
mock_imap.fetch.return_value = _make_uid_list_response([("56", "56")])
|
||||
|
||||
mock_imap.uid = AsyncMock(
|
||||
side_effect=lambda cmd, *args: (
|
||||
bytearray_ws_response if cmd == "fetch" else _make_imap_response()
|
||||
)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
assert emails == []
|
||||
assert new_uids == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional edge-case tests for remaining branch coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchImapEdgeCases:
|
||||
"""Tests for edge cases in _fetch_imap_emails not covered above."""
|
||||
|
||||
async def test_search_ok_but_empty_split_returns_empty(self):
|
||||
"""If SEARCH UNSEEN returns OK but lines[0].split() is empty, return []."""
|
||||
account = _make_account()
|
||||
processor = MailProcessor(account, "secret")
|
||||
|
||||
mock_imap = AsyncMock()
|
||||
mock_imap.wait_hello_from_server = AsyncMock()
|
||||
mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
|
||||
mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
|
||||
# lines[0] is a non-empty string with only spaces => split() returns []
|
||||
mock_imap.search = AsyncMock(
|
||||
return_value=_make_imap_response("OK", lines=[b" "])
|
||||
)
|
||||
mock_imap.logout = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
assert emails == []
|
||||
assert new_uids == []
|
||||
|
||||
async def test_stale_uid_store_failure_is_non_fatal(self):
|
||||
"""If the UID STORE to re-mark stale UIDs fails, processing continues."""
|
||||
account = _make_account()
|
||||
processor = MailProcessor(account, "secret")
|
||||
|
||||
mock_imap = AsyncMock()
|
||||
mock_imap.wait_hello_from_server = AsyncMock()
|
||||
mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
|
||||
mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
|
||||
mock_imap.search = AsyncMock(
|
||||
return_value=_make_imap_response("OK", lines=[b"1"])
|
||||
)
|
||||
mock_imap.fetch = AsyncMock(
|
||||
return_value=_make_uid_list_response([("1", "100")])
|
||||
)
|
||||
|
||||
async def uid_side_effect(cmd, *args):
|
||||
if cmd == "store":
|
||||
raise Exception("store failed")
|
||||
return _make_imap_response("OK")
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
mock_imap.logout = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, {"100"})
|
||||
|
||||
assert emails == []
|
||||
assert new_uids == []
|
||||
|
||||
async def test_delete_failure_is_non_fatal(self):
|
||||
"""If batch UID STORE \\Deleted fails, emails are still returned."""
|
||||
email_bytes = b"From: a@b.com\r\nSubject: hi\r\n\r\nbody"
|
||||
account = _make_account(delete_after_forward=True)
|
||||
processor = MailProcessor(account, "secret")
|
||||
|
||||
mock_imap = AsyncMock()
|
||||
mock_imap.wait_hello_from_server = AsyncMock()
|
||||
mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
|
||||
mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
|
||||
mock_imap.search = AsyncMock(
|
||||
return_value=_make_imap_response("OK", lines=[b"1"])
|
||||
)
|
||||
mock_imap.fetch = AsyncMock(return_value=_make_uid_list_response([("1", "42")]))
|
||||
|
||||
async def uid_side_effect(cmd, *args):
|
||||
if cmd == "fetch":
|
||||
return _make_fetch_response("42", email_bytes)
|
||||
if cmd == "store":
|
||||
raise Exception("delete failed")
|
||||
return _make_imap_response("OK")
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
mock_imap.logout = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
assert len(emails) == 1
|
||||
assert new_uids == ["42"]
|
||||
|
||||
async def test_mail_fetch_error_is_re_raised_directly(self):
|
||||
"""A MailFetchError raised inside the try block is re-raised, not wrapped."""
|
||||
account = _make_account()
|
||||
processor = MailProcessor(account, "secret")
|
||||
|
||||
mock_imap = AsyncMock()
|
||||
mock_imap.wait_hello_from_server = AsyncMock()
|
||||
mock_imap.login = AsyncMock(side_effect=MailFetchError("inner error"))
|
||||
mock_imap.logout = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
with pytest.raises(MailFetchError, match="inner error"):
|
||||
await processor._fetch_imap_emails(10, set())
|
||||
|
||||
async def test_response_lines_with_star_prefix_are_skipped(self):
|
||||
"""Response lines starting with b'*' are filtered out."""
|
||||
email_bytes = b"From: a@b.com\r\nSubject: test\r\n\r\nbody"
|
||||
account = _make_account()
|
||||
processor = MailProcessor(account, "secret")
|
||||
|
||||
mock_imap = AsyncMock()
|
||||
mock_imap.wait_hello_from_server = AsyncMock()
|
||||
mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
|
||||
mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
|
||||
mock_imap.search = AsyncMock(
|
||||
return_value=_make_imap_response("OK", lines=[b"1"])
|
||||
)
|
||||
mock_imap.fetch = AsyncMock(return_value=_make_uid_list_response([("1", "99")]))
|
||||
|
||||
fetch_resp = _make_imap_response(
|
||||
"OK",
|
||||
lines=[
|
||||
b"1 (UID 99 RFC822 {100}", # header containing RFC822
|
||||
"some string line", # non-bytes/bytearray => skipped
|
||||
b"* extra info", # starts with * => skipped
|
||||
email_bytes, # actual data
|
||||
b")", # closing paren => skipped
|
||||
],
|
||||
)
|
||||
|
||||
async def uid_side_effect(cmd, *args):
|
||||
if cmd == "fetch":
|
||||
return fetch_resp
|
||||
return _make_imap_response("OK")
|
||||
|
||||
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
|
||||
mock_imap.logout = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||
|
||||
assert len(emails) == 1
|
||||
assert emails[0] == email_bytes
|
||||
assert new_uids == ["99"]
|
||||
@@ -0,0 +1,782 @@
|
||||
"""
|
||||
Unit tests for POP3 fetch, connection testing, and email forwarding in MailProcessor.
|
||||
|
||||
These tests cover the methods not exercised by test_mail_processor_imap.py:
|
||||
- test_connection() routing to POP3 / IMAP helpers
|
||||
- _test_pop3_connection() for POP3_SSL and plain POP3
|
||||
- _test_imap_connection() for IMAP_SSL and plain IMAP
|
||||
- fetch_emails() delegation to POP3 / IMAP
|
||||
- _fetch_pop3_emails() end-to-end: UIDL, skip-seen, max_count, delete, errors
|
||||
- forward_email() via STARTTLS and SSL, multipart / plain, error paths
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.models.database_models import MailProtocol
|
||||
from app.services.mail_processor import (
|
||||
MailProcessor,
|
||||
MailFetchError,
|
||||
MailForwardError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_account(
|
||||
protocol="pop3_ssl",
|
||||
host="pop.example.com",
|
||||
port=995,
|
||||
username="user@example.com",
|
||||
delete_after_forward=False,
|
||||
account_id=1,
|
||||
max_emails_per_check=50,
|
||||
):
|
||||
"""Return a minimal MailAccount-like mock."""
|
||||
proto_map = {
|
||||
"pop3_ssl": MailProtocol.POP3_SSL,
|
||||
"pop3": MailProtocol.POP3,
|
||||
"imap_ssl": MailProtocol.IMAP_SSL,
|
||||
"imap": MailProtocol.IMAP,
|
||||
}
|
||||
account = MagicMock()
|
||||
account.id = account_id
|
||||
account.host = host
|
||||
account.port = port
|
||||
account.username = username
|
||||
account.delete_after_forward = delete_after_forward
|
||||
account.protocol = proto_map[protocol]
|
||||
account.max_emails_per_check = max_emails_per_check
|
||||
return account
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_connection – routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTestConnection:
|
||||
"""test_connection() should delegate to POP3 or IMAP helpers."""
|
||||
|
||||
async def test_routes_to_pop3_for_pop3_ssl(self):
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(
|
||||
proc, "_test_pop3_connection", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.return_value = (True, "ok")
|
||||
result = await proc.test_connection()
|
||||
mock.assert_awaited_once()
|
||||
assert result == (True, "ok")
|
||||
|
||||
async def test_routes_to_pop3_for_pop3(self):
|
||||
account = _make_account(protocol="pop3")
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(
|
||||
proc, "_test_pop3_connection", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.return_value = (True, "ok")
|
||||
result = await proc.test_connection()
|
||||
mock.assert_awaited_once()
|
||||
assert result == (True, "ok")
|
||||
|
||||
async def test_routes_to_imap_for_imap_ssl(self):
|
||||
account = _make_account(protocol="imap_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(
|
||||
proc, "_test_imap_connection", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.return_value = (True, "connected")
|
||||
result = await proc.test_connection()
|
||||
mock.assert_awaited_once()
|
||||
assert result == (True, "connected")
|
||||
|
||||
async def test_routes_to_imap_for_imap(self):
|
||||
account = _make_account(protocol="imap")
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(
|
||||
proc, "_test_imap_connection", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.return_value = (True, "connected")
|
||||
result = await proc.test_connection()
|
||||
mock.assert_awaited_once()
|
||||
assert result == (True, "connected")
|
||||
|
||||
async def test_returns_false_on_unexpected_exception(self):
|
||||
account = _make_account(protocol="imap_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(
|
||||
proc, "_test_imap_connection", new_callable=AsyncMock
|
||||
) as mock:
|
||||
mock.side_effect = RuntimeError("boom")
|
||||
success, msg = await proc.test_connection()
|
||||
assert success is False
|
||||
assert "boom" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _test_pop3_connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTestPop3Connection:
|
||||
"""Unit tests for _test_pop3_connection()."""
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_pop3_ssl_success(self, mock_poplib):
|
||||
"""POP3_SSL: successful connection reports message count."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.stat.return_value = (42, 123456)
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_pop3_connection()
|
||||
|
||||
assert success is True
|
||||
assert "42 messages" in msg
|
||||
mock_conn.user.assert_called_once_with("user@example.com")
|
||||
mock_conn.pass_.assert_called_once_with("secret")
|
||||
mock_conn.quit.assert_called_once()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_pop3_plain_success(self, mock_poplib):
|
||||
"""Plain POP3: uses POP3 (not POP3_SSL)."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.stat.return_value = (10, 5000)
|
||||
mock_poplib.POP3.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3", port=110)
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_pop3_connection()
|
||||
|
||||
assert success is True
|
||||
assert "10 messages" in msg
|
||||
mock_poplib.POP3.assert_called_once()
|
||||
mock_poplib.POP3_SSL.assert_not_called()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_pop3_auth_error(self, mock_poplib):
|
||||
"""Authentication failure returns False with auth message."""
|
||||
import poplib as real_poplib
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.user.side_effect = real_poplib.error_proto("authentication failed")
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
mock_poplib.error_proto = real_poplib.error_proto
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_pop3_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Authentication failed" in msg
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_pop3_protocol_error(self, mock_poplib):
|
||||
"""Non-auth protocol error returns False with protocol error message."""
|
||||
import poplib as real_poplib
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.user.side_effect = real_poplib.error_proto("some protocol error")
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
mock_poplib.error_proto = real_poplib.error_proto
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_pop3_connection()
|
||||
|
||||
assert success is False
|
||||
assert "POP3 protocol error" in msg
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_pop3_generic_exception(self, mock_poplib):
|
||||
"""Generic exception returns False with connection-failed message."""
|
||||
import poplib as real_poplib
|
||||
|
||||
mock_poplib.error_proto = real_poplib.error_proto
|
||||
mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_pop3_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Connection failed" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _test_imap_connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTestImapConnection:
|
||||
"""Unit tests for _test_imap_connection()."""
|
||||
|
||||
@patch("app.services.mail_processor.aioimaplib")
|
||||
async def test_imap_ssl_success(self, mock_aioimaplib):
|
||||
"""IMAP_SSL: successful connection reports message count."""
|
||||
mock_client = AsyncMock()
|
||||
mock_aioimaplib.IMAP4_SSL.return_value = mock_client
|
||||
|
||||
# login response
|
||||
login_resp = MagicMock()
|
||||
login_resp.result = "OK"
|
||||
mock_client.login.return_value = login_resp
|
||||
|
||||
# search response with 3 messages
|
||||
search_resp = MagicMock()
|
||||
search_resp.lines = [b"1 2 3"]
|
||||
mock_client.search.return_value = search_resp
|
||||
|
||||
account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_imap_connection()
|
||||
|
||||
assert success is True
|
||||
assert "3 messages" in msg
|
||||
mock_client.wait_hello_from_server.assert_awaited_once()
|
||||
mock_client.login.assert_awaited_once()
|
||||
mock_client.select.assert_awaited_once_with("INBOX")
|
||||
mock_client.logout.assert_awaited_once()
|
||||
|
||||
@patch("app.services.mail_processor.aioimaplib")
|
||||
async def test_imap_plain_success(self, mock_aioimaplib):
|
||||
"""Plain IMAP: uses IMAP4, not IMAP4_SSL."""
|
||||
mock_client = AsyncMock()
|
||||
mock_aioimaplib.IMAP4.return_value = mock_client
|
||||
|
||||
login_resp = MagicMock()
|
||||
login_resp.result = "OK"
|
||||
mock_client.login.return_value = login_resp
|
||||
|
||||
search_resp = MagicMock()
|
||||
search_resp.lines = [b"1"]
|
||||
mock_client.search.return_value = search_resp
|
||||
|
||||
account = _make_account(protocol="imap", host="imap.example.com", port=143)
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_imap_connection()
|
||||
|
||||
assert success is True
|
||||
mock_aioimaplib.IMAP4.assert_called_once()
|
||||
mock_aioimaplib.IMAP4_SSL.assert_not_called()
|
||||
|
||||
@patch("app.services.mail_processor.aioimaplib")
|
||||
async def test_imap_auth_failure(self, mock_aioimaplib):
|
||||
"""Authentication failure returns False with auth failure message."""
|
||||
mock_client = AsyncMock()
|
||||
mock_aioimaplib.IMAP4_SSL.return_value = mock_client
|
||||
|
||||
login_resp = MagicMock()
|
||||
login_resp.result = "NO"
|
||||
login_resp.lines = ["Invalid credentials"]
|
||||
mock_client.login.return_value = login_resp
|
||||
|
||||
account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_imap_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Authentication failed" in msg
|
||||
|
||||
@patch("app.services.mail_processor.aioimaplib")
|
||||
async def test_imap_generic_exception(self, mock_aioimaplib):
|
||||
"""Generic exception returns False with IMAP-connection-failed message."""
|
||||
mock_aioimaplib.IMAP4_SSL.side_effect = OSError("network unreachable")
|
||||
|
||||
account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
|
||||
proc = MailProcessor(account, "secret")
|
||||
success, msg = await proc._test_imap_connection()
|
||||
|
||||
assert success is False
|
||||
assert "IMAP connection failed" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_emails – routing and defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchEmails:
|
||||
"""fetch_emails() should route and fill defaults correctly."""
|
||||
|
||||
async def test_delegates_to_pop3_for_pop3_ssl(self):
|
||||
account = _make_account(protocol="pop3_ssl", max_emails_per_check=25)
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = ([b"email"], ["uid1"])
|
||||
result = await proc.fetch_emails()
|
||||
# Should use max_emails_per_check as default
|
||||
mock.assert_awaited_once_with(25, set())
|
||||
assert result == ([b"email"], ["uid1"])
|
||||
|
||||
async def test_delegates_to_imap_for_imap_ssl(self):
|
||||
account = _make_account(protocol="imap_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(proc, "_fetch_imap_emails", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = ([], [])
|
||||
await proc.fetch_emails(max_count=10, already_seen_uids={"u1"})
|
||||
mock.assert_awaited_once_with(10, {"u1"})
|
||||
|
||||
async def test_uses_max_count_when_provided(self):
|
||||
account = _make_account(protocol="pop3", max_emails_per_check=100)
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = ([], [])
|
||||
await proc.fetch_emails(max_count=5)
|
||||
mock.assert_awaited_once_with(5, set())
|
||||
|
||||
async def test_uses_max_emails_per_check_when_no_max_count(self):
|
||||
account = _make_account(protocol="pop3_ssl", max_emails_per_check=77)
|
||||
proc = MailProcessor(account, "secret")
|
||||
with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = ([], [])
|
||||
await proc.fetch_emails()
|
||||
mock.assert_awaited_once_with(77, set())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_pop3_emails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchPop3Emails:
|
||||
"""Unit tests for _fetch_pop3_emails()."""
|
||||
|
||||
def _make_pop3_mock(self, uid_entries, retr_data=None, retr_errors=None):
|
||||
"""Build a mock POP3 connection.
|
||||
|
||||
Args:
|
||||
uid_entries: list of (msg_num, uid_string) pairs
|
||||
retr_data: dict mapping msg_num -> bytes to return from retr()
|
||||
retr_errors: dict mapping msg_num -> exception for retr()
|
||||
"""
|
||||
mock_conn = MagicMock()
|
||||
uidl_lines = [f"{n} {uid}".encode() for n, uid in uid_entries]
|
||||
mock_conn.uidl.return_value = (b"+OK", uidl_lines, 0)
|
||||
|
||||
retr_data = retr_data or {}
|
||||
retr_errors = retr_errors or {}
|
||||
|
||||
def retr_side_effect(msg_num):
|
||||
if msg_num in retr_errors:
|
||||
raise retr_errors[msg_num]
|
||||
data = retr_data.get(msg_num, b"From: test\r\nSubject: hi\r\n\r\nbody")
|
||||
return (b"+OK", data.split(b"\r\n"), len(data))
|
||||
|
||||
mock_conn.retr.side_effect = retr_side_effect
|
||||
return mock_conn
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_fetch_pop3_ssl_basic(self, mock_poplib):
|
||||
"""POP3_SSL: fetches messages and returns email data + UIDs."""
|
||||
mock_conn = self._make_pop3_mock(
|
||||
uid_entries=[(1, "abc"), (2, "def")],
|
||||
retr_data={
|
||||
1: b"From: a@b.com\r\nSubject: A\r\n\r\nBody A",
|
||||
2: b"From: c@d.com\r\nSubject: B\r\n\r\nBody B",
|
||||
},
|
||||
)
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
assert len(emails) == 2
|
||||
assert uids == ["abc", "def"]
|
||||
mock_conn.quit.assert_called_once()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_fetch_pop3_plain(self, mock_poplib):
|
||||
"""Plain POP3: uses POP3 (not POP3_SSL)."""
|
||||
mock_conn = self._make_pop3_mock(uid_entries=[(1, "uid1")])
|
||||
mock_poplib.POP3.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3", port=110)
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
assert len(emails) == 1
|
||||
assert uids == ["uid1"]
|
||||
mock_poplib.POP3.assert_called_once()
|
||||
mock_poplib.POP3_SSL.assert_not_called()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_skips_already_seen_uids(self, mock_poplib):
|
||||
"""Already-seen UIDs are skipped."""
|
||||
mock_conn = self._make_pop3_mock(
|
||||
uid_entries=[(1, "seen1"), (2, "new1"), (3, "seen2")]
|
||||
)
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(10, {"seen1", "seen2"})
|
||||
|
||||
assert uids == ["new1"]
|
||||
assert len(emails) == 1
|
||||
# retr should only be called for msg 2
|
||||
mock_conn.retr.assert_called_once_with(2)
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_respects_max_count(self, mock_poplib):
|
||||
"""Only max_count messages are fetched."""
|
||||
mock_conn = self._make_pop3_mock(
|
||||
uid_entries=[(1, "a"), (2, "b"), (3, "c"), (4, "d")]
|
||||
)
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(2, set())
|
||||
|
||||
assert len(emails) == 2
|
||||
assert len(uids) == 2
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_delete_after_forward(self, mock_poplib):
|
||||
"""delete_after_forward=True issues dele() for fetched messages."""
|
||||
mock_conn = self._make_pop3_mock(uid_entries=[(1, "a"), (2, "b")])
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
|
||||
proc = MailProcessor(account, "secret")
|
||||
await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
assert mock_conn.dele.call_count == 2
|
||||
mock_conn.dele.assert_any_call(1)
|
||||
mock_conn.dele.assert_any_call(2)
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_no_delete_when_disabled(self, mock_poplib):
|
||||
"""delete_after_forward=False: no dele() calls."""
|
||||
mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")])
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl", delete_after_forward=False)
|
||||
proc = MailProcessor(account, "secret")
|
||||
await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
mock_conn.dele.assert_not_called()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_individual_retr_error_does_not_abort(self, mock_poplib):
|
||||
"""A single message retr() failure doesn't stop the entire fetch."""
|
||||
mock_conn = self._make_pop3_mock(
|
||||
uid_entries=[(1, "a"), (2, "b"), (3, "c")],
|
||||
retr_errors={2: Exception("corrupt message")},
|
||||
)
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
# Messages 1 and 3 should still be fetched
|
||||
assert len(emails) == 2
|
||||
assert "a" in uids
|
||||
assert "c" in uids
|
||||
assert "b" not in uids
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_delete_error_does_not_abort(self, mock_poplib):
|
||||
"""A dele() error is logged but doesn't raise."""
|
||||
mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")])
|
||||
mock_conn.dele.side_effect = Exception("delete failed")
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
|
||||
proc = MailProcessor(account, "secret")
|
||||
# Should not raise
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
assert len(emails) == 1
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_connection_failure_raises_mail_fetch_error(self, mock_poplib):
|
||||
"""Connection failure raises MailFetchError."""
|
||||
mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
with pytest.raises(MailFetchError, match="POP3 fetch error"):
|
||||
await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_empty_mailbox(self, mock_poplib):
|
||||
"""Empty mailbox returns empty lists."""
|
||||
mock_conn = self._make_pop3_mock(uid_entries=[])
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
assert emails == []
|
||||
assert uids == []
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_uidl_parsing_handles_extra_whitespace(self, mock_poplib):
|
||||
"""UIDL entries with extra whitespace in UID are stripped."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.uidl.return_value = (b"+OK", [b"1 uid_with_space "], 0)
|
||||
mock_conn.retr.return_value = (
|
||||
b"+OK",
|
||||
[b"From: x", b"", b"body"],
|
||||
10,
|
||||
)
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
assert uids == ["uid_with_space"]
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_malformed_uidl_entry_is_skipped(self, mock_poplib):
|
||||
"""UIDL entry without a space (malformed) is silently skipped."""
|
||||
mock_conn = MagicMock()
|
||||
# One malformed entry (no space), one valid entry
|
||||
mock_conn.uidl.return_value = (
|
||||
b"+OK",
|
||||
[b"malformed_no_space", b"2 valid_uid"],
|
||||
0,
|
||||
)
|
||||
mock_conn.retr.return_value = (b"+OK", [b"From: x", b"", b"body"], 10)
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
# Only the valid entry should be processed
|
||||
assert uids == ["valid_uid"]
|
||||
assert len(emails) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# forward_email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForwardEmail:
|
||||
"""Unit tests for forward_email()."""
|
||||
|
||||
SMTP_CONFIG = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "sender@example.com",
|
||||
"password": "smtp_pass",
|
||||
"use_tls": True,
|
||||
}
|
||||
|
||||
SMTP_CONFIG_SSL = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 465,
|
||||
"username": "sender@example.com",
|
||||
"password": "smtp_pass",
|
||||
"use_tls": False,
|
||||
}
|
||||
|
||||
SIMPLE_EMAIL = (
|
||||
b"From: original@sender.com\r\n"
|
||||
b"Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n"
|
||||
b"Subject: Test Subject\r\n"
|
||||
b"\r\n"
|
||||
b"Hello, this is the body."
|
||||
)
|
||||
|
||||
MULTIPART_EMAIL = (
|
||||
b"From: original@sender.com\r\n"
|
||||
b"Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n"
|
||||
b"Subject: Multipart Test\r\n"
|
||||
b"MIME-Version: 1.0\r\n"
|
||||
b'Content-Type: multipart/mixed; boundary="boundary123"\r\n'
|
||||
b"\r\n"
|
||||
b"--boundary123\r\n"
|
||||
b"Content-Type: text/plain; charset=utf-8\r\n"
|
||||
b"\r\n"
|
||||
b"Plain text body.\r\n"
|
||||
b"--boundary123--\r\n"
|
||||
)
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_starttls(self, mock_smtplib):
|
||||
"""STARTTLS path: SMTP + starttls() is used."""
|
||||
mock_server = MagicMock()
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
result = await MailProcessor.forward_email(
|
||||
self.SIMPLE_EMAIL, "MyAccount", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_smtplib.SMTP.assert_called_once_with("smtp.example.com", 587, timeout=30)
|
||||
mock_server.starttls.assert_called_once()
|
||||
mock_server.login.assert_called_once_with("sender@example.com", "smtp_pass")
|
||||
mock_server.send_message.assert_called_once()
|
||||
mock_server.quit.assert_called_once()
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_ssl(self, mock_smtplib):
|
||||
"""SSL path: SMTP_SSL is used when use_tls=False."""
|
||||
mock_server = MagicMock()
|
||||
mock_smtplib.SMTP_SSL.return_value = mock_server
|
||||
|
||||
result = await MailProcessor.forward_email(
|
||||
self.SIMPLE_EMAIL, "MyAccount", "dest@example.com", self.SMTP_CONFIG_SSL
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_smtplib.SMTP_SSL.assert_called_once_with(
|
||||
"smtp.example.com", 465, timeout=30
|
||||
)
|
||||
mock_server.starttls.assert_not_called()
|
||||
mock_server.login.assert_called_once()
|
||||
mock_server.send_message.assert_called_once()
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_preserves_subject(self, mock_smtplib):
|
||||
"""Forwarded email subject includes source account name and original subject."""
|
||||
mock_server = MagicMock()
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
await MailProcessor.forward_email(
|
||||
self.SIMPLE_EMAIL, "Work Mail", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
sent_msg = mock_server.send_message.call_args[0][0]
|
||||
assert "[Fwd from Work Mail]" in sent_msg["Subject"]
|
||||
assert "Test Subject" in sent_msg["Subject"]
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_sets_from_and_to(self, mock_smtplib):
|
||||
"""Forwarded email has correct From/To headers."""
|
||||
mock_server = MagicMock()
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
await MailProcessor.forward_email(
|
||||
self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
sent_msg = mock_server.send_message.call_args[0][0]
|
||||
assert sent_msg["From"] == "sender@example.com"
|
||||
assert sent_msg["To"] == "dest@example.com"
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_multipart_email(self, mock_smtplib):
|
||||
"""Multipart email: extracts text/plain body."""
|
||||
mock_server = MagicMock()
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
result = await MailProcessor.forward_email(
|
||||
self.MULTIPART_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
assert result is True
|
||||
sent_msg = mock_server.send_message.call_args[0][0]
|
||||
# Body should contain original header info and plain text
|
||||
payload = sent_msg.get_payload()
|
||||
assert len(payload) > 0
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_email_body_contains_header_info(self, mock_smtplib):
|
||||
"""Forwarded body includes original From, Date, Subject, Source Account."""
|
||||
mock_server = MagicMock()
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
await MailProcessor.forward_email(
|
||||
self.SIMPLE_EMAIL, "WorkAccount", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
sent_msg = mock_server.send_message.call_args[0][0]
|
||||
# Get body from the MIME parts
|
||||
body_part = sent_msg.get_payload()[0]
|
||||
body_text = body_part.get_payload(decode=True).decode("utf-8")
|
||||
|
||||
assert "Originally from: original@sender.com" in body_text
|
||||
assert "Source Account: WorkAccount" in body_text
|
||||
assert "Hello, this is the body." in body_text
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_smtp_error_raises_forward_error(self, mock_smtplib):
|
||||
"""SMTP send failure raises MailForwardError."""
|
||||
mock_server = MagicMock()
|
||||
mock_server.login.side_effect = Exception("auth failed")
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
with pytest.raises(MailForwardError, match="Forward error"):
|
||||
await MailProcessor.forward_email(
|
||||
self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_quit_error_does_not_mask_success(self, mock_smtplib):
|
||||
"""If quit() fails after successful send, True is still returned."""
|
||||
mock_server = MagicMock()
|
||||
mock_server.quit.side_effect = Exception("quit error")
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
result = await MailProcessor.forward_email(
|
||||
self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_connection_error_raises_forward_error(self, mock_smtplib):
|
||||
"""SMTP connection failure raises MailForwardError."""
|
||||
mock_smtplib.SMTP.side_effect = OSError("connection refused")
|
||||
|
||||
with pytest.raises(MailForwardError, match="Forward error"):
|
||||
await MailProcessor.forward_email(
|
||||
self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_email_without_payload(self, mock_smtplib):
|
||||
"""Email with no payload body is forwarded with just header info."""
|
||||
mock_server = MagicMock()
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
empty_body_email = b"From: x@y.com\r\n" b"Subject: Empty\r\n" b"\r\n"
|
||||
|
||||
result = await MailProcessor.forward_email(
|
||||
empty_body_email, "Acct", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
@patch("app.services.mail_processor.smtplib")
|
||||
async def test_forward_multipart_no_text_plain(self, mock_smtplib):
|
||||
"""Multipart email with no text/plain part forwards with empty body."""
|
||||
mock_server = MagicMock()
|
||||
mock_smtplib.SMTP.return_value = mock_server
|
||||
|
||||
html_only_email = (
|
||||
b"From: x@y.com\r\n"
|
||||
b"Subject: HTML Only\r\n"
|
||||
b"MIME-Version: 1.0\r\n"
|
||||
b'Content-Type: multipart/mixed; boundary="bnd"\r\n'
|
||||
b"\r\n"
|
||||
b"--bnd\r\n"
|
||||
b"Content-Type: text/html; charset=utf-8\r\n"
|
||||
b"\r\n"
|
||||
b"<p>HTML body</p>\r\n"
|
||||
b"--bnd--\r\n"
|
||||
)
|
||||
|
||||
result = await MailProcessor.forward_email(
|
||||
html_only_email, "Acct", "dest@example.com", self.SMTP_CONFIG
|
||||
)
|
||||
|
||||
assert result is True
|
||||
sent_msg = mock_server.send_message.call_args[0][0]
|
||||
body_part = sent_msg.get_payload()[0]
|
||||
body_text = body_part.get_payload(decode=True).decode("utf-8")
|
||||
# Body should have header info but no HTML content extracted
|
||||
assert "Originally from: x@y.com" in body_text
|
||||
@@ -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,15 +92,56 @@ 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
|
||||
|
||||
# Prometheus metrics collection
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.51.2
|
||||
container_name: inboxconverge-prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=30d"
|
||||
- "--web.enable-lifecycle"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
# Grafana dashboards
|
||||
grafana:
|
||||
image: grafana/grafana:10.4.3
|
||||
container_name: inboxconverge-grafana
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro
|
||||
depends_on:
|
||||
- prometheus
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -2,6 +2,51 @@
|
||||
|
||||
Comprehensive task breakdown for repository improvements and production readiness.
|
||||
|
||||
## ✅ Recently Completed
|
||||
|
||||
- [x] **Improved test coverage for `mail_processor.py`**: Added 46 new unit tests covering POP3 connection testing, POP3 email fetching, IMAP edge cases, email forwarding (STARTTLS/SSL), and `fetch_emails`/`test_connection` routing. Coverage increased from ~42% to 98%.
|
||||
|
||||
- [x] **Log noise reduction**: Suppressed `ignored untagged response` INFO messages from `aioimaplib` in Celery workers (set logger to WARNING). Eliminated repeated `file_cache is only supported with oauth2client<4.0.0` warnings from the Gmail API client by passing `cache_discovery=False` to `googleapiclient.discovery.build()`.
|
||||
- [x] **ESLint fix**: Converted `frontend/jest.config.js` to `jest.config.mjs` (ES module syntax) to resolve `@typescript-eslint/no-require-imports` lint error.
|
||||
- [x] **Codecov integration**: Added Codecov coverage reporting with `CODECOV_TOKEN` authentication. Set up Jest for frontend tests with lcov coverage, updated CI to collect and upload both backend (XML via pytest-cov) and frontend (lcov via Jest) coverage reports to Codecov with separate `backend` and `frontend` flags.
|
||||
- [x] **IMAP: fix all emails appearing empty** — `aioimaplib` stores RFC822 literal data as `bytearray`, not `bytes`. The extraction loop was checking `isinstance(line, bytes)` which returns `False` for `bytearray`, so every email body was silently skipped. Fixed to accept both types and convert to `bytes`. Affected T-Online, GMX, and all IMAP accounts.
|
||||
- [x] **CI: fix safety scan EOF error** — replaced `safety scan --json` (Safety CLI v3 requires interactive login) with `pip-audit` (no auth required, maintained by PyPA).
|
||||
- [x] **Security: upgrade fastapi/starlette and fix safety CI command** — Upgraded `fastapi` to `0.135.2` (pulls in `starlette>=1.0.0`) fixing 4 DoS CVEs in `starlette<=0.35.1`; replaced deprecated `safety check` with `safety scan`; added `.safety-policy.yml` to suppress unfixable `ecdsa` side-channel CVEs (maintainers won't fix).
|
||||
- [x] **IMAP RFC 3501 flag syntax & aioimaplib UID SEARCH fix**: `_fetch_imap_emails`
|
||||
now uses a plain `SEARCH UNSEEN` + `FETCH (UID)` to resolve sequence numbers to
|
||||
stable UIDs (aioimaplib blocks `uid("search")`), and wraps all flag names in
|
||||
parentheses (`+FLAGS (\Seen)`, `+FLAGS (\Deleted)`) as required by RFC 3501 to
|
||||
prevent T-Online and other strict servers from dropping the connection with
|
||||
"Too many invalid IMAP commands".
|
||||
- [x] **CI pipeline fixes**: Added `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` to `ci.yml` (Node.js 20 deprecation), fixed Codecov `file:` → `files:` invalid input, replaced `<img>` with `<Image />` from `next/image` in `ProviderWizard.tsx` (ESLint no-img-element).
|
||||
- [x] **IMAP reliability: switched to UID-based commands** — `_fetch_imap_emails` now uses `UID SEARCH`, `UID FETCH`, and `UID STORE` throughout. Sequence numbers are volatile (they shift on expunge), causing "Too many invalid IMAP commands" on strict servers (e.g. T-Online). UIDs are stable. The per-message `STORE +FLAGS \Seen` (redundant — RFC822 sets it implicitly) and per-message `STORE +FLAGS \Deleted` are replaced with single batch commands. Stale already-seen UIDs are re-marked `\Seen` in one command. Logout is now in a `finally` block so a mid-session `BYE` is handled gracefully.
|
||||
- [x] Fixed timezone display bug in Mailbox Activity and Admin Logs pages: ISO timestamps without a `Z` suffix were parsed as local time by JavaScript, shifting "Xm ago" / "Xh ago" displays and absolute dates by the client's UTC offset.
|
||||
- [x] Fixed worker `send_user_notification` using rolled-back DB session causing `greenlet_spawn has not been called` errors; status/`last_check_at` now always committed before sending notifications via a fresh session.
|
||||
- [x] **Dashboard redesign**: Replaced noisy "Recent Processing Runs" table with a per-account "Mailbox Status" view showing last-check status (OK/Error/Pending), relative timestamp, error messages, and lifetime counters. Stats cards updated to show all-time processed count and accounts-with-errors count.
|
||||
- [x] **Provider logos now saved on account creation**: `provider_name` field added to `MailAccountCreate` and `MailAccountUpdate` schemas (backend and frontend). `ProviderWizard` now passes `provider_name` in its `onSelect` callback; `AddMailAccountModal` stores it so logos are displayed correctly on the accounts page.
|
||||
- [x] **Domain-based logo fallback**: `ProviderLogoBanner` now falls back to email-domain matching when `provider_name` is absent, so all known providers (GMX, WEB.DE, T-Online, etc.) show their logo even on legacy accounts.
|
||||
- [x] **Fetch button UX improvements**: The "fetch emails" button on the accounts page now shows a "Fetch" text label for clarity, a tooltip explaining its purpose, a spinning "Fetching…" state during the API call, and a brief green "Queued!" confirmation after success.
|
||||
|
||||
- [x] **Pull Now**: Added "Pull Now" button on Accounts page that immediately queues a `process_mail_account` Celery task via `POST /mail-accounts/{id}/pull-now`. Button shows spinner while in flight and is disabled for inactive accounts.
|
||||
- [x] Fixed 21 mypy type errors: `Column[T]` vs native type mismatches in `notification_service.py`, `mail_processor.py`, `auth.py`, `tasks.py`, `providers.py`, `mail_accounts.py`, and `main.py` (`lifespan` parameter rename).
|
||||
- [x] **Provider logos rework**: Logos now displayed as full-width banner strips at the top of each account card using `next/image fill + object-contain`. Handles all aspect ratios (1:1 square to 6:1 wordmark) without distortion. Proton Mail added.
|
||||
- [x] **Proton Mail provider**: Added Proton Mail preset in backend and ProviderWizard frontend. Domains: proton.me, protonmail.com, protonmail.ch, pm.me. Auto-detect and IMAP/POP3 Bridge settings included.
|
||||
- [x] Redesigned user-facing Logs page to mailbox-centric "Mailbox Activity" view: shows last check status per account + only successful pulls, suppressing noise from empty polling cycles.
|
||||
- [x] Added `has_emails` filter to `GET /processing-runs` and `GET /mail-accounts/{id}/processing-runs` API endpoints.
|
||||
- [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`.
|
||||
- [x] Fixed Black formatting failure in CI (`admin.py` reformatted).
|
||||
- [x] Fixed `/processing-runs` endpoint 404s caused by duplicate path prefix in `logs.py`.
|
||||
- [x] Added Semantic Release workflow (`release.yml`) for automatic versioning and GitHub Releases.
|
||||
- [x] Added `pyproject.toml` with `[tool.semantic_release]` configuration.
|
||||
- [x] Fixed GitOps `update-k8s-manifest` job: corrected image tag computation and `yq` patterns to use `registry.cklnet.com` (private registry) matching the actual k8s manifest image references, so SHA-pinned tags are properly applied on each deploy.
|
||||
- [x] Added GitOps auto-deployment step in `ci.yml` to update preprod k8s manifest in `k8s-cluster-state` repo.
|
||||
- [x] Fixed GitOps `update-k8s-manifest` job: added PAT availability check to skip gracefully when `GH_PAT` secret is not configured, fixing 403 "Write access to repository not granted" pipeline failure.
|
||||
- [x] Fixed Celery `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_all_enabled_accounts` — all mail accounts were silently skipped on every scheduled run.
|
||||
- [x] Fixed **Test Connection** always reporting success regardless of authentication outcome.
|
||||
- [x] Added `POST /mail-accounts/{account_id}/test` endpoint to test existing accounts with stored credentials.
|
||||
|
||||
## 🔴 Critical - Security (In Progress)
|
||||
|
||||
### Completed ✅
|
||||
@@ -11,11 +56,13 @@ Comprehensive task breakdown for repository improvements and production readines
|
||||
- [x] Implement CSRF protection middleware
|
||||
- [x] Document all error codes in docs/ERRORS.md
|
||||
- [x] Create security ADR (Architecture Decision Records)
|
||||
- [x] Upgrade `python-jose` 3.3.0 → 3.5.0 (algorithm confusion with OpenSSH ECDSA keys, CVE, affected < 3.4.0)
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Enable rate limiting per user/tier
|
||||
- [x] Fix bare exception handlers throughout codebase
|
||||
- [x] Update datetime usage to timezone-aware (`DateTime(timezone=True)` columns and `lambda: datetime.now(timezone.utc)` defaults; fixes `DBAPIError` from asyncpg on timezone-naive columns)
|
||||
- [x] Fix `Exception terminating connection` in Celery workers: call `await engine.dispose()` inside task coroutine so pooled asyncpg connections are closed before the event loop is torn down
|
||||
- [ ] Validate redirect_uri to prevent open redirect vulnerabilities
|
||||
- [ ] Add per-user random salt for encryption (currently deterministic)
|
||||
|
||||
@@ -70,12 +117,14 @@ Comprehensive task breakdown for repository improvements and production readines
|
||||
- [x] Write unit tests for schemas and validation
|
||||
- [x] Write unit tests for application factory and core endpoints
|
||||
- [x] Reach 50%+ test coverage (currently 59%)
|
||||
- [x] Write tests for Celery tasks (96% coverage for `tasks.py`)
|
||||
- [x] Write unit tests for admin endpoints (87 tests, 100% coverage on admin.py)
|
||||
- [x] **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components and utility functions. Installed `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. New suites: `date-utils` (30 tests), API interceptors (9 tests), `AuthGuard` (6 tests), `QueryProvider` (2 tests), `DashboardLayout` (14 tests), `NotificationWizard` (32 tests), `ProviderWizard` (20 tests). Total frontend: 119 tests across 8 suites.
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Write unit tests for authentication (target 80%+ coverage)
|
||||
- [ ] Write unit tests for mail processing
|
||||
- [ ] Write integration tests for API endpoints
|
||||
- [ ] Write tests for Celery tasks
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Add end-to-end tests
|
||||
@@ -162,12 +211,21 @@ Comprehensive task breakdown for repository improvements and production readines
|
||||
|
||||
## 📊 Medium Priority - Observability
|
||||
|
||||
### Completed ✅
|
||||
- [x] Add Prometheus metrics endpoint (`/metrics`) to FastAPI backend
|
||||
- [x] Instrument HTTP layer (request count + latency histograms per method/endpoint/status)
|
||||
- [x] Instrument mail-processing tasks (runs, emails fetched/forwarded/failed, duration)
|
||||
- [x] Instrument Gmail API operations (inject, verify, get_profile, get_label — count + latency)
|
||||
- [x] Track OAuth token refreshes and credential invalidation events
|
||||
- [x] Instrument auth endpoints (logins, registrations, OAuth callbacks — by method/status)
|
||||
- [x] Instrument Celery tasks (count + duration per task name)
|
||||
- [x] Add Prometheus scrape config (`monitoring/prometheus.yml`)
|
||||
- [x] Add Grafana auto-provisioned datasource and pre-built dashboard (`monitoring/grafana/`)
|
||||
- [x] Add Prometheus + Grafana services to `docker-compose.new.yml` (Grafana on port 3001)
|
||||
|
||||
### Not Started 📋
|
||||
- [ ] Add Prometheus metrics endpoints
|
||||
- [ ] Integrate Sentry for error tracking
|
||||
- [ ] Add structured logging with correlation IDs
|
||||
- [ ] Create Grafana dashboard templates
|
||||
- [ ] Document monitoring setup
|
||||
- [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
|
||||
@@ -182,9 +240,13 @@ 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] Decoupled Google Sign-In from Gmail API permissions: login now requests only basic profile scopes; Gmail access is granted separately via Settings
|
||||
- [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
|
||||
- [ ] Complete notification service integration (Apprise)
|
||||
- [x] Complete notification service integration (Apprise)
|
||||
- [ ] Add advanced email filtering
|
||||
- [ ] Implement OAuth2 for Gmail (instead of App Passwords)
|
||||
- [ ] Add attachment handling improvements
|
||||
@@ -214,18 +276,35 @@ because the API client layer is missing.
|
||||
- [x] Mail accounts list with CRUD operations + enable/disable toggle
|
||||
- [x] Settings page — Profile, Gmail API connection, SMTP relay, Account info, Security
|
||||
- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields, is_enabled checkbox)
|
||||
- [x] Fix `AddMailAccountModal` edit mode: backend now returns `username` in `MailAccountResponse`; all fields (including protocol, host, port, use\_ssl, username) are editable in edit mode and pre-populated from the stored account; Auto-Detect is shown in edit mode too; only password is omitted from the update payload when left blank
|
||||
- [x] `DashboardLayout` with responsive sidebar
|
||||
- [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
|
||||
- [ ] Error boundary components
|
||||
- [ ] Loading skeletons / proper loading states
|
||||
- [ ] Notification preferences UI
|
||||
- [x] Notification channels page (`/notifications`) with full CRUD, wizard, and test button
|
||||
- [x] Apprise-powered notification wizard for Telegram, Discord, Slack, Email, Webhook, and custom URLs
|
||||
- [x] Admin system alert channels section (`/admin` page) with full CRUD and test
|
||||
- [ ] Subscription management / billing UI
|
||||
|
||||
### Admin Interface ✅
|
||||
- [x] Admin section in sidebar (visible to superusers only)
|
||||
- [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 `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
|
||||
|
||||
---
|
||||
|
||||
## 📅 Milestone Timeline
|
||||
@@ -324,7 +403,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",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import nextJest from 'next/jest.js';
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
dir: './',
|
||||
});
|
||||
|
||||
const customJestConfig = {
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testMatch: ['**/*.test.(ts|tsx|js|jsx)', '**/*.test.ts', '**/*.test.tsx'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/layout.tsx',
|
||||
'!src/**/page.tsx',
|
||||
'!src/instrumentation.ts',
|
||||
'!src/test-setup.ts',
|
||||
],
|
||||
};
|
||||
|
||||
export default createJestConfig(customJestConfig);
|
||||
@@ -6,7 +6,9 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --coverage --coverageReporters=lcov --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.95.0",
|
||||
@@ -19,11 +21,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"jest": "^30.3.0",
|
||||
"jest-environment-jsdom": "^30.3.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg viewBox="0 0 1000 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M156.19727 0 0 387.89062h109.33789l18.22266-54.66992h135.37109l18.22266 54.66992h109.33789L236.89844 0h-80.70117zm598.75585 0v387.89062h85.90821V0h-85.90821zM550.5957 85.908203c-89.81391 0-157.75976 69.768357-157.75976 156.197267 0 91.11491 70.80985 156.19726 157.75976 156.19726 86.94892 0 157.49805-65.08235 157.49805-156.19726 0-86.42891-67.68514-156.197267-157.49805-156.197267zM196.54883 122.35547l40.34961 130.16406h-80.70117l40.35156-130.16406zm354.04687 46.07812c38.78896-.26 70.54883 32.79992 70.54883 73.67188 0 40.61096-31.75987 73.67383-70.54883 73.67383-38.78896 0-70.54882-33.06287-70.54882-73.67383 0-40.87196 31.75986-73.67188 70.54882-73.67188zm394.73438 120.52149c-30.19797 0-54.66797 24.47-54.66797 54.66797 0 30.19797 24.47 54.66992 54.66797 54.66992 30.19797 0 54.66992-24.47195 54.66992-54.66992 0-30.19797-24.47195-54.66797-54.66992-54.66797z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1007 B |
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 761 256" enable-background="new 0 0 761 256" xml:space="preserve">
|
||||
<g id="Kasten_00000045588940817225090170000014015454618541351608_">
|
||||
<rect fill="#21314D" width="761" height="256"/>
|
||||
</g>
|
||||
<g id="Blade_00000056393830187627469590000001934641379702391954_">
|
||||
<g id="Blade_00000115488034364365278570000014383065578570728376_">
|
||||
<path id="Würfel_00000095330303590107368250000006829467438266176953_" fill="#84BC34" d="M113.1944,94.129
|
||||
c-4.475-1.0229-9.2058-2.8129-24.9323-10.9958C67.4213,72.393,62.8184,71.6259,60.1334,75.9731
|
||||
c-3.58,5.7536-0.2557,20.4574,14.0644,47.3076c9.3336,17.6445,24.0373,40.2754,26.7223,43.344
|
||||
c0.6393,0.6393,1.2786,0.7672,1.9179,0.3836c0.6393-0.3836,0.895-0.895,0.3836-1.79
|
||||
c-2.1736-4.0915-9.8451-19.9459-13.2972-28.1289c-8.1829-19.8181-10.1008-26.4667-7.2879-28.001
|
||||
c0.7672-0.3836,1.79-0.1279,4.0915,1.1507c7.9272,4.2193,25.3159,17.6445,28.3845,26.9781
|
||||
c1.2786,4.0915,2.0457,8.8222,2.3014,25.5717c0.3836,22.1195,2.0457,26.211,7.4158,26.211c7.16,0,19.1787-9.973,37.0788-35.0332
|
||||
c11.7629-16.3659,25.5716-39.5083,26.978-43.2162c0.3836-0.7672,0-1.4064-0.5114-1.79c-0.6393-0.3836-1.2786-0.2557-1.79,0.5114
|
||||
c-2.685,3.8358-13.553,17.9002-19.5623,24.8046c-14.3201,16.4937-19.5623,21.3524-22.3752,19.8181
|
||||
c-0.7672-0.3836-1.0229-1.4064-1.0229-3.8358c0-8.5665,3.7079-29.2796,10.8679-36.3118
|
||||
c3.0686-3.0686,7.2879-6.0094,22.7587-14.5759c20.4573-11.3794,23.398-14.7037,20.713-19.1788
|
||||
c-3.58-5.7536-18.7951-10.4844-51.1432-12.4023c-21.0966-1.2786-49.6089-0.7672-53.7004-0.1279
|
||||
c-0.895,0.1279-1.2786,0.6393-1.4064,1.2786c0,0.6393,0.3836,1.1507,1.4064,1.1507c4.8586,0.2557,23.5259,2.0457,32.8595,3.3243
|
||||
c22.503,3.1965,29.6631,5.1143,29.6631,8.1829c0,0.7672-0.7672,1.5343-3.0686,2.685
|
||||
C144.5197,89.0146,123.2952,96.3026,113.1944,94.129"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="freenet">
|
||||
<g id="Text_00000018233044646709815630000010139649164768858556_">
|
||||
<path id="net_00000118388088504095158600000004163244688393149569_" fill="#FFFFFF" d="M606.3997,119.3691h-3.2249
|
||||
c-12.8995,0-14.4171,1.5175-14.4171,12.899v1.8969h32.2488v-1.8969C621.0064,120.6969,619.2991,119.3691,606.3997,119.3691
|
||||
M634.8544,146.4949h-46.2865c0,7.967,0.5691,10.433,2.4661,12.1402c1.7073,1.7072,3.9837,2.2763,12.1407,2.2763h3.2249
|
||||
c7.9674,0,10.2437-0.3794,12.1407-2.2763c0.9485-0.9485,1.5176-1.8969,1.897-3.4144h14.2274
|
||||
c-2.2764,13.468-9.2952,18.5897-25.0402,18.5897h-9.8644c-9.2952,0-14.9861-1.7072-19.1595-6.0701
|
||||
c-4.1734-4.1732-5.8807-9.6742-5.8807-19.1588V131.699c0-9.4845,1.7073-14.9856,5.8807-19.1588
|
||||
c4.363-4.3629,10.054-6.0701,19.1595-6.0701h9.8644c9.2952,0,14.9862,1.7072,19.1596,5.8804
|
||||
c4.363,4.1732,5.8806,9.4845,5.8806,19.3485L634.8544,146.4949z M693.8507,173.6206h-13.6583
|
||||
c-9.2952,0-14.7964-1.7072-19.1596-6.0701c-4.363-4.1732-5.8806-9.6742-5.8806-19.1588v-28.4536h-8.3467v-13.0887h8.3467V90.3464
|
||||
h14.0377v16.5031h19.1596l-1.7073,13.0887h-17.4523v28.6433c0,10.0536,2.4661,11.9505,14.4171,11.9505h10.4334v13.0887
|
||||
L693.8507,173.6206z M533.9347,106.6598c9.2952,0,14.7964,1.7072,19.1595,6.0701c4.3631,4.1732,5.8807,9.6742,5.8807,19.1588
|
||||
v41.732h-14.0377v-39.266c0-7.7773-0.5691-10.2433-2.4661-12.1402c-1.7073-1.8969-3.9837-2.2763-12.1407-2.2763h-16.1244v53.6825
|
||||
h-14.0377v-66.9608H533.9347L533.9347,106.6598z"/>
|
||||
<path id="free_00000134962383033659826450000006705828997229569450_" fill="#FFFFFF" d="M277.4621,119.9381h-18.9699v53.6825
|
||||
h-14.0377v-53.6825h-8.3467v-13.0887h8.3467c0.1897-9.8639,1.7073-14.6062,5.8807-18.7794
|
||||
C254.6983,83.7072,260.3893,82,269.4948,82h13.6583v13.0887h-10.4334c-11.951,0-14.2274,1.3278-14.4171,11.5711h20.8669
|
||||
L277.4621,119.9381L277.4621,119.9381z"/>
|
||||
<path fill="#FFFFFF" d="M484.6131,131.8887c0-10.0536-1.7073-15.1753-5.8807-19.3484c-4.363-4.1732-10.054-5.8804-19.1596-5.8804
|
||||
h-9.8643c-9.2952,0-14.9862,1.7072-19.1596,6.0701c-4.3631,4.3629-5.8807,9.8639-5.8807,19.1588v16.8825
|
||||
c0,9.4845,1.7073,14.7959,5.8807,19.1588c4.363,4.3629,10.054,6.0701,19.1596,6.0701h9.8643
|
||||
c15.5553,0,22.5741-5.1216,25.0403-18.5897h-14.2274c-0.3794,1.7072-0.9485,2.6557-1.897,3.4144
|
||||
c-1.897,1.8969-4.1734,2.2763-12.1407,2.2763h-3.2249c-8.157,0-10.2437-0.5691-12.1407-2.2763
|
||||
c-1.897-1.8969-2.4661-4.3629-2.4661-12.1402h46.2865v-14.7959L484.6131,131.8887z M470.5754,133.9753h-32.2488v-1.8969
|
||||
c0-11.3815,1.7073-12.899,14.4171-12.899h3.2249c12.8995,0,14.6068,1.5175,14.4171,12.899L470.5754,133.9753L470.5754,133.9753z
|
||||
M409.3027,131.8887c0-10.0536-1.7073-15.1753-5.8806-19.3484c-4.3631-4.1732-10.054-5.8804-19.1596-5.8804h-9.8643
|
||||
c-9.2952,0-14.9862,1.7072-19.1596,6.0701c-4.363,4.3629-5.8807,9.8639-5.8807,19.1588v16.8825
|
||||
c0,9.4845,1.7073,14.7959,5.8807,19.1588c4.363,4.3629,10.054,6.0701,19.1596,6.0701h9.8643
|
||||
c15.5553,0,22.5742-5.1216,25.0403-18.5897h-14.2274c-0.3794,1.7072-0.9485,2.6557-1.897,3.4144
|
||||
c-1.897,1.8969-4.1734,2.2763-12.1407,2.2763h-3.2249c-8.157,0-10.4334-0.5691-12.1407-2.2763
|
||||
c-1.897-1.8969-2.4661-4.3629-2.4661-12.1402h46.2865C409.4925,146.6845,409.3027,131.8886,409.3027,131.8887z M395.265,133.9753
|
||||
h-32.2488v-1.8969c0-11.3815,1.7073-12.899,14.4171-12.899h3.2249c12.8995,0,14.6068,1.5175,14.4171,12.899L395.265,133.9753
|
||||
L395.265,133.9753z M290.9308,173.6206v-66.9608h24.6608c18.0214,0,24.8505,6.4495,25.2299,22.1938h-14.2274
|
||||
c-0.1897-3.035-0.9485-5.1216-2.4661-6.8289c-1.897-2.0866-4.5528-2.466-12.5201-2.466h-6.8292v53.6825h-13.848L290.9308,173.6206
|
||||
z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="52 42 88 66">
|
||||
<path fill="#4285f4" d="M58 108h14V74L52 59v43c0 3.32 2.69 6 6 6"/>
|
||||
<path fill="#34a853" d="M120 108h14c3.32 0 6-2.69 6-6V59l-20 15"/>
|
||||
<path fill="#fbbc04" d="M120 48v26l20-15v-8c0-7.42-8.47-11.65-14.4-7.2"/>
|
||||
<path fill="#ea4335" d="M72 74V48l24 18 24-18v26L96 92"/>
|
||||
<path fill="#c5221f" d="M52 51v8l20 15V48l-5.6-4.2c-5.94-4.45-14.4-.22-14.4 7.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) by Marsupilami -->
|
||||
<svg
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.1"
|
||||
id="svg3852"
|
||||
width="1024"
|
||||
height="347"
|
||||
viewBox="-1.2 -1.2 125.08 42.4">
|
||||
<defs
|
||||
id="defs3854" />
|
||||
<path
|
||||
style="fill:#1c449b;fill-opacity:1"
|
||||
id="path3685"
|
||||
d="m 109.17,18.84 10.66,-17.75 -10.88,0 -5.67,9 -6.1,-9 -11.92,0 12.57,17.73 -12.57,20 11.92,0 6.8,-11.37 8.06,11.37 10.64,0 z M 69.75,1.09 61.66,21.28 53.99,1.09 l -9.67,0 -6.42,37.78 9.77,0 3.16,-21.77 0.1,0 8.66,21.77 3.91,0 9.07,-21.77 0.12,0 2.75,21.77 9.82,0 -5.72,-37.78 z m -49.8,16.71 0,7.68 8,0 C 27.53,29.79 24.27,32 20.05,32 13.63,32 10.16,26.09 10.16,20.25 10.16,14.41 13.49,8.4 19.9,8.4 c 3.94,0 6.76,2.38 8.11,5.95 l 9.26,-3.88 C 34.04,3.47 28.06,0 20.34,0 8.25,0 0,8 0,20.19 0,32 8.2,40 20,40 26.27,40 31.8,37.76 35.46,32.64 38.75,28 39.2,23.28 39.3,17.8 Z"
|
||||
class="cls-1" />
|
||||
</svg>
|
||||
<!-- version: 20171223, original size: 122.68 40, border: 3% -->
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="90" height="31" xmlns="http://www.w3.org/2000/svg" class="apple-icloud-logo" aria-hidden="true"><g fill="none" fill-rule="nonzero"><path d="M77.005 23.215c1.568 0 2.767-.779 3.382-2.06h.061V23H83V8.204h-2.552v5.793h-.061c-.615-1.302-1.855-2.092-3.392-2.092-2.726 0-4.479 2.143-4.479 5.65v.01c0 3.497 1.742 5.65 4.489 5.65Zm.768-2.153c-1.64 0-2.654-1.333-2.654-3.497v-.01c0-2.163 1.025-3.496 2.654-3.496 1.568 0 2.675 1.374 2.675 3.496v.01c0 2.133-1.096 3.497-2.675 3.497Zm-13.05 2.153c1.64 0 2.757-.758 3.32-1.917h.052V23h2.552V12.13h-2.552v6.297c0 1.579-.933 2.635-2.398 2.635-1.455 0-2.173-.872-2.173-2.41v-6.521h-2.552v7.024c0 2.522 1.363 4.06 3.751 4.06Zm-10.826 0c3.187 0 5.257-2.122 5.257-5.65v-.02c0-3.507-2.1-5.64-5.267-5.64-3.157 0-5.248 2.154-5.248 5.64v.02c0 3.518 2.06 5.65 5.258 5.65Zm.01-2.06c-1.63 0-2.665-1.303-2.665-3.59v-.02c0-2.256 1.056-3.568 2.645-3.568 1.619 0 2.664 1.302 2.664 3.568v.02c0 2.277-1.035 3.59-2.644 3.59ZM44.137 23h2.55V8.204h-2.55V23Zm-8.357.256c3.402 0 5.913-2.102 6.292-5.137l.02-.102H39.5l-.031.102c-.482 1.825-1.804 2.84-3.69 2.84-2.572 0-4.232-2.07-4.232-5.362v-.01c0-3.282 1.65-5.343 4.233-5.343 1.926 0 3.228 1.056 3.658 2.748l.051.195h2.593l-.01-.103c-.39-3.014-2.89-5.137-6.292-5.137-4.243 0-6.938 2.912-6.938 7.64v.01c0 4.727 2.685 7.66 6.938 7.66ZM25.424 10.572a1.4 1.4 0 1 0 0-2.8c-.799 0-1.424.626-1.424 1.406 0 .759.625 1.394 1.424 1.394ZM24.144 23h2.551V12.13h-2.552V23Z" fill="#1D1D1F"></path><path d="M12.9 7.598c.608-.737 1.04-1.74 1.04-2.755 0-.14-.013-.28-.038-.394-.99.038-2.183.66-2.893 1.498-.559.635-1.079 1.65-1.079 2.666 0 .153.026.305.038.356.064.012.165.025.267.025.888 0 2.004-.597 2.664-1.396Zm.697 1.612c-1.484 0-2.69.901-3.464.901-.825 0-1.903-.85-3.197-.85C4.486 9.26 2 11.292 2 15.113c0 2.387.914 4.9 2.056 6.526.977 1.37 1.827 2.5 3.057 2.5 1.218 0 1.751-.812 3.261-.812 1.536 0 1.878.787 3.223.787 1.332 0 2.22-1.218 3.058-2.425.939-1.383 1.332-2.729 1.345-2.793-.076-.025-2.626-1.066-2.626-3.986 0-2.526 2.004-3.656 2.118-3.745-1.32-1.904-3.337-1.955-3.895-1.955Z" fill="#1D1D1F" opacity=".569"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 401 401" id="logo"><path fill="#fff" d="M.4.9h400v400H.4z"></path><path d="M9.8 10.2h381.3v381.3H9.8V10.2zm265.8
|
||||
122v-29.1h65.2v181.1h-35.7v-152h-29.5zm-31.2
|
||||
58c1.7 20-1.1 30.8-3.4 36.2l-28.2-38.3c29.6-20.8
|
||||
36.9-54.8 13-76.5-19.8-14-40.1-14-61
|
||||
0-21 19.3-19.6 42.6 4.2 69.8-40.7
|
||||
25-44.1 52.9-26.5 82.4 18.8 27.8 60.7
|
||||
34.3 97.1 10.5l7.1 9.7H285l-24.4-31.1c10.1-10
|
||||
15.9-31.9 13.9-62.6h-30.1v-.1zm-55.9
|
||||
15.5c-19.5 13-25.6 26.8-16 42 10.9
|
||||
12.2 28.3 11.8 47.5 0l-31.5-42zm5.8-42l-8-11.8c-5.7-11.9-2.5-21.9
|
||||
9.7-21.9 13.2.7 17.5 9.5 8.8 23.5l-10.5 10.2zM45.1
|
||||
132.3v-29.1h65.2v181.1H74.6v-152H45.1z" fill-rule="evenodd" clip-rule="evenodd" fill="#003d8f"></path></svg>
|
||||
|
After Width: | Height: | Size: 943 B |
@@ -0,0 +1,203 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="1040.8409"
|
||||
height="742.6319"
|
||||
viewBox="0 0 1040.8409 742.6319"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="Microsoft_Outlook_for_Windows_(23H2).svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#999999"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
showguides="true"
|
||||
inkscape:zoom="0.68540009"
|
||||
inkscape:cx="518.67515"
|
||||
inkscape:cy="371.31597"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="705"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g178" /><defs
|
||||
id="defs1"><linearGradient
|
||||
id="linearGradient35"
|
||||
inkscape:collect="always"><stop
|
||||
style="stop-color:#4de2fb;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop35" /><stop
|
||||
style="stop-color:#28aee8;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop36" /></linearGradient><linearGradient
|
||||
id="linearGradient5"
|
||||
inkscape:collect="always"><stop
|
||||
style="stop-color:#1582d8;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop5" /><stop
|
||||
style="stop-color:#0a64cc;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop6" /></linearGradient><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient5"
|
||||
id="linearGradient6"
|
||||
x1="749.08496"
|
||||
y1="212.54776"
|
||||
x2="738.25281"
|
||||
y2="843.01855"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-100)" /><filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter31"
|
||||
x="-0.082073628"
|
||||
y="-0.083710964"
|
||||
width="1.173352"
|
||||
height="1.1768103"><feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.380392"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood30" /><feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="22.291021"
|
||||
id="feGaussianBlur30" /><feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="6.000000"
|
||||
dy="6.000000"
|
||||
id="feOffset30" /><feComposite
|
||||
result="comp1"
|
||||
operator="in"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite30" /><feComposite
|
||||
result="comp2"
|
||||
operator="over"
|
||||
in="SourceGraphic"
|
||||
in2="comp1"
|
||||
id="feComposite31" /></filter><linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient35"
|
||||
id="linearGradient36"
|
||||
x1="1773.6024"
|
||||
y1="939.29254"
|
||||
x2="2272.7681"
|
||||
y2="729.0332"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(982.3684)" /><filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter150"
|
||||
x="-0.055988274"
|
||||
y="-0.098944267"
|
||||
width="1.139125"
|
||||
height="1.2259069"><feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.258824"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood149" /><feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="21.052631"
|
||||
id="feGaussianBlur149" /><feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="24.500000"
|
||||
dy="14.307693"
|
||||
id="feOffset149" /><feComposite
|
||||
result="comp1"
|
||||
operator="out"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite149" /><feComposite
|
||||
result="comp2"
|
||||
operator="atop"
|
||||
in="comp1"
|
||||
in2="SourceGraphic"
|
||||
id="feComposite150" /></filter><filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter178"
|
||||
x="-0.098988717"
|
||||
y="-0.15388756"
|
||||
width="1.2403472"
|
||||
height="1.3574102"><feFlood
|
||||
result="flood"
|
||||
in="SourceGraphic"
|
||||
flood-opacity="0.258824"
|
||||
flood-color="rgb(0,0,0)"
|
||||
id="feFlood177" /><feGaussianBlur
|
||||
result="blur"
|
||||
in="SourceGraphic"
|
||||
stdDeviation="31.600000"
|
||||
id="feGaussianBlur177" /><feOffset
|
||||
result="offset"
|
||||
in="blur"
|
||||
dx="32.461540"
|
||||
dy="24.461538"
|
||||
id="feOffset177" /><feComposite
|
||||
result="comp1"
|
||||
operator="out"
|
||||
in="flood"
|
||||
in2="offset"
|
||||
id="feComposite177" /><feComposite
|
||||
result="comp2"
|
||||
operator="atop"
|
||||
in="comp1"
|
||||
in2="SourceGraphic"
|
||||
id="feComposite178" /></filter></defs><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-446.11368,-168.81981)"><g
|
||||
id="g178"
|
||||
transform="matrix(0.7210846,0,0,0.7210846,-938.36876,150.65219)"><path
|
||||
style="display:inline;opacity:1;fill:#158fda;fill-opacity:1;stroke:none;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round"
|
||||
d="m 2367.3337,1055.0767 h 732.6061 c 85.7128,0 155.1967,-69.48394 155.1967,-155.19671 V 374.94388 c 0,-45.60025 -22.8376,-87.46848 -62.4463,-110.06358 L 2809.398,46.917857 C 2758.4496,17.853896 2695.916,17.96081 2645.0673,47.198812 L 2210.7257,296.94524 v 601.52342 c 0,86.49223 70.1158,156.60804 156.608,156.60804 z"
|
||||
id="path34"
|
||||
sodipodi:nodetypes="ccccccscsc" /><path
|
||||
style="opacity:1;fill:url(#linearGradient36);stroke:none;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round"
|
||||
d="m 2512.5126,955.90485 c 4.0919,-47.14548 33.902,-88.17006 77.478,-106.6248 0,0 396.8646,-222.88902 593.633,-338.22376 60.9006,-35.69659 71.5129,-108.06656 71.5129,-108.06656 V 898.2074 c 0,86.63652 -70.2328,156.8693 -156.8693,156.8693 h -475.7159 c -56.6639,0 -104.1681,-42.8129 -110.0387,-99.17185 z"
|
||||
id="path35"
|
||||
sodipodi:nodetypes="ccscccsc" /><g
|
||||
id="g42"
|
||||
style="display:inline"
|
||||
transform="translate(1651.4631)"><path
|
||||
d="M 1519.877,251.2168 837.52547,628.32813 973.5841,710.78516 c 50.4883,30.59807 123.685,47.15596 175.1597,18.24806 l 363.2363,-203.9922 c 48.6498,-27.32142 91.6933,-72.27526 91.6933,-150.09714 0,-79.04771 -54.5195,-105.13695 -83.7964,-123.72708 z"
|
||||
style="display:inline;opacity:1;fill:#01459b;fill-opacity:1;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round;filter:url(#filter178)"
|
||||
id="path41"
|
||||
sodipodi:nodetypes="ccsccsc" /><path
|
||||
d="m 1079.793,26.322261 c -29.9013,-0.0714 -59.8212,7.48914 -86.73635,22.685551 L 416.91603,374.29883 635.43751,506.73242 1305.6445,128.9082 1166.4199,49.421871 c -26.8423,-15.32484 -56.7257,-23.02816 -86.6269,-23.09961 z"
|
||||
style="opacity:1;fill:#26aee9;fill-opacity:1;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="path38" /><path
|
||||
d="M 1305.6445,128.9082 635.4375,506.73242 854.61328,639.5625 1537.8821,262.45117 c -5.424,-4.0613 -12.0375,-7.82732 -18.0051,-11.23437 z"
|
||||
style="opacity:1;fill:#0078d3;fill-opacity:1;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round;filter:url(#filter150)"
|
||||
id="path40"
|
||||
sodipodi:nodetypes="cccccc" /></g><g
|
||||
id="g6"
|
||||
style="display:inline"
|
||||
transform="rotate(-0.9287484,1474.7334,-101601.24)"><path
|
||||
style="display:inline;opacity:1;fill:url(#linearGradient6);stroke:none;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round;filter:url(#filter31)"
|
||||
d="m 380.53529,208.22328 537.09931,8.64896 a 52.98464,52.98464 0 0 1 52.1209,54.03844 l -10.4693,522.88117 a 54.6032,54.6032 0 0 1 -55.4714,53.50306 l -531.12401,-8.55273 a 55.65563,55.65563 0 0 1 -54.74837,-56.76254 l 10.48154,-523.49416 a 51.2957,51.2957 0 0 1 52.11133,-50.2622 z"
|
||||
id="path5" /><path
|
||||
fill="#ffffff"
|
||||
d="m 490.16342,435.72863 c 13.25707,-27.52059 34.65526,-50.55134 61.4478,-66.13255 29.67095,-16.55143 63.4538,-24.80264 97.62945,-23.84585 31.67488,-0.66941 62.93009,7.1541 90.35178,22.61579 25.78231,14.98096 46.54721,36.93136 59.77465,63.18764 14.40512,28.93194 21.58426,60.77252 20.95235,92.92329 0.69807,33.60039 -6.68849,66.89327 -21.56841,97.21171 -13.54236,27.19494 -34.96606,49.92756 -61.62353,65.38926 -6.0643,3.39337 -12.30713,6.42823 -18.69446,9.09614 -23.60846,9.861 -49.19125,14.70928 -75.03188,14.12058 -32.35158,0.76072 -64.29589,-7.15141 -92.34744,-22.87362 -26.00557,-15.00042 -47.03096,-36.97566 -60.56713,-63.30246 -14.49056,-28.51363 -21.75515,-59.99567 -21.18663,-91.80803 -0.60366,-33.31437 6.52861,-66.33062 20.86345,-96.5819 z m 65.49701,155.25283 c 7.06819,17.39811 19.05454,32.48715 34.56775,43.51674 15.80126,10.76035 34.72211,16.31173 53.99372,15.83973 20.52372,0.79094 40.75387,-4.94979 57.6329,-16.35472 15.31682,-10.99467 26.98825,-26.12467 33.56993,-43.51674 7.3576,-19.42313 10.98851,-39.99846 10.71081,-60.69999 0.2274,-20.89962 -3.18575,-41.68509 -10.09476,-61.47214 -6.10205,-17.86475 -17.39723,-33.61785 -32.51352,-45.3464 -16.4559,-11.94474 -36.63162,-18.00306 -57.13398,-17.15505 -19.68921,-0.49685 -39.03386,5.09818 -55.25548,15.98274 -15.77575,11.07592 -27.98953,26.29859 -35.21345,43.88804 -16.02521,40.31873 -16.10859,84.99973 -0.23498,125.37485 z"
|
||||
id="path21"
|
||||
sodipodi:nodetypes="ccccccccscccccccccccccccccc"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke-width:0.680212" /></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="263px" height="154px" viewBox="0 0 263 154" enable-background="new 0 0 263 154" xml:space="preserve">
|
||||
<style>
|
||||
.maintext {fill: #fff;}
|
||||
.point {fill: #ee955c;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="maintext" d="M34.885,93.998l-8.927,35.462l-6.453,0.714l8.927-35.462L34.885,93.998z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="maintext" d="M53.396,91.948l-8.927,35.463l-6.453,0.714l8.927-35.463L53.396,91.948z"/>
|
||||
<path class="maintext" d="M80.005,99.756c0.165,1.491,0.098,2.945-0.201,4.361c-0.3,1.417-0.908,2.703-1.825,3.857
|
||||
c-0.918,1.154-2.185,2.136-3.801,2.943c-1.617,0.809-3.668,1.35-6.152,1.625l-1.653,0.183l1.341,12.113l-7.222,0.799l-3.77-34.058
|
||||
l9.458-1.047c2.189-0.242,4.107-0.207,5.753,0.106c1.646,0.313,3.036,0.866,4.17,1.661c1.133,0.794,2.02,1.82,2.663,3.076
|
||||
C79.407,96.635,79.82,98.094,80.005,99.756z M65.717,106.809l1.421-0.158c2.035-0.225,3.523-0.798,4.467-1.72
|
||||
c0.943-0.923,1.314-2.292,1.113-4.108c-0.188-1.693-0.788-2.893-1.801-3.598c-1.014-0.705-2.507-0.949-4.479-0.73l-1.841,0.204
|
||||
L65.717,106.809z"/>
|
||||
<path class="maintext" d="M114.972,102.324c0.29,2.624,0.248,5.049-0.126,7.275c-0.376,2.227-1.106,4.179-2.193,5.855
|
||||
c-1.088,1.678-2.545,3.041-4.37,4.092s-4.042,1.721-6.651,2.01c-2.608,0.288-4.919,0.119-6.93-0.507s-3.727-1.639-5.146-3.038
|
||||
c-1.421-1.399-2.558-3.148-3.41-5.246c-0.854-2.099-1.427-4.469-1.719-7.108s-0.251-5.069,0.125-7.288s1.107-4.159,2.197-5.82
|
||||
c1.09-1.662,2.547-3.014,4.373-4.057s4.052-1.71,6.677-2c2.608-0.289,4.914-0.124,6.916,0.496
|
||||
c2.002,0.619,3.713,1.624,5.133,3.016c1.419,1.392,2.555,3.129,3.406,5.211C114.104,97.299,114.678,99.668,114.972,102.324z
|
||||
M91.862,104.881c0.197,1.787,0.53,3.373,0.998,4.76s1.079,2.537,1.832,3.452c0.754,0.914,1.649,1.582,2.687,2.001
|
||||
c1.037,0.42,2.23,0.555,3.582,0.406c1.382-0.154,2.536-0.549,3.465-1.186c0.928-0.637,1.651-1.484,2.171-2.539
|
||||
c0.521-1.057,0.853-2.312,0.998-3.766c0.146-1.455,0.119-3.075-0.078-4.861s-0.526-3.373-0.986-4.76
|
||||
c-0.46-1.388-1.06-2.543-1.799-3.469c-0.739-0.924-1.627-1.596-2.663-2.016c-1.037-0.419-2.239-0.554-3.605-0.402
|
||||
c-1.352,0.149-2.495,0.543-3.43,1.182c-0.937,0.638-1.672,1.489-2.206,2.555s-0.878,2.326-1.031,3.781
|
||||
C91.642,101.475,91.665,103.096,91.862,104.881z"/>
|
||||
<path class="maintext" d="M140.364,107.225c0.168,1.521,0.06,2.921-0.325,4.197c-0.386,1.277-1.031,2.401-1.938,3.374
|
||||
c-0.906,0.974-2.06,1.772-3.461,2.399c-1.399,0.627-3.041,1.043-4.919,1.252c-1.646,0.182-3.254,0.187-4.82,0.016
|
||||
c-1.568-0.174-3.042-0.529-4.422-1.068l-0.743-6.709c0.785,0.275,1.577,0.537,2.374,0.787c0.798,0.25,1.604,0.463,2.418,0.641
|
||||
c0.812,0.177,1.621,0.3,2.422,0.368s1.59,0.06,2.367-0.026c0.792-0.088,1.461-0.252,2.009-0.493s0.978-0.54,1.292-0.897
|
||||
s0.529-0.766,0.643-1.227c0.114-0.461,0.142-0.955,0.084-1.483c-0.071-0.637-0.269-1.185-0.595-1.644s-0.757-0.875-1.294-1.248
|
||||
c-0.536-0.373-1.174-0.723-1.908-1.051c-0.736-0.327-1.557-0.668-2.46-1.024c-0.756-0.294-1.605-0.655-2.549-1.085
|
||||
c-0.944-0.431-1.853-0.994-2.723-1.691c-0.871-0.697-1.633-1.572-2.282-2.625c-0.651-1.052-1.062-2.354-1.234-3.907
|
||||
c-0.169-1.522-0.067-2.913,0.304-4.173c0.371-1.258,0.978-2.355,1.817-3.289s1.9-1.691,3.18-2.273s2.742-0.964,4.389-1.146
|
||||
c0.823-0.091,1.633-0.126,2.43-0.104c0.796,0.021,1.59,0.091,2.381,0.208c0.791,0.116,1.592,0.279,2.4,0.488
|
||||
c0.81,0.21,1.646,0.462,2.513,0.76l-1.708,5.872c-0.734-0.249-1.419-0.464-2.053-0.646c-0.633-0.182-1.246-0.325-1.839-0.433
|
||||
c-0.594-0.107-1.171-0.174-1.731-0.197c-0.562-0.025-1.121-0.006-1.68,0.056c-1.212,0.134-2.105,0.56-2.678,1.274
|
||||
c-0.573,0.717-0.801,1.609-0.683,2.682c0.063,0.574,0.217,1.072,0.461,1.494c0.243,0.42,0.6,0.802,1.069,1.143
|
||||
c0.471,0.342,1.065,0.676,1.786,1.006c0.72,0.328,1.59,0.695,2.61,1.102c1.232,0.492,2.375,1.007,3.425,1.543
|
||||
c1.049,0.536,1.971,1.157,2.765,1.863c0.793,0.706,1.438,1.535,1.937,2.485C139.894,104.745,140.217,105.889,140.364,107.225z"/>
|
||||
<path class="maintext" d="M159.019,114.732l-7.198,0.797l-3.104-28.047l-8.549,0.945l-0.666-6.01l24.273-2.687l0.666,6.011
|
||||
l-8.526,0.943L159.019,114.732z"/>
|
||||
<path class="maintext" d="M191.255,111.164l-19.614,2.172l-3.77-34.059l19.614-2.17l0.655,5.917l-12.394,1.372l0.828,7.479
|
||||
l11.53-1.276l0.655,5.917l-11.53,1.276l0.972,8.782l12.393-1.372L191.255,111.164z"/>
|
||||
<path class="maintext" d="M224.303,90.223c0.29,2.625,0.249,5.05-0.126,7.276c-0.374,2.227-1.105,4.179-2.192,5.855
|
||||
c-1.088,1.677-2.544,3.04-4.369,4.091s-4.043,1.721-6.652,2.01s-4.919,0.12-6.93-0.506c-2.011-0.627-3.727-1.639-5.146-3.039
|
||||
c-1.421-1.398-2.558-3.147-3.41-5.246c-0.854-2.098-1.427-4.468-1.719-7.107c-0.292-2.641-0.251-5.07,0.125-7.289
|
||||
s1.107-4.158,2.197-5.82c1.089-1.661,2.547-3.013,4.373-4.056c1.826-1.043,4.052-1.71,6.677-2.001
|
||||
c2.608-0.289,4.914-0.124,6.916,0.496c2.002,0.619,3.713,1.625,5.133,3.016c1.42,1.391,2.555,3.128,3.406,5.211
|
||||
S224.009,87.566,224.303,90.223z M201.194,92.78c0.197,1.786,0.53,3.372,0.998,4.759s1.079,2.537,1.832,3.452
|
||||
c0.754,0.915,1.649,1.582,2.687,2.002s2.23,0.555,3.582,0.405c1.382-0.153,2.537-0.548,3.465-1.186
|
||||
c0.928-0.637,1.651-1.483,2.171-2.539c0.521-1.056,0.853-2.311,0.998-3.766c0.146-1.454,0.119-3.074-0.078-4.861
|
||||
c-0.197-1.785-0.526-3.372-0.986-4.76c-0.46-1.387-1.06-2.543-1.799-3.468c-0.738-0.924-1.627-1.597-2.663-2.016
|
||||
c-1.037-0.42-2.239-0.554-3.605-0.403c-1.352,0.15-2.495,0.544-3.43,1.182c-0.937,0.639-1.671,1.49-2.206,2.555
|
||||
c-0.534,1.066-0.878,2.326-1.031,3.781S200.997,90.994,201.194,92.78z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="point" d="M229.975,103.507c-0.081-0.729-0.045-1.358,0.109-1.886c0.153-0.529,0.399-0.969,0.737-1.32
|
||||
c0.339-0.352,0.748-0.625,1.23-0.82c0.481-0.195,1.01-0.324,1.584-0.387c0.544-0.061,1.067-0.049,1.572,0.037
|
||||
c0.504,0.086,0.961,0.264,1.367,0.533c0.407,0.27,0.748,0.645,1.021,1.125c0.272,0.48,0.45,1.086,0.531,1.815
|
||||
c0.077,0.699,0.034,1.31-0.128,1.83c-0.163,0.521-0.412,0.966-0.749,1.333c-0.336,0.367-0.741,0.652-1.215,0.854
|
||||
c-0.473,0.201-0.979,0.332-1.523,0.393c-0.574,0.063-1.119,0.049-1.633-0.043c-0.513-0.093-0.974-0.282-1.383-0.566
|
||||
c-0.408-0.285-0.746-0.664-1.01-1.139C230.223,104.792,230.052,104.206,229.975,103.507z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,29 @@
|
||||
<svg width="506" height="86" viewBox="0 0 506 86" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M144 55.3119V69.1575H153.668V55.9167C153.668 54.6315 154.172 53.3895 155.085 52.4823C155.988 51.5751 157.223 51.0567 158.501 51.0567H168.416C173.035 51.0567 177.471 49.2099 180.737 45.9159C184.002 42.6327 185.839 38.1723 185.839 33.5284C185.839 28.8844 184.002 24.424 180.737 21.13C177.471 17.8468 173.035 16 168.405 16H144V33.3016H153.668V25.1476H167.761C169.952 25.1476 172.047 26.0224 173.593 27.5776C175.14 29.1328 176.01 31.2388 176.01 33.442C176.01 35.6452 175.14 37.7511 173.593 39.3063C172.047 40.8615 169.952 41.7363 167.761 41.7363H157.524C155.751 41.7363 153.99 42.0819 152.357 42.7731C150.714 43.4535 149.231 44.4579 147.974 45.7215C146.718 46.9851 145.729 48.4863 145.042 50.1279C144.354 51.7587 144 53.5299 144 55.3119Z" fill="#1B1340"/>
|
||||
<path d="M187.332 69.1574V48.0651C187.332 39.4575 192.327 32.6103 202.328 32.6103C203.928 32.5887 205.529 32.7615 207.097 33.1395V41.8119C205.958 41.7363 204.981 41.7363 204.519 41.7363C199.223 41.7363 196.946 44.1771 196.946 49.1235V69.1574H187.332Z" fill="#1B1340"/>
|
||||
<path d="M209.975 51.2728C209.975 40.6889 217.924 32.6213 228.988 32.6213C240.052 32.6213 248.001 40.6889 248.001 51.2728C248.001 61.8568 240.052 70 228.988 70C217.924 70 209.975 61.846 209.975 51.2728ZM238.527 51.2728C238.527 45.2573 234.51 40.9913 228.988 40.9913C223.456 40.9913 219.45 45.2465 219.45 51.2728C219.45 57.364 223.467 61.5544 228.988 61.5544C234.52 61.5544 238.527 57.3532 238.527 51.2728Z" fill="#1B1340"/>
|
||||
<path d="M278.744 51.2728C278.744 40.6889 286.693 32.6213 297.757 32.6213C308.81 32.6213 316.759 40.6889 316.759 51.2728C316.759 61.8568 308.81 70 297.757 70C286.693 70 278.744 61.846 278.744 51.2728ZM307.285 51.2728C307.285 45.2573 303.268 40.9913 297.746 40.9913C292.225 40.9913 288.208 45.2465 288.208 51.2728C288.208 57.364 292.225 61.5544 297.746 61.5544C303.268 61.5544 307.285 57.3532 307.285 51.2728Z" fill="#1B1340"/>
|
||||
<path d="M321.754 69.1576V48.8321C321.754 39.3929 327.738 32.6105 338.415 32.6105C349.017 32.6105 355 39.3821 355 48.8321V69.1576H345.461V49.5881C345.461 44.3393 343.109 41.0561 338.415 41.0561C333.721 41.0561 331.368 44.3285 331.368 49.5881V69.1576H321.754Z" fill="#1B1340"/>
|
||||
<path d="M275.865 41.0663H265.489V54.3935C265.489 59.0375 267.154 61.1651 271.923 61.1651C272.374 61.1651 273.513 61.1651 274.952 61.0895V68.9303C272.987 69.4595 271.247 69.7727 269.345 69.7727C261.321 69.7727 255.864 64.9019 255.864 55.6895V41.0663H249.419V33.3768H251.031C251.664 33.3768 252.298 33.2472 252.878 33.0096C253.469 32.7612 253.995 32.4048 254.446 31.9512C254.898 31.4976 255.252 30.9684 255.499 30.3744C255.746 29.7804 255.864 29.154 255.864 28.5168V21.27H265.478V33.3768H275.855V41.0663H275.865Z" fill="#1B1340"/>
|
||||
<path d="M374 17.3717H387.505L400.164 48.4366C401.292 51.0397 402.257 53.6969 403.071 56.4081H403.201C404.014 53.6969 404.98 51.0289 406.108 48.4366L418.767 17.3717H432.271V69.1754H422.487V34.4704C422.476 33.3254 422.531 32.1805 422.65 31.0463H422.487C422.183 32.2669 421.782 33.4658 421.272 34.6216L407.247 68.5705H399.133L385.064 34.6216C384.554 33.455 384.12 32.2669 383.773 31.0463H383.621C383.73 32.1805 383.784 33.3254 383.773 34.4704V69.1862H374V17.3717Z" fill="#6D4AFF"/>
|
||||
<path d="M465.735 34.9997C468.566 36.4795 470.909 38.7478 472.482 41.5238C474.163 44.5374 475.009 47.9507 474.944 51.3963V69.1755H466.375L465.768 63.8396C464.65 65.773 463.012 67.35 461.038 68.4086C458.923 69.4995 456.558 70.0504 454.172 69.9964C451.124 70.0288 448.13 69.2187 445.516 67.6525C442.869 66.0539 440.721 63.7639 439.289 61.042C437.727 58.0392 436.946 54.7016 437.012 51.3207C436.968 47.9939 437.825 44.721 439.485 41.8262C441.112 39.0179 443.466 36.7064 446.318 35.1402C449.269 33.5091 452.588 32.6666 455.962 32.7098C459.357 32.645 462.719 33.4335 465.735 34.9997ZM462.6 58.9141C464.466 57.1427 465.388 54.6475 465.388 51.3207C465.507 48.5987 464.542 45.9416 462.72 43.9217C461.852 43.0144 460.81 42.3015 459.65 41.8046C458.489 41.3078 457.253 41.0593 455.994 41.0593C454.736 41.0593 453.489 41.3078 452.339 41.8046C451.178 42.3015 450.137 43.0144 449.269 43.9217C447.533 45.9956 446.579 48.6095 446.579 51.3099C446.579 54.0103 447.533 56.6242 449.269 58.6981C450.126 59.627 451.167 60.3507 452.339 60.8476C453.499 61.3336 454.758 61.5821 456.016 61.5497C457.231 61.5713 458.446 61.3444 459.585 60.8908C460.702 60.4479 461.732 59.7674 462.6 58.9141Z" fill="#6D4AFF"/>
|
||||
<path d="M480.227 26.0777C479.652 25.5485 479.197 24.9112 478.882 24.1983C478.567 23.4854 478.415 22.7185 478.426 21.9408C478.415 21.1631 478.578 20.3854 478.882 19.6617C479.197 18.938 479.652 18.2899 480.227 17.7606C481.366 16.6373 482.906 16 484.512 16C486.117 16 487.657 16.6373 488.796 17.7606C489.371 18.3007 489.827 18.9488 490.13 19.6617C490.434 20.3854 490.597 21.1523 490.586 21.9408C490.597 22.7185 490.445 23.4854 490.13 24.1983C489.827 24.9112 489.371 25.5485 488.796 26.0777C487.646 27.1795 486.106 27.7952 484.512 27.7952C482.906 27.7952 481.377 27.1795 480.227 26.0777ZM489.339 69.1754H479.706V33.4767H489.339V69.1754Z" fill="#6D4AFF"/>
|
||||
<path d="M506 69.1754H496.357V17.3717H506V69.1754Z" fill="#6D4AFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M83.4613 16.1551V86H95.0393C101.094 86 106 81.026 106 74.9015V2.47101C106 0.378398 103.596 -0.761032 102.003 0.575608L83.4613 16.1551Z" fill="url(#paint0_linear_11985_190892)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M67.3128 29.7407L46.1604 48.6618C42.5538 51.8829 37.1709 51.9596 33.4777 48.848L0 20.6691V2.482C0 0.38939 2.40441 -0.760996 3.99653 0.575643L45.998 35.8761C50.0595 39.2944 55.9514 39.2944 60.0129 35.8761L67.3128 29.7407Z" fill="url(#paint1_linear_11985_190892)"/>
|
||||
<path d="M83.4613 16.1661L67.3128 29.7407L67.3236 29.7406L46.1604 48.6618C42.5538 51.8829 37.1709 51.9596 33.4777 48.848L0 20.6691V74.9015C0 81.0259 4.9063 86 10.9607 86L83.4613 86V16.1661Z" fill="url(#paint2_radial_11985_190892)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_11985_190892" x1="265.975" y1="141.754" x2="244.3" y2="-73.4041" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.271" stop-color="#E3D9FF"/>
|
||||
<stop offset="1" stop-color="#7341FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_11985_190892" x1="62.6346" y1="86.7984" x2="18.553" y2="-87.2799" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E3D9FF"/>
|
||||
<stop offset="1" stop-color="#7341FF"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint2_radial_11985_190892" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(105.538 10.5272) scale(123.614 125.045)">
|
||||
<stop offset="0.5561" stop-color="#6D4AFF"/>
|
||||
<stop offset="0.9944" stop-color="#AA8EFF"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 299 69"><defs><style>.cls-1{fill:none;}.cls-2{fill:#fff;}.cls-3{clip-path:url(#clip-path);}.cls-4{fill:#1d1d1d;}.cls-5{fill:#e32178;}</style><clipPath id="clip-path"><rect class="cls-1" x="10.19" y="9.93" width="278.62" height="49.13"/></clipPath></defs><title>t-onlinede_logo</title><g id="Ebene_2" data-name="Ebene 2"><rect class="cls-2" width="299" height="69"/></g><g id="svg2"><g id="g10"><g id="g12"><g class="cls-3"><g id="g14"><g id="g20"><path id="path22" class="cls-4" d="M25.27,29.72V47.26c0,1.74,0,3.07.09,4a7.43,7.43,0,0,0,.75,3.2c1,1.62,3,2.44,6,2.44a16,16,0,0,0,3.42-.43v-4.1a8.91,8.91,0,0,1-2.38.43,2.61,2.61,0,0,1-2.51-1.34,10.77,10.77,0,0,1-.35-3.61V29.72h5.24V25.58H30.25v-10l-5,2.1.05,7.89H20.84v4.14ZM37.15,42.35h13V37h-13Zm21.68-12.6q-3.06,4.32-3.06,11.16,0,7.1,3.42,11.66t10,4.62a11.66,11.66,0,0,0,8.95-3.58q4.14-4.43,4.14-12.73,0-7.49-3.49-11.78a11.65,11.65,0,0,0-9.53-4.46,12,12,0,0,0-10.42,5.11m16,1.95c1.58,2.21,2.37,5.29,2.37,9.21q0,6.2-2.7,9.7a6.42,6.42,0,0,1-5.37,2.58,6.88,6.88,0,0,1-5.93-3c-1.6-2.28-2.41-5.37-2.41-9.28s.83-6.92,2.48-9.24a6.65,6.65,0,0,1,5.66-3,6.84,6.84,0,0,1,5.9,3.06M93.76,56.24V41.37A39.43,39.43,0,0,1,94,36.06a10,10,0,0,1,1.75-4.75,6.42,6.42,0,0,1,5.38-2.67,5.33,5.33,0,0,1,3.22,1,4.74,4.74,0,0,1,1.79,2.6,25.67,25.67,0,0,1,.49,6V56.24h5V36.42a28.52,28.52,0,0,0-.26-4.49,8.23,8.23,0,0,0-3.06-5.47,9.18,9.18,0,0,0-5.76-1.82,10.46,10.46,0,0,0-9.12,5.08V25.58H88.78V56.24Zm32-44.43h-5V56.24h5Zm14.55,0h-5v6.77h5Zm0,13.77h-5V56.24h5Zm14.42,30.66V41.37a41.18,41.18,0,0,1,.26-5.31,10,10,0,0,1,1.76-4.75,6.38,6.38,0,0,1,5.37-2.67,5.31,5.31,0,0,1,3.22,1,4.69,4.69,0,0,1,1.79,2.6,25.12,25.12,0,0,1,.49,6V56.24h5V36.42a28.52,28.52,0,0,0-.26-4.49,8.23,8.23,0,0,0-3.06-5.47,9.14,9.14,0,0,0-5.76-1.82,10.46,10.46,0,0,0-9.12,5.08V25.58h-4.65V56.24Zm50-18.84q-.75-6.48-4.2-9.77a11.24,11.24,0,0,0-8-3,11.08,11.08,0,0,0-9.28,4.52,18.77,18.77,0,0,0-3.49,11.66,19.75,19.75,0,0,0,3,11.29q3.42,5.09,10.09,5.08a10.84,10.84,0,0,0,9.31-4.56,14.58,14.58,0,0,0,2.51-6.18h-4.82a18.15,18.15,0,0,1-1.2,3.25,6.12,6.12,0,0,1-5.86,3.49q-5.17,0-7.13-5.57a17.34,17.34,0,0,1-.88-5.4h20.22c-.05-1.94-.15-3.54-.3-4.82m-19.86.81a16.69,16.69,0,0,1,1.67-5.83,6.49,6.49,0,0,1,6.05-3.74,6.37,6.37,0,0,1,6.15,4.14A16,16,0,0,1,200,38.21Zm61.78-26.4H243V30.66a9.13,9.13,0,0,0-1.79-2.74,9.78,9.78,0,0,0-7.43-3.28,10.71,10.71,0,0,0-8.92,4.46q-3.32,4.32-3.32,11.81,0,7.65,3.55,12.08a10.7,10.7,0,0,0,8.66,4.2q6.61,0,9.54-6.58v5.63h3.38Zm-6.08,19.24q2.56,3.64,2.57,10a16.81,16.81,0,0,1-2.57,9.7,7.53,7.53,0,0,1-6.51,3.42,7.37,7.37,0,0,1-6.38-3.45Q225.24,47,225.23,41a17.78,17.78,0,0,1,2.21-9.41A7.36,7.36,0,0,1,234,27.7a7.67,7.67,0,0,1,6.61,3.35m32.94-3.42a10.38,10.38,0,0,0-7.42-3,10.77,10.77,0,0,0-8.82,4.46q-3.55,4.51-3.55,11.78,0,6.93,3.06,11.39a11,11,0,0,0,9.57,4.92,10.47,10.47,0,0,0,8.92-4.39,14.63,14.63,0,0,0,2.38-5.54h-3.52q-2.15,6.87-7.75,6.87-5.18,0-7.55-5a18.21,18.21,0,0,1-1.46-7.36h20.73q0-9.82-4.59-14.13m-16.14,11.2q.58-6,3.38-8.89a7.29,7.29,0,0,1,5.34-2.34,6.4,6.4,0,0,1,3.51,1A8.9,8.9,0,0,1,273,32.29a15.89,15.89,0,0,1,1.5,6.54Z"/></g><path id="path24" class="cls-5" d="M217.62,57.08h-8v-8h8Z"/></g></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><path fill="#FFD800" d="M224 256H32c-17.6 0-32-14.4-32-32V32C0 14.4 14.4 0 32 0h192c17.6 0 32 14.4 32 32v192c0 17.6-14.4 32-32 32z"/><path fill="#333" d="M57.73 78.61c2.06.76 4.79-.61 6.77-.96 1.96-.34 10.01-2.15 8.49-.32-.85 1.03-3.06 1.04-5.03 1.61-2.73.8-7.11 1.26-7.28 4.77-.39 8.35 12.59 2.8 16.4 1.44 10.31-3.59 22.13-4.2 32.56-5.95 11.58-1.93 23.47-2.1 35.16-2.55 12.3-.47 25.53-.99 37.61.72 3.22.45 6.26.81 9.53 1.15 2.84.29 8.81 2.89 11.34 1.7 2.29-1.08 1.25-3.79 2.44-5.18 1.71-1.99 5.07-1.64 2.73-5.9-1.78-3.24-8.26-4.29-11.62-3.63-.34-2.05 1.23-1.14 1.64-2.2.59-1.53.59-3.9-.4-5.6-1.8-3.1-10.36-4.19-10.36-4.19-20.53-3.31-57.47-6.87-96.79-2.28-6.52.76-17.97 1.58-24.65 4.28-2.96 1.2-2.91 1.22-3.28 3.16-.35 2.76.97 5.97 4.29 5.28 6.35-1.31 17.57-3.12 20.78-3.94 15.86-4.04 68.64-3.25 75.44-1.64 4.2.9 27.28 1.65 27.84 5.88-53.86-2.23-74.42-2.21-122.79 3.54-4.04.67-6.47 1.55-9.65 2.63-3.95 1.34-6.6 6.17-1.17 8.18zm71.22-33.39c1.88.16 16.76.61 17.62-5.21.49-3.3-2.78-8.66-3.71-9.63-1.23-1.02-3.95 7.87-4.64-.87-.11-1.43-.47-3.63-1.15-4.98-.34-.67-.75-1.17-1.26-1.17-.32 0-.67.19-.82.29-.43.28-1.04 1.18-1.35 1.94-.84 2.08-1.21 10.1-3.29 10.23-2.26.15-2.19-5.14-1.89-6.67.05-.25-.01-.31-.11-.29-.22.06-.28.1-.46.16-1.36.52-2.91 2.1-3.02 2.22-2.13 2.18-5.01 7.89-3.56 10.51 1.51 2.69 6.05 3.33 7.64 3.47zm103.68 114.69c-.16-1.14-.49-1.84-1.27-2.89-2.8-3.84-15.98-6.4-15.98-6.36-6.62-1.45-14.23-2.69-22.74-3.73 1.21-5.75-.06-16.36-.09-20.05.17-6.59.03-23.62-.46-27.92-.38-4.05-.92-5.81-3.14-9.16-1.59-2.39-2.08-4.72-5.75-4.36-3.34.33-4.57 4.97-3.99 7.78l1.44 5.73c.6 1.19 2.22 38.34 1.85 41.57.03 1.57-.12 3.59-.16 5.3-8.67-.82-18.1-1.46-28.2-1.9l.4-.31c4.83-3.76 3.16-8.75 2.35-14.19-1.1-8.71-.26-33.42-1.3-36.4-.7-2.33-2.69-3.68-4.42-5.46-2.13-2.19-4.8-2.87-7.26-.35-2.41 2.18.6 33.39.77 47.1.22 2.87-.42 6.47.72 9.24-.93-.04-25.73-.55-27.81-.51.03-.48-.3-1.1-.53-1.57-.62-1.26-1.5-4.19-1.75-5.56-.41-2.24-.75-5.19-.86-7.9-.19-5.67.34-23.21.97-27.79.71-5.07 3.41-14.03-5.39-12.06-.95.21-5.61.79-6.86 1.77-1.45 1.14-1.31 3.99-1.36 5.57-.21 5.99-.87 24.86-.83 29.39.05 3.81.72 8.71 2.12 12.42.85 2.26 3.37 4.51 6 5.93-4.84.13-9.5.29-11.16.38-8.17.46-14.44.91-20.55 1.51-.04-.18-.04-.18-.08-.29-1.02-3.15.45-16.17 3.28-40.59.68-5.89-.35-6.12-3.94-9.44-2.06-1.9-3.8-3.19-7.37-.32-2.27 1.82-1.68 4.15-2.07 6.78-1.36 9.16-3.34 23.54-4.18 32.86-.24 2.57.12 8.46.78 12.03.06.17.03.36.12.54-3.55.48-7.12 1-11.15 1.61-9.81 1.52-18.48 1.9-24.38 4.54-3.62 1.63-3.83 2.17-4.68 3.49-.21.36-.38.77-.45 1.56-.01.1.07 1.4.17 1.79.2.78.58 1.5 1.11 2.1.58.64 1.33 1.12 2.16 1.37.45.14 3.18.37 3.47.4 6.58.66 10.21-.94 16.65-3.47 31.89-12.72 123.13-8.87 143.22-5.07 8.73 1.74 22.2 3.56 30.55 8.34 1.36.74 2.64 1.29 3.81 1.64.58.18 1.15.26 1.66.39 1.28.3 2.64.03 2.87-.02.46-.09.9-.22 1.3-.39.81-.34 1.46-.84 1.77-1.45.22-.43.5-1.27.63-1.96.09-.26.05-1.18-.01-1.66zM41.8 187.33h-8.47l7.46 38.09h9.8l5.7-25.79 5.75 25.79h9.8l7.83-38.09h-7.94l-4.79 29.14-6.23-29.14h-8.26l-6.34 29.04-4.31-29.04zm66.78 0H84.76v38.09h24.35v-6.39h-16.3v-9.48h13.9v-6.39h-13.9v-9.43h15.77v-6.4zm6.79 0v38.09h14.17c8.95 0 13.21-4.26 13.21-10.65 0-8.15-7.19-9.06-7.19-9.06s5.11-2.18 5.11-8.52c0-6.39-4.1-9.86-12.57-9.86h-12.73zm8.05 16.04V193.3h2.66c4.74 0 6.66 1.6 6.66 4.95 0 3.41-1.92 5.11-5.86 5.11l-3.46.01zm0 16.09v-10.28h4.9c4.1 0 6.34 1.6 6.34 5.01s-2.08 5.27-6.82 5.27h-4.42zm28.6 6.5c2.45 0 4.15-1.71 4.15-4.1 0-2.45-1.7-3.94-4.05-3.94-2.45 0-4.21 1.54-4.21 4.05.01 2.23 1.4 3.99 4.11 3.99zm11.06-38.63v38.09h9.48c13.11 0 19.82-7.46 19.82-19.39 0-11.67-5.91-18.7-19.02-18.7h-10.28zm8.05 31.7v-25.3h2.18c7.35 0 10.92 4.37 10.92 12.84 0 8.42-3.46 12.47-10.87 12.47l-2.23-.01zm51.1-31.7h-23.81v38.09h24.35v-6.39h-16.3v-9.48h13.9v-6.39h-13.9v-9.43h15.77v-6.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg width="884" height="159" viewBox="0 0 884 159" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_5070)">
|
||||
<path d="M587.97 126.5H556.859V41.4937H587.97V51.5258C592.562 44.2138 600.21 39.1147 611.091 39.1147C622.142 39.1147 630.302 44.0478 634.894 52.7158C640.841 42.6888 651.215 39.1147 661.413 39.1147C680.624 39.1147 690.993 51.8667 690.993 71.7557V126.5H659.882V80.2578C659.882 72.0978 656.143 67.5057 649.855 67.5057C643.562 67.5057 639.481 72.2687 639.481 80.5997V126.5H608.37V80.2578C608.37 72.0978 604.631 67.5057 598.343 67.5057C592.05 67.5057 587.969 72.2687 587.969 80.5997L587.97 126.5Z" fill="black"/>
|
||||
<path d="M821.512 34.699C811.992 34.699 804.168 26.875 804.168 17.525C804.168 8.17496 811.992 0.355957 821.512 0.355957C831.369 0.355957 839.022 8.34596 839.022 17.525C839.022 26.704 831.369 34.699 821.512 34.699ZM806.039 126.5V41.494H837.15V126.5H806.039Z" fill="black"/>
|
||||
<path d="M883.47 3.83105V126.5H852.359V3.83105H883.47Z" fill="black"/>
|
||||
<path d="M745.063 101.559C735.054 101.559 727.415 93.925 727.415 84.085C727.415 74.24 735.054 66.606 745.063 66.606C754.903 66.606 762.542 74.24 762.542 84.085C762.542 93.925 754.903 101.559 745.063 101.559ZM761.855 49.4699C757.274 43.5339 748.797 39.4609 738.957 39.4609C715.712 39.4609 698.402 59.9909 698.402 84.0839C698.402 108.853 715.544 128.702 738.957 128.702C748.797 128.702 757.274 124.798 761.855 118.525V126.496H790.867V41.4959H761.855V49.4699Z" fill="black"/>
|
||||
<path d="M144.234 101.562C134.225 101.562 126.586 93.9279 126.586 84.0879C126.586 74.2429 134.225 66.6089 144.234 66.6089C154.074 66.6089 161.713 74.2429 161.713 84.0879C161.713 93.9279 154.074 101.562 144.234 101.562ZM161.027 49.4738C156.446 43.5378 147.969 39.4648 138.129 39.4648C114.884 39.4648 97.5742 59.9959 97.5742 84.0879C97.5742 108.857 114.716 128.706 138.129 128.706C147.969 128.706 156.446 124.802 161.027 118.529V126.5H190.04V41.4998H161.027V49.4738Z" fill="#7D2EFF"/>
|
||||
<path d="M252.307 39.465C242.635 39.465 235.177 43.2 229.917 49.813V3.83398H200.059V126.502H230.085V81.2C230.085 72.381 234.328 67.116 241.113 67.116C247.726 67.116 251.459 71.699 251.459 80.183V126.501H281.488V73.057C281.488 52.359 270.295 39.465 252.307 39.465Z" fill="#7D2EFF"/>
|
||||
<path d="M429.267 101.225C419.427 101.225 412.306 93.5909 412.306 84.0879C412.306 74.5869 419.427 66.9478 429.267 66.9478C439.107 66.9478 446.238 74.5869 446.238 84.0879C446.238 93.5909 439.108 101.225 429.267 101.225ZM429.267 39.4648C403.313 39.4648 383.801 58.6369 383.801 84.0879C383.801 109.536 403.313 128.708 429.267 128.708C455.231 128.708 474.742 109.536 474.742 84.0879C474.743 58.6369 455.231 39.4648 429.267 39.4648Z" fill="#7D2EFF"/>
|
||||
<path d="M333.236 101.225C323.396 101.225 316.275 93.5909 316.275 84.0879C316.275 74.5869 323.396 66.9478 333.236 66.9478C343.076 66.9478 350.206 74.5869 350.206 84.0879C350.207 93.5909 343.077 101.225 333.236 101.225ZM333.236 39.4648C307.282 39.4648 287.77 58.6369 287.77 84.0879C287.77 109.536 307.282 128.708 333.236 128.708C359.2 128.708 378.711 109.536 378.711 84.0879C378.712 58.6369 359.2 39.4648 333.236 39.4648Z" fill="#7D2EFF"/>
|
||||
<path d="M72.2983 41.5L52.6173 91.383L33.1083 41.5H0.529297L36.8413 127.182L23.7763 158.056H55.6663L104.029 41.5H72.2983Z" fill="#7D2EFF"/>
|
||||
<path d="M498.667 88.3271C487.124 88.3271 478.469 97.6591 478.469 108.52C478.469 119.205 486.785 128.029 497.99 128.029C509.523 128.029 518.179 118.868 518.179 107.838C518.178 96.9821 509.86 88.3271 498.667 88.3271Z" fill="#7D2EFF"/>
|
||||
<path d="M521.225 3.83496L489.334 80.861H524.96L556.86 3.83496H521.225Z" fill="#7D2EFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_5070">
|
||||
<rect width="884" height="159" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -4,13 +4,96 @@ import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { mailAccountsApi, MailAccount } from '@/lib/api';
|
||||
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { AddMailAccountModal } from '@/components/AddMailAccountModal';
|
||||
|
||||
// Map provider_name (from backend) to the SVG icon filename in /public/providers/
|
||||
const PROVIDER_ICON_MAP: Record<string, string> = {
|
||||
'Gmail': 'gmail',
|
||||
'GMX': 'gmx',
|
||||
'WEB.DE': 'webde',
|
||||
'Outlook / Hotmail': 'outlook',
|
||||
'Yahoo Mail': 'yahoo',
|
||||
'AOL Mail': 'aol',
|
||||
'T-Online': 'tonline',
|
||||
'1&1 / IONOS': 'ionos',
|
||||
'Freenet': 'freenet',
|
||||
'Posteo': 'posteo',
|
||||
'mail.de': 'mailde',
|
||||
'iCloud Mail': 'icloud',
|
||||
'Proton Mail': 'protonmail',
|
||||
};
|
||||
|
||||
// Fallback: map email domains to SVG icon filenames for accounts without a provider_name
|
||||
const DOMAIN_ICON_MAP: Record<string, string> = {
|
||||
// Gmail
|
||||
'gmail.com': 'gmail', 'googlemail.com': 'gmail',
|
||||
// GMX
|
||||
'gmx.de': 'gmx', 'gmx.net': 'gmx', 'gmx.at': 'gmx', 'gmx.ch': 'gmx', 'gmx.com': 'gmx',
|
||||
// WEB.DE
|
||||
'web.de': 'webde',
|
||||
// Outlook / Hotmail
|
||||
'outlook.com': 'outlook', 'hotmail.com': 'outlook', 'live.com': 'outlook',
|
||||
'msn.com': 'outlook', 'outlook.de': 'outlook',
|
||||
// Yahoo Mail
|
||||
'yahoo.com': 'yahoo', 'yahoo.de': 'yahoo', 'yahoo.co.uk': 'yahoo', 'ymail.com': 'yahoo',
|
||||
// AOL Mail
|
||||
'aol.com': 'aol', 'aim.com': 'aol',
|
||||
// T-Online
|
||||
't-online.de': 'tonline',
|
||||
// 1&1 / IONOS
|
||||
'online.de': 'ionos', 'onlinehome.de': 'ionos', '1und1.de': 'ionos',
|
||||
// Freenet
|
||||
'freenet.de': 'freenet',
|
||||
// iCloud Mail
|
||||
'icloud.com': 'icloud', 'me.com': 'icloud', 'mac.com': 'icloud',
|
||||
// Posteo
|
||||
'posteo.de': 'posteo', 'posteo.net': 'posteo',
|
||||
// Proton Mail
|
||||
'proton.me': 'protonmail', 'protonmail.com': 'protonmail',
|
||||
'protonmail.ch': 'protonmail', 'pm.me': 'protonmail',
|
||||
};
|
||||
|
||||
/**
|
||||
* Full-width logo banner rendered at the top of a card.
|
||||
* Uses next/image fill + object-contain so every logo – regardless of its
|
||||
* native aspect ratio (1:1 square up to ~6:1 wordmark) – fits correctly
|
||||
* inside the fixed-height strip without distortion.
|
||||
*
|
||||
* Resolves the icon by first checking provider_name, then falling back to
|
||||
* the email domain so that accounts created without a provider_name still
|
||||
* show the correct logo.
|
||||
*/
|
||||
function ProviderLogoBanner({ providerName, email }: { providerName?: string | null; email?: string | null }) {
|
||||
let icon = providerName ? PROVIDER_ICON_MAP[providerName] : undefined;
|
||||
if (!icon && email) {
|
||||
const atIndex = email.lastIndexOf('@');
|
||||
const domain = atIndex !== -1 ? email.slice(atIndex + 1).toLowerCase() : undefined;
|
||||
if (domain) icon = DOMAIN_ICON_MAP[domain];
|
||||
}
|
||||
if (!icon) return null;
|
||||
const label = providerName ?? email?.split('@')[1] ?? 'provider';
|
||||
return (
|
||||
<div className="relative h-16 w-full bg-gray-50 border-b border-gray-100 overflow-hidden">
|
||||
<Image
|
||||
src={`/providers/${icon}.svg`}
|
||||
alt={`${label} logo`}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
style={{ objectFit: 'contain', padding: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountsPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<MailAccount | null>(null);
|
||||
const [pullingIds, setPullingIds] = useState<Set<number>>(new Set());
|
||||
const [successIds, setSuccessIds] = useState<Set<number>>(new Set());
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: accounts, isLoading } = useQuery({
|
||||
@@ -55,6 +138,29 @@ export default function AccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePullNow = async (id: number) => {
|
||||
setPullingIds((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
await mailAccountsApi.pullNow(id);
|
||||
setSuccessIds((prev) => new Set(prev).add(id));
|
||||
setTimeout(() => {
|
||||
setSuccessIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, 2000);
|
||||
} catch {
|
||||
alert('Failed to queue pull');
|
||||
} finally {
|
||||
setPullingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingAccount(null);
|
||||
@@ -88,15 +194,18 @@ export default function AccountsPage() {
|
||||
account.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Provider logo banner – full-width strip that accommodates any aspect ratio */}
|
||||
<ProviderLogoBanner providerName={account.provider_name} email={account.email_address} />
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1 truncate">
|
||||
{account.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{account.email_address}</p>
|
||||
<p className="text-sm text-gray-500 truncate">{account.email_address}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 ml-2 flex-shrink-0">
|
||||
{account.is_enabled ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
@@ -164,6 +273,26 @@ export default function AccountsPage() {
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePullNow(account.id)}
|
||||
disabled={!account.is_enabled || pullingIds.has(account.id)}
|
||||
title="Fetch new emails from this account now"
|
||||
aria-label="Fetch emails now"
|
||||
className={`flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 ${
|
||||
successIds.has(account.id)
|
||||
? 'text-green-600 bg-green-50 hover:bg-green-100'
|
||||
: 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${pullingIds.has(account.id) ? 'animate-spin' : ''}`} />
|
||||
<span>
|
||||
{pullingIds.has(account.id)
|
||||
? 'Fetching…'
|
||||
: successIds.has(account.id)
|
||||
? 'Queued!'
|
||||
: 'Fetch'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(account)}
|
||||
className="flex-1 flex items-center justify-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { adminApi, AdminProcessingRun } from '@/lib/api';
|
||||
import { formatDate, formatDuration } from '@/lib/date-utils';
|
||||
import {
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
partial_failure: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !user.is_superuser) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['admin-processing-runs', page, statusFilter],
|
||||
queryFn: () =>
|
||||
adminApi.listProcessingRuns({
|
||||
page,
|
||||
page_size: 25,
|
||||
...(statusFilter ? { status: statusFilter } : {}),
|
||||
}),
|
||||
enabled: !!user?.is_superuser,
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Activity className="h-6 w-6 text-purple-600" />
|
||||
Activity Logs
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
All email processing runs across every user account.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="partial_failure">Partial failure</option>
|
||||
<option value="running">Running</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-center py-16 text-red-500">
|
||||
Failed to load activity logs. Please try again.
|
||||
</div>
|
||||
) : data && data.items.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-3">Started</th>
|
||||
<th className="px-4 py-3">User</th>
|
||||
<th className="px-4 py-3">Account</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3 text-right">Fetched</th>
|
||||
<th className="px-4 py-3 text-right">Forwarded</th>
|
||||
<th className="px-4 py-3 text-right">Failed</th>
|
||||
<th className="px-4 py-3 text-right">Duration</th>
|
||||
<th className="px-4 py-3">OK</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.items.map((run: AdminProcessingRun) => {
|
||||
const statusClass =
|
||||
STATUS_STYLES[run.status] ?? 'bg-gray-100 text-gray-800';
|
||||
const ok = run.emails_failed === 0 && run.status !== 'failed';
|
||||
return (
|
||||
<tr key={run.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-900 whitespace-nowrap">
|
||||
{formatDate(run.started_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
|
||||
{run.user_email ?? `#${run.user_id}`}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
|
||||
<div className="font-medium">{run.account_name ?? '—'}</div>
|
||||
<div className="text-xs text-gray-400">{run.account_email ?? ''}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${statusClass}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 text-right whitespace-nowrap">
|
||||
{run.emails_fetched}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 text-right whitespace-nowrap">
|
||||
{run.emails_forwarded}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right whitespace-nowrap">
|
||||
{run.emails_failed > 0 ? (
|
||||
<span className="text-red-600">{run.emails_failed}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-right whitespace-nowrap">
|
||||
{formatDuration(run.duration_seconds)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{ok ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.pages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {data.page} of {data.pages} ({data.total} total runs)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
|
||||
disabled={page === data.pages}
|
||||
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white rounded-lg shadow border border-dashed border-gray-300">
|
||||
<Activity className="mx-auto h-12 w-12 text-gray-300 mb-3" />
|
||||
<h3 className="text-base font-semibold text-gray-700 mb-1">No activity yet</h3>
|
||||
<p className="text-sm text-gray-500 max-w-xs mx-auto">
|
||||
Processing run logs will appear here once mail accounts are active.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
'use client';
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
adminApi,
|
||||
adminNotificationsApi,
|
||||
AdminNotificationConfig,
|
||||
AdminNotificationConfigCreate,
|
||||
AdminNotificationConfigUpdate,
|
||||
} from '@/lib/api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Mail,
|
||||
Activity,
|
||||
Shield,
|
||||
Bell,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// ── Admin Notification Modal ─────────────────────────────────────────────
|
||||
|
||||
interface AdminNotificationModalProps {
|
||||
config?: AdminNotificationConfig | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AdminNotificationModal({ config, onClose }: AdminNotificationModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!config;
|
||||
|
||||
const [formData, setFormData] = useState<AdminNotificationConfigCreate>({
|
||||
name: config?.name ?? '',
|
||||
apprise_url: config?.apprise_url ?? '',
|
||||
is_enabled: config?.is_enabled ?? true,
|
||||
notify_on_errors: config?.notify_on_errors ?? true,
|
||||
notify_on_system_events: config?.notify_on_system_events ?? true,
|
||||
description: config?.description ?? '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const onSuccess = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: AdminNotificationConfigCreate) => adminNotificationsApi.create(data),
|
||||
onSuccess,
|
||||
onError: () => setError('Failed to save. Please check the Apprise URL and try again.'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: AdminNotificationConfigUpdate) =>
|
||||
adminNotificationsApi.update(config!.id, data),
|
||||
onSuccess,
|
||||
onError: () => setError('Failed to save. Please check the Apprise URL and try again.'),
|
||||
});
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!formData.name.trim() || !formData.apprise_url.trim()) {
|
||||
setError('Name and Apprise URL are required.');
|
||||
return;
|
||||
}
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{isEdit ? 'Edit System Alert Channel' : 'Add System Alert Channel'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="e.g. Admin Telegram Alert"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Apprise URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.apprise_url}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, apprise_url: e.target.value }))}
|
||||
placeholder="tgram://bot_token/chat_id/"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Any valid{' '}
|
||||
<a
|
||||
href="https://apprise.readthedocs.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Apprise
|
||||
</a>{' '}
|
||||
notification URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description{' '}
|
||||
<span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description ?? ''}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value || null }))}
|
||||
placeholder="What is this channel used for?"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">Triggers</p>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_enabled}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, is_enabled: e.target.checked }))}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-800">Enabled</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.notify_on_errors}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, notify_on_errors: e.target.checked }))}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-800">Notify on errors</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.notify_on_system_events}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, notify_on_system_events: e.target.checked }))
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-800">Notify on system events</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded p-2">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex-1 py-2 bg-purple-600 text-white rounded-md text-sm hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isEdit ? 'Save Changes' : 'Add Channel'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Admin Page ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showNotifModal, setShowNotifModal] = useState(false);
|
||||
const [editingNotif, setEditingNotif] = useState<AdminNotificationConfig | null>(null);
|
||||
const [testingNotifId, setTestingNotifId] = useState<number | null>(null);
|
||||
const [notifTestResults, setNotifTestResults] = useState<
|
||||
Record<number, { success: boolean; message: string }>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !user.is_superuser) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ['admin-stats'],
|
||||
queryFn: adminApi.getStats,
|
||||
enabled: !!user?.is_superuser,
|
||||
});
|
||||
|
||||
const { data: adminNotifications, isLoading: notifLoading } = useQuery({
|
||||
queryKey: ['admin-notifications'],
|
||||
queryFn: adminNotificationsApi.list,
|
||||
enabled: !!user?.is_superuser,
|
||||
});
|
||||
|
||||
const deleteNotifMutation = useMutation({
|
||||
mutationFn: adminNotificationsApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEditNotif = (config: AdminNotificationConfig) => {
|
||||
setEditingNotif(config);
|
||||
setShowNotifModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteNotif = async (id: number) => {
|
||||
if (!confirm('Delete this system alert channel?')) return;
|
||||
try {
|
||||
await deleteNotifMutation.mutateAsync(id);
|
||||
} catch {
|
||||
alert('Failed to delete channel');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNotif = async (config: AdminNotificationConfig) => {
|
||||
setTestingNotifId(config.id);
|
||||
try {
|
||||
const result = await adminNotificationsApi.test(config.apprise_url);
|
||||
setNotifTestResults((prev) => ({ ...prev, [config.id]: result }));
|
||||
} catch {
|
||||
setNotifTestResults((prev) => ({
|
||||
...prev,
|
||||
[config.id]: { success: false, message: 'Test request failed' },
|
||||
}));
|
||||
} finally {
|
||||
setTestingNotifId(null);
|
||||
setTimeout(() => {
|
||||
setNotifTestResults((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[config.id];
|
||||
return next;
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseNotifModal = () => {
|
||||
setShowNotifModal(false);
|
||||
setEditingNotif(null);
|
||||
};
|
||||
|
||||
// AuthGuard must always render so it can fetch the current user and handle
|
||||
// unauthenticated redirects. The early-return that was here prevented
|
||||
// AuthGuard from ever mounting on a direct navigation to /admin, leaving a
|
||||
// permanent blank page. The superuser guard is now applied inside the
|
||||
// layout so that the auth check always runs first.
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
{!user?.is_superuser ? (
|
||||
// Shown briefly while AuthGuard resolves the current user, or while
|
||||
// the non-superuser redirect in the useEffect at the top of this
|
||||
// component fires (router.replace('/dashboard')).
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-purple-600" />
|
||||
Admin Overview
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
System-wide statistics and management tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-purple-100">
|
||||
<Users className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Total Users</p>
|
||||
<p className="text-3xl font-semibold text-gray-900">{stats?.total_users ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-blue-100">
|
||||
<Mail className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Mail Accounts</p>
|
||||
<p className="text-3xl font-semibold text-gray-900">{stats?.total_mail_accounts ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-green-100">
|
||||
<Activity className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Processing Runs</p>
|
||||
<p className="text-3xl font-semibold text-gray-900">{stats?.total_processing_runs ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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"
|
||||
>
|
||||
<div className="p-3 rounded-full bg-purple-100 group-hover:bg-purple-200 transition-colors">
|
||||
<Users className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">Manage Users</p>
|
||||
<p className="text-sm text-gray-500">View, edit, assign plans, promote to admin</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/plans"
|
||||
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-blue-100 group-hover:bg-blue-200 transition-colors">
|
||||
<Mail className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">Manage Plans</p>
|
||||
<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 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-purple-600" />
|
||||
System Alert Channels
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Admin-level channels that receive system-wide error and event notifications.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingNotif(null);
|
||||
setShowNotifModal(true);
|
||||
}}
|
||||
className="flex items-center px-3 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notifLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : adminNotifications && adminNotifications.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{adminNotifications.map((config) => {
|
||||
const testResult = notifTestResults[config.id];
|
||||
const isTesting = testingNotifId === config.id;
|
||||
return (
|
||||
<div
|
||||
key={config.id}
|
||||
className={`bg-white rounded-lg shadow border transition-opacity ${
|
||||
config.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||
{config.name}
|
||||
</p>
|
||||
{config.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 flex-shrink-0 flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
config.is_enabled
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{config.is_enabled ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{config.is_enabled ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{config.notify_on_errors && (
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-red-50 text-red-700 border border-red-200">
|
||||
On Errors
|
||||
</span>
|
||||
)}
|
||||
{config.notify_on_system_events && (
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-purple-50 text-purple-700 border border-purple-200">
|
||||
System Events
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`mb-3 p-2 rounded text-xs flex items-center gap-1.5 ${
|
||||
testResult.success
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => handleTestNotif(config)}
|
||||
disabled={isTesting}
|
||||
title="Send test notification"
|
||||
className="flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{isTesting ? '…' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditNotif(config)}
|
||||
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Edit2 className="h-3.5 w-3.5 mr-1" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteNotif(config.id)}
|
||||
disabled={deleteNotifMutation.isPending}
|
||||
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10 bg-white rounded-lg shadow border border-dashed border-gray-300">
|
||||
<Bell className="mx-auto h-8 w-8 text-gray-300 mb-2" />
|
||||
<p className="text-sm text-gray-500">No system alert channels configured yet.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingNotif(null);
|
||||
setShowNotifModal(true);
|
||||
}}
|
||||
className="mt-3 inline-flex items-center px-3 py-1.5 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add First Channel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNotifModal && (
|
||||
<AdminNotificationModal config={editingNotif} onClose={handleCloseNotifModal} />
|
||||
)}
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
'use client';
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
adminApi,
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlanCreate,
|
||||
SubscriptionPlanUpdate,
|
||||
} from '@/lib/api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Shield, Plus, Pencil, Trash2, X, Check } from 'lucide-react';
|
||||
|
||||
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
|
||||
|
||||
const DEFAULT_FORM: SubscriptionPlanCreate = {
|
||||
tier: 'free',
|
||||
name: '',
|
||||
description: '',
|
||||
price_monthly: 0,
|
||||
price_yearly: undefined,
|
||||
max_mail_accounts: 1,
|
||||
max_emails_per_day: 1000,
|
||||
check_interval_minutes: 5,
|
||||
support_level: 'community',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
function PlanFormModal({
|
||||
plan,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: {
|
||||
plan: SubscriptionPlan | null;
|
||||
onClose: () => void;
|
||||
onCreate?: (data: SubscriptionPlanCreate) => void;
|
||||
onUpdate?: (data: SubscriptionPlanUpdate) => void;
|
||||
}) {
|
||||
const isEdit = plan !== null;
|
||||
const [form, setForm] = useState<SubscriptionPlanCreate>(
|
||||
plan
|
||||
? {
|
||||
tier: plan.tier,
|
||||
name: plan.name,
|
||||
description: plan.description ?? '',
|
||||
price_monthly: plan.price_monthly,
|
||||
price_yearly: plan.price_yearly ?? undefined,
|
||||
max_mail_accounts: plan.max_mail_accounts,
|
||||
max_emails_per_day: plan.max_emails_per_day,
|
||||
check_interval_minutes: plan.check_interval_minutes,
|
||||
support_level: plan.support_level,
|
||||
is_active: plan.is_active,
|
||||
}
|
||||
: DEFAULT_FORM
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6 overflow-y-auto max-h-[90vh]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{isEdit ? 'Edit Plan' : 'Create Plan'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Tier</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.tier}
|
||||
onChange={(e) => setForm({ ...form, tier: e.target.value })}
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.description ?? ''}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Monthly Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.price_monthly}
|
||||
onChange={(e) => setForm({ ...form, price_monthly: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Yearly Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.price_yearly ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
price_yearly: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Max Mailboxes</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.max_mail_accounts}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, max_mail_accounts: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Max Emails/Day</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.max_emails_per_day}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, max_emails_per_day: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Check Interval (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.check_interval_minutes}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, check_interval_minutes: parseInt(e.target.value) || 5 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Support Level</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.support_level}
|
||||
onChange={(e) => setForm({ ...form, support_level: e.target.value })}
|
||||
>
|
||||
{['community', 'email', 'priority'].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
checked={form.is_active}
|
||||
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
|
||||
/>
|
||||
Active (visible to users)
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isEdit && onUpdate) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tier: _tier, ...updateFields } = form;
|
||||
onUpdate(updateFields);
|
||||
} else if (!isEdit && onCreate) {
|
||||
onCreate(form);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
|
||||
>
|
||||
{isEdit ? 'Save Changes' : 'Create Plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPlansPage() {
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !user.is_superuser) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const { data: plans, isLoading } = useQuery({
|
||||
queryKey: ['admin-plans'],
|
||||
queryFn: adminApi.listPlans,
|
||||
enabled: !!user?.is_superuser,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: SubscriptionPlanCreate) => adminApi.createPlan(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: SubscriptionPlanUpdate }) =>
|
||||
adminApi.updatePlan(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
setEditingPlan(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => adminApi.deletePlan(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
// AuthGuard must always render (see admin/page.tsx for explanation).
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
{!user?.is_superuser ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-purple-600" />
|
||||
Manage Plans
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure subscription plans, limits, and pricing.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Plan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{['Tier', 'Name', 'Price/mo', 'Mailboxes', 'Emails/day', 'Interval', 'Support', 'Status', 'Actions'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{(plans ?? []).map((p) => (
|
||||
<tr key={p.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
|
||||
{p.tier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{p.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
${p.price_monthly.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{p.max_mail_accounts}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{p.max_emails_per_day.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{p.check_interval_minutes}m
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 capitalize">
|
||||
{p.support_level}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
p.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{p.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditingPlan(p)}
|
||||
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
|
||||
title="Edit plan"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{deleteConfirm === p.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(p.id)}
|
||||
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
|
||||
title="Confirm delete"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(p.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete plan"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(plans ?? []).length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">
|
||||
No plans found. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<PlanFormModal
|
||||
plan={null}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={(data) => createMutation.mutate(data)}
|
||||
/>
|
||||
)}
|
||||
{editingPlan && (
|
||||
<PlanFormModal
|
||||
plan={editingPlan}
|
||||
onClose={() => setEditingPlan(null)}
|
||||
onUpdate={(data) =>
|
||||
updateMutation.mutate({ id: editingPlan.id, data })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi, AdminUser, AdminUserUpdate } from '@/lib/api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Shield, Pencil, Trash2, X, Check } from 'lucide-react';
|
||||
|
||||
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
|
||||
const STATUSES = ['active', 'canceled', 'past_due'];
|
||||
|
||||
function EditUserModal({
|
||||
user,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
onSave: (data: AdminUserUpdate) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<AdminUserUpdate>({
|
||||
full_name: user.full_name ?? '',
|
||||
email: user.email,
|
||||
is_active: user.is_active,
|
||||
is_superuser: user.is_superuser,
|
||||
subscription_tier: user.subscription_tier,
|
||||
subscription_status: user.subscription_status,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Edit User</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.full_name ?? ''}
|
||||
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.email ?? ''}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Subscription Tier</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.subscription_tier ?? 'free'}
|
||||
onChange={(e) => setForm({ ...form, subscription_tier: e.target.value })}
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Subscription Status</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.subscription_status ?? 'active'}
|
||||
onChange={(e) => setForm({ ...form, subscription_status: e.target.value })}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
checked={form.is_active ?? true}
|
||||
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
checked={form.is_superuser ?? false}
|
||||
onChange={(e) => setForm({ ...form, is_superuser: e.target.checked })}
|
||||
/>
|
||||
Admin (superuser)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(form)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser && !currentUser.is_superuser) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [currentUser, router]);
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: () => adminApi.listUsers(),
|
||||
enabled: !!currentUser?.is_superuser,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: AdminUserUpdate }) =>
|
||||
adminApi.updateUser(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
setEditingUser(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => adminApi.deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
// AuthGuard must always render (see admin/page.tsx for explanation).
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
{!currentUser?.is_superuser ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-purple-600" />
|
||||
Manage Users
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
View all registered users, assign plans, and manage admin privileges.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{['User', 'Plan', 'Status', 'Accounts', 'Last Login', 'Role', 'Actions'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{(users ?? []).map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{u.full_name || '—'}</p>
|
||||
<p className="text-xs text-gray-500">{u.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
|
||||
{u.subscription_tier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
u.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{u.mail_account_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{u.last_login_at
|
||||
? new Date(u.last_login_at).toLocaleDateString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.is_superuser ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<Shield className="h-3 w-3" />
|
||||
Admin
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">User</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditingUser(u)}
|
||||
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
|
||||
title="Edit user"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{u.id !== currentUser?.id && (
|
||||
deleteConfirm === u.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(u.id)}
|
||||
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
|
||||
title="Confirm delete"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(u.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(users ?? []).length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No users found.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingUser && (
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSave={(data) => updateMutation.mutate({ id: editingUser.id, data })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { authApi, userApi } from '@/lib/api';
|
||||
import { authApi, gmailApi, userApi } from '@/lib/api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
@@ -17,6 +17,7 @@ function AuthCallbackContent() {
|
||||
const handleCallback = async () => {
|
||||
const code = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const state = searchParams.get('state');
|
||||
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
@@ -32,8 +33,31 @@ function AuthCallbackContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
// Gmail reconnect flow: user was already logged in and clicked
|
||||
// "Connect Gmail" in Settings. The authorize URL includes state=gmail_connect.
|
||||
if (state === 'gmail_connect') {
|
||||
try {
|
||||
await gmailApi.saveCallback(code, redirectUri);
|
||||
setStatus('success');
|
||||
setMessage('Gmail connected successfully! Redirecting to settings…');
|
||||
setTimeout(() => router.push('/settings'), 1500);
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
err instanceof Error && 'response' in err
|
||||
? (err as { response?: { data?: { detail?: string } } }).response?.data
|
||||
?.detail
|
||||
: null;
|
||||
setStatus('error');
|
||||
setMessage(detail || 'Failed to connect Gmail. Please try again.');
|
||||
setTimeout(() => router.push('/settings'), 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal Google Sign-In flow
|
||||
try {
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
const response = await authApi.googleAuth(code, redirectUri);
|
||||
|
||||
localStorage.setItem('access_token', response.access_token);
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { mailAccountsApi, processingRunsApi } from '@/lib/api';
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
CheckCircle,
|
||||
import { mailAccountsApi, MailAccount } from '@/lib/api';
|
||||
import { formatRelative } from '@/lib/date-utils';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
Clock
|
||||
Clock,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Inbox,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
@@ -18,22 +22,15 @@ interface StatCardProps {
|
||||
value: string | number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
iconColor: string;
|
||||
trend?: string;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon: Icon, iconColor, trend }: StatCardProps) {
|
||||
function StatCard({ title, value, icon: Icon, iconColor }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
|
||||
{trend && (
|
||||
<div className="mt-2 flex items-center text-sm">
|
||||
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||
<span className="text-green-600">{trend}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-full ${iconColor}`}>
|
||||
<Icon className="h-8 w-8 text-white" />
|
||||
@@ -43,27 +40,78 @@ function StatCard({ title, value, icon: Icon, iconColor, trend }: StatCardProps)
|
||||
);
|
||||
}
|
||||
|
||||
function AccountStatusRow({ account }: { account: MailAccount }) {
|
||||
const hasError = !!account.last_error_message;
|
||||
const lastChecked = account.last_check_at;
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 border-b border-gray-100 last:border-b-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left: name + email */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Inbox className="h-4 w-4 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">{account.name}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{account.email_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: status badge + last check */}
|
||||
<div className="text-right shrink-0">
|
||||
{hasError ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600">
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
Error
|
||||
</span>
|
||||
) : lastChecked ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
OK
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-400">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{formatRelative(lastChecked)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && (
|
||||
<div className="mt-2 flex items-start gap-1.5 p-2 bg-red-50 border border-red-200 rounded">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-700 line-clamp-2">{account.last_error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lifetime counters (only when there's activity) */}
|
||||
{(account.total_emails_processed > 0 || account.total_emails_failed > 0) && (
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>{account.total_emails_processed.toLocaleString()} processed</span>
|
||||
{account.total_emails_failed > 0 && (
|
||||
<span className="text-red-500">{account.total_emails_failed.toLocaleString()} failed</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: accounts } = useQuery({
|
||||
const { data: accounts, isLoading: accountsLoading } = useQuery({
|
||||
queryKey: ['mail-accounts'],
|
||||
queryFn: mailAccountsApi.list,
|
||||
});
|
||||
|
||||
const { data: runs, isLoading: runsLoading } = useQuery({
|
||||
queryKey: ['processing-runs'],
|
||||
queryFn: () => processingRunsApi.list(),
|
||||
});
|
||||
|
||||
const stats = {
|
||||
totalAccounts: accounts?.length || 0,
|
||||
activeAccounts: accounts?.filter((a) => a.is_enabled).length || 0,
|
||||
emailsToday: runs
|
||||
?.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,
|
||||
totalProcessed: accounts?.reduce((sum, a) => sum + a.total_emails_processed, 0) || 0,
|
||||
accountsWithErrors: accounts?.filter((a) => !!a.last_error_message).length || 0,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,8 +127,8 @@ export default function DashboardPage() {
|
||||
iconColor="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Emails Forwarded Today"
|
||||
value={stats.emailsToday}
|
||||
title="Emails Processed"
|
||||
value={stats.totalProcessed.toLocaleString()}
|
||||
icon={Send}
|
||||
iconColor="bg-green-500"
|
||||
/>
|
||||
@@ -91,98 +139,44 @@ export default function DashboardPage() {
|
||||
iconColor="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Errors"
|
||||
value={stats.errors}
|
||||
title="Accounts with Errors"
|
||||
value={stats.accountsWithErrors}
|
||||
icon={AlertCircle}
|
||||
iconColor="bg-red-500"
|
||||
iconColor={stats.accountsWithErrors > 0 ? 'bg-red-500' : 'bg-gray-400'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Processing Runs */}
|
||||
{/* Mailbox Status Overview */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Recent Processing Runs</h3>
|
||||
</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 ? (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Account
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Started At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fetched
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Forwarded
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Errors
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{runs.slice(0, 10).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}`}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1 text-gray-400" />
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
run.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: run.status === 'failed'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{run.emails_fetched}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{run.emails_forwarded}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{run.emails_failed > 0 ? (
|
||||
<span className="text-red-600 font-medium">{run.emails_failed}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No processing runs yet</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Mailbox Status</h3>
|
||||
<Link href="/logs" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
|
||||
View activity & history →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{accountsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : accounts && accounts.length > 0 ? (
|
||||
<div>
|
||||
{accounts.map((account) => (
|
||||
<AccountStatusRow key={account.id} account={account} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Clock className="mx-auto h-10 w-10 text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-500">No mail accounts configured yet.</p>
|
||||
<Link
|
||||
href="/accounts"
|
||||
className="mt-3 inline-block text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
Add your first account →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Datenschutz – ${APP_NAME}`,
|
||||
};
|
||||
|
||||
const LAST_UPDATED = 'March 26, 2026';
|
||||
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
|
||||
|
||||
export default function DatenschutzPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Datenschutzhinweis
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mb-8">
|
||||
Letzte Aktualisierung: {LAST_UPDATED}
|
||||
</p>
|
||||
|
||||
{/* TOC */}
|
||||
<nav className="mb-8 p-4 bg-gray-50 rounded-md text-sm text-blue-700 space-y-1">
|
||||
<p className="font-semibold text-gray-700 mb-2">Inhalt</p>
|
||||
{[
|
||||
'Verantwortlicher',
|
||||
'Geltungsbereich dieser Datenschutzerklärung',
|
||||
'Datenerhebung & Zwecke',
|
||||
'Datenminimierung & Zweckbindung',
|
||||
'Verwendung von Cookies & ähnlichen Technologien',
|
||||
'Drittanbieter-Dienste',
|
||||
'Internationale Datenübertragungen',
|
||||
'Datenaufbewahrung',
|
||||
'Datensicherheit',
|
||||
'Ihre Rechte (EU / EWR / UK / Schweiz)',
|
||||
'Zusätzliche Rechte – Vereinigte Staaten (CCPA/CPRA)',
|
||||
'Zusätzliche Rechte – Kanada (PIPEDA / Gesetz 25)',
|
||||
'Zusätzliche Rechte – Lateinamerika (LGPD & andere)',
|
||||
'Zusätzliche Rechte – Asien-Pazifik & Japan',
|
||||
'Zusätzliche Rechte – Ukraine',
|
||||
'Aktualisierungen zu dieser Datenschutzerklärung',
|
||||
].map((title, i) => (
|
||||
<div key={i}>
|
||||
<a href={`#section-${i + 1}`} className="hover:underline">
|
||||
{i + 1}. {title}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Sections */}
|
||||
<section id="section-1" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
1. Verantwortlicher
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Der für die Verarbeitung Ihrer personenbezogenen Daten gemäß der
|
||||
EU-Datenschutz-Grundverordnung (DSGVO) und gleichwertiger
|
||||
Datenschutzgesetze weltweit verantwortliche Verantwortliche ist:
|
||||
</p>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Christian Louis IT Beratung<br />
|
||||
Alter Steinweg 3, 20459 Hamburg, Deutschland
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Kontakt-E-Mail:{' '}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
Bei allen datenschutzbezogenen Anfragen (Zugriff, Löschung,
|
||||
Berichtigung, Widerspruch oder Beschwerden) kontaktieren Sie uns
|
||||
bitte unter der obenstehenden E-Mail-Adresse. Wir werden innerhalb
|
||||
von 30 Tagen (oder dem durch geltendes Recht vorgeschriebenen
|
||||
Zeitraum) antworten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-2" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
2. Geltungsbereich dieser Datenschutzerklärung
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
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
|
||||
Ukraine, den Vereinigten Staaten (US), Kanada, Lateinamerika,
|
||||
dem asiatisch-pazifischen Raum und Japan.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-3" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
3. Datenerhebung & Zwecke
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Benutzerauthentifizierung:</strong> Wir verwenden OAuth 2.0
|
||||
(Google) und optionale lokale Authentifizierung. Über OAuth können
|
||||
wir Ihren Namen, Ihre E-Mail-Adresse und Ihr Profilbild erhalten.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>E-Mail-Verarbeitung:</strong> E-Mails, die von Ihren
|
||||
konfigurierten POP3-Konten abgerufen werden, werden ausschließlich
|
||||
für den Zweck der Weiterleitung an Gmail verarbeitet. Der
|
||||
E-Mail-Inhalt wird nicht länger gespeichert, als es betrieblich
|
||||
notwendig ist.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Audit-Protokolle:</strong> Wir führen begrenzte Protokolle
|
||||
(Aktionsart, Zeitstempel, Benutzeridentifikator), um die Integrität
|
||||
und Sicherheit des Dienstes zu gewährleisten.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
<strong>Rechtsgrundlage (DSGVO Art. 6):</strong> (1)(b)
|
||||
Vertragsdurchführung, (1)(c) Rechtliche Verpflichtung, (1)(f)
|
||||
Berechtigte Interessen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-4" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
4. Datenminimierung & Zweckbindung
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-1">
|
||||
<li>
|
||||
Wir erheben nur die minimalen personenbezogenen Daten, die zur
|
||||
Durchführung des Dienstes erforderlich sind.
|
||||
</li>
|
||||
<li>
|
||||
E-Mail-Inhalte werden ausschließlich für den von Ihnen
|
||||
initiierten Weiterleitungszweck verarbeitet.
|
||||
</li>
|
||||
<li>
|
||||
Es wird keine Werbung, Verhaltensverfolgung oder Profilierung
|
||||
durchgeführt.
|
||||
</li>
|
||||
<li>
|
||||
Es werden keine Tracking-Cookies oder Analysescripts geladen.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="section-5" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
5. Verwendung von Cookies & ähnlichen Technologien
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Der Dienst verwendet nur unbedingt erforderliche Sitzungscookies,
|
||||
um Ihre authentifizierte Sitzung aufrechtzuerhalten. Diese Cookies
|
||||
sind für die Funktion des Dienstes unerlässlich und sind von den
|
||||
Anforderungen an vorherige Zustimmung gemäß der EU-E-Privacy-
|
||||
Richtlinie (Art. 5(3)) ausgenommen. Wir verwenden keine
|
||||
Analyse-Cookies, Werbe-Cookies oder Tracking-Pixel.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-6" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
6. Drittanbieter-Dienste
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>OAuth-Anbieter (Google):</strong> Wenn Sie sich über OAuth
|
||||
authentifizieren, verarbeitet der jeweilige Anbieter Ihre
|
||||
Anmeldeinformationen gemäß seiner eigenen Datenschutzrichtlinie.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Gmail API:</strong> E-Mails werden über die Gmail API in
|
||||
Ihr Gmail-Konto eingespielt. Ihre OAuth-Zugangsdaten werden
|
||||
verschlüsselt gespeichert.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
<strong>Kein Verkauf oder Teilen für Werbung:</strong> Wir
|
||||
verkaufen, vermieten oder teilen Ihre personenbezogenen Daten
|
||||
nicht mit Dritten zu Werbungs- oder Marketingzwecken.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-7" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
7. Internationale Datenübertragungen
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Wenn personenbezogene Daten außerhalb des EWR übertragen werden,
|
||||
verlassen wir uns auf geeignete Schutzmaßnahmen, einschließlich
|
||||
Standardvertragsklauseln (SCCs) gemäß EU-Beschluss 2021/914/EU
|
||||
sowie Angemessenheitsentscheidungen der Europäischen Kommission.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-8" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
8. Datenaufbewahrung
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-1">
|
||||
<li>
|
||||
<strong>Sitzungsdaten:</strong> Wird gelöscht, wenn Sie sich
|
||||
abmelden oder nach Ablauf der Sitzung.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kontokonfiguration & Metadaten:</strong> Für die Dauer
|
||||
Ihrer Nutzung des Dienstes aufbewahrt.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Audit-Protokolle:</strong> Für bis zu 90 Tage
|
||||
aufbewahrt.
|
||||
</li>
|
||||
<li>
|
||||
<strong>OAuth-Token:</strong> In verschlüsselter Form gespeichert
|
||||
und jederzeit über Ihren OAuth-Anbieter widerrufbar.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="section-9" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
9. Datensicherheit
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-1">
|
||||
<li>Verschlüsselung von Anmeldeinformationen im Ruhezustand.</li>
|
||||
<li>
|
||||
Transport Layer Security (TLS/HTTPS) für alle Kommunikationen.
|
||||
</li>
|
||||
<li>
|
||||
Rollenbasierte Zugriffskontrollen zum Schutz personenbezogener
|
||||
Daten.
|
||||
</li>
|
||||
<li>CSRF-Schutz für alle zustandsändernden Anfragen.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="section-10" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
10. Ihre Rechte (EU / EWR / UK / Schweiz)
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-1">
|
||||
<li>
|
||||
<strong>Recht auf Zugang (Art. 15):</strong> Kopie der über Sie
|
||||
gespeicherten Daten.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Recht auf Berichtigung (Art. 16):</strong> Korrektur
|
||||
ungenauer Daten.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Recht auf Löschung (Art. 17):</strong> Löschung Ihrer
|
||||
persönlichen Daten.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Recht auf Einschränkung (Art. 18):</strong> Vorübergehende
|
||||
Aussetzung der Verarbeitung.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Recht auf Datenübertragbarkeit (Art. 20):</strong> Daten
|
||||
in maschinenlesbarem Format.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Widerspruchsrecht (Art. 21):</strong> Widerspruch gegen
|
||||
Verarbeitung auf Basis berechtigter Interessen.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-3">
|
||||
Um eines dieser Rechte auszuüben, kontaktieren Sie uns unter{' '}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
. Beschwerden können an die zuständige Datenschutzbehörde (in
|
||||
Deutschland: BfDI) gerichtet werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-11" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
11. Zusätzliche Rechte – Vereinigte Staaten (CCPA / CPRA)
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Wenn Sie ein Bewohner Kaliforniens oder eines anderen US-
|
||||
Bundesstaates mit anwendbarer Datenschutzgesetzgebung sind, haben
|
||||
Sie das Recht auf Auskunft, Löschung, Berichtigung und Widerspruch
|
||||
gegen den Verkauf persönlicher Daten. Wir verkaufen oder teilen
|
||||
keine personenbezogenen Informationen. Kontakt:{' '}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-12" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
12. Zusätzliche Rechte – Kanada (PIPEDA / Gesetz 25)
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Wenn Sie sich in Kanada befinden, haben Sie das Recht auf Zugang,
|
||||
Korrektur und Widerruf der Zustimmung gemäß PIPEDA und den
|
||||
provinziellen Datenschutzgesetzen. Kontakt:{' '}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-13" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
13. Zusätzliche Rechte – Lateinamerika (LGPD & andere)
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Brasilianische Nutzer haben gemäß LGPD (Lei 13.709/2018) das Recht
|
||||
auf Bestätigung der Verarbeitung, Zugang, Berichtigung, Löschung
|
||||
und Datenübertragbarkeit. Nutzer in anderen lateinamerikanischen
|
||||
Ländern können entsprechende Rechte gemäß nationalem Recht ausüben.
|
||||
Kontakt:{' '}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-14" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
14. Zusätzliche Rechte – Asien-Pazifik & Japan
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Nutzer in Japan (APPI), Australien (Privacy Act 1988), Südkorea
|
||||
(PIPA) sowie anderen APJ-Märkten (Singapur PDPA, Neuseeland, Indien
|
||||
DPDP) können entsprechende Datenschutzrechte ausüben. Kontakt:{' '}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-15" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
15. Zusätzliche Rechte – Ukraine
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Nutzer in der Ukraine sind nach dem ukrainischen Gesetz „Über den
|
||||
Schutz persönlicher Daten“ (Nr. 2297-VI) geschützt. Ihre Rechte
|
||||
umfassen Zugang, Berichtigung, Sperrung, Löschung und das Recht
|
||||
auf Widerspruch gegen die Verarbeitung. Kontakt:{' '}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-16" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
16. Aktualisierungen zu dieser Datenschutzerklärung
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Wir können diese Mitteilung von Zeit zu Zeit aktualisieren. Das
|
||||
Datum „Letzte Aktualisierung“ am Anfang dieser Seite gibt an, wann
|
||||
die Mitteilung zuletzt überarbeitet wurde. Bei wesentlichen
|
||||
Änderungen werden wir die Nutzer durch eine Benachrichtigung in der
|
||||
Anwendung oder per E-Mail informieren.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-3">
|
||||
Bei Fragen oder Bedenken kontaktieren Sie uns unter{' '}
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 flex gap-4 text-sm text-gray-500">
|
||||
<Link href="/impressum" className="hover:text-blue-600">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/login" className="hover:text-blue-600">
|
||||
Zurück zur Anmeldung
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 – ${APP_NAME}`,
|
||||
};
|
||||
|
||||
export default function ImpressumPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Impressum</h1>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-2">
|
||||
Angaben gemäß § 5 TMG
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Christian Louis IT Beratung<br />
|
||||
Alter Steinweg 3<br />
|
||||
20459 Hamburg<br />
|
||||
Deutschland
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-2">
|
||||
Vertreten durch
|
||||
</h2>
|
||||
<p className="text-gray-700">Christian Krakau-Louis</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-2">Kontakt</h2>
|
||||
<p className="text-gray-700">
|
||||
Fax: +49 40 97074609<br />
|
||||
E-Mail:{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-2">
|
||||
Umsatzsteuer-Identifikationsnummer
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:
|
||||
<br />
|
||||
DE202899017
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<p className="text-gray-700">
|
||||
Dieses Projekt ist Teil einer Open-Source-Initiative. Alle Rechte
|
||||
vorbehalten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 flex gap-4 text-sm text-gray-500">
|
||||
<Link href="/datenschutz" className="hover:text-blue-600">
|
||||
Datenschutz
|
||||
</Link>
|
||||
<Link href="/login" className="hover:text-blue-600">
|
||||
Zurück zur Anmeldung
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "POP3 Forwarder - Automatic Email Forwarding to Gmail",
|
||||
description: "Forward your POP3 emails to Gmail automatically with our secure and reliable service",
|
||||
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.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -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
|
||||
@@ -149,6 +149,15 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex justify-center gap-4 text-xs text-gray-400">
|
||||
<Link href="/impressum" className="hover:text-gray-600 transition-colors">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="hover:text-gray-600 transition-colors">
|
||||
Datenschutz
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { processingRunsApi, mailAccountsApi, MailAccount, ProcessingRun, ProcessingLog } from '@/lib/api';
|
||||
import { formatRelative, formatDate, formatDuration } from '@/lib/date-utils';
|
||||
import {
|
||||
Inbox,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
function RunDetailRow({ run }: { run: ProcessingRun }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [logsPage, setLogsPage] = useState(1);
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['run-logs', run.id, logsPage],
|
||||
queryFn: () => processingRunsApi.getLogs(run.id, { page: logsPage, page_size: 20 }),
|
||||
enabled: expanded,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="hover:bg-gray-50 cursor-pointer text-sm"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<td className="px-4 py-2 text-gray-700 whitespace-nowrap">{formatDate(run.started_at)}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-900 font-medium">{run.emails_fetched}</td>
|
||||
<td className="px-4 py-2 text-right text-gray-700">{run.emails_forwarded}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{run.emails_failed > 0 ? (
|
||||
<span className="text-red-600 font-medium">{run.emails_failed}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-gray-500">{formatDuration(run.duration_seconds)}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 text-gray-400 inline" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400 inline" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-gray-50 px-6 py-3 border-b border-gray-100">
|
||||
{run.error_message && (
|
||||
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{run.error_message}
|
||||
</div>
|
||||
)}
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 py-1">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : logsData && logsData.items.length > 0 ? (
|
||||
<>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400 uppercase tracking-wide">
|
||||
<th className="text-left pb-1 pr-4">Time</th>
|
||||
<th className="text-left pb-1 pr-4">Subject</th>
|
||||
<th className="text-left pb-1 pr-4">From</th>
|
||||
<th className="text-left pb-1">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logsData.items.map((log: ProcessingLog) => (
|
||||
<tr key={log.id} className="border-t border-gray-100">
|
||||
<td className="py-1 pr-4 text-gray-500 whitespace-nowrap">
|
||||
{formatDate(log.timestamp)}
|
||||
</td>
|
||||
<td className="py-1 pr-4 text-gray-700 max-w-xs truncate">
|
||||
{log.email_subject ?? '—'}
|
||||
</td>
|
||||
<td className="py-1 pr-4 text-gray-500 max-w-xs truncate">
|
||||
{log.email_from ?? '—'}
|
||||
</td>
|
||||
<td className="py-1">
|
||||
{log.success ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{logsData.pages > 1 && (
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLogsPage((p) => Math.max(1, p - 1));
|
||||
}}
|
||||
disabled={logsPage === 1}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span>Page {logsPage} of {logsData.pages}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLogsPage((p) => Math.min(logsData.pages, p + 1));
|
||||
}}
|
||||
disabled={logsPage === logsData.pages}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 py-1">No per-email logs for this run.</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MailboxCard({
|
||||
account,
|
||||
runs,
|
||||
}: {
|
||||
account: MailAccount;
|
||||
runs: ProcessingRun[];
|
||||
}) {
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const hasError = !!account.last_error_message;
|
||||
const lastChecked = account.last_check_at;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Mailbox header */}
|
||||
<div className="px-5 py-4 flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Inbox className="h-5 w-5 text-blue-500 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-gray-900 truncate">{account.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{account.email_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Last attempt status */}
|
||||
<div className="text-right shrink-0">
|
||||
{hasError ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600">
|
||||
<XCircle className="h-4 w-4" />
|
||||
Error
|
||||
</span>
|
||||
) : lastChecked ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
OK
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Last check: {formatRelative(lastChecked)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && (
|
||||
<div className="mx-5 mb-3 p-2 bg-red-50 border border-red-200 rounded flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-700">{account.last_error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Successful pulls summary */}
|
||||
<div className="border-t border-gray-100 px-5 py-3">
|
||||
{runs.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">No emails fetched yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowHistory((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
{showHistory ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
{runs.length} successful pull{runs.length !== 1 ? 's' : ''}
|
||||
{runs[0] && (
|
||||
<span className="text-gray-400 font-normal text-xs ml-1">
|
||||
— last {formatRelative(runs[0].started_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showHistory && (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
<th className="px-4 pb-2">Date</th>
|
||||
<th className="px-4 pb-2 text-right">Fetched</th>
|
||||
<th className="px-4 pb-2 text-right">Forwarded</th>
|
||||
<th className="px-4 pb-2 text-right">Failed</th>
|
||||
<th className="px-4 pb-2 text-right">Duration</th>
|
||||
<th className="px-4 pb-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{runs.map((run) => (
|
||||
<RunDetailRow key={run.id} run={run} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [runsPage, setRunsPage] = useState(1);
|
||||
|
||||
const { data: accounts, isLoading: accountsLoading } = useQuery({
|
||||
queryKey: ['mail-accounts'],
|
||||
queryFn: mailAccountsApi.list,
|
||||
});
|
||||
|
||||
// Fetch recent successful runs (emails_fetched > 0) across all accounts
|
||||
const { data: runsData, isLoading: runsLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['processing-runs-meaningful', runsPage],
|
||||
queryFn: () =>
|
||||
processingRunsApi.list({ page: runsPage, page_size: 100, has_emails: true }),
|
||||
});
|
||||
|
||||
const isLoading = accountsLoading || runsLoading;
|
||||
|
||||
// Group runs by account id
|
||||
const runsByAccount = (runsData?.items ?? []).reduce<Record<number, ProcessingRun[]>>(
|
||||
(acc, run) => {
|
||||
if (!acc[run.mail_account_id]) acc[run.mail_account_id] = [];
|
||||
acc[run.mail_account_id].push(run);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Inbox className="h-6 w-6 text-blue-600" />
|
||||
Mailbox Activity
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Last check status and successful email pulls for each mailbox.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-center py-16 text-red-500">
|
||||
Failed to load mailbox activity. Please try again.
|
||||
</div>
|
||||
) : accounts && accounts.length > 0 ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{accounts.map((account) => (
|
||||
<MailboxCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
runs={runsByAccount[account.id] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination for runs (only shown when there are multiple pages) */}
|
||||
{runsData && runsData.pages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-lg">
|
||||
<span className="text-sm text-gray-500">
|
||||
Showing page {runsData.page} of {runsData.pages} ({runsData.total} successful pulls)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setRunsPage((p) => Math.max(1, p - 1))}
|
||||
disabled={runsPage === 1}
|
||||
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRunsPage((p) => Math.min(runsData.pages, p + 1))}
|
||||
disabled={runsPage === runsData.pages}
|
||||
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white rounded-lg shadow border border-dashed border-gray-300">
|
||||
<Clock className="mx-auto h-12 w-12 text-gray-300 mb-3" />
|
||||
<h3 className="text-base font-semibold text-gray-700 mb-1">No mail accounts yet</h3>
|
||||
<p className="text-sm text-gray-500 max-w-xs mx-auto">
|
||||
Add a mail account to start seeing activity here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notificationsApi, NotificationConfig, NotificationConfigCreate, NotificationConfigUpdate } from '@/lib/api';
|
||||
import { NotificationWizard } from '@/components/NotificationWizard';
|
||||
import { Plus, Edit2, Trash2, Bell, Send, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
const CHANNEL_DISPLAY: Record<string, { icon: string; label: string; color: string }> = {
|
||||
telegram: { icon: '🤖', label: 'Telegram', color: 'bg-blue-100 text-blue-800' },
|
||||
discord: { icon: '💬', label: 'Discord', color: 'bg-indigo-100 text-indigo-800' },
|
||||
slack: { icon: '💼', label: 'Slack', color: 'bg-yellow-100 text-yellow-800' },
|
||||
email: { icon: '📧', label: 'Email', color: 'bg-green-100 text-green-800' },
|
||||
webhook: { icon: '🔗', label: 'Webhook', color: 'bg-purple-100 text-purple-800' },
|
||||
custom: { icon: '⚙️', label: 'Custom', color: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<NotificationConfig | null>(null);
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<number, { success: boolean; message: string }>>({});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: notifications, isLoading } = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: notificationsApi.list,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: NotificationConfigCreate) => notificationsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
setShowWizard(false);
|
||||
setEditingConfig(null);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: NotificationConfigUpdate }) =>
|
||||
notificationsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
setShowWizard(false);
|
||||
setEditingConfig(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: notificationsApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, is_enabled }: { id: number; is_enabled: boolean }) =>
|
||||
notificationsApi.update(id, { is_enabled }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleWizardComplete = (config: {
|
||||
name: string;
|
||||
channel: string;
|
||||
apprise_url: string;
|
||||
notify_on_errors: boolean;
|
||||
notify_on_success: boolean;
|
||||
}) => {
|
||||
if (editingConfig) {
|
||||
updateMutation.mutate({ id: editingConfig.id, data: config });
|
||||
} else {
|
||||
createMutation.mutate(config);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (config: NotificationConfig) => {
|
||||
setEditingConfig(config);
|
||||
setShowWizard(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this notification channel?')) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
} catch {
|
||||
alert('Failed to delete notification channel');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (config: NotificationConfig) => {
|
||||
if (!config.apprise_url) return;
|
||||
setTestingId(config.id);
|
||||
try {
|
||||
const result = await notificationsApi.test(config.apprise_url);
|
||||
setTestResults((prev) => ({ ...prev, [config.id]: result }));
|
||||
} catch {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[config.id]: { success: false, message: 'Test request failed' },
|
||||
}));
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
setTimeout(() => {
|
||||
setTestResults((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[config.id];
|
||||
return next;
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenWizard = () => {
|
||||
setEditingConfig(null);
|
||||
setShowWizard(true);
|
||||
};
|
||||
|
||||
const handleCancelWizard = () => {
|
||||
setShowWizard(false);
|
||||
setEditingConfig(null);
|
||||
};
|
||||
|
||||
if (showWizard) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
<div className="max-w-xl mx-auto">
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<NotificationWizard
|
||||
onComplete={handleWizardComplete}
|
||||
onCancel={handleCancelWizard}
|
||||
initialData={
|
||||
editingConfig
|
||||
? {
|
||||
name: editingConfig.name,
|
||||
channel: editingConfig.channel,
|
||||
apprise_url: editingConfig.apprise_url,
|
||||
notify_on_errors: editingConfig.notify_on_errors,
|
||||
notify_on_success: editingConfig.notify_on_success,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Bell className="h-6 w-6 text-blue-600" />
|
||||
Notification Channels
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get alerts when emails are processed or errors occur.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenWizard}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Channel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : notifications && notifications.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{notifications.map((config) => {
|
||||
const channel = CHANNEL_DISPLAY[config.channel] ?? CHANNEL_DISPLAY.custom;
|
||||
const testResult = testResults[config.id];
|
||||
const isTesting = testingId === config.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={config.id}
|
||||
className={`bg-white rounded-lg shadow border overflow-hidden transition-opacity ${
|
||||
config.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-2xl flex-shrink-0">{channel.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900 truncate">
|
||||
{config.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-block mt-0.5 px-2 py-0.5 rounded-full text-xs font-medium ${channel.color}`}
|
||||
>
|
||||
{channel.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleMutation.mutate({
|
||||
id: config.id,
|
||||
is_enabled: !config.is_enabled,
|
||||
})
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
title={config.is_enabled ? 'Disable' : 'Enable'}
|
||||
className={`ml-2 flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
config.is_enabled
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{config.is_enabled ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{config.is_enabled ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{config.notify_on_errors && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-red-50 text-red-700 border border-red-200">
|
||||
On Errors
|
||||
</span>
|
||||
)}
|
||||
{config.notify_on_success && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 border border-green-200">
|
||||
On Success
|
||||
</span>
|
||||
)}
|
||||
{!config.notify_on_errors && !config.notify_on_success && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-50 text-gray-500 border border-gray-200">
|
||||
No triggers set
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`mb-3 p-2 rounded text-xs flex items-center gap-1.5 ${
|
||||
testResult.success
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => handleTest(config)}
|
||||
disabled={isTesting || !config.apprise_url}
|
||||
title="Send test notification"
|
||||
className="flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{isTesting ? '…' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(config)}
|
||||
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Edit2 className="h-3.5 w-3.5 mr-1" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(config.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white rounded-lg shadow border border-dashed border-gray-300">
|
||||
<Bell className="mx-auto h-12 w-12 text-gray-300 mb-3" />
|
||||
<h3 className="text-base font-semibold text-gray-700 mb-1">
|
||||
No notification channels yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-4 max-w-xs mx-auto">
|
||||
Add a channel to receive alerts when emails are processed or errors occur.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleOpenWizard}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Your First Channel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-1">About Notifications</h4>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">
|
||||
Notifications are powered by{' '}
|
||||
<a
|
||||
href="https://github.com/caronc/apprise"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-900"
|
||||
>
|
||||
Apprise
|
||||
</a>
|
||||
, which supports 80+ notification services including Telegram, Discord, Slack, email,
|
||||
and many more. Each channel can be configured independently with different triggers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,61 @@ import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { userApi } from '@/lib/api';
|
||||
import { Mail, ArrowRight, Shield, Zap, Clock } from 'lucide-react';
|
||||
import { userApi, SubscriptionPlan } from '@/lib/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { Mail, ArrowRight, Shield, Zap, Clock, Check } from 'lucide-react';
|
||||
|
||||
async function fetchPublicPlans(): Promise<SubscriptionPlan[]> {
|
||||
const res = await api.get<SubscriptionPlan[]>('/subscriptions/plans');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function PricingCard({ plan }: { plan: SubscriptionPlan }) {
|
||||
const yearlyMonthly = plan.price_yearly
|
||||
? (plan.price_yearly / 12).toFixed(2)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-md p-8 flex flex-col border border-gray-100 hover:shadow-lg transition-shadow">
|
||||
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 flex-1">{plan.description}</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-4xl font-extrabold text-gray-900">
|
||||
€{plan.price_monthly.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-gray-500">/month</span>
|
||||
{yearlyMonthly && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
or €{yearlyMonthly}/mo billed yearly (€{plan.price_yearly?.toFixed(2)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-6 space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
{plan.max_mail_accounts === 1
|
||||
? '1 mailbox'
|
||||
: `Up to ${plan.max_mail_accounts} mailboxes`}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
Checked every {plan.check_interval_minutes} minute{plan.check_interval_minutes > 1 ? 's' : ''}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
Up to {plan.max_emails_per_day.toLocaleString()} emails/day
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="/register"
|
||||
className="mt-8 block text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
@@ -29,6 +82,12 @@ export default function Home() {
|
||||
});
|
||||
}, [router, setUser, setLoading]);
|
||||
|
||||
const { data: plans } = useQuery({
|
||||
queryKey: ['public-plans'],
|
||||
queryFn: fetchPublicPlans,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -37,6 +96,8 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasPaidPlans = plans && plans.length > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
||||
{/* Header */}
|
||||
@@ -45,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">POP3 Forwarder</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">InboxConverge</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
@@ -58,31 +119,37 @@ export default function Home() {
|
||||
href="/register"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign Up
|
||||
Sign Up Free
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
|
||||
{/* Hero */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
|
||||
Forward Your POP3 Emails to Gmail
|
||||
<br />
|
||||
<span className="text-blue-600">Automatically</span>
|
||||
Your old inboxes,{' '}
|
||||
<span className="text-blue-600">delivered to Gmail.</span>
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
Connect your POP3 email accounts and automatically forward all messages to Gmail.
|
||||
Simple, secure, and reliable email forwarding service.
|
||||
<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. InboxConverge
|
||||
quietly polls them all and drops everything into your Gmail. Set it once,
|
||||
forget it exists.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<p className="text-base text-gray-500 mb-8 max-w-xl mx-auto">
|
||||
No forwarding rules to configure. No email clients to keep open.
|
||||
Just your mail, where you actually read it.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 flex-wrap">
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-lg font-medium"
|
||||
>
|
||||
Get Started Free
|
||||
Get Started — it's free
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
@@ -101,11 +168,11 @@ export default function Home() {
|
||||
<Zap className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Auto-Detection
|
||||
Auto-detects everything
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Automatically detect POP3 server settings from your email address.
|
||||
Quick and easy setup in minutes.
|
||||
Type your old email address and InboxConverge figures out the server
|
||||
settings. No Googling port numbers required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -114,11 +181,11 @@ export default function Home() {
|
||||
<Clock className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Scheduled Checks
|
||||
Runs in the background
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Set custom check intervals for each account. From every minute to once a day,
|
||||
you control the frequency.
|
||||
Checks your old inboxes on a schedule you choose — from every minute
|
||||
to once a day. New mail appears in Gmail as if it was always there.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -127,11 +194,11 @@ export default function Home() {
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Secure & Private
|
||||
Your passwords stay yours
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Your credentials are encrypted and secure. We use SSL/TLS for all connections
|
||||
and OAuth2 for Gmail.
|
||||
Credentials are encrypted at rest and never shared. All connections
|
||||
use SSL/TLS and Gmail delivery uses OAuth2 — no app passwords needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,53 +206,71 @@ export default function Home() {
|
||||
{/* How It Works */}
|
||||
<div className="mt-20">
|
||||
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||
How It Works
|
||||
Three steps and you're done
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||
1
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Connect Accounts
|
||||
</h4>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
|
||||
<p className="text-gray-600">
|
||||
Add your POP3 email accounts with auto-detected settings
|
||||
Paste the email address — InboxConverge auto-detects the POP3/IMAP
|
||||
settings in seconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||
2
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Authorize Gmail
|
||||
</h4>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
|
||||
<p className="text-gray-600">
|
||||
Sign in with Google to allow forwarding to your Gmail
|
||||
Sign in with Google once. InboxConverge delivers mail directly into
|
||||
your inbox using the Gmail API — no SMTP relay needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||
3
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Relax & Enjoy
|
||||
</h4>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Close the tab</h4>
|
||||
<p className="text-gray-600">
|
||||
Emails are automatically forwarded. Monitor activity from your dashboard
|
||||
Seriously, that's it. Your mail arrives automatically from now on.
|
||||
Check the dashboard whenever you like, but you won't need to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing — only rendered when paid plans exist (hidden in enterprise/all-free mode) */}
|
||||
{hasPaidPlans && (
|
||||
<div className="mt-24">
|
||||
<h3 className="text-3xl font-bold text-center text-gray-900 mb-4">
|
||||
Pricing
|
||||
</h3>
|
||||
<p className="text-center text-gray-500 mb-12 max-w-xl mx-auto">
|
||||
Start free. Upgrade if you need more inboxes or faster checks.
|
||||
Cancel any time — no questions, no fuss.
|
||||
</p>
|
||||
<div className={`grid gap-8 ${plans.length === 1 ? 'max-w-sm mx-auto' : plans.length === 2 ? 'md:grid-cols-2 max-w-2xl mx-auto' : 'md:grid-cols-3'}`}>
|
||||
{plans.map((plan) => (
|
||||
<PricingCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-8 text-center text-sm text-gray-500">
|
||||
All paid plans include a{' '}
|
||||
<span className="font-medium">free tier</span> when you first sign up
|
||||
— no credit card required.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<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">
|
||||
© 2024 POP3 Forwarder. Secure email forwarding service.
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
© {new Date().getFullYear()} InboxConverge — made for people, not enterprises.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -15,8 +15,113 @@ import {
|
||||
Server,
|
||||
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>
|
||||
@@ -104,6 +209,29 @@ function SettingsContent() {
|
||||
},
|
||||
});
|
||||
|
||||
const [debugEmailResult, setDebugEmailResult] = useState<string | null>(null);
|
||||
const [gmailLabelsSaved, setGmailLabelsSaved] = useState(false);
|
||||
const sendDebugEmailMutation = useMutation({
|
||||
mutationFn: gmailApi.sendDebugEmail,
|
||||
onSuccess: () => {
|
||||
setDebugEmailResult('success');
|
||||
setTimeout(() => setDebugEmailResult(null), 5000);
|
||||
},
|
||||
onError: () => {
|
||||
setDebugEmailResult('error');
|
||||
setTimeout(() => setDebugEmailResult(null), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
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: () => {
|
||||
@@ -175,7 +303,7 @@ function SettingsContent() {
|
||||
|
||||
const handleConnectGmail = async () => {
|
||||
try {
|
||||
const redirectUri = `${window.location.origin}/auth/gmail-callback`;
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
const url = await gmailApi.getAuthorizeUrl(redirectUri);
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
@@ -302,35 +430,75 @@ function SettingsContent() {
|
||||
)}
|
||||
|
||||
{!gmailLoading && gmailConnected && (
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Connected as <span className="font-semibold">{gmailCredential.gmail_email}</span>
|
||||
</p>
|
||||
{gmailCredential.last_verified_at && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Connected as <span className="font-semibold">{gmailCredential.gmail_email}</span>
|
||||
</p>
|
||||
)}
|
||||
{gmailCredential.last_verified_at && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => sendDebugEmailMutation.mutate()}
|
||||
disabled={sendDebugEmailMutation.isPending}
|
||||
title="Inject a test email into your Gmail inbox"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-amber-50 text-amber-700 rounded-md hover:bg-amber-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{sendDebugEmailMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Sending…</>
|
||||
) : (
|
||||
<><Bug className="h-4 w-4" />Send Debug Email</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConnectGmail}
|
||||
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
Re-authorise
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDisconnectGmail}
|
||||
disabled={disconnectGmailMutation.isPending}
|
||||
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleConnectGmail}
|
||||
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
Re-authorise
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDisconnectGmail}
|
||||
disabled={disconnectGmailMutation.isPending}
|
||||
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
{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 for the
|
||||
configured import labels plus a <strong className="mx-1">test</strong>{' '}
|
||||
label.
|
||||
</div>
|
||||
)}
|
||||
{debugEmailResult === 'error' && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -549,4 +717,3 @@ function SettingsContent() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { mailAccountsApi, MailAccount, MailAccountCreate } from '@/lib/api';
|
||||
import { mailAccountsApi, MailAccount, MailAccountCreate, MailAccountUpdate } from '@/lib/api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { X, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { ProviderWizard } from './ProviderWizard';
|
||||
@@ -17,10 +17,11 @@ type WizardStep = 'provider' | 'form';
|
||||
export function AddMailAccountModal({ account, onClose }: AddMailAccountModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
const isEditMode = !!account;
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
const [autoDetecting, setAutoDetecting] = useState(false);
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>(account ? 'form' : 'provider');
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>(isEditMode ? 'form' : 'provider');
|
||||
|
||||
const [formData, setFormData] = useState<MailAccountCreate>({
|
||||
name: account?.name || '',
|
||||
@@ -28,7 +29,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
protocol: account?.protocol || 'pop3_ssl',
|
||||
host: account?.host || '',
|
||||
port: account?.port || 995,
|
||||
username: '',
|
||||
username: account?.username || '',
|
||||
password: '',
|
||||
use_ssl: account?.use_ssl ?? true,
|
||||
use_tls: account?.use_tls ?? false,
|
||||
@@ -38,15 +39,22 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
check_interval_minutes: account?.check_interval_minutes || 5,
|
||||
max_emails_per_check: account?.max_emails_per_check || 50,
|
||||
delete_after_forward: account?.delete_after_forward ?? true,
|
||||
provider_name: account?.provider_name ?? null,
|
||||
});
|
||||
|
||||
const onMutationSuccess = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: MailAccountCreate) =>
|
||||
account ? mailAccountsApi.update(account.id, data) : mailAccountsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
|
||||
onClose();
|
||||
},
|
||||
mutationFn: (data: MailAccountCreate) => mailAccountsApi.create(data),
|
||||
onSuccess: onMutationSuccess,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: MailAccountUpdate) => mailAccountsApi.update(account!.id, data),
|
||||
onSuccess: onMutationSuccess,
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
@@ -68,7 +76,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
});
|
||||
};
|
||||
|
||||
const handleProviderSelect = (config: { name: string; protocol: string; host: string; port: number; use_ssl: boolean }) => {
|
||||
const handleProviderSelect = (config: { name: string; provider_name: string; protocol: string; host: string; port: number; use_ssl: boolean }) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
name: config.name,
|
||||
@@ -76,6 +84,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
use_ssl: config.use_ssl,
|
||||
provider_name: config.provider_name,
|
||||
}));
|
||||
setWizardStep('form');
|
||||
};
|
||||
@@ -107,43 +116,88 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!formData.username || !formData.password || !formData.host) {
|
||||
alert('Please fill in username, password, and host');
|
||||
return;
|
||||
const extractErrorMessage = (error: unknown, fallback: string): string => {
|
||||
interface FastAPIValidationError { msg: string; loc?: unknown[]; type?: string }
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const msgs = (detail as FastAPIValidationError[]).map((d) => d?.msg ?? JSON.stringify(d));
|
||||
return msgs.join('; ');
|
||||
}
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestStatus('testing');
|
||||
setTestMessage('');
|
||||
try {
|
||||
await mailAccountsApi.test({
|
||||
protocol: formData.protocol,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
use_ssl: formData.use_ssl,
|
||||
});
|
||||
setTestStatus('success');
|
||||
setTestMessage('Connection successful!');
|
||||
let result: { success: boolean; message: string };
|
||||
|
||||
if (isEditMode && !formData.password && account?.id) {
|
||||
// Edit mode with no new password entered — test using stored credentials
|
||||
result = await mailAccountsApi.testExisting(account.id);
|
||||
} else {
|
||||
if (!formData.username || !formData.password || !formData.host) {
|
||||
setTestStatus('error');
|
||||
setTestMessage('Please fill in username, password, and host');
|
||||
return;
|
||||
}
|
||||
result = await mailAccountsApi.test({
|
||||
protocol: formData.protocol,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
use_ssl: formData.use_ssl,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
setTestStatus('success');
|
||||
setTestMessage(result.message);
|
||||
} else {
|
||||
setTestStatus('error');
|
||||
setTestMessage(result.message || 'Connection failed');
|
||||
}
|
||||
} catch (error) {
|
||||
setTestStatus('error');
|
||||
const errorMessage = error instanceof Error && 'response' in error
|
||||
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: null;
|
||||
setTestMessage(errorMessage || 'Connection failed');
|
||||
setTestMessage(extractErrorMessage(error, 'Connection failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await createMutation.mutateAsync(formData);
|
||||
if (isEditMode) {
|
||||
// Send all fields so the user can change any aspect of the account.
|
||||
// Password is the only exception: omit it when blank so the stored
|
||||
// credential is preserved.
|
||||
const updateData: MailAccountUpdate = {
|
||||
name: formData.name,
|
||||
email_address: formData.email_address,
|
||||
protocol: formData.protocol,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
use_ssl: formData.use_ssl,
|
||||
use_tls: formData.use_tls,
|
||||
username: formData.username,
|
||||
forward_to: formData.forward_to,
|
||||
delivery_method: formData.delivery_method,
|
||||
is_enabled: formData.is_enabled,
|
||||
check_interval_minutes: formData.check_interval_minutes,
|
||||
max_emails_per_check: formData.max_emails_per_check,
|
||||
delete_after_forward: formData.delete_after_forward,
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
await updateMutation.mutateAsync(updateData);
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error && 'response' in error
|
||||
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: null;
|
||||
alert(errorMessage || 'Failed to save account');
|
||||
alert(extractErrorMessage(error, 'Failed to save account'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +211,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
<div className="bg-white px-6 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{account ? 'Edit Mail Account' : 'Add Mail Account'}
|
||||
{isEditMode ? 'Edit Mail Account' : 'Add Mail Account'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -168,14 +222,14 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{wizardStep === 'provider' && !account ? (
|
||||
{wizardStep === 'provider' && !isEditMode ? (
|
||||
<ProviderWizard
|
||||
onSelect={handleProviderSelect}
|
||||
onManual={() => setWizardStep('form')}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{!account && (
|
||||
{!isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWizardStep('provider')}
|
||||
@@ -210,7 +264,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
required={!isEditMode}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
@@ -223,6 +277,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
{autoDetecting ? 'Detecting...' : 'Auto-Detect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -234,9 +289,9 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required={!account}
|
||||
required={!isEditMode}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={account ? 'Leave blank to keep current password' : 'Password'}
|
||||
placeholder={isEditMode ? 'Leave blank to keep current password' : 'Password'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -267,7 +322,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
name="host"
|
||||
value={formData.host}
|
||||
onChange={handleChange}
|
||||
required
|
||||
required={!isEditMode}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="pop.gmail.com"
|
||||
/>
|
||||
@@ -282,7 +337,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
name="port"
|
||||
value={formData.port}
|
||||
onChange={handleChange}
|
||||
required
|
||||
required={!isEditMode}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -446,10 +501,10 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Saving...' : 'Save'}
|
||||
{(createMutation.isPending || updateMutation.isPending) ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { AuthGuard } from './AuthGuard';
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = jest.fn();
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
// Mock userApi
|
||||
const mockGetCurrentUser = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
userApi: {
|
||||
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock authStore
|
||||
const mockSetUser = jest.fn();
|
||||
const mockSetLoading = jest.fn();
|
||||
let mockUser: Record<string, unknown> | null = null;
|
||||
let mockIsLoading = true;
|
||||
|
||||
jest.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
user: mockUser,
|
||||
isLoading: mockIsLoading,
|
||||
setUser: mockSetUser,
|
||||
setLoading: mockSetLoading,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUserData = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
full_name: 'Test User',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
subscription_tier: 'free',
|
||||
subscription_status: 'active',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockUser = null;
|
||||
mockIsLoading = true;
|
||||
});
|
||||
|
||||
it('should show loading spinner while isLoading is true', () => {
|
||||
mockIsLoading = true;
|
||||
localStorage.setItem('access_token', 'test-token');
|
||||
mockGetCurrentUser.mockResolvedValue(mockUserData);
|
||||
|
||||
render(
|
||||
<AuthGuard>
|
||||
<div>Protected Content</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
// Should show spinner (via animate-spin class)
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children when user is authenticated', () => {
|
||||
mockUser = mockUserData;
|
||||
mockIsLoading = false;
|
||||
localStorage.setItem('access_token', 'test-token');
|
||||
|
||||
render(
|
||||
<AuthGuard>
|
||||
<div>Protected Content</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render nothing when not loading and no user', () => {
|
||||
mockUser = null;
|
||||
mockIsLoading = false;
|
||||
|
||||
const { container } = render(
|
||||
<AuthGuard>
|
||||
<div>Protected Content</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should redirect to /login when no token exists', async () => {
|
||||
// No token in localStorage
|
||||
mockGetCurrentUser.mockResolvedValue(mockUserData);
|
||||
|
||||
render(
|
||||
<AuthGuard>
|
||||
<div>Protected Content</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetLoading).toHaveBeenCalledWith(false);
|
||||
expect(mockPush).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch user data when token exists', async () => {
|
||||
localStorage.setItem('access_token', 'valid-token');
|
||||
mockGetCurrentUser.mockResolvedValue(mockUserData);
|
||||
|
||||
render(
|
||||
<AuthGuard>
|
||||
<div>Protected Content</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetCurrentUser).toHaveBeenCalled();
|
||||
expect(mockSetUser).toHaveBeenCalledWith(mockUserData);
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to /login when API call fails', async () => {
|
||||
localStorage.setItem('access_token', 'expired-token');
|
||||
mockGetCurrentUser.mockRejectedValue(new Error('Unauthorized'));
|
||||
|
||||
render(
|
||||
<AuthGuard>
|
||||
<div>Protected Content</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetUser).toHaveBeenCalledWith(null);
|
||||
expect(mockPush).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { DashboardLayout } from './DashboardLayout';
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = jest.fn();
|
||||
let mockPathname = '/dashboard';
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
// Mock next/link to render a simple anchor
|
||||
jest.mock('next/link', () => {
|
||||
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
);
|
||||
MockLink.displayName = 'MockLink';
|
||||
return MockLink;
|
||||
});
|
||||
|
||||
// Mock @tanstack/react-query
|
||||
let mockVersionInfo: Record<string, string> | null = null;
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => ({ data: mockVersionInfo }),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons as simple spans
|
||||
jest.mock('lucide-react', () => {
|
||||
const iconNames = [
|
||||
'LayoutDashboard', 'Mail', 'Settings', 'LogOut', 'Menu', 'X',
|
||||
'User', 'Shield', 'Users', 'CreditCard', 'Bell', 'Inbox', 'Activity',
|
||||
];
|
||||
const icons: Record<string, React.FC<{ className?: string }>> = {};
|
||||
iconNames.forEach((name) => {
|
||||
icons[name] = ({ className }: { className?: string }) => (
|
||||
<span data-testid={`icon-${name}`} className={className} />
|
||||
);
|
||||
});
|
||||
return icons;
|
||||
});
|
||||
|
||||
// Mock authStore
|
||||
const mockLogout = jest.fn();
|
||||
let mockUser: Record<string, unknown> | null = null;
|
||||
|
||||
jest.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
user: mockUser,
|
||||
logout: mockLogout,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock versionApi
|
||||
jest.mock('@/lib/api', () => ({
|
||||
versionApi: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const regularUser = {
|
||||
id: 1,
|
||||
email: 'user@example.com',
|
||||
full_name: 'Regular User',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
subscription_tier: 'free',
|
||||
subscription_status: 'active',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const adminUser = {
|
||||
...regularUser,
|
||||
id: 2,
|
||||
email: 'admin@example.com',
|
||||
full_name: 'Admin User',
|
||||
is_superuser: true,
|
||||
};
|
||||
|
||||
describe('DashboardLayout', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPathname = '/dashboard';
|
||||
mockUser = regularUser;
|
||||
mockVersionInfo = null;
|
||||
});
|
||||
|
||||
it('should render the InboxConverge branding', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
// Desktop sidebar has the branding
|
||||
expect(screen.getAllByText('InboxConverge').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render main navigation items', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.getAllByText('Dashboard').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Mail Accounts').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Mailbox Activity').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Settings').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render children in main content area', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div data-testid="page-content">Page Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.getByTestId('page-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Page Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display user info in the top bar', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.getByText('Regular User')).toBeInTheDocument();
|
||||
expect(screen.getByText('user@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show admin navigation for regular users', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.queryByText('Admin Overview')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Manage Users')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Manage Plans')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Activity Logs')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show admin navigation for superusers', () => {
|
||||
mockUser = adminUser;
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.getAllByText('Admin Overview').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Manage Users').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Manage Plans').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Activity Logs').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show Admin badge for superusers', () => {
|
||||
mockUser = adminUser;
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
// The Admin badge has a specific class for styling
|
||||
const adminBadges = screen.getAllByText('Admin');
|
||||
const badge = adminBadges.find((el) => el.classList.contains('bg-purple-100'));
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show Admin badge for regular users', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call logout and redirect on Logout button click', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
// Click the first Logout button (desktop sidebar)
|
||||
const logoutButtons = screen.getAllByText('Logout');
|
||||
fireEvent.click(logoutButtons[0]);
|
||||
expect(mockLogout).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('should display the current page title based on pathname', () => {
|
||||
mockPathname = '/accounts';
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
// The top bar should show "Mail Accounts" as the h2 heading
|
||||
const headings = screen.getAllByText('Mail Accounts');
|
||||
// At least one should be a heading in the top bar
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show version info in the footer when available', () => {
|
||||
mockVersionInfo = { version: '1.2.3', build_date: '2024-06-15T12:00:00Z' };
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show version info when not available', () => {
|
||||
mockVersionInfo = null;
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render footer links', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
expect(screen.getByText('Impressum')).toBeInTheDocument();
|
||||
expect(screen.getByText('Datenschutz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open mobile sidebar on menu button click', () => {
|
||||
render(
|
||||
<DashboardLayout>
|
||||
<div>Content</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
// The mobile menu button has a Menu icon
|
||||
const menuButton = screen.getByTestId('icon-Menu').closest('button');
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
fireEvent.click(menuButton!);
|
||||
|
||||
// After clicking, the close (X) button should appear
|
||||
expect(screen.getByTestId('icon-X')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||