Compare commits
444 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85ef54d980 | |||
| bab787a8cb | |||
| c8926ce88b | |||
| 91133136d6 | |||
| 7e0b44d995 | |||
| 2de22ab30f | |||
| 97ee4d0e3c | |||
| ff70cd3606 | |||
| 37280321cd | |||
| eae610cc95 | |||
| 8d15fa8602 | |||
| 1c209ad7bc | |||
| 42b18e10a6 | |||
| 4b09a3beab | |||
| 9cc9af2735 | |||
| 8130a6f81c | |||
| 7d84f4395e | |||
| fdc87218af | |||
| 37b48e1c37 | |||
| ec5b2e12f4 | |||
| a1ff9e70f7 | |||
| 3b123d974f | |||
| 4eaeb1ad8d | |||
| a364748df7 | |||
| 64b06bd1ed | |||
| 34f6f7b8e9 | |||
| f7f4745f55 | |||
| 311c1bc408 | |||
| cde645844c | |||
| 58a2474255 | |||
| d4a2160e01 | |||
| 60a1a9b2ae | |||
| ebb88391db | |||
| 9b0448cd3b | |||
| 83b53aca9f | |||
| bcca5a9192 | |||
| e6027792c6 | |||
| fb8a11ffba | |||
| 2a7ca74db0 | |||
| 6c42c3759c | |||
| 8295b7aaf2 | |||
| 8750ecb3cc | |||
| 31652c5eb2 | |||
| 1420149d8b | |||
| 19a859ecce | |||
| 6437a4cf5e | |||
| fcdd51e6aa | |||
| c65f37a8dd | |||
| 5d8c20ff3a | |||
| f808d6262d | |||
| 428850b476 | |||
| 92f60c6052 | |||
| 0838a6b718 | |||
| b771484afa | |||
| eea50f5d74 | |||
| dd5f28ad25 | |||
| a21e2f7df6 | |||
| 01a85ebede | |||
| edb4e8b0cc | |||
| f2bdfbb9a1 | |||
| 0396cc6f33 | |||
| 2b0f05f971 | |||
| 38dfed3360 | |||
| 2a005d9103 | |||
| 28a7592731 | |||
| 45a2dd8be2 | |||
| 89ebdd2b79 | |||
| 87a4b15b96 | |||
| c3e608d82f | |||
| 742f0eca6b | |||
| 651194178f | |||
| b1dd1714f7 | |||
| 79d664c0e4 | |||
| ed4c4f5a60 | |||
| 6f8690850d | |||
| a8f37d6a1b | |||
| 7bf0819338 | |||
| f0b6bbcf65 | |||
| eeb3fa5c66 | |||
| aab60d6866 | |||
| db893d05e9 | |||
| e98c81cce4 | |||
| f39241a547 | |||
| 0026afe131 | |||
| 4bc440d11c | |||
| 9e9a5858de | |||
| be812dda79 | |||
| 075ba92f09 | |||
| 3470213fb3 | |||
| 992e157f02 | |||
| 00d06c63de | |||
| d00523f5ba | |||
| d6d4df2df1 | |||
| bb0806e198 | |||
| b31e13195b | |||
| 54ecd7f95c | |||
| fa890df5e4 | |||
| a31030e3e3 | |||
| 77878e3441 | |||
| 222fc96eff | |||
| c735a77079 | |||
| a9f3c22764 | |||
| 3cf51e68ab | |||
| 4f1635ee3b | |||
| 40262c1d87 | |||
| 0ed6256fb7 | |||
| f66435cbb6 | |||
| 5e959a6a6c | |||
| 14ef146328 | |||
| 91dac9ccdb | |||
| 18726343a5 | |||
| 06c59242c8 | |||
| 34955881ee | |||
| 78610ef09d | |||
| 095ee1059c | |||
| b20da8940f | |||
| 641007d51c | |||
| 06ea1958aa | |||
| 42c5900c06 | |||
| 8441f73478 | |||
| 8df1465a64 | |||
| 24a85d6554 | |||
| 197d7fe060 | |||
| 45a237bdcb | |||
| 4c03352650 | |||
| 56a85e9c9e | |||
| d4bd95efeb | |||
| 592495c1af | |||
| 74ef362833 | |||
| 377f5a7270 | |||
| b8aec18add | |||
| 31dc059274 | |||
| 3dd71cd61d | |||
| 4b203965c9 | |||
| ff7b50d944 | |||
| 4c6fc339d2 | |||
| 025684386b | |||
| 517edfdfa3 | |||
| efa553c8d5 | |||
| 05966f74ae | |||
| e1fde09407 | |||
| 1f5b0fe38d | |||
| 900c279a28 | |||
| 7503316435 | |||
| f91f1a7b8d | |||
| a52bd5b2df | |||
| 5dbc880cbc | |||
| 74f0c322c4 | |||
| 1f8c4f46e6 | |||
| 233595e5dd | |||
| bfaa639eed | |||
| 4bb92ac9d0 | |||
| 466e852c8b | |||
| 6283d1c4b4 | |||
| 6f414e175a | |||
| af88176da4 | |||
| d7479f5f8e | |||
| 6bfedc748f | |||
| a11831a725 | |||
| cbf29d289e | |||
| 0f5973680e | |||
| 582311a18e | |||
| bd51a5b541 | |||
| 284fbba47f | |||
| 9d53b117d4 | |||
| 3bb7a7cd46 | |||
| a5d8700119 | |||
| f479974a5a | |||
| 9553fae6b7 | |||
| cc1edd8f5d | |||
| b2d044d8c2 | |||
| 6fe2f9ad95 | |||
| 89b23e641f | |||
| 0ffe5fd875 | |||
| 7afa33f375 | |||
| 5f0e9ed598 | |||
| 3a2066d320 | |||
| 0a42c5c6e9 | |||
| 149ff3d997 | |||
| 0521c97394 | |||
| 39052af4e2 | |||
| 58f9ad72ed | |||
| 4a6f93e5ac | |||
| 61c5f70158 | |||
| 1c926cca73 | |||
| 1c3d902c9b | |||
| 587c2bf874 | |||
| 190e41058e | |||
| 0012972ed6 | |||
| 987a91b627 | |||
| 669ddf70fb | |||
| c764f5bb6f | |||
| 5992a9e802 | |||
| 3432b93712 | |||
| e2b3bac1cc | |||
| bdbc49e5ba | |||
| c06074e0d5 | |||
| e5d3a7c166 | |||
| 0ac7bd7fb4 | |||
| 3b580c36c7 | |||
| e669cc54d4 | |||
| 937acf5fe8 | |||
| e84fb44bcb | |||
| 1fc95b4be8 | |||
| 348c3477dc | |||
| 726e089c46 | |||
| ec90cd600b | |||
| 77c84266fd | |||
| b914ac0ecd | |||
| 094b1e177f | |||
| ac6a837522 | |||
| c50cf6e0b3 | |||
| 0e87da85a6 | |||
| f0213a0b03 | |||
| f4c8e4a1dc | |||
| d919939647 | |||
| 917dacc7cb | |||
| 594e380301 | |||
| 4dbe30b0ea | |||
| 2c26c156f6 | |||
| 68a6163fef | |||
| a5920937cb | |||
| c3ee2fc821 | |||
| 8eff0833fa | |||
| 6e7139645b | |||
| 9c12555a81 | |||
| 06efeed7be | |||
| d79f9caf9e | |||
| 88e8abae4e | |||
| 4d1451ecde | |||
| 65318b8c1b | |||
| 4d07d04c63 | |||
| 55536c964f | |||
| 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 ──────────────────────────────────────────────────────
|
||||
@@ -24,7 +26,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -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
|
||||
@@ -97,7 +99,7 @@ jobs:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -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
|
||||
uses: codecov/codecov-action@v5
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
file: ./backend/coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
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@v6
|
||||
with:
|
||||
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@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./frontend/coverage/lcov.info
|
||||
flags: frontend
|
||||
name: frontend-coverage
|
||||
fail_ci_if_error: false
|
||||
|
||||
# ── Phase 3: Security ──────────────────────────────────────────────────
|
||||
@@ -138,7 +165,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -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,39 +200,30 @@ 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
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Requires PRIVATE_REGISTRY_USERNAME and PRIVATE_REGISTRY_PASSWORD secrets
|
||||
- name: Log in to private registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.PRIVATE_REGISTRY }}
|
||||
username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.PRIVATE_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image_name }}
|
||||
${{ env.PRIVATE_REGISTRY }}/${{ matrix.image_name }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
@@ -215,7 +233,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
@@ -223,4 +241,87 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
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.GHCR_REGISTRY }}/${{ github.repository_owner }}/inboxconverge/backend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/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@v6
|
||||
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.52.5
|
||||
env:
|
||||
IMAGE: ${{ steps.tag.outputs.backend_image }}
|
||||
with:
|
||||
cmd: |
|
||||
yq -i '(.. | select(tag == "!!str") | select(test("^(registry\\.cklnet\\.com|ghcr\\.io/christianlouis)/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.52.5
|
||||
env:
|
||||
IMAGE: ${{ steps.tag.outputs.frontend_image }}
|
||||
with:
|
||||
cmd: |
|
||||
yq -i '(.. | select(tag == "!!str") | select(test("^(registry\\.cklnet\\.com|ghcr\\.io/christianlouis)/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@v6
|
||||
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"
|
||||
@@ -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")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Add debug_logging column to mail_accounts
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-05-03
|
||||
|
||||
Adds a boolean ``debug_logging`` column to ``mail_accounts``.
|
||||
|
||||
When True, the next processing run will record a detailed connection trace
|
||||
(timings, phase-by-phase events, message UIDs/sizes) and persist it as a
|
||||
``ProcessingLog`` row with ``level='DEBUG'``. The column auto-resets to
|
||||
False after 5 completed runs in a 24-hour window to prevent it being left
|
||||
on indefinitely.
|
||||
|
||||
The column defaults to False so all existing rows are unaffected.
|
||||
Using IF NOT EXISTS makes the migration idempotent against fresh installs
|
||||
where create_all() already created the column.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE mail_accounts "
|
||||
"ADD COLUMN IF NOT EXISTS debug_logging BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE mail_accounts DROP COLUMN IF EXISTS debug_logging")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Add debug_logging_run_count and error_notification_sent columns
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-05-03
|
||||
|
||||
Changes:
|
||||
- debug_logging_run_count (INTEGER, default 0): counts completed/partial_failure
|
||||
runs since debug_logging was last enabled. Auto-disables debug_logging once
|
||||
this counter reaches 5. The counter is reset to 0 whenever debug_logging is
|
||||
toggled back on via the API.
|
||||
|
||||
- error_notification_sent (BOOLEAN, default FALSE): tracks whether a failure
|
||||
notification has already been dispatched for the current consecutive error
|
||||
streak. Prevents notification spam: only the first failure in a streak fires
|
||||
a notification. Cleared (and a recovery notice sent) when a run succeeds.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE mail_accounts "
|
||||
"ADD COLUMN IF NOT EXISTS debug_logging_run_count INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE mail_accounts "
|
||||
"ADD COLUMN IF NOT EXISTS error_notification_sent BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE mail_accounts DROP COLUMN IF EXISTS debug_logging_run_count"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE mail_accounts DROP COLUMN IF EXISTS error_notification_sent"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add sender_email column to user_smtp_configs
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-05-03
|
||||
|
||||
Adds a ``sender_email`` column to ``user_smtp_configs``.
|
||||
|
||||
SMTP providers such as Postmark use an API token (UUID) as the SMTP username
|
||||
for authentication, but require a real email address as the ``From:`` header.
|
||||
This column stores the address that should appear as the sender; when blank,
|
||||
the existing ``username`` value is used as a fallback so existing rows remain
|
||||
fully functional.
|
||||
|
||||
Using IF NOT EXISTS makes the migration idempotent against fresh installs
|
||||
where create_all() already created the column.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE user_smtp_configs "
|
||||
"ADD COLUMN IF NOT EXISTS sender_email VARCHAR(255) NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE user_smtp_configs DROP COLUMN IF EXISTS sender_email")
|
||||
@@ -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.model_dump())
|
||||
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.model_dump(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
|
||||
@@ -105,73 +182,126 @@ async def google_oauth(
|
||||
Authenticate with Google OAuth2.
|
||||
Exchange authorization code for access token and user info.
|
||||
"""
|
||||
|
||||
# Get user info from Google
|
||||
user_info = await oauth_service.get_google_user_info(
|
||||
code=auth_request.code, redirect_uri=auth_request.redirect_uri
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: callback received (redirect_uri=%s)",
|
||||
auth_request.redirect_uri,
|
||||
)
|
||||
|
||||
if not user_info.get("verified_email"):
|
||||
try:
|
||||
# Get user info from Google
|
||||
user_info = await oauth_service.get_google_user_info(
|
||||
code=auth_request.code, redirect_uri=auth_request.redirect_uri
|
||||
)
|
||||
|
||||
if not user_info.get("verified_email"):
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="error").inc()
|
||||
logger.warning(
|
||||
"OAuth [Google sign-in]: rejecting unverified email=%s",
|
||||
user_info.get("email"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email not verified with Google",
|
||||
)
|
||||
|
||||
email = user_info["email"]
|
||||
google_id = user_info["google_id"]
|
||||
|
||||
# Check if user exists
|
||||
result = await db.execute(
|
||||
select(User).where((User.email == email) | (User.google_id == google_id))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
# Update Google ID if not set
|
||||
if not user.google_id:
|
||||
user.google_id = google_id # type: ignore[assignment]
|
||||
user.oauth_provider = "google" # type: ignore[assignment]
|
||||
logger.info(
|
||||
"OAuth [Google sign-in]: linked Google ID to existing account "
|
||||
"(email=%s)",
|
||||
email,
|
||||
)
|
||||
|
||||
# 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=_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)
|
||||
|
||||
# Create tokens
|
||||
tokens = oauth_service.create_tokens_for_user(user)
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: sign-in complete, JWT tokens issued for user_id=%s",
|
||||
user.id,
|
||||
)
|
||||
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="success").inc()
|
||||
return tokens
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="error").inc()
|
||||
logger.error(
|
||||
"OAuth [Google sign-in]: unhandled error during sign-in flow",
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email not verified with Google",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="OAuth authentication failed",
|
||||
)
|
||||
|
||||
email = user_info["email"]
|
||||
google_id = user_info["google_id"]
|
||||
|
||||
# Check if user exists
|
||||
result = await db.execute(
|
||||
select(User).where((User.email == email) | (User.google_id == google_id))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
# Update Google ID if not set
|
||||
if not user.google_id:
|
||||
user.google_id = google_id # type: ignore[assignment]
|
||||
user.oauth_provider = "google" # type: ignore[assignment]
|
||||
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
logger.info(f"Existing user logged in with Google: {user.email}")
|
||||
else:
|
||||
# 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,
|
||||
is_active=True,
|
||||
last_login_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
logger.info(f"New user registered with Google: {user.email}")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Create tokens
|
||||
tokens = oauth_service.create_tokens_for_user(user)
|
||||
|
||||
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)."""
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: authorization URL requested "
|
||||
"(redirect_uri=%s, scopes=%s)",
|
||||
redirect_uri,
|
||||
GOOGLE_LOGIN_SCOPES,
|
||||
)
|
||||
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,8 @@ 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,
|
||||
debug_logging=account_in.debug_logging,
|
||||
provider_name=account_in.provider_name,
|
||||
)
|
||||
|
||||
db.add(account)
|
||||
@@ -146,9 +176,14 @@ 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)
|
||||
|
||||
# When debug logging is (re-)enabled, reset the run counter so the user
|
||||
# always gets exactly 5 debugged runs from the moment they check the box.
|
||||
if update_data.get("debug_logging") is True:
|
||||
update_data["debug_logging_run_count"] = 0
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(account, field, value)
|
||||
@@ -214,6 +249,72 @@ async def toggle_mail_account(
|
||||
return account
|
||||
|
||||
|
||||
@router.post("/{account_id}/clear-error", response_model=MailAccountResponse)
|
||||
async def clear_account_error(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Clear the error status of a mail account.
|
||||
|
||||
Resets last_error_message, last_error_at and sets status to ACTIVE
|
||||
if the account is currently in ERROR state. Use this after fixing the
|
||||
underlying problem (e.g. wrong password, DNS issue) to immediately remove
|
||||
the error indicator without waiting for the next successful fetch.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
account.last_error_message = None # type: ignore[assignment]
|
||||
account.last_error_at = None # type: ignore[assignment]
|
||||
if account.status == AccountStatus.ERROR:
|
||||
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(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 +343,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 +388,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,8 +28,8 @@ async def create_notification_config(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create notification configuration"""
|
||||
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
|
||||
"""Create a new notification configuration"""
|
||||
config = NotificationConfig(user_id=current_user.id, **config_in.model_dump())
|
||||
db.add(config)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
@@ -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.model_dump(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",
|
||||
@@ -188,6 +217,13 @@ async def save_gmail_credential(
|
||||
Save Gmail API OAuth2 credentials for the current user.
|
||||
These are used to inject emails directly into Gmail via the API.
|
||||
"""
|
||||
logger.debug(
|
||||
"OAuth [Gmail credential]: verifying credentials for user_id=%s "
|
||||
"(gmail=%s, has_refresh_token=%s)",
|
||||
current_user.id,
|
||||
credential_in.gmail_email,
|
||||
bool(credential_in.refresh_token),
|
||||
)
|
||||
# Verify the credentials work
|
||||
gmail_service = GmailService(
|
||||
access_token=credential_in.access_token,
|
||||
@@ -198,6 +234,12 @@ async def save_gmail_credential(
|
||||
|
||||
is_valid = await gmail_service.verify_access()
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
"OAuth [Gmail credential]: credential verification failed for user_id=%s "
|
||||
"(gmail=%s)",
|
||||
current_user.id,
|
||||
credential_in.gmail_email,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Gmail API credentials are invalid or expired",
|
||||
@@ -221,10 +263,19 @@ 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()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
"OAuth [Gmail credential]: updated credential for user_id=%s (gmail=%s)",
|
||||
current_user.id,
|
||||
credential_in.gmail_email,
|
||||
)
|
||||
return existing
|
||||
else:
|
||||
# Create new
|
||||
@@ -233,12 +284,18 @@ 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),
|
||||
)
|
||||
db.add(credential)
|
||||
await db.commit()
|
||||
await db.refresh(credential)
|
||||
logger.info(
|
||||
"OAuth [Gmail credential]: created new credential for user_id=%s (gmail=%s)",
|
||||
current_user.id,
|
||||
credential_in.gmail_email,
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
@@ -279,10 +336,50 @@ async def delete_gmail_credential(
|
||||
detail="No Gmail credentials found",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"OAuth [Gmail credential]: deleting credential for user_id=%s (gmail=%s)",
|
||||
current_user.id,
|
||||
credential.gmail_email,
|
||||
)
|
||||
await db.delete(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.",
|
||||
)
|
||||
|
||||
validated = _validated_import_label_templates(labels_in.import_label_templates)
|
||||
logger.info(
|
||||
"OAuth [Gmail credential]: updating import labels for user_id=%s "
|
||||
"(gmail=%s, labels=%s)",
|
||||
current_user.id,
|
||||
credential.gmail_email,
|
||||
validated,
|
||||
)
|
||||
credential.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
|
||||
extract_granted_scopes(credential.scopes),
|
||||
validated,
|
||||
)
|
||||
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 +399,14 @@ async def get_gmail_authorize_url(
|
||||
detail="Google OAuth2 is not configured on this server.",
|
||||
)
|
||||
|
||||
scope = " ".join(GMAIL_API_SCOPES)
|
||||
logger.info(
|
||||
"OAuth [Gmail connect]: authorization URL requested for user_id=%s "
|
||||
"(redirect_uri=%s, scopes=%s)",
|
||||
current_user.id,
|
||||
redirect_uri,
|
||||
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 +415,119 @@ 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.
|
||||
"""
|
||||
logger.info(
|
||||
"OAuth [Gmail debug-email]: debug injection requested by user_id=%s",
|
||||
current_user.id,
|
||||
)
|
||||
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:
|
||||
logger.warning(
|
||||
"OAuth [Gmail debug-email]: no valid credential found for user_id=%s",
|
||||
current_user.id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No valid Gmail credentials found. Connect Gmail first.",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"OAuth [Gmail debug-email]: using credential for user_id=%s (gmail=%s, "
|
||||
"token_expiry=%s, has_refresh_token=%s)",
|
||||
current_user.id,
|
||||
credential.gmail_email,
|
||||
credential.token_expiry,
|
||||
bool(credential.encrypted_refresh_token),
|
||||
)
|
||||
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:
|
||||
logger.error(
|
||||
"OAuth [Gmail debug-email]: injection failed for user_id=%s (gmail=%s): %s",
|
||||
current_user.id,
|
||||
credential.gmail_email,
|
||||
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:
|
||||
logger.debug(
|
||||
"OAuth [Gmail debug-email]: access token was auto-refreshed for "
|
||||
"user_id=%s; persisting new expiry=%s",
|
||||
current_user.id,
|
||||
refreshed.get("expiry"),
|
||||
)
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
"OAuth [Gmail debug-email]: injection succeeded for user_id=%s (gmail=%s, "
|
||||
"message_id=%s)",
|
||||
current_user.id,
|
||||
credential.gmail_email,
|
||||
inject_result.get("message_id"),
|
||||
)
|
||||
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,
|
||||
@@ -347,7 +560,18 @@ async def gmail_oauth_callback(
|
||||
detail="Google OAuth2 is not configured on this server.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"OAuth [Gmail connect]: callback received for user_id=%s (redirect_uri=%s)",
|
||||
current_user.id,
|
||||
callback_in.redirect_uri,
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: exchanging authorization code for tokens "
|
||||
"(user_id=%s)",
|
||||
current_user.id,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_resp = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
@@ -361,7 +585,13 @@ async def gmail_oauth_callback(
|
||||
)
|
||||
|
||||
if token_resp.status_code != 200:
|
||||
logger.error(f"Gmail token exchange failed: {token_resp.text}")
|
||||
logger.error(
|
||||
"OAuth [Gmail connect]: token exchange failed for user_id=%s "
|
||||
"(status=%s, body=%s)",
|
||||
current_user.id,
|
||||
token_resp.status_code,
|
||||
token_resp.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code with Google.",
|
||||
@@ -371,13 +601,41 @@ async def gmail_oauth_callback(
|
||||
access_token: Optional[str] = token_data.get("access_token")
|
||||
refresh_token: Optional[str] = token_data.get("refresh_token")
|
||||
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: token exchange succeeded for user_id=%s — "
|
||||
"scopes=%s, has_refresh_token=%s, expires_in=%s",
|
||||
current_user.id,
|
||||
token_data.get("scope", ""),
|
||||
bool(refresh_token),
|
||||
token_data.get("expires_in"),
|
||||
)
|
||||
|
||||
if not access_token:
|
||||
logger.error(
|
||||
"OAuth [Gmail connect]: Google response contained no access_token "
|
||||
"for user_id=%s (keys_present=%s)",
|
||||
current_user.id,
|
||||
list(token_data.keys()),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Google did not return an access token.",
|
||||
)
|
||||
|
||||
if not refresh_token:
|
||||
logger.warning(
|
||||
"OAuth [Gmail connect]: Google did not return a refresh_token for "
|
||||
"user_id=%s — token refresh may fail after 1 hour. "
|
||||
"This can happen if the user has previously authorised the app and "
|
||||
"access_type=offline was not honoured.",
|
||||
current_user.id,
|
||||
)
|
||||
|
||||
# Fetch the Gmail email address to associate with this credential
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: fetching Google profile for user_id=%s",
|
||||
current_user.id,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
profile_resp = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
@@ -387,6 +645,20 @@ async def gmail_oauth_callback(
|
||||
gmail_email = current_user.email # fallback
|
||||
if profile_resp.status_code == 200:
|
||||
gmail_email = profile_resp.json().get("email", current_user.email)
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: Google profile retrieved for user_id=%s "
|
||||
"(gmail=%s)",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"OAuth [Gmail connect]: could not fetch Google profile for user_id=%s "
|
||||
"(status=%s); falling back to account email=%s",
|
||||
current_user.id,
|
||||
profile_resp.status_code,
|
||||
current_user.email,
|
||||
)
|
||||
|
||||
# Calculate token expiry (Google access tokens last 1 hour)
|
||||
token_expiry = datetime.now(timezone.utc) + timedelta(
|
||||
@@ -394,6 +666,11 @@ async def gmail_oauth_callback(
|
||||
)
|
||||
|
||||
# Verify the credentials actually work with the Gmail API
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: verifying Gmail API access for user_id=%s (gmail=%s)",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
)
|
||||
gmail_service = GmailService(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -402,6 +679,12 @@ async def gmail_oauth_callback(
|
||||
)
|
||||
is_valid = await gmail_service.verify_access()
|
||||
if not is_valid:
|
||||
logger.error(
|
||||
"OAuth [Gmail connect]: Gmail API access verification failed for "
|
||||
"user_id=%s (gmail=%s) — ensure gmail.insert scope was granted",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Obtained tokens but could not verify Gmail API access. "
|
||||
@@ -423,11 +706,23 @@ 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()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
"OAuth [Gmail connect]: updated credential for user_id=%s "
|
||||
"(gmail=%s, token_expiry=%s, has_refresh_token=%s, scopes=%s)",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
token_expiry,
|
||||
bool(encrypted_refresh),
|
||||
token_data.get("scope", ""),
|
||||
)
|
||||
return existing
|
||||
else:
|
||||
credential = GmailCredential(
|
||||
@@ -436,11 +731,20 @@ 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),
|
||||
)
|
||||
db.add(credential)
|
||||
await db.commit()
|
||||
await db.refresh(credential)
|
||||
logger.info(
|
||||
"OAuth [Gmail connect]: created new credential for user_id=%s "
|
||||
"(gmail=%s, token_expiry=%s, has_refresh_token=%s, scopes=%s)",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
token_expiry,
|
||||
bool(encrypted_refresh),
|
||||
token_data.get("scope", ""),
|
||||
)
|
||||
return credential
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
"""User management endpoints"""
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formatdate, make_msgid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
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.models.database_models import User, UserSmtpConfig
|
||||
from app.models.schemas import (
|
||||
UserDetailResponse,
|
||||
UserUpdate,
|
||||
UserSmtpConfigUpdate,
|
||||
UserSmtpConfigResponse,
|
||||
SmtpTestResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -66,6 +72,7 @@ async def get_smtp_config(
|
||||
host=config.host, # type: ignore[arg-type]
|
||||
port=config.port, # type: ignore[arg-type]
|
||||
username=config.username, # type: ignore[arg-type]
|
||||
sender_email=config.sender_email, # type: ignore[arg-type]
|
||||
use_tls=config.use_tls, # type: ignore[arg-type]
|
||||
has_password=bool(config.encrypted_password),
|
||||
created_at=config.created_at, # type: ignore[arg-type]
|
||||
@@ -89,6 +96,7 @@ async def upsert_smtp_config(
|
||||
config.host = config_in.host # type: ignore[assignment]
|
||||
config.port = config_in.port # type: ignore[assignment]
|
||||
config.username = config_in.username # type: ignore[assignment]
|
||||
config.sender_email = config_in.sender_email # type: ignore[assignment]
|
||||
config.use_tls = config_in.use_tls # type: ignore[assignment]
|
||||
if config_in.password is not None:
|
||||
config.encrypted_password = encrypt_credential(config_in.password) # type: ignore[assignment]
|
||||
@@ -98,6 +106,7 @@ async def upsert_smtp_config(
|
||||
host=config_in.host,
|
||||
port=config_in.port,
|
||||
username=config_in.username,
|
||||
sender_email=config_in.sender_email,
|
||||
encrypted_password=(
|
||||
encrypt_credential(config_in.password) if config_in.password else ""
|
||||
),
|
||||
@@ -114,6 +123,7 @@ async def upsert_smtp_config(
|
||||
host=config.host, # type: ignore[arg-type]
|
||||
port=config.port, # type: ignore[arg-type]
|
||||
username=config.username, # type: ignore[arg-type]
|
||||
sender_email=config.sender_email, # type: ignore[arg-type]
|
||||
use_tls=config.use_tls, # type: ignore[arg-type]
|
||||
has_password=bool(config.encrypted_password),
|
||||
created_at=config.created_at, # type: ignore[arg-type]
|
||||
@@ -134,3 +144,87 @@ async def delete_smtp_config(
|
||||
if config:
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/smtp-config/test", response_model=SmtpTestResponse)
|
||||
async def test_smtp_config(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send a test email using the current user's saved SMTP configuration"""
|
||||
result = await db.execute(
|
||||
select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No SMTP configuration found. Save one first.",
|
||||
)
|
||||
|
||||
if not config.encrypted_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No SMTP password stored. Save the configuration with a password first.",
|
||||
)
|
||||
|
||||
password = decrypt_credential(config.encrypted_password) # type: ignore[arg-type]
|
||||
recipient = current_user.email # type: ignore[arg-type]
|
||||
|
||||
def _send_test() -> None:
|
||||
msg = MIMEText(
|
||||
"This is a test email sent by InboxConverge to verify your SMTP settings.",
|
||||
"plain",
|
||||
"utf-8",
|
||||
)
|
||||
msg["From"] = config.sender_email or config.username # type: ignore[index]
|
||||
msg["To"] = recipient
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid()
|
||||
msg["Subject"] = "InboxConverge – SMTP Test"
|
||||
|
||||
if config.use_tls: # type: ignore[union-attr]
|
||||
server: smtplib.SMTP = smtplib.SMTP(
|
||||
config.host, config.port, timeout=30 # type: ignore[arg-type]
|
||||
)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP_SSL(
|
||||
config.host, config.port, timeout=30 # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
try:
|
||||
server.login(config.username, password) # type: ignore[arg-type]
|
||||
server.send_message(msg)
|
||||
finally:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _send_test)
|
||||
return SmtpTestResponse(
|
||||
success=True,
|
||||
message=f"Test email sent successfully to {recipient}.",
|
||||
)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
smtp_err = exc.smtp_error
|
||||
detail = (
|
||||
smtp_err.decode(errors="replace")
|
||||
if isinstance(smtp_err, bytes)
|
||||
else str(exc)
|
||||
)
|
||||
return SmtpTestResponse(
|
||||
success=False,
|
||||
message=f"Authentication failed: {detail}",
|
||||
)
|
||||
except smtplib.SMTPException as exc:
|
||||
return SmtpTestResponse(success=False, message=f"SMTP error: {exc}")
|
||||
except OSError as exc:
|
||||
return SmtpTestResponse(
|
||||
success=False,
|
||||
message=f"Connection error: {exc}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -90,13 +95,31 @@ class Settings(BaseSettings):
|
||||
# Apprise (notifications)
|
||||
APPRISE_ENABLED: bool = True
|
||||
|
||||
# Mail connectivity resilience
|
||||
# When True the app caches the last successfully resolved IPv4 address for
|
||||
# every mail host and uses that address as a fallback if DNS subsequently
|
||||
# fails (e.g. EAI_AGAIN / Temporary failure in name resolution). This also
|
||||
# makes every outgoing POP3/IMAP connection prefer IPv4, avoiding
|
||||
# ENETUNREACH errors on Docker hosts where IPv6 is not routed to the
|
||||
# internet.
|
||||
DNS_CACHE_FALLBACK_ENABLED: bool = True
|
||||
|
||||
# Logging
|
||||
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 +131,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 # type: ignore[attr-defined]
|
||||
|
||||
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
|
||||
@@ -165,6 +171,18 @@ class MailAccount(Base):
|
||||
provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX"
|
||||
auto_detected = Column(Boolean, default=False)
|
||||
|
||||
# Debug logging
|
||||
debug_logging = Column(Boolean, default=False)
|
||||
# Number of completed/partial_failure runs since debug_logging was last
|
||||
# enabled. Resets to 0 when debug_logging is toggled True via the API.
|
||||
# debug_logging is auto-disabled once this reaches 5.
|
||||
debug_logging_run_count = Column(Integer, default=0)
|
||||
|
||||
# Notification backoff: True once an error notification has been sent for
|
||||
# the current consecutive failure streak. Cleared (with a recovery notice)
|
||||
# when a run succeeds so the next new failure streak triggers a fresh alert.
|
||||
error_notification_sent = Column(Boolean, default=False)
|
||||
|
||||
# Statistics
|
||||
total_emails_processed = Column(Integer, default=0)
|
||||
total_emails_failed = Column(Integer, default=0)
|
||||
@@ -289,6 +307,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)
|
||||
@@ -461,6 +484,7 @@ class UserSmtpConfig(Base):
|
||||
username = Column(String(255), nullable=False, default="")
|
||||
encrypted_password = Column(Text, nullable=False, default="")
|
||||
use_tls = Column(Boolean, default=True)
|
||||
sender_email = Column(String(255), nullable=False, default="")
|
||||
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
@@ -557,6 +581,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 +626,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,8 @@ 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)
|
||||
debug_logging: bool = False
|
||||
|
||||
|
||||
class MailAccountCreate(MailAccountBase):
|
||||
@@ -120,6 +123,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 +137,8 @@ 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)
|
||||
debug_logging: Optional[bool] = None
|
||||
|
||||
|
||||
class MailAccountResponse(MailAccountBase):
|
||||
@@ -145,9 +157,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 +208,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 +230,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 +315,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 +334,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 +452,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
|
||||
@@ -346,6 +481,7 @@ class UserSmtpConfigBase(BaseModel):
|
||||
host: str = "smtp.gmail.com"
|
||||
port: int = Field(587, gt=0, lt=65536)
|
||||
username: str = ""
|
||||
sender_email: str = ""
|
||||
use_tls: bool = True
|
||||
|
||||
|
||||
@@ -363,6 +499,11 @@ class UserSmtpConfigResponse(UserSmtpConfigBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SmtpTestResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# Gmail OAuth Schemas
|
||||
class GmailAuthorizeResponse(BaseModel):
|
||||
authorization_url: str
|
||||
@@ -371,3 +512,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(
|
||||
@@ -48,6 +49,11 @@ class OAuthService:
|
||||
"""
|
||||
try:
|
||||
# Exchange code for token
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: exchanging authorization code for tokens "
|
||||
"(redirect_uri=%s)",
|
||||
redirect_uri,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
@@ -61,7 +67,12 @@ class OAuthService:
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"Google token exchange failed: {token_response.text}")
|
||||
logger.error(
|
||||
"OAuth [Google sign-in]: token exchange failed "
|
||||
"(status=%s, body=%s)",
|
||||
token_response.status_code,
|
||||
token_response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code",
|
||||
@@ -71,12 +82,26 @@ class OAuthService:
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
logger.error(
|
||||
"OAuth [Google sign-in]: token exchange response contained "
|
||||
"no access_token (keys_present=%s)",
|
||||
list(token_data.keys()),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No access token received",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: token exchange succeeded — "
|
||||
"scopes=%s, has_refresh_token=%s, expires_in=%s",
|
||||
token_data.get("scope", ""),
|
||||
bool(token_data.get("refresh_token")),
|
||||
token_data.get("expires_in"),
|
||||
)
|
||||
|
||||
# Get user info
|
||||
logger.debug("OAuth [Google sign-in]: fetching Google user profile")
|
||||
user_info_response = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
@@ -84,7 +109,10 @@ class OAuthService:
|
||||
|
||||
if user_info_response.status_code != 200:
|
||||
logger.error(
|
||||
f"Google user info fetch failed: {user_info_response.text}"
|
||||
"OAuth [Google sign-in]: user-info fetch failed "
|
||||
"(status=%s, body=%s)",
|
||||
user_info_response.status_code,
|
||||
user_info_response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -92,6 +120,12 @@ class OAuthService:
|
||||
)
|
||||
|
||||
user_info = user_info_response.json()
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: user profile retrieved — "
|
||||
"email=%s, verified=%s",
|
||||
user_info.get("email"),
|
||||
user_info.get("verified_email"),
|
||||
)
|
||||
|
||||
return {
|
||||
"email": user_info.get("email"),
|
||||
@@ -99,12 +133,20 @@ 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:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth error: {e}")
|
||||
logger.error(
|
||||
"OAuth [Google sign-in]: unexpected error: %s",
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="OAuth authentication failed",
|
||||
@@ -121,6 +163,7 @@ class OAuthService:
|
||||
Returns:
|
||||
Dict with access_token, refresh_token, and token_type
|
||||
"""
|
||||
logger.debug("OAuth: issuing application JWT tokens for user_id=%s", user.id)
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
|
||||
@@ -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,33 @@ 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, timedelta, timezone
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import format_datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import google.auth.exceptions
|
||||
from google.auth.transport.requests import Request as GoogleAuthRequest
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
@@ -30,6 +45,12 @@ class GmailInjectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GmailAuthError(GmailInjectionError):
|
||||
"""Raised when Gmail OAuth2 authentication fails (token expired or revoked)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GmailService:
|
||||
"""
|
||||
Service for injecting emails into Gmail via the Gmail API.
|
||||
@@ -66,12 +87,21 @@ class GmailService:
|
||||
scopes=GMAIL_SCOPES,
|
||||
)
|
||||
self._service = None
|
||||
logger.debug(
|
||||
"OAuth [GmailService]: initialized — has_refresh_token=%s, "
|
||||
"has_client_id=%s, has_client_secret=%s",
|
||||
bool(refresh_token),
|
||||
bool(client_id),
|
||||
bool(client_secret),
|
||||
)
|
||||
|
||||
@property
|
||||
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 +141,7 @@ class GmailService:
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
_start = time.perf_counter()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
@@ -120,6 +151,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,13 +167,29 @@ 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)}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
# Surface 401 so callers can mark credentials as invalid
|
||||
raise GmailInjectionError(error_msg)
|
||||
except google.auth.exceptions.RefreshError 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 token refresh failed — the refresh token may have been revoked. "
|
||||
f"The user must re-authorise. Detail: {e}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise GmailAuthError(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 +203,31 @@ 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 google.auth.exceptions.RefreshError 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 token refresh failed during access verification — "
|
||||
f"refresh token may be revoked. Detail: {e}"
|
||||
)
|
||||
return False
|
||||
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 +240,286 @@ 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(),
|
||||
)
|
||||
return result.get("emailAddress")
|
||||
_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)
|
||||
email = result.get("emailAddress")
|
||||
logger.debug("OAuth [GmailService]: fetched email address=%s", email)
|
||||
return email
|
||||
except google.auth.exceptions.RefreshError 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(
|
||||
"OAuth [GmailService]: token refresh failed while fetching email "
|
||||
"address — refresh token may be revoked. Detail: %s",
|
||||
e,
|
||||
)
|
||||
return None
|
||||
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 google.auth.exceptions.RefreshError 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 token refresh failed while managing label '{name}' — "
|
||||
f"refresh token may be revoked. Detail: {e}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise GmailAuthError(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
|
||||
|
||||
@staticmethod
|
||||
def _tz_aware_expiry(expiry: Optional[datetime]) -> Optional[datetime]:
|
||||
"""Return *expiry* with UTC tzinfo attached if it is naive.
|
||||
|
||||
google-auth sets ``credentials.expiry`` as a naive UTC datetime
|
||||
(``datetime.utcnow() + timedelta(...)``). Storing a naive datetime
|
||||
into a ``DateTime(timezone=True)`` column causes silent tz-mismatch
|
||||
bugs in comparisons and storage, so we always normalise before
|
||||
returning expiry values to callers.
|
||||
"""
|
||||
if expiry is not None and expiry.tzinfo is None:
|
||||
return expiry.replace(tzinfo=timezone.utc)
|
||||
return expiry
|
||||
|
||||
def is_token_expiring_soon(self, within_minutes: int = 30) -> bool:
|
||||
"""
|
||||
Return True if the access token has already expired or will expire
|
||||
within *within_minutes* minutes.
|
||||
|
||||
When ``credentials.expiry`` is None the expiry is unknown, which is
|
||||
treated as expiring soon so a proactive refresh is performed.
|
||||
|
||||
Args:
|
||||
within_minutes: Threshold in minutes before which a token is
|
||||
considered "expiring soon".
|
||||
|
||||
Returns:
|
||||
True if the token needs refreshing, False otherwise.
|
||||
"""
|
||||
if self.credentials.expiry is None:
|
||||
return True
|
||||
threshold = datetime.now(timezone.utc) + timedelta(minutes=within_minutes)
|
||||
expiry = self.credentials.expiry
|
||||
# google-auth stores expiry as a naive UTC datetime; make it tz-aware.
|
||||
if expiry.tzinfo is None:
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
return expiry <= threshold
|
||||
|
||||
async def proactive_refresh(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Explicitly refresh the access token using the stored refresh token.
|
||||
|
||||
Unlike the lazy refresh that happens automatically during API calls,
|
||||
this method triggers a refresh regardless of whether the current
|
||||
access token has expired. Use this from a scheduled task to keep
|
||||
tokens fresh and to detect revocation early.
|
||||
|
||||
Returns:
|
||||
Dict with ``access_token`` and ``expiry`` (datetime | None)
|
||||
representing the newly obtained access token.
|
||||
|
||||
Raises:
|
||||
GmailAuthError: If the refresh token is missing, has been revoked,
|
||||
or the refresh request fails for an auth-related reason.
|
||||
GmailInjectionError: For unexpected non-auth errors.
|
||||
"""
|
||||
if not self.credentials.refresh_token:
|
||||
raise GmailAuthError(
|
||||
"Cannot refresh: no refresh token stored. "
|
||||
"Re-authorise Gmail to obtain a new refresh token."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.credentials.refresh(GoogleAuthRequest()),
|
||||
)
|
||||
GMAIL_TOKEN_REFRESHES_TOTAL.inc()
|
||||
logger.info(
|
||||
"Gmail access token refreshed proactively; new expiry: %s",
|
||||
self.credentials.expiry,
|
||||
)
|
||||
return {
|
||||
"access_token": self.credentials.token,
|
||||
"expiry": self._tz_aware_expiry(self.credentials.expiry),
|
||||
}
|
||||
except google.auth.exceptions.RefreshError as e:
|
||||
error_msg = f"Gmail refresh token has been revoked or is invalid — the user must re-authorise. Detail: {e}"
|
||||
logger.error(error_msg)
|
||||
raise GmailAuthError(error_msg)
|
||||
except Exception as e:
|
||||
raise GmailInjectionError(
|
||||
f"Unexpected error during Gmail token refresh: {e}"
|
||||
)
|
||||
|
||||
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return the current access token and expiry if the token was refreshed
|
||||
@@ -198,8 +535,14 @@ class GmailService:
|
||||
"""
|
||||
current_token = self.credentials.token
|
||||
if current_token and current_token != self._initial_access_token:
|
||||
GMAIL_TOKEN_REFRESHES_TOTAL.inc()
|
||||
logger.debug(
|
||||
"OAuth [GmailService]: access token was auto-refreshed during API "
|
||||
"call; new expiry=%s",
|
||||
self.credentials.expiry,
|
||||
)
|
||||
return {
|
||||
"access_token": current_token,
|
||||
"expiry": self.credentials.expiry,
|
||||
"expiry": self._tz_aware_expiry(self.credentials.expiry),
|
||||
}
|
||||
return None
|
||||
|
||||
@@ -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,13 @@ 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)
|
||||
},
|
||||
"refresh-gmail-tokens": {
|
||||
"task": "app.workers.tasks.refresh_gmail_tokens",
|
||||
"schedule": crontab(minute="*/45"), # Every 45 minutes
|
||||
},
|
||||
"cleanup-old-logs": {
|
||||
"task": "app.workers.tasks.cleanup_old_logs",
|
||||
|
||||
@@ -3,13 +3,25 @@ Celery tasks for background email processing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import email as email_lib
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
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,
|
||||
@@ -20,22 +32,42 @@ from app.models.database_models import (
|
||||
DownloadedMessageId,
|
||||
UserSmtpConfig,
|
||||
)
|
||||
from app.services.mail_processor import MailProcessor
|
||||
from app.services.gmail_service import GmailService
|
||||
from app.services.mail_processor import MailProcessor, MailDebugRecorder
|
||||
from app.services.gmail_service import GmailService, GmailAuthError
|
||||
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
|
||||
from sqlalchemy import select, delete, or_, update as sa_update
|
||||
|
||||
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 +78,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 +91,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)
|
||||
@@ -79,8 +117,13 @@ async def process_mail_account(account_id: int):
|
||||
)
|
||||
already_seen_uids = set(seen_result.scalars().all())
|
||||
|
||||
# Create debug recorder if debug logging is enabled for this account
|
||||
_debug_recorder: Optional[MailDebugRecorder] = (
|
||||
MailDebugRecorder() if account.debug_logging else None # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
# Create processor
|
||||
processor = MailProcessor(account, password)
|
||||
processor = MailProcessor(account, password, debug_recorder=_debug_recorder)
|
||||
|
||||
# Fetch emails (returns raw bytes + new UIDs)
|
||||
emails, new_uids = await processor.fetch_emails(
|
||||
@@ -89,6 +132,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 +143,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
|
||||
@@ -117,6 +162,27 @@ async def process_mail_account(account_id: int):
|
||||
if gmail_cred.encrypted_refresh_token
|
||||
else None
|
||||
)
|
||||
# Log token expiry to help diagnose timeout issues.
|
||||
if gmail_cred.token_expiry:
|
||||
_now = datetime.now(timezone.utc)
|
||||
_expiry = gmail_cred.token_expiry
|
||||
if _expiry.tzinfo is None:
|
||||
_expiry = _expiry.replace(tzinfo=timezone.utc)
|
||||
_secs = (_expiry - _now).total_seconds()
|
||||
if _secs < 0:
|
||||
logger.info(
|
||||
"Gmail access token for user %s expired %.0f s ago; "
|
||||
"google-auth will refresh automatically using the refresh token.",
|
||||
account.user_id,
|
||||
-_secs,
|
||||
)
|
||||
elif _secs < 300:
|
||||
logger.info(
|
||||
"Gmail access token for user %s expires in %.0f s; "
|
||||
"will be refreshed on the first API call.",
|
||||
account.user_id,
|
||||
_secs,
|
||||
)
|
||||
gmail_service = GmailService(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -144,6 +210,7 @@ async def process_mail_account(account_id: int):
|
||||
"host": user_smtp.host,
|
||||
"port": user_smtp.port,
|
||||
"username": user_smtp.username,
|
||||
"sender_email": user_smtp.sender_email or "",
|
||||
"password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type]
|
||||
"use_tls": user_smtp.use_tls,
|
||||
}
|
||||
@@ -156,10 +223,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 +240,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,15 +337,61 @@ 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:
|
||||
logger.warning(
|
||||
"Email delivery returned False for uid=%s "
|
||||
"on account %s (subject=%r, from=%r); "
|
||||
"message will be retried on next run",
|
||||
uid,
|
||||
account.id,
|
||||
email_subject,
|
||||
email_from,
|
||||
)
|
||||
emails_failed += 1
|
||||
|
||||
except GmailAuthError as e:
|
||||
# Token refresh failed: refresh token is likely revoked.
|
||||
# Mark the credential invalid so the user is prompted to
|
||||
# re-authorise, and stop retrying for this run.
|
||||
if use_gmail_api and gmail_cred:
|
||||
gmail_cred.is_valid = False # type: ignore[assignment]
|
||||
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
|
||||
logger.warning(
|
||||
"Gmail credentials revoked for user %s "
|
||||
"(account %s, token_expiry=%s). "
|
||||
"User must re-authorise. Error: %s",
|
||||
account.user_id,
|
||||
account.id,
|
||||
getattr(gmail_cred, "token_expiry", "unknown"),
|
||||
e,
|
||||
)
|
||||
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}' "
|
||||
f"have been revoked or have expired. "
|
||||
f"Please re-authorize Gmail access in Settings."
|
||||
),
|
||||
notify_on_error=True,
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
logger.warning(
|
||||
"Failed to send revocation notification: %s", notify_exc
|
||||
)
|
||||
error_msg = str(e)
|
||||
emails_failed += 1
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
# If Gmail returns 401/403 the refresh token was revoked –
|
||||
# mark credentials invalid so the user gets notified.
|
||||
# Catch any remaining auth-style errors that slipped through
|
||||
# (e.g. HttpError 401/403 returned after a successful refresh).
|
||||
if (
|
||||
use_gmail_api
|
||||
and gmail_cred
|
||||
@@ -203,13 +402,90 @@ 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."
|
||||
"Gmail credentials invalidated for user %s "
|
||||
"(account %s, token_expiry=%s) due to HTTP auth error. "
|
||||
"User must re-authorise. Error: %s",
|
||||
account.user_id,
|
||||
account.id,
|
||||
getattr(gmail_cred, "token_expiry", "unknown"),
|
||||
e,
|
||||
)
|
||||
logger.error(f"Error delivering email: {e}")
|
||||
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}' "
|
||||
f"have been revoked. Please re-authorize Gmail access "
|
||||
f"in Settings."
|
||||
),
|
||||
notify_on_error=True,
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
logger.warning(
|
||||
f"Failed to send revocation notification: {notify_exc}"
|
||||
)
|
||||
logger.error(
|
||||
"Error delivering email (account %s, uid=%s): %s",
|
||||
account.id,
|
||||
uid,
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
# Post-process: mark successfully forwarded messages as \Seen (IMAP)
|
||||
# and/or delete them from the source mailbox. This is done AFTER
|
||||
# the forwarding loop so that any message that failed to forward is
|
||||
# left untouched in the source and will be retried on the next run.
|
||||
if successfully_forwarded_uids:
|
||||
try:
|
||||
await processor.post_process_messages(successfully_forwarded_uids)
|
||||
except Exception as post_exc:
|
||||
logger.warning(
|
||||
"Failed to post-process messages for account %s: %s",
|
||||
account.id,
|
||||
post_exc,
|
||||
)
|
||||
|
||||
# Persist debug connection trace (if debug logging was enabled)
|
||||
if _debug_recorder and _debug_recorder.has_entries():
|
||||
db.add(
|
||||
ProcessingLog(
|
||||
user_id=account.user_id,
|
||||
mail_account_id=account.id,
|
||||
processing_run_id=run.id,
|
||||
level="DEBUG",
|
||||
message=f"Connection trace ({len(_debug_recorder)} entries)",
|
||||
success=True,
|
||||
error_details=_debug_recorder.as_details(),
|
||||
)
|
||||
)
|
||||
|
||||
# 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 +496,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 +524,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
|
||||
@@ -245,16 +534,99 @@ async def process_mail_account(account_id: int):
|
||||
account.total_emails_failed += emails_failed # type: ignore[assignment]
|
||||
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
if emails_failed == 0:
|
||||
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
||||
else:
|
||||
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment]
|
||||
# The fetch/connection succeeded: always clear any connection-level error
|
||||
# and mark the account ACTIVE regardless of per-email forwarding failures.
|
||||
# Per-email failures are already tracked in ProcessingLog and the
|
||||
# run's emails_failed counter so the user can drill into them without
|
||||
# having the account badge stuck in ERROR indefinitely.
|
||||
# Capture pre-mutation state for the post-commit notification logic.
|
||||
# These must be read before the account status and flag are cleared below.
|
||||
_was_in_error = account.status == AccountStatus.ERROR # type: ignore[comparison-overlap]
|
||||
_had_notified = bool(account.error_notification_sent) # type: ignore[attr-defined]
|
||||
|
||||
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]
|
||||
# Clear the notification-sent flag so a future error streak fires a fresh alert.
|
||||
account.error_notification_sent = False # type: ignore[assignment]
|
||||
|
||||
# Auto-disable debug logging after 5 runs since it was last enabled.
|
||||
# The counter resets to 0 each time the user turns the flag on via
|
||||
# the API, so "5 runs" is always counted from the moment of enabling.
|
||||
if account.debug_logging: # type: ignore[attr-defined]
|
||||
account.debug_logging_run_count = (account.debug_logging_run_count or 0) + 1 # type: ignore[attr-defined,assignment]
|
||||
if account.debug_logging_run_count >= 5: # type: ignore[attr-defined]
|
||||
account.debug_logging = False # type: ignore[assignment]
|
||||
account.debug_logging_run_count = 0 # type: ignore[assignment]
|
||||
logger.info(
|
||||
"Auto-disabled debug logging for account %s after 5 runs",
|
||||
account.id,
|
||||
)
|
||||
|
||||
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.
|
||||
#
|
||||
# Backoff policy:
|
||||
# - Recovery: if the account was previously in ERROR and we had
|
||||
# already sent a failure notification, send one recovery notice now.
|
||||
# - New error streak: only notify on the FIRST run that has failures
|
||||
# (error_notification_sent was False before this run). Subsequent
|
||||
# failing runs stay silent until recovery resets the flag.
|
||||
if _was_in_error and _had_notified:
|
||||
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 Account Recovered",
|
||||
body=f"Mail account '{account.name}' is processing normally again.",
|
||||
notify_on_error=True,
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
logger.warning(
|
||||
f"Failed to send recovery notification: {notify_exc}"
|
||||
)
|
||||
|
||||
if emails_failed > 0 and not _had_notified:
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
# Persist the flag so the next failing run stays silent.
|
||||
async with notif_db.begin():
|
||||
await notif_db.execute(
|
||||
sa_update(MailAccount)
|
||||
.where(MailAccount.id == account.id)
|
||||
.values(error_notification_sent=True)
|
||||
)
|
||||
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 +635,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 +667,55 @@ 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".
|
||||
#
|
||||
# Backoff: only notify on the first failure in a consecutive error
|
||||
# streak. error_notification_sent is set True here and cleared on
|
||||
# the next successful run, so repeat failures stay silent until the
|
||||
# account recovers.
|
||||
if "account" in locals() and account is not None:
|
||||
_already_notified = bool(
|
||||
getattr(account, "error_notification_sent", False)
|
||||
)
|
||||
if not _already_notified:
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
# Persist the flag first so even if the notification
|
||||
# delivery fails the flag is set and the next run won't
|
||||
# try again.
|
||||
async with notif_db.begin():
|
||||
await notif_db.execute(
|
||||
sa_update(MailAccount)
|
||||
.where(MailAccount.id == account.id)
|
||||
.values(error_notification_sent=True)
|
||||
)
|
||||
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 +724,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 +763,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 +781,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 +872,177 @@ 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
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(base=AsyncTask, name="app.workers.tasks.refresh_gmail_tokens")
|
||||
async def refresh_gmail_tokens():
|
||||
"""
|
||||
Proactively refresh Gmail OAuth2 access tokens that are close to expiry.
|
||||
|
||||
Runs every 45 minutes via Celery Beat. Any credential whose access token
|
||||
expires within the next 30 minutes (or whose expiry is unknown) is
|
||||
refreshed using its stored refresh token.
|
||||
|
||||
Benefits:
|
||||
- Keeps the DB's ``encrypted_access_token`` and ``token_expiry`` columns
|
||||
up to date so mail-processing tasks never start with an already-expired
|
||||
token (which would cause a delayed in-band refresh).
|
||||
- Detects revoked refresh tokens early, before they block email delivery,
|
||||
and sends the user a re-authorisation notification.
|
||||
|
||||
Credentials without a refresh token are skipped — they cannot be refreshed
|
||||
automatically and will simply fail on the next delivery attempt.
|
||||
"""
|
||||
_task_start = time.monotonic()
|
||||
async with async_session_maker() as db:
|
||||
try:
|
||||
# Find all valid credentials with a refresh token whose access
|
||||
# token expires within the next 30 minutes (or expiry is unknown).
|
||||
refresh_threshold = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||
cred_result = await db.execute(
|
||||
select(GmailCredential).where(
|
||||
GmailCredential.is_valid == True, # noqa: E712
|
||||
GmailCredential.encrypted_refresh_token.is_not(None),
|
||||
or_(
|
||||
GmailCredential.token_expiry.is_(None),
|
||||
GmailCredential.token_expiry <= refresh_threshold,
|
||||
),
|
||||
)
|
||||
)
|
||||
credentials_to_refresh = cred_result.scalars().all()
|
||||
|
||||
if not credentials_to_refresh:
|
||||
logger.debug("refresh_gmail_tokens: no credentials need refreshing")
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="refresh_gmail_tokens", status="success"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(
|
||||
task_name="refresh_gmail_tokens"
|
||||
).observe(_task_duration)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"refresh_gmail_tokens: refreshing %d credential(s)",
|
||||
len(credentials_to_refresh),
|
||||
)
|
||||
|
||||
refreshed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for cred in credentials_to_refresh:
|
||||
access_token = decrypt_credential(cred.encrypted_access_token) # type: ignore[arg-type]
|
||||
refresh_token = decrypt_credential(cred.encrypted_refresh_token) # type: ignore[arg-type]
|
||||
|
||||
gmail_service = GmailService(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
client_id=settings.GOOGLE_CLIENT_ID,
|
||||
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||
)
|
||||
|
||||
try:
|
||||
new_token_info = await gmail_service.proactive_refresh()
|
||||
cred.encrypted_access_token = encrypt_credential( # type: ignore[assignment]
|
||||
new_token_info["access_token"]
|
||||
)
|
||||
if new_token_info.get("expiry"):
|
||||
cred.token_expiry = new_token_info["expiry"] # type: ignore[assignment]
|
||||
cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
logger.info(
|
||||
"refresh_gmail_tokens: refreshed token for user %s "
|
||||
"(gmail=%s, new_expiry=%s)",
|
||||
cred.user_id,
|
||||
cred.gmail_email,
|
||||
new_token_info.get("expiry"),
|
||||
)
|
||||
refreshed_count += 1
|
||||
|
||||
except GmailAuthError as e:
|
||||
# Refresh token is revoked — mark the credential invalid
|
||||
# and notify the user.
|
||||
cred.is_valid = False # type: ignore[assignment]
|
||||
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
|
||||
logger.warning(
|
||||
"refresh_gmail_tokens: refresh token revoked for user %s "
|
||||
"(gmail=%s). Marking invalid. Error: %s",
|
||||
cred.user_id,
|
||||
cred.gmail_email,
|
||||
e,
|
||||
)
|
||||
failed_count += 1
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
await send_user_notification(
|
||||
db=notif_db,
|
||||
user_id=int(cred.user_id),
|
||||
title="InboxRescue: Gmail Re-authorisation Required",
|
||||
body=(
|
||||
"Your Gmail access has been revoked. "
|
||||
"Please open Settings → Gmail API and click "
|
||||
"'Connect Gmail' to restore email delivery."
|
||||
),
|
||||
notify_on_error=True,
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
logger.warning(
|
||||
"refresh_gmail_tokens: failed to send revocation "
|
||||
"notification for user %s: %s",
|
||||
cred.user_id,
|
||||
notify_exc,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Non-auth error (e.g. network timeout) — log but do not
|
||||
# mark as invalid; it may succeed on the next run.
|
||||
logger.warning(
|
||||
"refresh_gmail_tokens: unexpected error refreshing token "
|
||||
"for user %s (gmail=%s): %s",
|
||||
cred.user_id,
|
||||
cred.gmail_email,
|
||||
e,
|
||||
)
|
||||
failed_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"refresh_gmail_tokens: finished — %d refreshed, %d failed",
|
||||
refreshed_count,
|
||||
failed_count,
|
||||
)
|
||||
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="refresh_gmail_tokens", status="success"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(
|
||||
task_name="refresh_gmail_tokens"
|
||||
).observe(_task_duration)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("refresh_gmail_tokens: unexpected error: %s", e)
|
||||
_task_duration = time.monotonic() - _task_start
|
||||
CELERY_TASKS_TOTAL.labels(
|
||||
task_name="refresh_gmail_tokens", status="failure"
|
||||
).inc()
|
||||
CELERY_TASK_DURATION_SECONDS.labels(
|
||||
task_name="refresh_gmail_tokens"
|
||||
).observe(_task_duration)
|
||||
|
||||
@@ -1,58 +1,59 @@
|
||||
# Core Framework
|
||||
fastapi==0.109.1 # Updated: Fixed ReDoS vulnerability (was 0.109.0)
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.13.1
|
||||
fastapi==0.136.3 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0
|
||||
uvicorn[standard]==0.48.0
|
||||
pydantic==2.13.4
|
||||
pydantic-settings==2.14.1
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.48 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
|
||||
alembic==1.13.1
|
||||
psycopg2-binary==2.9.11
|
||||
sqlalchemy==2.0.50 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
|
||||
alembic==1.18.4
|
||||
psycopg2-binary==2.9.12
|
||||
asyncpg==0.31.0
|
||||
|
||||
# Authentication
|
||||
python-jose[cryptography]==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)
|
||||
httpx==0.26.0
|
||||
|
||||
python-jose[cryptography]==3.5.0 # Updated: Fixed algorithm confusion with OpenSSH ECDSA keys (was 3.3.0)
|
||||
bcrypt==5.0.0
|
||||
python-multipart==0.0.29 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
|
||||
authlib==1.7.2 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
|
||||
httpx==0.28.1
|
||||
|
||||
# Payment Processing
|
||||
stripe==14.4.1
|
||||
stripe==15.1.0
|
||||
|
||||
# Email & Mail Processing
|
||||
aiosmtplib==3.0.1
|
||||
aiohttp==3.13.3 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
|
||||
aiosmtplib==5.1.0
|
||||
aiohttp==3.13.5 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
|
||||
aioimaplib==2.0.1
|
||||
email-validator==2.1.0.post1
|
||||
email-validator==2.3.0
|
||||
|
||||
# Gmail API (for direct email injection)
|
||||
google-api-python-client==2.193.0
|
||||
google-auth==2.49.1
|
||||
google-auth-oauthlib==1.2.0
|
||||
google-auth-httplib2==0.3.0
|
||||
google-api-python-client==2.196.0
|
||||
google-auth==2.53.0
|
||||
google-auth-oauthlib==1.4.0
|
||||
google-auth-httplib2==0.4.0
|
||||
|
||||
# Job Queue & Cache
|
||||
celery==5.6.2
|
||||
redis==7.3.0
|
||||
celery==5.6.3
|
||||
redis==7.4.0
|
||||
|
||||
# Security & Encryption
|
||||
cryptography==46.0.5 # Updated: Fixed NULL pointer dereference (was 42.0.0)
|
||||
cryptography==48.0.0 # Updated: Fixed NULL pointer dereference (was 42.0.0)
|
||||
|
||||
# Notifications
|
||||
apprise==1.7.1
|
||||
apprise==1.10.0
|
||||
|
||||
# Monitoring & Logging
|
||||
prometheus-client==0.19.0
|
||||
prometheus-client==0.25.0
|
||||
python-json-logger==2.0.7
|
||||
|
||||
# Development & Testing
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
pytest-cov==4.1.0
|
||||
faker==22.6.0
|
||||
pytest==9.0.3
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==7.1.0
|
||||
faker==40.19.1
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
schedule==1.2.0
|
||||
python-dotenv==1.2.2
|
||||
schedule==1.2.2
|
||||
tenacity==9.1.4
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
|
||||
def test_alembic_has_single_head() -> None:
|
||||
backend_dir = Path(__file__).resolve().parents[2]
|
||||
config = Config(str(backend_dir / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(backend_dir / "alembic"))
|
||||
|
||||
script = ScriptDirectory.from_config(config)
|
||||
|
||||
assert script.get_heads() == ["0004"]
|
||||
@@ -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,241 @@
|
||||
"""
|
||||
Unit tests for app settings endpoints (api/v1/endpoints/app_settings.py).
|
||||
|
||||
All database interactions and auth dependencies are mocked.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_superuser, get_current_user
|
||||
from app.models.database_models import User, SubscriptionTier
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_admin_user(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="admin@example.com",
|
||||
full_name="Admin",
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
u = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(u, k, v)
|
||||
return u
|
||||
|
||||
|
||||
def _make_setting(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
key="SOME_SETTING",
|
||||
value="some_value",
|
||||
value_type="string",
|
||||
description="A setting",
|
||||
is_secret=False,
|
||||
category="general",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
s = MagicMock()
|
||||
for k, v in defaults.items():
|
||||
setattr(s, k, v)
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.delete = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user():
|
||||
return _make_admin_user()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(app, admin_user, mock_db):
|
||||
async def _override_superuser():
|
||||
return admin_user
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_current_superuser] = _override_superuser
|
||||
app.dependency_overrides[get_current_user] = _override_superuser
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── GET /app-settings ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListSettings:
|
||||
async def test_returns_list(self, admin_client):
|
||||
setting = _make_setting()
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
|
||||
new=AsyncMock(return_value=[setting]),
|
||||
):
|
||||
response = await admin_client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["key"] == "SOME_SETTING"
|
||||
assert data[0]["value"] == "some_value"
|
||||
|
||||
async def test_secret_value_masked(self, admin_client):
|
||||
setting = _make_setting(key="SECRET_KEY", value="supersecret", is_secret=True)
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
|
||||
new=AsyncMock(return_value=[setting]),
|
||||
):
|
||||
response = await admin_client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data[0]["value"] == "********"
|
||||
|
||||
async def test_filters_by_category(self, admin_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
|
||||
new=AsyncMock(return_value=[]),
|
||||
):
|
||||
response = await admin_client.get("/api/v1/settings?category=general")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_unauthenticated_401(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/settings")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ── PUT /app-settings/{key} ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpsertSetting:
|
||||
async def test_upsert_creates_or_updates(self, admin_client):
|
||||
setting = _make_setting(key="MY_KEY", value="myval")
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.set",
|
||||
new=AsyncMock(return_value=setting),
|
||||
):
|
||||
response = await admin_client.put(
|
||||
"/api/v1/settings/MY_KEY",
|
||||
json={
|
||||
"key": "MY_KEY",
|
||||
"value": "myval",
|
||||
"value_type": "string",
|
||||
"is_secret": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == "MY_KEY"
|
||||
assert data["value"] == "myval"
|
||||
|
||||
async def test_bootstrap_key_rejected_400(self, admin_client):
|
||||
response = await admin_client.put(
|
||||
"/api/v1/settings/SECRET_KEY",
|
||||
json={"key": "SECRET_KEY", "value": "new_secret"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "bootstrap" in response.json()["detail"].lower()
|
||||
|
||||
async def test_database_url_rejected(self, admin_client):
|
||||
response = await admin_client.put(
|
||||
"/api/v1/settings/DATABASE_URL",
|
||||
json={"key": "DATABASE_URL", "value": "postgresql://..."},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_secret_value_masked_in_response(self, admin_client):
|
||||
setting = _make_setting(key="API_KEY", value="secret!", is_secret=True)
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.set",
|
||||
new=AsyncMock(return_value=setting),
|
||||
):
|
||||
response = await admin_client.put(
|
||||
"/api/v1/settings/API_KEY",
|
||||
json={
|
||||
"key": "API_KEY",
|
||||
"value": "secret!",
|
||||
"is_secret": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["value"] == "********"
|
||||
|
||||
|
||||
# ── DELETE /app-settings/{key} ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteSetting:
|
||||
async def test_deletes_existing_setting(self, admin_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.delete",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
response = await admin_client.delete("/api/v1/settings/MY_KEY")
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_404_when_not_found(self, admin_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.delete",
|
||||
new=AsyncMock(return_value=False),
|
||||
):
|
||||
response = await admin_client.delete("/api/v1/settings/MISSING_KEY")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_bootstrap_key_rejected_400(self, admin_client):
|
||||
response = await admin_client.delete("/api/v1/settings/SECRET_KEY")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ── POST /app-settings/seed-defaults ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSeedDefaultSettings:
|
||||
async def test_seeds_defaults(self, admin_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.seed_defaults",
|
||||
new=AsyncMock(return_value=5),
|
||||
):
|
||||
response = await admin_client.post("/api/v1/settings/seed-defaults")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["created"] == 5
|
||||
assert "Seeded 5" in data["message"]
|
||||
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
Unit tests for auth endpoints (backend/app/api/v1/endpoints/auth.py).
|
||||
|
||||
All tests mock the database session, security functions, and OAuth service
|
||||
so no real PostgreSQL instance or external API is required.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import quote as urlquote
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.models.database_models import User, SubscriptionTier
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a User ORM instance."""
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
hashed_password="hashedpw",
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
user = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(user, k, v)
|
||||
return user
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
"""Create a mock result whose .scalar_one_or_none() returns *value*."""
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
def _fake_tokens():
|
||||
return {
|
||||
"access_token": "fake-access-token",
|
||||
"refresh_token": "fake-refresh-token",
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
# ── fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.delete = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app, mock_db):
|
||||
"""AsyncClient with only get_db overridden (no auth required)."""
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Helper functions
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
"""Unit tests for private helper functions in auth.py."""
|
||||
|
||||
def test_domain_of(self):
|
||||
from app.api.v1.endpoints.auth import _domain_of
|
||||
|
||||
assert _domain_of("alice@Example.COM") == "example.com"
|
||||
assert _domain_of("bob@sub.domain.org") == "sub.domain.org"
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_check_domain_allowed_no_restriction(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _check_domain_allowed
|
||||
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
_check_domain_allowed("anyone@whatever.com") # should not raise
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_check_domain_allowed_passes(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _check_domain_allowed
|
||||
|
||||
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
||||
_check_domain_allowed("alice@acme.com") # should not raise
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_check_domain_allowed_blocks(self, mock_settings):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.endpoints.auth import _check_domain_allowed
|
||||
|
||||
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_domain_allowed("alice@blocked.com")
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_default_tier_valid(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
|
||||
mock_settings.DEFAULT_USER_TIER = "pro"
|
||||
assert _default_tier() == SubscriptionTier.PRO
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_default_tier_invalid_falls_back(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
|
||||
mock_settings.DEFAULT_USER_TIER = "invalid_tier"
|
||||
assert _default_tier() == SubscriptionTier.FREE
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_is_admin_email_match(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
mock_settings.ADMIN_EMAIL = "Admin@Example.com"
|
||||
assert _is_admin_email("admin@example.com") is True
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_is_admin_email_no_match(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
mock_settings.ADMIN_EMAIL = "admin@example.com"
|
||||
assert _is_admin_email("other@example.com") is False
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_is_admin_email_none(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
assert _is_admin_email("anyone@example.com") is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# POST /api/v1/auth/register
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRegister:
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch("app.api.v1.endpoints.auth.get_password_hash", return_value="hashed123")
|
||||
async def test_register_success(self, _mock_hash, mock_settings, client, mock_db):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
# Simulate db.refresh assigning an id and required fields
|
||||
def _set_id(obj):
|
||||
obj.id = 42
|
||||
obj.email = "new@example.com"
|
||||
obj.full_name = "New User"
|
||||
obj.is_active = True
|
||||
obj.subscription_tier = SubscriptionTier.FREE
|
||||
obj.subscription_status = "active"
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_set_id)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "new@example.com",
|
||||
"full_name": "New User",
|
||||
"password": "secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["email"] == "new@example.com"
|
||||
assert body["id"] == 42
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_awaited_once()
|
||||
|
||||
async def test_register_duplicate_email(self, client, mock_db):
|
||||
existing = _make_user(email="dup@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "dup@example.com", "full_name": "Dup", "password": "pw"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "already registered" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
async def test_register_domain_restricted(self, mock_settings, client, mock_db):
|
||||
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "user@blocked.com",
|
||||
"full_name": "Blocked",
|
||||
"password": "pw",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "not authorised" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# POST /api/v1/auth/login
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestLogin:
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value=_fake_tokens(),
|
||||
)
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
||||
async def test_login_success(
|
||||
self, _mock_verify, _mock_tokens, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
user = _make_user(email="login@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "login@example.com", "password": "correct"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["access_token"] == "fake-access-token"
|
||||
assert body["token_type"] == "bearer"
|
||||
mock_db.commit.assert_awaited_once()
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=False)
|
||||
async def test_login_user_not_found(self, _mock_verify, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "nobody@example.com", "password": "pw"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect email or password" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=False)
|
||||
async def test_login_wrong_password(self, _mock_verify, client, mock_db):
|
||||
user = _make_user(email="login@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "login@example.com", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect email or password" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
||||
async def test_login_inactive_user(self, _mock_verify, client, mock_db):
|
||||
user = _make_user(email="inactive@example.com", is_active=False)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "inactive@example.com", "password": "pw"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "inactive" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value=_fake_tokens(),
|
||||
)
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
||||
async def test_login_admin_auto_promotion(
|
||||
self, _mock_verify, _mock_tokens, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.ADMIN_EMAIL = "admin@example.com"
|
||||
|
||||
user = _make_user(email="admin@example.com", is_superuser=False, is_active=True)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin@example.com", "password": "pw"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# The endpoint should have set is_superuser = True on the user mock
|
||||
assert user.is_superuser is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# POST /api/v1/auth/google
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGoogleOAuth:
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value=_fake_tokens(),
|
||||
)
|
||||
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
||||
async def test_google_existing_user(
|
||||
self, mock_google_info, _mock_tokens, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
mock_google_info.return_value = {
|
||||
"email": "existing@example.com",
|
||||
"google_id": "g-123",
|
||||
"full_name": "Existing User",
|
||||
"verified_email": True,
|
||||
}
|
||||
|
||||
user = _make_user(email="existing@example.com", google_id="g-123")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["access_token"] == "fake-access-token"
|
||||
mock_db.commit.assert_awaited_once()
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value=_fake_tokens(),
|
||||
)
|
||||
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
||||
async def test_google_new_user(
|
||||
self, mock_google_info, _mock_tokens, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
mock_google_info.return_value = {
|
||||
"email": "brand-new@example.com",
|
||||
"google_id": "g-456",
|
||||
"full_name": "Brand New",
|
||||
"verified_email": True,
|
||||
}
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
def _set_id(obj):
|
||||
obj.id = 99
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_set_id)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["access_token"] == "fake-access-token"
|
||||
mock_db.add.assert_called_once()
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
||||
async def test_google_email_not_verified(self, mock_google_info, client, mock_db):
|
||||
mock_google_info.return_value = {
|
||||
"email": "unverified@example.com",
|
||||
"google_id": "g-789",
|
||||
"verified_email": False,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "not verified" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
||||
async def test_google_domain_restricted_new_user(
|
||||
self, mock_google_info, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
mock_google_info.return_value = {
|
||||
"email": "person@blocked.com",
|
||||
"google_id": "g-block",
|
||||
"full_name": "Blocked",
|
||||
"verified_email": True,
|
||||
}
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "not authorised" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# GET /api/v1/auth/google/authorize-url
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGoogleAuthorizeUrl:
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
async def test_returns_correct_url(self, mock_settings, client):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/auth/google/authorize-url",
|
||||
params={"redirect_uri": "http://localhost:3000/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
url = body["authorization_url"]
|
||||
assert "accounts.google.com" in url
|
||||
assert "client_id=test-client-id" in url
|
||||
assert "redirect_uri=http://localhost:3000/callback" in url
|
||||
expected_scope = urlquote("openid email profile")
|
||||
assert f"scope={expected_scope}" in url
|
||||
assert "prompt=select_account" in url
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Unit tests for authentication endpoints (api/v1/endpoints/auth.py).
|
||||
|
||||
All database interactions and the oauth_service are mocked so no real
|
||||
PostgreSQL instance or Google credentials are needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.models.database_models import User, SubscriptionTier
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
hashed_password=None,
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
u = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(u, k, v)
|
||||
return u
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
r = MagicMock()
|
||||
r.scalar_one_or_none.return_value = value
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def anon_client(app, mock_db):
|
||||
"""Client with no auth (db mocked)."""
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── /register ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegisterEndpoint:
|
||||
async def test_register_new_user_201(self, anon_client, mock_db):
|
||||
# DB returns no existing user
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
# db.refresh will be called with the new User object; we simulate it
|
||||
# by setting the required response fields on that object.
|
||||
from datetime import datetime, timezone
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
async def _refresh(obj):
|
||||
obj.id = 99
|
||||
obj.email = "new@example.com"
|
||||
obj.full_name = None
|
||||
obj.is_active = True
|
||||
obj.subscription_tier = SubscriptionTier.FREE
|
||||
obj.subscription_status = "active"
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
obj.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_refresh)
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "new@example.com", "password": "secretpassword"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
async def test_register_existing_user_400(self, anon_client, mock_db):
|
||||
existing = _make_user(email="taken@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "taken@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already registered" in response.json()["detail"]
|
||||
|
||||
async def test_register_blocked_domain_403(self, app, mock_db):
|
||||
"""When ALLOWED_DOMAINS is set, unknown domains should get 403."""
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
with patch("app.api.v1.endpoints.auth.settings") as mock_settings:
|
||||
mock_settings.ALLOWED_DOMAINS = ["allowed.com"]
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "user@blocked.com", "password": "pass"},
|
||||
)
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ── /login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLoginEndpoint:
|
||||
async def test_login_success_returns_tokens(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("correctpassword")
|
||||
user = _make_user(email="user@example.com", hashed_password=hashed)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "correctpassword"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
async def test_login_wrong_password_401(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("correctpassword")
|
||||
user = _make_user(email="user@example.com", hashed_password=hashed)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_login_user_not_found_401(self, anon_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "unknown@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_login_inactive_user_403(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("password")
|
||||
user = _make_user(
|
||||
email="user@example.com", hashed_password=hashed, is_active=False
|
||||
)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "password"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_login_no_password_hash_401(self, anon_client, mock_db):
|
||||
"""OAuth-only users have no hashed_password – login should fail."""
|
||||
user = _make_user(hashed_password=None)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ── /google ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGoogleOAuthEndpoint:
|
||||
async def test_google_oauth_unverified_email_400(self, anon_client, mock_db):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "user@gmail.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Test",
|
||||
"verified_email": False,
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_google_oauth_new_user_created(self, anon_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "google@example.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Google User",
|
||||
"verified_email": True,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value={
|
||||
"access_token": "tok",
|
||||
"refresh_token": "ref",
|
||||
"token_type": "bearer",
|
||||
},
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_token"] == "tok"
|
||||
|
||||
async def test_google_oauth_existing_user_logs_in(self, anon_client, mock_db):
|
||||
existing = _make_user(email="google@example.com", google_id="g123")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "google@example.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Google User",
|
||||
"verified_email": True,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value={
|
||||
"access_token": "tok2",
|
||||
"refresh_token": "ref2",
|
||||
"token_type": "bearer",
|
||||
},
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── /google/authorize-url ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGoogleAuthorizeUrl:
|
||||
async def test_returns_authorization_url(self, anon_client):
|
||||
response = await anon_client.get(
|
||||
"/api/v1/auth/google/authorize-url",
|
||||
params={"redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "authorization_url" in data
|
||||
assert data["authorization_url"].startswith(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
)
|
||||
|
||||
async def test_url_contains_redirect_uri(self, anon_client):
|
||||
redirect = "http://myapp.example.com/callback"
|
||||
response = await anon_client.get(
|
||||
"/api/v1/auth/google/authorize-url",
|
||||
params={"redirect_uri": redirect},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── helper functions (domain checks, tier, admin email) ───────────────────────
|
||||
|
||||
|
||||
class TestAuthHelpers:
|
||||
def test_domain_of(self):
|
||||
from app.api.v1.endpoints.auth import _domain_of
|
||||
|
||||
assert _domain_of("user@Example.COM") == "example.com"
|
||||
assert _domain_of("a@b.de") == "b.de"
|
||||
|
||||
def test_default_tier_fallback(self):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.DEFAULT_USER_TIER = "invalid_tier"
|
||||
tier = _default_tier()
|
||||
assert tier == SubscriptionTier.FREE
|
||||
|
||||
def test_default_tier_valid(self):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.DEFAULT_USER_TIER = "pro"
|
||||
tier = _default_tier()
|
||||
assert tier == SubscriptionTier.PRO
|
||||
|
||||
def test_is_admin_email_match(self):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.ADMIN_EMAIL = "admin@example.com"
|
||||
assert _is_admin_email("ADMIN@EXAMPLE.COM") is True
|
||||
assert _is_admin_email("other@example.com") is False
|
||||
|
||||
def test_is_admin_email_none_config(self):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.ADMIN_EMAIL = None
|
||||
assert _is_admin_email("admin@example.com") is False
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Unit tests for the OAuth / auth service (services/auth_service.py).
|
||||
|
||||
External HTTP calls are mocked via httpx. No real network or DB needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.auth_service import OAuthService
|
||||
from app.models.database_models import User
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(id: int = 1, email: str = "user@example.com") -> MagicMock:
|
||||
u = MagicMock(spec=User)
|
||||
u.id = id
|
||||
u.email = email
|
||||
return u
|
||||
|
||||
|
||||
def _mock_token_response(
|
||||
access_token: str = "access123", refresh_token: str = "refresh456"
|
||||
):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"expires_in": 3600,
|
||||
"scope": "openid email profile",
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _mock_user_info_response(
|
||||
email: str = "user@google.com",
|
||||
name: str = "Test User",
|
||||
google_id: str = "g123",
|
||||
verified: bool = True,
|
||||
):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {
|
||||
"email": email,
|
||||
"name": name,
|
||||
"id": google_id,
|
||||
"picture": "https://example.com/pic.jpg",
|
||||
"verified_email": verified,
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
# ── OAuthService.get_google_user_info ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetGoogleUserInfo:
|
||||
async def test_success(self):
|
||||
svc = OAuthService()
|
||||
token_resp = _mock_token_response()
|
||||
user_info_resp = _mock_user_info_response()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_client.get = AsyncMock(return_value=user_info_resp)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
result = await svc.get_google_user_info(
|
||||
code="authcode", redirect_uri="http://localhost/callback"
|
||||
)
|
||||
|
||||
assert result["email"] == "user@google.com"
|
||||
assert result["google_id"] == "g123"
|
||||
assert result["verified_email"] is True
|
||||
assert result["access_token"] == "access123"
|
||||
|
||||
async def test_token_exchange_fails(self):
|
||||
svc = OAuthService()
|
||||
bad_resp = MagicMock()
|
||||
bad_resp.status_code = 400
|
||||
bad_resp.text = "bad_request"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=bad_resp)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.get_google_user_info(
|
||||
code="bad", redirect_uri="http://localhost"
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
async def test_no_access_token_in_response(self):
|
||||
svc = OAuthService()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {} # no access_token
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=resp)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.get_google_user_info(
|
||||
code="c", redirect_uri="http://localhost"
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
async def test_user_info_fetch_fails(self):
|
||||
svc = OAuthService()
|
||||
token_resp = _mock_token_response()
|
||||
bad_info = MagicMock()
|
||||
bad_info.status_code = 500
|
||||
bad_info.text = "server error"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_client.get = AsyncMock(return_value=bad_info)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.get_google_user_info(
|
||||
code="c", redirect_uri="http://localhost"
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
async def test_unexpected_exception_becomes_500(self):
|
||||
svc = OAuthService()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(side_effect=RuntimeError("network down"))
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.get_google_user_info(
|
||||
code="c", redirect_uri="http://localhost"
|
||||
)
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
# ── OAuthService.create_tokens_for_user ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateTokensForUser:
|
||||
def test_returns_all_fields(self):
|
||||
user = _make_user(id=7)
|
||||
result = OAuthService.create_tokens_for_user(user)
|
||||
assert "access_token" in result
|
||||
assert "refresh_token" in result
|
||||
assert result["token_type"] == "bearer"
|
||||
|
||||
def test_access_token_is_string(self):
|
||||
user = _make_user(id=3)
|
||||
result = OAuthService.create_tokens_for_user(user)
|
||||
assert isinstance(result["access_token"], str)
|
||||
assert len(result["access_token"]) > 0
|
||||
|
||||
def test_refresh_token_is_string(self):
|
||||
user = _make_user(id=5)
|
||||
result = OAuthService.create_tokens_for_user(user)
|
||||
assert isinstance(result["refresh_token"], str)
|
||||
assert len(result["refresh_token"]) > 0
|
||||
|
||||
def test_different_users_get_different_tokens(self):
|
||||
u1 = _make_user(id=1)
|
||||
u2 = _make_user(id=2)
|
||||
tokens1 = OAuthService.create_tokens_for_user(u1)
|
||||
tokens2 = OAuthService.create_tokens_for_user(u2)
|
||||
assert tokens1["access_token"] != tokens2["access_token"]
|
||||
@@ -172,9 +172,8 @@ class TestConfigServiceSet:
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.execute.return_value = mock_result
|
||||
|
||||
# The method will call db.add() and db.commit()
|
||||
await ConfigService.set(
|
||||
"SMTP_HOST", "new.host.com", db=mock_db, category="smtp"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Unit tests for the DNS 8.8.8.8 fallback resolver in mail_processor.py.
|
||||
|
||||
Covers:
|
||||
- _build_dns_query() produces a valid DNS A-record packet
|
||||
- _parse_dns_a_response() extracts the first A record correctly
|
||||
- _query_google_dns_sync() sends UDP query to 8.8.8.8 and returns an IPv4
|
||||
- _resolve_ipv4_sync() falls through to 8.8.8.8 when system DNS and cache fail
|
||||
- _resolve_ipv4_sync() caches the 8.8.8.8 result for subsequent calls
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from app.services.mail_processor import (
|
||||
_build_dns_query,
|
||||
_parse_dns_a_response,
|
||||
_query_google_dns_sync,
|
||||
_resolve_ipv4_sync,
|
||||
_dns_cache,
|
||||
_dns_cache_lock,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_dns_response(txid: int, ip: str) -> bytes:
|
||||
"""Craft a minimal valid DNS A-record response for the given IP."""
|
||||
flags = 0x8180 # response, recursion available
|
||||
qdcount = 1
|
||||
ancount = 1
|
||||
header = struct.pack(">HHHHHH", txid, flags, qdcount, ancount, 0, 0)
|
||||
# Question: dummy single-label name "x" + QTYPE=A + QCLASS=IN
|
||||
qname = b"\x01x\x00"
|
||||
question = qname + struct.pack(">HH", 1, 1)
|
||||
# Answer: pointer to question name (0xC00C), TYPE=A, CLASS=IN, TTL, RDLEN=4, IP
|
||||
octets = tuple(int(o) for o in ip.split("."))
|
||||
answer = struct.pack(">HHHiH", 0xC00C, 1, 1, 300, 4) + bytes(octets)
|
||||
return header + question + answer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_dns_query
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildDnsQuery:
|
||||
def test_contains_hostname_labels(self):
|
||||
data = _build_dns_query("pop.example.com")
|
||||
# "pop" label should appear: length byte 3 followed by b"pop"
|
||||
assert b"\x03pop" in data
|
||||
|
||||
def test_qtype_a_and_class_in(self):
|
||||
data = _build_dns_query("mail.example.com")
|
||||
# Last 4 bytes of question: QTYPE=0x0001, QCLASS=0x0001
|
||||
assert data[-4:] == b"\x00\x01\x00\x01"
|
||||
|
||||
def test_transaction_id_is_0xAB12(self):
|
||||
data = _build_dns_query("x.example.com")
|
||||
txid = struct.unpack(">H", data[:2])[0]
|
||||
assert txid == 0xAB12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_dns_a_response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseDnsAResponse:
|
||||
def test_extracts_ip_from_valid_response(self):
|
||||
response = _make_dns_response(0xAB12, "1.2.3.4")
|
||||
result = _parse_dns_a_response(response, "example.com")
|
||||
assert result == "1.2.3.4"
|
||||
|
||||
def test_returns_none_for_wrong_txid(self):
|
||||
response = _make_dns_response(0x1234, "1.2.3.4")
|
||||
result = _parse_dns_a_response(response, "example.com")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_short_data(self):
|
||||
assert _parse_dns_a_response(b"\x00\x01", "example.com") is None
|
||||
|
||||
def test_returns_none_when_no_answers(self):
|
||||
# Build a header with ancount=0
|
||||
header = struct.pack(">HHHHHH", 0xAB12, 0x8180, 0, 0, 0, 0)
|
||||
assert _parse_dns_a_response(header, "x") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _query_google_dns_sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryGoogleDnsSync:
|
||||
def test_returns_ip_on_success(self):
|
||||
response = _make_dns_response(0xAB12, "5.6.7.8")
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.recvfrom.return_value = (response, ("8.8.8.8", 53))
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = _query_google_dns_sync("pop.example.com")
|
||||
|
||||
assert result == "5.6.7.8"
|
||||
mock_sock.sendto.assert_called_once()
|
||||
|
||||
def test_returns_none_on_socket_error(self):
|
||||
with patch("socket.socket", side_effect=OSError("network down")):
|
||||
result = _query_google_dns_sync("pop.example.com")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_on_timeout(self):
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.recvfrom.side_effect = socket.timeout("timed out")
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = _query_google_dns_sync("pop.example.com")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_ipv4_sync — 8.8.8.8 fallback path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveIpv4SyncGoogleFallback:
|
||||
def setup_method(self):
|
||||
"""Clear the DNS cache before each test to avoid state bleed."""
|
||||
with _dns_cache_lock:
|
||||
_dns_cache.clear()
|
||||
|
||||
def test_falls_through_to_google_when_system_dns_and_cache_fail(self):
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=OSError("Name or service not known"),
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor._query_google_dns_sync",
|
||||
return_value="9.10.11.12",
|
||||
) as mock_google,
|
||||
patch(
|
||||
"app.services.mail_processor.settings.DNS_CACHE_FALLBACK_ENABLED",
|
||||
False,
|
||||
),
|
||||
):
|
||||
result = _resolve_ipv4_sync("pop.web.de", 995)
|
||||
|
||||
assert result == "9.10.11.12"
|
||||
mock_google.assert_called_once_with("pop.web.de")
|
||||
|
||||
def test_google_result_is_cached(self):
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=OSError("Name or service not known"),
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor._query_google_dns_sync",
|
||||
return_value="9.10.11.12",
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor.settings.DNS_CACHE_FALLBACK_ENABLED",
|
||||
True,
|
||||
),
|
||||
):
|
||||
_resolve_ipv4_sync("pop.web.de", 995)
|
||||
|
||||
with _dns_cache_lock:
|
||||
cached = _dns_cache.get(("pop.web.de", 995))
|
||||
assert cached == "9.10.11.12"
|
||||
|
||||
def test_returns_none_when_all_strategies_fail(self):
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=OSError("Name or service not known"),
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor._query_google_dns_sync",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor.settings.DNS_CACHE_FALLBACK_ENABLED",
|
||||
False,
|
||||
),
|
||||
):
|
||||
result = _resolve_ipv4_sync("nonexistent.invalid", 995)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_system_dns_success_skips_google(self):
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[(None, None, None, None, ("1.2.3.4", 995))],
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor._query_google_dns_sync",
|
||||
) as mock_google,
|
||||
patch(
|
||||
"app.services.mail_processor.settings.DNS_CACHE_FALLBACK_ENABLED",
|
||||
False,
|
||||
),
|
||||
):
|
||||
result = _resolve_ipv4_sync("pop.example.com", 995)
|
||||
|
||||
assert result == "1.2.3.4"
|
||||
mock_google.assert_not_called()
|
||||
@@ -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
|
||||
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Unit tests for _format_connection_error() and MailDebugRecorder.
|
||||
|
||||
These tests verify:
|
||||
- _format_connection_error returns human-readable, non-empty strings
|
||||
for every relevant exception type.
|
||||
- MailDebugRecorder caps its trace at the configured limits and never
|
||||
includes password or credential data in its output.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import poplib
|
||||
import socket
|
||||
import ssl
|
||||
|
||||
|
||||
from app.services.mail_processor import (
|
||||
MailDebugRecorder,
|
||||
_format_connection_error,
|
||||
_MAX_TRACE_ENTRIES,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_connection_error – helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fmt(exc: BaseException, host: str = "mail.example.com", port: int = 993) -> str:
|
||||
return _format_connection_error(exc, host, port, "IMAP")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DNS failure (socket.gaierror)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatConnectionErrorDns:
|
||||
def test_gaierror_includes_hostname(self):
|
||||
exc = socket.gaierror(-5, "No address associated with hostname")
|
||||
msg = _fmt(exc, host="pop.web.de", port=995)
|
||||
assert "pop.web.de" in msg
|
||||
assert "DNS" in msg or "resolve" in msg.lower()
|
||||
|
||||
def test_gaierror_never_empty(self):
|
||||
exc = socket.gaierror(-2, "Name or service not known")
|
||||
assert _fmt(exc)
|
||||
|
||||
def test_gaierror_without_host(self):
|
||||
exc = socket.gaierror(-5, "No address associated with hostname")
|
||||
msg = _format_connection_error(exc)
|
||||
assert msg
|
||||
assert "DNS" in msg or "lookup" in msg.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatConnectionErrorTimeout:
|
||||
def test_socket_timeout_includes_host_port(self):
|
||||
exc = socket.timeout("timed out")
|
||||
msg = _fmt(exc, host="imap.gmx.net", port=993)
|
||||
# Verify both the host and timeout indicator appear in the message
|
||||
assert "imap.gmx.net" in msg
|
||||
assert "993" in msg
|
||||
assert "timed out" in msg.lower() or "timeout" in msg.lower()
|
||||
|
||||
def test_asyncio_timeout_error(self):
|
||||
exc = asyncio.TimeoutError()
|
||||
msg = _fmt(exc)
|
||||
assert msg # never empty even though str() is ""
|
||||
assert "timed out" in msg.lower() or "timeout" in msg.lower()
|
||||
|
||||
def test_bare_timeout_error(self):
|
||||
"""Python 3.11+ TimeoutError (subclass of OSError) should be handled."""
|
||||
exc = TimeoutError("timed out")
|
||||
msg = _fmt(exc)
|
||||
assert msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection refused / reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatConnectionErrorRefused:
|
||||
def test_connection_refused_includes_host_port(self):
|
||||
exc = ConnectionRefusedError(111, "Connection refused")
|
||||
msg = _fmt(exc, host="smtp.example.com", port=587)
|
||||
# Verify both host and port appear in the message
|
||||
assert "smtp.example.com" in msg
|
||||
assert "587" in msg
|
||||
assert "refused" in msg.lower()
|
||||
|
||||
def test_connection_reset(self):
|
||||
exc = ConnectionResetError(104, "Connection reset by peer")
|
||||
msg = _fmt(exc)
|
||||
assert msg
|
||||
assert "reset" in msg.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSL errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatConnectionErrorSsl:
|
||||
def test_ssl_cert_verification(self):
|
||||
try:
|
||||
# Create a real SSLCertVerificationError if possible
|
||||
exc = ssl.SSLCertVerificationError(
|
||||
1, "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed"
|
||||
)
|
||||
except Exception:
|
||||
exc = ssl.SSLError(1, "CERTIFICATE_VERIFY_FAILED") # type: ignore[assignment]
|
||||
msg = _fmt(exc)
|
||||
assert msg
|
||||
assert "TLS" in msg or "SSL" in msg or "certificate" in msg.lower()
|
||||
|
||||
def test_ssl_generic_error(self):
|
||||
exc = ssl.SSLError(1, "wrong version number")
|
||||
msg = _fmt(exc)
|
||||
assert msg
|
||||
assert "TLS" in msg or "SSL" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POP3 protocol errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatConnectionErrorPop3:
|
||||
def test_auth_error_identifies_auth_failure(self):
|
||||
exc = poplib.error_proto("-ERR authentication failed")
|
||||
msg = _format_connection_error(exc, "pop.example.com", 995, "POP3")
|
||||
# Verify both host and port appear in the message
|
||||
assert "pop.example.com" in msg
|
||||
assert "995" in msg
|
||||
assert "Authentication" in msg or "authentication" in msg
|
||||
|
||||
def test_generic_pop3_error(self):
|
||||
exc = poplib.error_proto("-ERR mailbox is locked")
|
||||
msg = _format_connection_error(exc, "pop.example.com", 995, "POP3")
|
||||
assert msg
|
||||
assert "POP3" in msg
|
||||
|
||||
def test_empty_pop3_error(self):
|
||||
"""Even a bare error_proto with no message must produce a non-empty string."""
|
||||
exc = poplib.error_proto(b"")
|
||||
msg = _format_connection_error(exc, "pop.example.com", 995, "POP3")
|
||||
assert msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catch-all: empty-string exception
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatConnectionErrorFallback:
|
||||
def test_empty_string_exception(self):
|
||||
"""An exception whose str() is empty must still produce a non-empty message."""
|
||||
exc = asyncio.TimeoutError()
|
||||
assert str(exc) == "" # confirm the premise
|
||||
msg = _fmt(exc)
|
||||
assert msg
|
||||
assert len(msg) > 0
|
||||
|
||||
def test_generic_exception(self):
|
||||
exc = RuntimeError("something went wrong")
|
||||
msg = _fmt(exc)
|
||||
assert "something went wrong" in msg or "RuntimeError" in msg
|
||||
|
||||
def test_no_host(self):
|
||||
exc = RuntimeError("test")
|
||||
msg = _format_connection_error(exc)
|
||||
assert msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MailDebugRecorder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMailDebugRecorder:
|
||||
def test_record_entry_appears_in_trace(self):
|
||||
rec = MailDebugRecorder()
|
||||
rec.record("connect", "Connected to mail.example.com:993", {"elapsed_ms": 42})
|
||||
details = rec.as_details()
|
||||
assert len(details["trace"]) == 1
|
||||
entry = details["trace"][0]
|
||||
assert entry["phase"] == "connect"
|
||||
assert entry["msg"] == "Connected to mail.example.com:993"
|
||||
assert entry["data"]["elapsed_ms"] == 42
|
||||
assert not details["truncated"]
|
||||
|
||||
def test_has_entries_false_when_empty(self):
|
||||
rec = MailDebugRecorder()
|
||||
assert not rec.has_entries()
|
||||
|
||||
def test_has_entries_true_after_record(self):
|
||||
rec = MailDebugRecorder()
|
||||
rec.record("auth", "Logged in")
|
||||
assert rec.has_entries()
|
||||
|
||||
def test_size_cap_triggers_truncation(self):
|
||||
"""Recording beyond _MAX_TRACE_BYTES silently truncates."""
|
||||
rec = MailDebugRecorder()
|
||||
# Each entry is ~100 chars; flood until truncated
|
||||
long_msg = "x" * 1000
|
||||
for i in range(200):
|
||||
rec.record("flood", long_msg, {"i": i})
|
||||
details = rec.as_details()
|
||||
assert details["truncated"]
|
||||
# After truncation, recording new entries is a no-op
|
||||
prev_count = len(rec)
|
||||
rec.record("after_truncate", "should be ignored")
|
||||
assert len(rec) == prev_count
|
||||
|
||||
def test_entry_cap_triggers_truncation(self):
|
||||
"""Recording more than _MAX_TRACE_ENTRIES entries truncates."""
|
||||
rec = MailDebugRecorder()
|
||||
for i in range(_MAX_TRACE_ENTRIES + 10):
|
||||
rec.record("phase", f"entry {i}")
|
||||
assert rec.as_details()["truncated"]
|
||||
# Total entries should be MAX + 1 (the truncation sentinel)
|
||||
assert len(rec.as_details()["trace"]) <= _MAX_TRACE_ENTRIES + 1
|
||||
|
||||
def test_no_password_in_trace_from_production_phases(self):
|
||||
"""Production instrumentation records only usernames and counts, never passwords.
|
||||
|
||||
The recorder itself has no auto-redaction; the contract is that callers
|
||||
(the instrumented IMAP/POP3 code) must never pass credential data.
|
||||
This test verifies the expected production-phase entries contain no
|
||||
password strings.
|
||||
"""
|
||||
password = "s3cr3tP@ssw0rd!"
|
||||
rec = MailDebugRecorder()
|
||||
# Simulate the entries that production code actually records
|
||||
rec.record("auth", "Authenticated as user@example.com", {"elapsed_ms": 5})
|
||||
rec.record("stat", "Mailbox has 3 messages", {"count": 3})
|
||||
|
||||
import json
|
||||
|
||||
serialised = json.dumps(rec.as_details())
|
||||
# The password must not appear in any of these entries
|
||||
assert password not in serialised
|
||||
|
||||
def test_timestamps_are_iso_format(self):
|
||||
rec = MailDebugRecorder()
|
||||
rec.record("connect", "ok")
|
||||
ts = rec.as_details()["trace"][0]["ts"]
|
||||
# Should be parseable as ISO 8601
|
||||
from datetime import datetime
|
||||
|
||||
datetime.fromisoformat(ts) # raises if invalid
|
||||
|
||||
def test_multiple_phases_ordered(self):
|
||||
rec = MailDebugRecorder()
|
||||
for phase in ["connect", "auth", "select", "search", "fetch_msg", "logout"]:
|
||||
rec.record(phase, f"{phase} done")
|
||||
details = rec.as_details()
|
||||
phases = [e["phase"] for e in details["trace"]]
|
||||
assert phases == ["connect", "auth", "select", "search", "fetch_msg", "logout"]
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Unit tests for GDPR data masking utilities (core/gdpr.py).
|
||||
No database or HTTP layer required – pure function tests.
|
||||
"""
|
||||
|
||||
from app.core.gdpr import mask_email, mask_name, mask_from_header
|
||||
|
||||
|
||||
class TestMaskEmail:
|
||||
def test_typical_address(self):
|
||||
assert mask_email("john.doe@example.com") == "jo***@e***.com"
|
||||
|
||||
def test_short_local(self):
|
||||
# local part has only 1 char; still returns that char + ***
|
||||
result = mask_email("a@b.de")
|
||||
assert result == "a***@b***.de"
|
||||
|
||||
def test_two_char_local(self):
|
||||
result = mask_email("ab@x.io")
|
||||
assert result == "ab***@x***.io"
|
||||
|
||||
def test_domain_with_subdomain_tld(self):
|
||||
# rsplit('.', 1) splits on the last dot only
|
||||
result = mask_email("user@mail.example.org")
|
||||
assert result == "us***@m***.org"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert mask_email("") == "***"
|
||||
|
||||
def test_no_at_sign(self):
|
||||
assert mask_email("notanemail") == "***"
|
||||
|
||||
def test_preserves_tld(self):
|
||||
result = mask_email("hello@world.co.uk")
|
||||
assert result.endswith(".uk")
|
||||
|
||||
def test_single_char_sld(self):
|
||||
result = mask_email("user@x.com")
|
||||
assert result == "us***@x***.com"
|
||||
|
||||
|
||||
class TestMaskName:
|
||||
def test_single_word(self):
|
||||
assert mask_name("Alice") == "Al***"
|
||||
|
||||
def test_two_words(self):
|
||||
assert mask_name("John Doe") == "Jo*** Do***"
|
||||
|
||||
def test_single_char_word(self):
|
||||
result = mask_name("X")
|
||||
assert result == "X***"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert mask_name("") == "***"
|
||||
|
||||
def test_three_words(self):
|
||||
result = mask_name("Jean-Luc Picard")
|
||||
# Two words separated by space
|
||||
parts = result.split(" ")
|
||||
assert len(parts) == 2
|
||||
assert all(p.endswith("***") for p in parts)
|
||||
|
||||
def test_long_name(self):
|
||||
result = mask_name("Alexander")
|
||||
assert result == "Al***"
|
||||
|
||||
|
||||
class TestMaskFromHeader:
|
||||
def test_display_name_with_angle_email(self):
|
||||
result = mask_from_header("John Doe <john.doe@example.com>")
|
||||
assert "<" in result
|
||||
assert "jo***" in result
|
||||
assert "Jo***" in result
|
||||
|
||||
def test_plain_email(self):
|
||||
result = mask_from_header("john.doe@example.com")
|
||||
assert result == "jo***@e***.com"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert mask_from_header("") == "***"
|
||||
|
||||
def test_no_display_name_angle_email(self):
|
||||
result = mask_from_header("<user@example.com>")
|
||||
assert result == "us***@e***.com"
|
||||
|
||||
def test_fallback_no_email_pattern(self):
|
||||
# String with no recognisable email → falls back to mask_name
|
||||
result = mask_from_header("JustAName")
|
||||
assert result == "Ju***"
|
||||
|
||||
def test_quoted_display_name(self):
|
||||
result = mask_from_header('"Alice Smith" <alice@example.com>')
|
||||
assert "Al***" in result
|
||||
assert "al***" in result
|
||||
|
||||
def test_angle_email_no_display(self):
|
||||
# Edge: angle brackets but empty display part
|
||||
result = mask_from_header(" <admin@site.org>")
|
||||
# No display name → returns masked email only
|
||||
assert "@" in result
|
||||
assert "***" in result
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Unit tests for Gmail label utilities (utils/gmail_labels.py).
|
||||
Pure function tests – no database or HTTP layer required.
|
||||
"""
|
||||
|
||||
from app.utils.gmail_labels import (
|
||||
DEFAULT_IMPORT_LABEL_TEMPLATES,
|
||||
MAX_IMPORT_LABELS,
|
||||
_normalize_string_list,
|
||||
normalize_import_label_templates,
|
||||
extract_granted_scopes,
|
||||
extract_import_label_templates,
|
||||
build_gmail_credential_scopes,
|
||||
render_import_labels,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeStringList:
|
||||
def test_basic_dedup(self):
|
||||
result = _normalize_string_list(["a", "A", "b"])
|
||||
assert result == ["a", "b"]
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
result = _normalize_string_list([" hello ", "world"])
|
||||
assert result == ["hello", "world"]
|
||||
|
||||
def test_empty_strings_filtered(self):
|
||||
result = _normalize_string_list(["", " ", "real"])
|
||||
assert result == ["real"]
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
assert _normalize_string_list(None) == []
|
||||
|
||||
def test_preserves_case_in_output(self):
|
||||
# Case-insensitive dedup but preserves original casing
|
||||
result = _normalize_string_list(["Hello", "hello"])
|
||||
assert result == ["Hello"]
|
||||
|
||||
def test_order_preserved(self):
|
||||
items = ["c", "a", "b"]
|
||||
assert _normalize_string_list(items) == ["c", "a", "b"]
|
||||
|
||||
|
||||
class TestNormalizeImportLabelTemplates:
|
||||
def test_none_returns_defaults(self):
|
||||
result = normalize_import_label_templates(None)
|
||||
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_empty_list_returns_defaults(self):
|
||||
result = normalize_import_label_templates([])
|
||||
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_custom_templates(self):
|
||||
result = normalize_import_label_templates(["archive", "inbox"])
|
||||
assert result == ["archive", "inbox"]
|
||||
|
||||
def test_deduplicates(self):
|
||||
result = normalize_import_label_templates(["tag", "TAG"])
|
||||
assert result == ["tag"]
|
||||
|
||||
def test_returns_copy_of_defaults(self):
|
||||
result = normalize_import_label_templates(None)
|
||||
result.append("extra")
|
||||
assert "extra" not in DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
|
||||
class TestExtractGrantedScopes:
|
||||
def test_list_format(self):
|
||||
result = extract_granted_scopes(["scope1", "scope2"])
|
||||
assert result == ["scope1", "scope2"]
|
||||
|
||||
def test_list_filters_non_strings(self):
|
||||
result = extract_granted_scopes(["valid", 42, None, "other"])
|
||||
assert result == ["valid", "other"]
|
||||
|
||||
def test_dict_format(self):
|
||||
data = {"granted_scopes": ["https://mail.google.com/", "openid"]}
|
||||
result = extract_granted_scopes(data)
|
||||
assert result == ["https://mail.google.com/", "openid"]
|
||||
|
||||
def test_dict_missing_granted_scopes(self):
|
||||
result = extract_granted_scopes({})
|
||||
assert result == []
|
||||
|
||||
def test_dict_non_list_granted_scopes(self):
|
||||
result = extract_granted_scopes({"granted_scopes": "not-a-list"})
|
||||
assert result == []
|
||||
|
||||
def test_unrecognized_type(self):
|
||||
assert extract_granted_scopes(None) == []
|
||||
assert extract_granted_scopes(42) == []
|
||||
assert extract_granted_scopes("string") == []
|
||||
|
||||
|
||||
class TestExtractImportLabelTemplates:
|
||||
def test_dict_with_templates(self):
|
||||
data = {"import_label_templates": ["archive", "imported"]}
|
||||
result = extract_import_label_templates(data)
|
||||
assert result == ["archive", "imported"]
|
||||
|
||||
def test_dict_missing_key(self):
|
||||
result = extract_import_label_templates({})
|
||||
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_non_dict(self):
|
||||
assert extract_import_label_templates(None) == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
assert extract_import_label_templates([]) == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_empty_templates_falls_back_to_defaults(self):
|
||||
result = extract_import_label_templates({"import_label_templates": []})
|
||||
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_filters_non_strings(self):
|
||||
data = {"import_label_templates": ["valid", 123, None]}
|
||||
result = extract_import_label_templates(data)
|
||||
assert result == ["valid"]
|
||||
|
||||
|
||||
class TestBuildGmailCredentialScopes:
|
||||
def test_basic(self):
|
||||
result = build_gmail_credential_scopes(
|
||||
["https://mail.google.com/"], ["{{source_email}}", "imported"]
|
||||
)
|
||||
assert "granted_scopes" in result
|
||||
assert "import_label_templates" in result
|
||||
assert result["granted_scopes"] == ["https://mail.google.com/"]
|
||||
|
||||
def test_none_granted_scopes(self):
|
||||
result = build_gmail_credential_scopes(None)
|
||||
assert result["granted_scopes"] == []
|
||||
assert result["import_label_templates"] == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_deduplication(self):
|
||||
result = build_gmail_credential_scopes(["scope", "SCOPE"])
|
||||
assert result["granted_scopes"] == ["scope"]
|
||||
|
||||
|
||||
class TestRenderImportLabels:
|
||||
def test_source_email_substitution(self):
|
||||
result = render_import_labels(["{{source_email}}"], "user@example.com")
|
||||
assert result == ["user@example.com"]
|
||||
|
||||
def test_literal_template(self):
|
||||
result = render_import_labels(["imported"], "user@example.com")
|
||||
assert result == ["imported"]
|
||||
|
||||
def test_mixed_templates(self):
|
||||
result = render_import_labels(
|
||||
["{{source_email}}", "imported"], "user@example.com"
|
||||
)
|
||||
assert result == ["user@example.com", "imported"]
|
||||
|
||||
def test_deduplication(self):
|
||||
result = render_import_labels(["tag", "TAG"], "user@example.com")
|
||||
assert result == ["tag"]
|
||||
|
||||
def test_none_source_email(self):
|
||||
# Template containing source_email placeholder with no email → empty string,
|
||||
# gets stripped, filtered out.
|
||||
result = render_import_labels(["{{source_email}}"], None)
|
||||
assert result == []
|
||||
|
||||
def test_empty_source_email(self):
|
||||
result = render_import_labels(["{{source_email}}"], "")
|
||||
assert result == []
|
||||
|
||||
def test_none_templates_uses_defaults(self):
|
||||
result = render_import_labels(None, "user@example.com")
|
||||
assert "user@example.com" in result
|
||||
assert "imported" in result
|
||||
|
||||
def test_whitespace_only_template_filtered(self):
|
||||
# Whitespace-only entries are stripped to empty strings and then filtered
|
||||
# by normalize_import_label_templates. When no valid templates remain,
|
||||
# defaults are returned. The source_email template then renders to the
|
||||
# source email, and "imported" is included too.
|
||||
result = render_import_labels([" "], "user@example.com")
|
||||
# normalize_import_label_templates falls back to defaults → includes
|
||||
# {{source_email}} (renders to "user@example.com") and "imported"
|
||||
assert result == ["user@example.com", "imported"]
|
||||
|
||||
def test_max_labels_constant(self):
|
||||
assert MAX_IMPORT_LABELS == 10
|
||||
@@ -2,9 +2,22 @@
|
||||
Unit tests for Gmail service module.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
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 +166,325 @@ 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"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper for HttpError construction
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _make_http_error(
|
||||
status_code: int = 401, reason: str = "Unauthorized"
|
||||
) -> HttpError:
|
||||
resp = MagicMock()
|
||||
resp.status = status_code
|
||||
resp.reason = reason
|
||||
content = json.dumps({"error": {"message": reason}}).encode()
|
||||
return HttpError(resp, content, uri="https://gmail.googleapis.com/test")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# inject_email – HttpError branch
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_email_http_error(self):
|
||||
"""HttpError in inject_email is caught and re-raised as GmailInjectionError."""
|
||||
service = GmailService(access_token="test-access-token")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.users().messages().insert().execute.side_effect = (
|
||||
self._make_http_error(403, "Forbidden")
|
||||
)
|
||||
service._service = mock_api
|
||||
|
||||
with pytest.raises(GmailInjectionError, match="Gmail API error"):
|
||||
await service.inject_email(
|
||||
raw_email=b"From: a@b.com\r\nSubject: X\r\n\r\nBody",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_or_create_label – existing label found
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_label_existing(self):
|
||||
"""Returns ID of an existing label matched case-insensitively."""
|
||||
service = GmailService(access_token="test-access-token")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.users().labels().list().execute.return_value = {
|
||||
"labels": [
|
||||
{"id": "Label_1", "name": "imported"},
|
||||
{"id": "INBOX", "name": "INBOX"},
|
||||
]
|
||||
}
|
||||
service._service = mock_api
|
||||
|
||||
label_id = await service.get_or_create_label("Imported")
|
||||
assert label_id == "Label_1"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_or_create_label – label not found, creates new one
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_label_creates_new(self):
|
||||
"""Creates a new label when no existing label matches."""
|
||||
service = GmailService(access_token="test-access-token")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.users().labels().list().execute.return_value = {
|
||||
"labels": [{"id": "INBOX", "name": "INBOX"}]
|
||||
}
|
||||
mock_api.users().labels().create().execute.return_value = {
|
||||
"id": "Label_new",
|
||||
"name": "new-label",
|
||||
}
|
||||
service._service = mock_api
|
||||
|
||||
label_id = await service.get_or_create_label("new-label")
|
||||
assert label_id == "Label_new"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_or_create_label – HttpError handling
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_label_http_error(self):
|
||||
"""HttpError during label list raises GmailInjectionError."""
|
||||
service = GmailService(access_token="test-access-token")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.users().labels().list().execute.side_effect = self._make_http_error(
|
||||
500, "Internal Server Error"
|
||||
)
|
||||
service._service = mock_api
|
||||
|
||||
with pytest.raises(GmailInjectionError, match="Gmail API error"):
|
||||
await service.get_or_create_label("test")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_or_create_label – generic Exception handling
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_label_generic_exception(self):
|
||||
"""Generic exception during label management raises GmailInjectionError."""
|
||||
service = GmailService(access_token="test-access-token")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.users().labels().list().execute.side_effect = RuntimeError("boom")
|
||||
service._service = mock_api
|
||||
|
||||
with pytest.raises(GmailInjectionError, match="Failed to get/create"):
|
||||
await service.get_or_create_label("oops")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# inject_debug_email – full flow
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_debug_email(self):
|
||||
"""inject_debug_email creates labels and injects a test email."""
|
||||
service = GmailService(access_token="test-access-token")
|
||||
|
||||
service.build_import_label_ids = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=["INBOX", "Label_imported"]
|
||||
)
|
||||
service.get_or_create_label = AsyncMock( # type: ignore[method-assign]
|
||||
return_value="Label_test"
|
||||
)
|
||||
service.inject_email = AsyncMock( # type: ignore[method-assign]
|
||||
return_value={
|
||||
"message_id": "msg1",
|
||||
"thread_id": "t1",
|
||||
"label_ids": ["INBOX", "Label_imported", "Label_test"],
|
||||
}
|
||||
)
|
||||
|
||||
result = await service.inject_debug_email("user@gmail.com")
|
||||
|
||||
assert result["message_id"] == "msg1"
|
||||
assert "Label_test" in result["label_ids"]
|
||||
service.build_import_label_ids.assert_awaited_once()
|
||||
service.get_or_create_label.assert_awaited_once_with("test")
|
||||
service.inject_email.assert_awaited_once()
|
||||
# The test label should have been appended to label_ids
|
||||
call_kwargs = service.inject_email.call_args
|
||||
assert "Label_test" in call_kwargs.kwargs["label_ids"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# inject_debug_email – test label already present in import labels
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_debug_email_test_label_already_present(self):
|
||||
"""inject_debug_email does not duplicate the test label."""
|
||||
service = GmailService(access_token="test-access-token")
|
||||
|
||||
service.build_import_label_ids = AsyncMock( # type: ignore[method-assign]
|
||||
return_value=["INBOX", "Label_test"]
|
||||
)
|
||||
service.get_or_create_label = AsyncMock( # type: ignore[method-assign]
|
||||
return_value="Label_test"
|
||||
)
|
||||
service.inject_email = AsyncMock( # type: ignore[method-assign]
|
||||
return_value={
|
||||
"message_id": "msg2",
|
||||
"thread_id": "t2",
|
||||
"label_ids": ["INBOX", "Label_test"],
|
||||
}
|
||||
)
|
||||
|
||||
result = await service.inject_debug_email("user@gmail.com")
|
||||
|
||||
assert result["message_id"] == "msg2"
|
||||
call_kwargs = service.inject_email.call_args
|
||||
# Label_test should appear only once
|
||||
assert call_kwargs.kwargs["label_ids"].count("Label_test") == 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_refreshed_token – token was NOT refreshed
|
||||
# ------------------------------------------------------------------
|
||||
def test_get_refreshed_token_no_change(self):
|
||||
"""Returns None when the token has not changed."""
|
||||
service = GmailService(access_token="original-token")
|
||||
assert service.get_refreshed_token() is None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_refreshed_token – token was refreshed
|
||||
# ------------------------------------------------------------------
|
||||
def test_get_refreshed_token_changed(self):
|
||||
"""Returns new token info when the token was refreshed."""
|
||||
service = GmailService(access_token="original-token")
|
||||
|
||||
# Simulate an automatic token refresh by mutating credentials
|
||||
new_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
service.credentials.token = "new-refreshed-token"
|
||||
service.credentials.expiry = new_expiry
|
||||
|
||||
result = service.get_refreshed_token()
|
||||
assert result is not None
|
||||
assert result["access_token"] == "new-refreshed-token"
|
||||
assert result["expiry"] == new_expiry
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# service property – lazy init builds the service
|
||||
# ------------------------------------------------------------------
|
||||
def test_service_property_builds_service(self):
|
||||
"""Accessing .service triggers googleapiclient.discovery.build."""
|
||||
with patch("app.services.gmail_service.build") as mock_build:
|
||||
mock_build.return_value = MagicMock()
|
||||
service = GmailService(access_token="test-access-token")
|
||||
assert service._service is None
|
||||
|
||||
api = service.service # trigger lazy init
|
||||
|
||||
mock_build.assert_called_once_with(
|
||||
"gmail", "v1", credentials=service.credentials, cache_discovery=False
|
||||
)
|
||||
assert api is mock_build.return_value
|
||||
# Second access should NOT call build again
|
||||
_ = service.service
|
||||
mock_build.assert_called_once()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _tz_aware_expiry – timezone normalisation helper
|
||||
# ------------------------------------------------------------------
|
||||
def test_tz_aware_expiry_naive_becomes_utc(self):
|
||||
"""A naive datetime is made tz-aware (UTC)."""
|
||||
naive = datetime(2026, 6, 1, 12, 0, 0) # no tzinfo
|
||||
result = GmailService._tz_aware_expiry(naive)
|
||||
assert result is not None
|
||||
assert result.tzinfo is not None
|
||||
assert result.utcoffset().total_seconds() == 0
|
||||
assert result.replace(tzinfo=None) == naive
|
||||
|
||||
def test_tz_aware_expiry_aware_unchanged(self):
|
||||
"""An already tz-aware datetime is returned as-is."""
|
||||
aware = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = GmailService._tz_aware_expiry(aware)
|
||||
assert result is aware
|
||||
|
||||
def test_tz_aware_expiry_none_unchanged(self):
|
||||
"""None is returned unchanged."""
|
||||
assert GmailService._tz_aware_expiry(None) is None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_refreshed_token – naive expiry is normalised to UTC
|
||||
# ------------------------------------------------------------------
|
||||
def test_get_refreshed_token_naive_expiry_becomes_utc(self):
|
||||
"""get_refreshed_token converts a naive expiry from google-auth to UTC-aware."""
|
||||
service = GmailService(access_token="original-token")
|
||||
|
||||
# google-auth sets credentials.expiry as naive UTC
|
||||
naive_expiry = datetime(2099, 1, 1, 0, 0, 0) # no tzinfo
|
||||
service.credentials.token = "new-refreshed-token"
|
||||
service.credentials.expiry = naive_expiry
|
||||
|
||||
result = service.get_refreshed_token()
|
||||
assert result is not None
|
||||
expiry = result["expiry"]
|
||||
assert expiry is not None
|
||||
assert expiry.tzinfo is not None, "expiry must be tz-aware for DB storage"
|
||||
assert expiry.utcoffset().total_seconds() == 0
|
||||
assert expiry.replace(tzinfo=None) == naive_expiry
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# proactive_refresh – naive expiry is normalised to UTC
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_proactive_refresh_naive_expiry_becomes_utc(self):
|
||||
"""proactive_refresh converts a naive expiry from google-auth to UTC-aware."""
|
||||
service = GmailService(
|
||||
access_token="old-token",
|
||||
refresh_token="refresh-token",
|
||||
)
|
||||
|
||||
naive_expiry = datetime(2099, 6, 1, 0, 0, 0) # no tzinfo
|
||||
|
||||
def _fake_refresh(_request):
|
||||
service.credentials.token = "new-token"
|
||||
service.credentials.expiry = naive_expiry
|
||||
|
||||
with patch.object(service.credentials, "refresh", side_effect=_fake_refresh):
|
||||
result = await service.proactive_refresh()
|
||||
|
||||
assert result["access_token"] == "new-token"
|
||||
expiry = result["expiry"]
|
||||
assert expiry is not None
|
||||
assert expiry.tzinfo is not None, "expiry must be tz-aware for DB storage"
|
||||
assert expiry.utcoffset().total_seconds() == 0
|
||||
assert expiry.replace(tzinfo=None) == naive_expiry
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Unit tests for processing logs / runs endpoints (api/v1/endpoints/logs.py).
|
||||
|
||||
All database interactions and auth dependencies are mocked.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.models.database_models import (
|
||||
User,
|
||||
ProcessingRun,
|
||||
ProcessingLog,
|
||||
SubscriptionTier,
|
||||
)
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
u = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(u, k, v)
|
||||
return u
|
||||
|
||||
|
||||
def _make_run(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
mail_account_id=10,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
duration_seconds=1.5,
|
||||
emails_fetched=3,
|
||||
emails_forwarded=3,
|
||||
emails_failed=0,
|
||||
status="completed",
|
||||
error_message=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
run = MagicMock(spec=ProcessingRun)
|
||||
for k, v in defaults.items():
|
||||
setattr(run, k, v)
|
||||
return run
|
||||
|
||||
|
||||
def _make_log(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
level="INFO",
|
||||
message="processed",
|
||||
email_subject="Hello",
|
||||
email_from="sender@example.com",
|
||||
success=True,
|
||||
mail_account_id=10,
|
||||
processing_run_id=1,
|
||||
email_size_bytes=1024,
|
||||
error_details=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
log = MagicMock(spec=ProcessingLog)
|
||||
for k, v in defaults.items():
|
||||
setattr(log, k, v)
|
||||
return log
|
||||
|
||||
|
||||
def _scalar_one(value):
|
||||
r = MagicMock()
|
||||
r.scalar_one.return_value = value
|
||||
return r
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
r = MagicMock()
|
||||
r.scalar_one_or_none.return_value = value
|
||||
return r
|
||||
|
||||
|
||||
def _rows_all(rows):
|
||||
r = MagicMock()
|
||||
r.all.return_value = rows
|
||||
return r
|
||||
|
||||
|
||||
def _scalars_all(values):
|
||||
r = MagicMock()
|
||||
scalars = MagicMock()
|
||||
scalars.all.return_value = values
|
||||
r.scalars.return_value = scalars
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user():
|
||||
return _make_user()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(app, current_user, mock_db):
|
||||
async def _override_user():
|
||||
return current_user
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_current_active_user] = _override_user
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── GET /logs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListProcessingRuns:
|
||||
async def test_returns_paginated_list(self, auth_client, mock_db):
|
||||
run = _make_run()
|
||||
# The endpoint queries total count then rows
|
||||
# First execute → count, second execute → rows with joined columns
|
||||
row = MagicMock()
|
||||
row.ProcessingRun = run
|
||||
row.name = "My Account"
|
||||
row.email_address = "me@example.com"
|
||||
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one(1), # count query
|
||||
_rows_all([row]), # data query
|
||||
]
|
||||
)
|
||||
response = await auth_client.get("/api/v1/processing-runs")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
async def test_empty_result(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one(0),
|
||||
_rows_all([]),
|
||||
]
|
||||
)
|
||||
response = await auth_client.get("/api/v1/processing-runs")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
async def test_unauthenticated_401(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/processing-runs")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_pagination_params(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one(0),
|
||||
_rows_all([]),
|
||||
]
|
||||
)
|
||||
response = await auth_client.get("/api/v1/processing-runs?page=2&page_size=5")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["page"] == 2
|
||||
assert data["page_size"] == 5
|
||||
|
||||
async def test_filter_by_account_id(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one(0),
|
||||
_rows_all([]),
|
||||
]
|
||||
)
|
||||
response = await auth_client.get("/api/v1/processing-runs?account_id=5")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_filter_by_status(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one(0),
|
||||
_rows_all([]),
|
||||
]
|
||||
)
|
||||
response = await auth_client.get("/api/v1/processing-runs?status=completed")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_filter_has_emails(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one(0),
|
||||
_rows_all([]),
|
||||
]
|
||||
)
|
||||
response = await auth_client.get("/api/v1/processing-runs?has_emails=true")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── GET /logs/{run_id} ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetProcessingRun:
|
||||
async def test_returns_run(self, auth_client, mock_db):
|
||||
run = _make_run(id=42)
|
||||
row = MagicMock()
|
||||
row.ProcessingRun = run
|
||||
row.name = "Account"
|
||||
row.email_address = "me@example.com"
|
||||
|
||||
result = MagicMock()
|
||||
result.one_or_none.return_value = row
|
||||
mock_db.execute = AsyncMock(return_value=result)
|
||||
|
||||
response = await auth_client.get("/api/v1/processing-runs/42")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == 42
|
||||
|
||||
async def test_404_when_not_found(self, auth_client, mock_db):
|
||||
result = MagicMock()
|
||||
result.one_or_none.return_value = None
|
||||
mock_db.execute = AsyncMock(return_value=result)
|
||||
|
||||
response = await auth_client.get("/api/v1/processing-runs/999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ── GET /logs/{run_id}/logs ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetRunLogs:
|
||||
async def test_returns_log_entries(self, auth_client, mock_db):
|
||||
run = _make_run(id=1)
|
||||
log_entry = _make_log(processing_run_id=1)
|
||||
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one_or_none(run), # ownership check
|
||||
_scalar_one(1), # count
|
||||
_scalars_all([log_entry]), # log entries
|
||||
]
|
||||
)
|
||||
response = await auth_client.get("/api/v1/processing-runs/1/logs")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
async def test_404_when_run_not_found(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
response = await auth_client.get("/api/v1/processing-runs/999/logs")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_empty_log_entries(self, auth_client, mock_db):
|
||||
run = _make_run(id=1)
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one_or_none(run),
|
||||
_scalar_one(0),
|
||||
_scalars_all([]),
|
||||
]
|
||||
)
|
||||
response = await auth_client.get("/api/v1/processing-runs/1/logs")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
# ── helper: _paginate ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPaginateHelper:
|
||||
def test_single_page(self):
|
||||
from app.api.v1.endpoints.logs import _paginate
|
||||
|
||||
result = _paginate(total=10, page=1, page_size=20)
|
||||
assert result["total"] == 10
|
||||
assert result["pages"] == 1
|
||||
|
||||
def test_multiple_pages(self):
|
||||
from app.api.v1.endpoints.logs import _paginate
|
||||
|
||||
result = _paginate(total=25, page=2, page_size=10)
|
||||
assert result["pages"] == 3
|
||||
|
||||
def test_zero_total(self):
|
||||
from app.api.v1.endpoints.logs import _paginate
|
||||
|
||||
result = _paginate(total=0, page=1, page_size=20)
|
||||
assert result["pages"] == 1
|
||||
assert result["total"] == 0
|
||||
@@ -0,0 +1,775 @@
|
||||
"""
|
||||
Unit tests for mail account endpoints (backend/app/api/v1/endpoints/mail_accounts.py).
|
||||
|
||||
All tests mock the database session and auth dependencies so no real
|
||||
PostgreSQL instance is required.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.models.database_models import (
|
||||
User,
|
||||
MailAccount,
|
||||
ProcessingRun,
|
||||
ProcessingLog,
|
||||
SubscriptionPlan,
|
||||
SubscriptionTier,
|
||||
AccountStatus,
|
||||
)
|
||||
|
||||
BASE = "/api/v1/mail-accounts"
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a User ORM instance."""
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
hashed_password="hashed",
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
user = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(user, k, v)
|
||||
return user
|
||||
|
||||
|
||||
def _make_superuser(**overrides) -> MagicMock:
|
||||
return _make_user(is_superuser=True, **overrides)
|
||||
|
||||
|
||||
def _make_account(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a MailAccount ORM instance."""
|
||||
defaults = dict(
|
||||
id=10,
|
||||
user_id=1,
|
||||
name="Test Account",
|
||||
email_address="test@example.com",
|
||||
protocol="pop3_ssl",
|
||||
host="pop.example.com",
|
||||
port=995,
|
||||
use_ssl=True,
|
||||
use_tls=False,
|
||||
username="test@example.com",
|
||||
encrypted_password="encrypted_pass",
|
||||
forward_to="me@gmail.com",
|
||||
delivery_method="gmail_api",
|
||||
status="active",
|
||||
is_enabled=True,
|
||||
check_interval_minutes=5,
|
||||
max_emails_per_check=50,
|
||||
delete_after_forward=True,
|
||||
debug_logging=False,
|
||||
provider_name="Gmail",
|
||||
auto_detected=False,
|
||||
total_emails_processed=100,
|
||||
total_emails_failed=2,
|
||||
last_check_at=datetime.now(timezone.utc),
|
||||
last_successful_check_at=datetime.now(timezone.utc),
|
||||
last_error_at=None,
|
||||
last_error_message=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
account = MagicMock(spec=MailAccount)
|
||||
for k, v in defaults.items():
|
||||
setattr(account, k, v)
|
||||
return account
|
||||
|
||||
|
||||
def _make_run(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a ProcessingRun ORM instance."""
|
||||
defaults = dict(
|
||||
id=100,
|
||||
mail_account_id=10,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
completed_at=datetime.now(timezone.utc),
|
||||
duration_seconds=1.5,
|
||||
emails_fetched=5,
|
||||
emails_forwarded=4,
|
||||
emails_failed=1,
|
||||
status="completed",
|
||||
error_message=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
run = MagicMock(spec=ProcessingRun)
|
||||
for k, v in defaults.items():
|
||||
setattr(run, k, v)
|
||||
return run
|
||||
|
||||
|
||||
def _make_log(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a ProcessingLog ORM instance."""
|
||||
defaults = dict(
|
||||
id=200,
|
||||
user_id=1,
|
||||
mail_account_id=10,
|
||||
processing_run_id=100,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
level="INFO",
|
||||
message="Processed email",
|
||||
email_subject="Hello",
|
||||
email_from="sender@example.com",
|
||||
email_size_bytes=1024,
|
||||
success=True,
|
||||
error_details=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
log = MagicMock(spec=ProcessingLog)
|
||||
for k, v in defaults.items():
|
||||
setattr(log, k, v)
|
||||
return log
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
"""Create a mock result whose .scalar_one_or_none() returns *value*."""
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
def _scalar_one(value):
|
||||
result = MagicMock()
|
||||
result.scalar_one.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
def _scalars_all(values):
|
||||
result = MagicMock()
|
||||
scalars = MagicMock()
|
||||
scalars.all.return_value = values
|
||||
result.scalars.return_value = scalars
|
||||
return result
|
||||
|
||||
|
||||
# ── fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regular_user():
|
||||
return _make_user()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def superuser():
|
||||
return _make_superuser()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.delete = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app, regular_user, mock_db):
|
||||
"""AsyncClient where the caller is a regular user and db is mocked."""
|
||||
|
||||
async def _override_user():
|
||||
return regular_user
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_current_active_user] = _override_user
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def superuser_client(app, superuser, mock_db):
|
||||
"""AsyncClient where the caller is a superuser and db is mocked."""
|
||||
|
||||
async def _override_user():
|
||||
return superuser
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_current_active_user] = _override_user
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── account payload helper ───────────────────────────────────────────────
|
||||
|
||||
VALID_ACCOUNT_PAYLOAD = dict(
|
||||
name="My Account",
|
||||
email_address="inbox@example.com",
|
||||
protocol="pop3_ssl",
|
||||
host="pop.example.com",
|
||||
port=995,
|
||||
use_ssl=True,
|
||||
use_tls=False,
|
||||
username="inbox@example.com",
|
||||
password="secret",
|
||||
forward_to="me@gmail.com",
|
||||
delivery_method="gmail_api",
|
||||
is_enabled=True,
|
||||
check_interval_minutes=5,
|
||||
max_emails_per_check=50,
|
||||
delete_after_forward=True,
|
||||
provider_name="Gmail",
|
||||
)
|
||||
|
||||
|
||||
# ── tests: create mail account ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateMailAccount:
|
||||
"""POST /api/v1/mail-accounts"""
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
||||
async def test_create_superuser_bypasses_limit(
|
||||
self, mock_encrypt, superuser_client, mock_db
|
||||
):
|
||||
"""Superusers skip the subscription-limit check entirely."""
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
created = {}
|
||||
|
||||
def capture_add(obj):
|
||||
created["obj"] = obj
|
||||
# Give the added object all the response fields
|
||||
for k, v in {
|
||||
"id": 10,
|
||||
"user_id": 1,
|
||||
"status": "active",
|
||||
"auto_detected": False,
|
||||
"total_emails_processed": 0,
|
||||
"total_emails_failed": 0,
|
||||
"last_check_at": None,
|
||||
"last_successful_check_at": None,
|
||||
"last_error_at": None,
|
||||
"last_error_message": None,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}.items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
mock_db.add = MagicMock(side_effect=capture_add)
|
||||
|
||||
resp = await superuser_client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "My Account"
|
||||
assert data["email_address"] == "inbox@example.com"
|
||||
mock_encrypt.assert_called_once_with("secret")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
||||
async def test_create_regular_user_within_limit(
|
||||
self, mock_encrypt, client, mock_db
|
||||
):
|
||||
"""Regular user under their plan limit can create an account."""
|
||||
# 1st execute: count existing accounts (returns 0 accounts)
|
||||
# 2nd execute: fetch subscription plan
|
||||
plan = MagicMock(spec=SubscriptionPlan)
|
||||
plan.max_mail_accounts = 5
|
||||
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalars_all([]), # existing accounts
|
||||
_scalar_one_or_none(plan), # subscription plan
|
||||
]
|
||||
)
|
||||
|
||||
def capture_add(obj):
|
||||
for k, v in {
|
||||
"id": 10,
|
||||
"user_id": 1,
|
||||
"status": "active",
|
||||
"auto_detected": False,
|
||||
"total_emails_processed": 0,
|
||||
"total_emails_failed": 0,
|
||||
"last_check_at": None,
|
||||
"last_successful_check_at": None,
|
||||
"last_error_at": None,
|
||||
"last_error_message": None,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}.items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
mock_db.add = MagicMock(side_effect=capture_add)
|
||||
|
||||
resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
mock_encrypt.assert_called_once_with("secret")
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
||||
async def test_create_subscription_limit_reached(
|
||||
self, mock_encrypt, client, mock_db
|
||||
):
|
||||
"""402 when account limit is reached."""
|
||||
existing = [_make_account(id=i) for i in range(1)]
|
||||
plan = MagicMock(spec=SubscriptionPlan)
|
||||
plan.max_mail_accounts = 1
|
||||
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalars_all(existing), # existing accounts (1 already)
|
||||
_scalar_one_or_none(plan), # plan says max=1
|
||||
]
|
||||
)
|
||||
|
||||
resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
||||
assert resp.status_code == 402
|
||||
assert "limit" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
||||
async def test_create_uses_db_plan_limit(self, mock_encrypt, client, mock_db):
|
||||
"""When a SubscriptionPlan exists in the DB, use its max_mail_accounts."""
|
||||
plan = MagicMock(spec=SubscriptionPlan)
|
||||
plan.max_mail_accounts = 3
|
||||
|
||||
existing = [_make_account(id=i) for i in range(3)]
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalars_all(existing), # 3 existing
|
||||
_scalar_one_or_none(plan), # plan limit = 3
|
||||
]
|
||||
)
|
||||
|
||||
resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
||||
assert resp.status_code == 402
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc")
|
||||
async def test_create_fallback_tier_limit_when_no_plan(
|
||||
self, mock_encrypt, client, mock_db
|
||||
):
|
||||
"""When no SubscriptionPlan row exists, falls back to settings tier limits."""
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalars_all([]), # 0 existing accounts
|
||||
_scalar_one_or_none(None), # no plan in DB → fallback
|
||||
]
|
||||
)
|
||||
|
||||
def capture_add(obj):
|
||||
for k, v in {
|
||||
"id": 10,
|
||||
"user_id": 1,
|
||||
"status": "active",
|
||||
"auto_detected": False,
|
||||
"total_emails_processed": 0,
|
||||
"total_emails_failed": 0,
|
||||
"last_check_at": None,
|
||||
"last_successful_check_at": None,
|
||||
"last_error_at": None,
|
||||
"last_error_message": None,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}.items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
mock_db.add = MagicMock(side_effect=capture_add)
|
||||
|
||||
resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD)
|
||||
# free tier default is 1, and 0 existing → should succeed
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
# ── tests: list mail accounts ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListMailAccounts:
|
||||
"""GET /api/v1/mail-accounts"""
|
||||
|
||||
async def test_list_accounts(self, client, mock_db):
|
||||
accounts = [_make_account(id=1), _make_account(id=2)]
|
||||
mock_db.execute = AsyncMock(return_value=_scalars_all(accounts))
|
||||
|
||||
resp = await client.get(BASE)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
# ── tests: get mail account ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetMailAccount:
|
||||
"""GET /api/v1/mail-accounts/{account_id}"""
|
||||
|
||||
async def test_get_account_success(self, client, mock_db):
|
||||
account = _make_account()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.get(f"{BASE}/10")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == 10
|
||||
|
||||
async def test_get_account_not_found(self, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.get(f"{BASE}/999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── tests: update mail account ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpdateMailAccount:
|
||||
"""PUT /api/v1/mail-accounts/{account_id}"""
|
||||
|
||||
async def test_update_account_success(self, client, mock_db):
|
||||
account = _make_account()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.put(f"{BASE}/10", json={"name": "Updated Name"})
|
||||
assert resp.status_code == 200
|
||||
mock_db.commit.assert_called()
|
||||
|
||||
@patch(
|
||||
"app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="new_enc"
|
||||
)
|
||||
async def test_update_account_with_password(self, mock_encrypt, client, mock_db):
|
||||
account = _make_account()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.put(f"{BASE}/10", json={"password": "newpass"})
|
||||
assert resp.status_code == 200
|
||||
mock_encrypt.assert_called_once_with("newpass")
|
||||
# Verify encrypted_password was set on the account
|
||||
assert account.encrypted_password == "new_enc"
|
||||
|
||||
async def test_update_account_not_found(self, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.put(f"{BASE}/999", json={"name": "X"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── tests: delete mail account ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteMailAccount:
|
||||
"""DELETE /api/v1/mail-accounts/{account_id}"""
|
||||
|
||||
async def test_delete_account_success(self, client, mock_db):
|
||||
account = _make_account()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.delete(f"{BASE}/10")
|
||||
assert resp.status_code == 204
|
||||
mock_db.delete.assert_called_once_with(account)
|
||||
mock_db.commit.assert_called()
|
||||
|
||||
async def test_delete_account_not_found(self, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.delete(f"{BASE}/999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── tests: toggle mail account ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToggleMailAccount:
|
||||
"""PATCH /api/v1/mail-accounts/{account_id}/toggle"""
|
||||
|
||||
async def test_toggle_enable_resets_error(self, client, mock_db):
|
||||
"""Toggling an ERROR account to enabled resets status to ACTIVE."""
|
||||
account = _make_account(is_enabled=False, status=AccountStatus.ERROR)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.patch(f"{BASE}/10/toggle")
|
||||
assert resp.status_code == 200
|
||||
# After toggle: is_enabled=True and status reset from ERROR → ACTIVE
|
||||
assert account.is_enabled is True
|
||||
assert account.status == AccountStatus.ACTIVE
|
||||
mock_db.commit.assert_called()
|
||||
|
||||
async def test_toggle_disable(self, client, mock_db):
|
||||
"""Toggling an enabled account disables it."""
|
||||
account = _make_account(is_enabled=True, status=AccountStatus.ACTIVE)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.patch(f"{BASE}/10/toggle")
|
||||
assert resp.status_code == 200
|
||||
assert account.is_enabled is False
|
||||
|
||||
async def test_toggle_not_found(self, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.patch(f"{BASE}/999/toggle")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── tests: pull now ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPullNow:
|
||||
"""POST /api/v1/mail-accounts/{account_id}/pull-now"""
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task")
|
||||
async def test_pull_now_success(self, mock_task, client, mock_db):
|
||||
account = _make_account(is_enabled=True)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.post(f"{BASE}/10/pull-now")
|
||||
assert resp.status_code == 202
|
||||
assert "queued" in resp.json()["message"].lower()
|
||||
mock_task.delay.assert_called_once_with(10)
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task")
|
||||
async def test_pull_now_disabled_account(self, mock_task, client, mock_db):
|
||||
account = _make_account(is_enabled=False)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.post(f"{BASE}/10/pull-now")
|
||||
assert resp.status_code == 409
|
||||
assert "disabled" in resp.json()["detail"].lower()
|
||||
mock_task.delay.assert_not_called()
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task")
|
||||
async def test_pull_now_not_found(self, mock_task, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.post(f"{BASE}/999/pull-now")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── tests: test connection (new) ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTestConnection:
|
||||
"""POST /api/v1/mail-accounts/test"""
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.MailProcessor")
|
||||
async def test_connection_success(self, mock_processor_cls, client):
|
||||
instance = mock_processor_cls.return_value
|
||||
instance.test_connection = AsyncMock(
|
||||
return_value=(True, "Connection successful")
|
||||
)
|
||||
|
||||
payload = dict(
|
||||
host="pop.example.com",
|
||||
port=995,
|
||||
protocol="pop3_ssl",
|
||||
username="user@example.com",
|
||||
password="pass",
|
||||
use_ssl=True,
|
||||
use_tls=False,
|
||||
)
|
||||
resp = await client.post(f"{BASE}/test", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["message"] == "Connection successful"
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.MailProcessor")
|
||||
async def test_connection_failure(self, mock_processor_cls, client):
|
||||
instance = mock_processor_cls.return_value
|
||||
instance.test_connection = AsyncMock(return_value=(False, "Connection refused"))
|
||||
|
||||
payload = dict(
|
||||
host="pop.example.com",
|
||||
port=995,
|
||||
protocol="pop3_ssl",
|
||||
username="user@example.com",
|
||||
password="pass",
|
||||
use_ssl=True,
|
||||
use_tls=False,
|
||||
)
|
||||
resp = await client.post(f"{BASE}/test", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
assert data["message"] == "Connection refused"
|
||||
|
||||
|
||||
# ── tests: test existing connection ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestTestExistingConnection:
|
||||
"""POST /api/v1/mail-accounts/{account_id}/test"""
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.MailProcessor")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.mail_accounts.decrypt_credential",
|
||||
return_value="decrypted_pass",
|
||||
)
|
||||
async def test_existing_connection_success(
|
||||
self, mock_decrypt, mock_processor_cls, client, mock_db
|
||||
):
|
||||
account = _make_account()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
instance = mock_processor_cls.return_value
|
||||
instance.test_connection = AsyncMock(return_value=(True, "Connected"))
|
||||
|
||||
resp = await client.post(f"{BASE}/10/test")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
mock_decrypt.assert_called_once_with("encrypted_pass")
|
||||
mock_processor_cls.assert_called_once_with(account, "decrypted_pass")
|
||||
|
||||
async def test_existing_connection_not_found(self, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.post(f"{BASE}/999/test")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.MailProcessor")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.mail_accounts.decrypt_credential",
|
||||
side_effect=Exception("Decryption failed"),
|
||||
)
|
||||
async def test_existing_connection_decrypt_failure(
|
||||
self, mock_decrypt, mock_processor_cls, client, mock_db
|
||||
):
|
||||
account = _make_account()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account))
|
||||
|
||||
resp = await client.post(f"{BASE}/10/test")
|
||||
assert resp.status_code == 500
|
||||
assert "decrypt" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ── tests: auto-detect ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAutoDetect:
|
||||
"""POST /api/v1/mail-accounts/auto-detect"""
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.MailServerAutoDetect")
|
||||
async def test_auto_detect_success(self, mock_auto_cls, client):
|
||||
mock_auto_cls.detect.return_value = [
|
||||
{"host": "pop.gmail.com", "port": 995, "protocol": "pop3_ssl"}
|
||||
]
|
||||
|
||||
resp = await client.post(
|
||||
f"{BASE}/auto-detect",
|
||||
json={"email_address": "user@gmail.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["suggestions"]) == 1
|
||||
mock_auto_cls.detect.assert_called_once_with("user@gmail.com")
|
||||
|
||||
@patch("app.api.v1.endpoints.mail_accounts.MailServerAutoDetect")
|
||||
async def test_auto_detect_no_suggestions(self, mock_auto_cls, client):
|
||||
mock_auto_cls.detect.return_value = []
|
||||
|
||||
resp = await client.post(
|
||||
f"{BASE}/auto-detect",
|
||||
json={"email_address": "user@unknown-domain.xyz"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
assert data["suggestions"] == []
|
||||
|
||||
|
||||
# ── tests: processing runs ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListProcessingRuns:
|
||||
"""GET /api/v1/mail-accounts/{account_id}/processing-runs"""
|
||||
|
||||
async def test_list_runs_success(self, client, mock_db):
|
||||
account = _make_account()
|
||||
run = _make_run()
|
||||
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one_or_none(account), # ownership check
|
||||
_scalar_one(1), # count
|
||||
_scalars_all([run]), # run data
|
||||
]
|
||||
)
|
||||
|
||||
resp = await client.get(f"{BASE}/10/processing-runs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["page"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["id"] == 100
|
||||
assert data["items"][0]["account_name"] == "Test Account"
|
||||
assert data["items"][0]["account_email"] == "test@example.com"
|
||||
|
||||
async def test_list_runs_account_not_found(self, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.get(f"{BASE}/999/processing-runs")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── tests: processing logs ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListProcessingLogs:
|
||||
"""GET /api/v1/mail-accounts/{account_id}/logs"""
|
||||
|
||||
async def test_list_logs_success(self, client, mock_db):
|
||||
account = _make_account()
|
||||
log = _make_log()
|
||||
|
||||
mock_db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
_scalar_one_or_none(account), # ownership check
|
||||
_scalar_one(1), # count
|
||||
_scalars_all([log]), # log data
|
||||
]
|
||||
)
|
||||
|
||||
resp = await client.get(f"{BASE}/10/logs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["id"] == 200
|
||||
assert data["items"][0]["level"] == "INFO"
|
||||
assert data["items"][0]["message"] == "Processed email"
|
||||
|
||||
async def test_list_logs_account_not_found(self, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.get(f"{BASE}/999/logs")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,886 @@
|
||||
"""
|
||||
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", "(BODY.PEEK[])")
|
||||
|
||||
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_fetch_does_not_delete_after_forward(self):
|
||||
"""_fetch_imap_emails must NOT issue \\Deleted STORE even when delete_after_forward=True.
|
||||
|
||||
Deletion is deferred to post_process_imap() so that only successfully
|
||||
forwarded messages are removed from the source mailbox.
|
||||
"""
|
||||
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"]
|
||||
|
||||
# No STORE for \\Deleted should be issued during fetch
|
||||
delete_store_calls = [
|
||||
c
|
||||
for c in mock_imap.uid.call_args_list
|
||||
if c.args[0] == "store" and "(\\Deleted)" in c.args
|
||||
]
|
||||
assert delete_store_calls == [], "fetch must not delete messages"
|
||||
# expunge must NOT be called during fetch either
|
||||
mock_imap.expunge.assert_not_awaited()
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# post_process_imap tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPostProcessImap:
|
||||
"""Tests for the post_process_imap() method."""
|
||||
|
||||
def _make_processor(self, delete_after_forward=False, protocol="imap_ssl"):
|
||||
from app.services.mail_processor import MailProcessor
|
||||
|
||||
account = _make_account(
|
||||
delete_after_forward=delete_after_forward, protocol=protocol
|
||||
)
|
||||
return MailProcessor(account=account, decrypted_password="secret")
|
||||
|
||||
def _mock_imap_client(self):
|
||||
client = MagicMock()
|
||||
client.wait_hello_from_server = AsyncMock()
|
||||
client.login = AsyncMock()
|
||||
client.select = AsyncMock()
|
||||
client.uid = AsyncMock(return_value=_make_imap_response())
|
||||
client.expunge = AsyncMock()
|
||||
client.logout = AsyncMock()
|
||||
return client
|
||||
|
||||
async def test_marks_seen_without_delete(self):
|
||||
"""post_process_imap marks UIDs \\Seen when delete_after_forward=False."""
|
||||
processor = self._make_processor(delete_after_forward=False)
|
||||
mock_imap = self._mock_imap_client()
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
await processor.post_process_imap(["10", "11"])
|
||||
|
||||
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
|
||||
assert len(store_calls) == 1
|
||||
assert set(store_calls[0].args[1].split(",")) == {"10", "11"}
|
||||
assert store_calls[0].args[3] == "(\\Seen)"
|
||||
mock_imap.expunge.assert_not_awaited()
|
||||
|
||||
async def test_marks_seen_and_deleted_with_delete(self):
|
||||
"""post_process_imap marks \\Seen + \\Deleted and expunges when configured."""
|
||||
processor = self._make_processor(delete_after_forward=True)
|
||||
mock_imap = self._mock_imap_client()
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
await processor.post_process_imap(["5", "6"])
|
||||
|
||||
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
|
||||
assert len(store_calls) == 2
|
||||
flags_used = {c.args[3] for c in store_calls}
|
||||
assert "(\\Seen)" in flags_used
|
||||
assert "(\\Deleted)" in flags_used
|
||||
mock_imap.expunge.assert_awaited_once()
|
||||
|
||||
async def test_noop_on_empty_uid_list(self):
|
||||
"""post_process_imap does nothing when the UID list is empty."""
|
||||
processor = self._make_processor()
|
||||
mock_imap = self._mock_imap_client()
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
await processor.post_process_imap([])
|
||||
|
||||
mock_imap.login.assert_not_awaited()
|
||||
|
||||
async def test_exception_is_swallowed_and_logged(self):
|
||||
"""post_process_imap catches exceptions and does not propagate them."""
|
||||
processor = self._make_processor()
|
||||
mock_imap = self._mock_imap_client()
|
||||
mock_imap.login = AsyncMock(side_effect=OSError("connection refused"))
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
await processor.post_process_imap(["1"]) # must not raise
|
||||
|
||||
mock_imap.logout.assert_awaited_once()
|
||||
|
||||
async def test_uses_imap4_for_non_ssl(self):
|
||||
"""Plain 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.uid = AsyncMock(return_value=_make_imap_response())
|
||||
mock_imap.logout = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4",
|
||||
return_value=mock_imap,
|
||||
) as mock_cls,
|
||||
patch("app.services.mail_processor.aioimaplib.IMAP4_SSL") as mock_ssl_cls,
|
||||
):
|
||||
await processor.post_process_imap(["1"])
|
||||
mock_cls.assert_called_once()
|
||||
mock_ssl_cls.assert_not_called()
|
||||
|
||||
async def test_logout_called_even_on_error(self):
|
||||
"""logout() is called in the finally block even when an exception occurs."""
|
||||
processor = self._make_processor()
|
||||
mock_imap = self._mock_imap_client()
|
||||
mock_imap.uid = AsyncMock(side_effect=Exception("store failed"))
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||
return_value=mock_imap,
|
||||
):
|
||||
await processor.post_process_imap(["99"])
|
||||
|
||||
mock_imap.logout.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# post_process_messages routing test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPostProcessMessages:
|
||||
"""post_process_messages() routes to the correct protocol handler."""
|
||||
|
||||
async def test_routes_to_imap_for_imap_ssl(self):
|
||||
from app.services.mail_processor import MailProcessor
|
||||
|
||||
account = _make_account(protocol="imap_ssl")
|
||||
processor = MailProcessor(account=account, decrypted_password="pw")
|
||||
with (
|
||||
patch.object(
|
||||
processor, "post_process_imap", new_callable=AsyncMock
|
||||
) as mock_imap,
|
||||
patch.object(
|
||||
processor, "post_process_pop3", new_callable=AsyncMock
|
||||
) as mock_pop3,
|
||||
):
|
||||
await processor.post_process_messages(["1", "2"])
|
||||
mock_imap.assert_awaited_once_with(["1", "2"])
|
||||
mock_pop3.assert_not_awaited()
|
||||
|
||||
async def test_routes_to_pop3_for_pop3_ssl(self):
|
||||
from app.services.mail_processor import MailProcessor
|
||||
|
||||
account = _make_account(protocol="imap_ssl")
|
||||
account.protocol = __import__(
|
||||
"app.models.database_models", fromlist=["MailProtocol"]
|
||||
).MailProtocol.POP3_SSL
|
||||
processor = MailProcessor(account=account, decrypted_password="pw")
|
||||
with (
|
||||
patch.object(
|
||||
processor, "post_process_pop3", new_callable=AsyncMock
|
||||
) as mock_pop3,
|
||||
patch.object(
|
||||
processor, "post_process_imap", new_callable=AsyncMock
|
||||
) as mock_imap,
|
||||
):
|
||||
await processor.post_process_messages(["a"])
|
||||
mock_pop3.assert_awaited_once_with(["a"])
|
||||
mock_imap.assert_not_awaited()
|
||||
@@ -0,0 +1,940 @@
|
||||
"""
|
||||
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 rejected" in msg or "authentication" in msg.lower()
|
||||
|
||||
@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" in msg and "error" in msg.lower()
|
||||
|
||||
@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 msg # must be non-empty
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _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" in msg or "rejected" 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 msg # must be non-empty
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_no_delete_during_fetch(self, mock_poplib):
|
||||
"""_fetch_pop3_emails must NOT call dele() regardless of delete_after_forward.
|
||||
|
||||
Deletion is deferred to post_process_pop3() so that only successfully
|
||||
forwarded messages are removed from the source mailbox.
|
||||
"""
|
||||
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())
|
||||
|
||||
mock_conn.dele.assert_not_called()
|
||||
|
||||
@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_fetch_does_not_call_dele(self, mock_poplib):
|
||||
"""_fetch_pop3_emails never calls dele() — deletion is in post_process_pop3."""
|
||||
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=True)
|
||||
proc = MailProcessor(account, "secret")
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
assert len(emails) == 1
|
||||
mock_conn.dele.assert_not_called()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_connection_failure_raises_mail_fetch_error(self, mock_poplib):
|
||||
"""Connection failure raises MailFetchError with non-empty 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")
|
||||
with pytest.raises(MailFetchError) as exc_info:
|
||||
await proc._fetch_pop3_emails(10, set())
|
||||
# Message should be non-empty and describe the failure
|
||||
assert str(exc_info.value)
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# post_process_pop3 tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPostProcessPop3:
|
||||
"""Unit tests for post_process_pop3()."""
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_deletes_only_forwarded_uids(self, mock_poplib):
|
||||
"""Only successfully forwarded UIDs are deleted from the source mailbox."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.uidl.return_value = (b"+OK", [b"1 uid-a", b"2 uid-b", b"3 uid-c"], 0)
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
|
||||
proc = MailProcessor(account, "secret")
|
||||
await proc.post_process_pop3(["uid-a", "uid-c"])
|
||||
|
||||
# Only messages 1 and 3 should be deleted (uid-a and uid-c)
|
||||
assert mock_conn.dele.call_count == 2
|
||||
mock_conn.dele.assert_any_call(1)
|
||||
mock_conn.dele.assert_any_call(3)
|
||||
mock_conn.quit.assert_called_once()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_noop_when_delete_after_forward_false(self, mock_poplib):
|
||||
"""post_process_pop3 does nothing when delete_after_forward=False."""
|
||||
mock_conn = MagicMock()
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl", delete_after_forward=False)
|
||||
proc = MailProcessor(account, "secret")
|
||||
await proc.post_process_pop3(["uid-a"])
|
||||
|
||||
mock_poplib.POP3_SSL.assert_not_called()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_noop_on_empty_uid_list(self, mock_poplib):
|
||||
"""post_process_pop3 does nothing when the UID list is empty."""
|
||||
mock_conn = MagicMock()
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
|
||||
proc = MailProcessor(account, "secret")
|
||||
await proc.post_process_pop3([])
|
||||
|
||||
mock_poplib.POP3_SSL.assert_not_called()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_dele_error_does_not_abort(self, mock_poplib):
|
||||
"""A dele() error is logged but post_process_pop3 does not raise."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.uidl.return_value = (b"+OK", [b"1 uid-a"], 0)
|
||||
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")
|
||||
await proc.post_process_pop3(["uid-a"]) # must not raise
|
||||
|
||||
mock_conn.quit.assert_called_once()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_connection_error_is_swallowed(self, mock_poplib):
|
||||
"""A connection failure is logged but does not propagate."""
|
||||
mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
|
||||
|
||||
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
|
||||
proc = MailProcessor(account, "secret")
|
||||
await proc.post_process_pop3(["uid-a"]) # must not raise
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retry logic tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchPop3Retry:
|
||||
"""_fetch_pop3_emails retries on transient errors."""
|
||||
|
||||
@patch("app.services.mail_processor._resolve_ipv4_sync", return_value=None)
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_on_eof_error(self, mock_poplib, _mock_resolve):
|
||||
"""EOF error on first attempt triggers a retry; second attempt succeeds."""
|
||||
import poplib as real_poplib
|
||||
|
||||
# First call raises EOF; second succeeds
|
||||
good_conn = MagicMock()
|
||||
good_conn.stat.return_value = (0, 0)
|
||||
good_conn.uidl.return_value = (b"+OK", [], 0)
|
||||
good_conn.quit.return_value = None
|
||||
|
||||
mock_poplib.error_proto = real_poplib.error_proto
|
||||
mock_poplib.POP3_SSL.side_effect = [
|
||||
real_poplib.error_proto("-ERR EOF"),
|
||||
good_conn,
|
||||
]
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.asyncio.sleep", new_callable=AsyncMock
|
||||
) as mock_sleep:
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
assert emails == []
|
||||
assert uids == []
|
||||
# sleep should have been called once between attempt 1 and attempt 2
|
||||
mock_sleep.assert_awaited_once()
|
||||
|
||||
@patch("app.services.mail_processor._resolve_ipv4_sync", return_value=None)
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_after_max_attempts(self, mock_poplib, _mock_resolve):
|
||||
"""All attempts fail with EOF → MailFetchError is raised."""
|
||||
import poplib as real_poplib
|
||||
|
||||
mock_poplib.error_proto = real_poplib.error_proto
|
||||
mock_poplib.POP3_SSL.side_effect = real_poplib.error_proto("-ERR EOF")
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
|
||||
with patch("app.services.mail_processor.asyncio.sleep", new_callable=AsyncMock):
|
||||
with pytest.raises(MailFetchError):
|
||||
await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
@patch("app.services.mail_processor._resolve_ipv4_sync", return_value=None)
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_retry_on_auth_error(self, mock_poplib, _mock_resolve):
|
||||
"""Authentication errors are not retried (non-transient)."""
|
||||
import poplib as real_poplib
|
||||
|
||||
conn = MagicMock()
|
||||
mock_poplib.error_proto = real_poplib.error_proto
|
||||
mock_poplib.POP3_SSL.return_value = conn
|
||||
# user() succeeds; pass_() raises auth error
|
||||
conn.user.return_value = b"+OK"
|
||||
conn.pass_.side_effect = real_poplib.error_proto("-ERR Authentication failed")
|
||||
|
||||
account = _make_account(protocol="pop3_ssl")
|
||||
proc = MailProcessor(account, "secret")
|
||||
|
||||
with patch(
|
||||
"app.services.mail_processor.asyncio.sleep", new_callable=AsyncMock
|
||||
) as mock_sleep:
|
||||
with pytest.raises(MailFetchError):
|
||||
await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
# No sleep = no retry
|
||||
mock_sleep.assert_not_awaited()
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Unit tests for the notification service (services/notification_service.py).
|
||||
|
||||
All external dependencies (database, Apprise) are mocked so no real
|
||||
infrastructure is needed.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.services.notification_service import (
|
||||
send_user_notification,
|
||||
send_admin_notification,
|
||||
test_notification as _test_notification,
|
||||
_send_apprise,
|
||||
)
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_notification_config(
|
||||
*,
|
||||
id: int = 1,
|
||||
user_id: int = 42,
|
||||
apprise_url: str = "json://localhost",
|
||||
is_enabled: bool = True,
|
||||
notify_on_errors: bool = True,
|
||||
notify_on_success: bool = False,
|
||||
):
|
||||
cfg = MagicMock()
|
||||
cfg.id = id
|
||||
cfg.user_id = user_id
|
||||
cfg.apprise_url = apprise_url
|
||||
cfg.is_enabled = is_enabled
|
||||
cfg.notify_on_errors = notify_on_errors
|
||||
cfg.notify_on_success = notify_on_success
|
||||
return cfg
|
||||
|
||||
|
||||
def _make_db(configs=None):
|
||||
"""Return an AsyncMock db with execute returning the given config list."""
|
||||
db = AsyncMock()
|
||||
result = MagicMock()
|
||||
scalars = MagicMock()
|
||||
scalars.all.return_value = configs or []
|
||||
result.scalars.return_value = scalars
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
return db
|
||||
|
||||
|
||||
# ── send_user_notification ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSendUserNotification:
|
||||
async def test_returns_zero_when_no_configs(self):
|
||||
db = _make_db(configs=[])
|
||||
count = await send_user_notification(db, user_id=1, title="T", body="B")
|
||||
assert count == 0
|
||||
|
||||
async def test_sends_to_error_channel(self):
|
||||
cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False)
|
||||
db = _make_db(configs=[cfg])
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as mock_send:
|
||||
count = await send_user_notification(
|
||||
db, user_id=42, title="Err", body="msg", notify_on_error=True
|
||||
)
|
||||
assert count == 1
|
||||
mock_send.assert_awaited_once()
|
||||
|
||||
async def test_skips_error_channel_for_success_notification(self):
|
||||
cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False)
|
||||
db = _make_db(configs=[cfg])
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as mock_send:
|
||||
count = await send_user_notification(
|
||||
db, user_id=42, title="Ok", body="msg", notify_on_error=False
|
||||
)
|
||||
assert count == 0
|
||||
mock_send.assert_not_awaited()
|
||||
|
||||
async def test_sends_to_success_channel(self):
|
||||
cfg = _make_notification_config(notify_on_errors=False, notify_on_success=True)
|
||||
db = _make_db(configs=[cfg])
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as mock_send:
|
||||
count = await send_user_notification(
|
||||
db, user_id=42, title="Ok", body="msg", notify_on_error=False
|
||||
)
|
||||
assert count == 1
|
||||
mock_send.assert_awaited_once()
|
||||
|
||||
async def test_failed_apprise_not_counted(self):
|
||||
cfg = _make_notification_config(notify_on_errors=True)
|
||||
db = _make_db(configs=[cfg])
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(return_value=False),
|
||||
):
|
||||
count = await send_user_notification(db, user_id=42, title="T", body="B")
|
||||
assert count == 0
|
||||
|
||||
async def test_exception_in_apprise_swallowed(self):
|
||||
cfg = _make_notification_config(notify_on_errors=True)
|
||||
db = _make_db(configs=[cfg])
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(side_effect=Exception("boom")),
|
||||
):
|
||||
count = await send_user_notification(db, user_id=42, title="T", body="B")
|
||||
assert count == 0
|
||||
|
||||
async def test_multiple_channels_counted_individually(self):
|
||||
cfg1 = _make_notification_config(id=1, notify_on_errors=True)
|
||||
cfg2 = _make_notification_config(id=2, notify_on_errors=True)
|
||||
db = _make_db(configs=[cfg1, cfg2])
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
count = await send_user_notification(db, user_id=42, title="T", body="B")
|
||||
assert count == 2
|
||||
|
||||
|
||||
# ── send_admin_notification ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSendAdminNotification:
|
||||
async def test_returns_zero_when_no_configs(self):
|
||||
db = _make_db(configs=[])
|
||||
count = await send_admin_notification(db, title="T", body="B")
|
||||
assert count == 0
|
||||
|
||||
async def test_sends_to_enabled_channel(self):
|
||||
cfg = MagicMock()
|
||||
cfg.id = 1
|
||||
cfg.apprise_url = "json://localhost"
|
||||
db = _make_db(configs=[cfg])
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
count = await send_admin_notification(db, title="T", body="B")
|
||||
assert count == 1
|
||||
|
||||
async def test_exception_in_channel_swallowed(self):
|
||||
cfg = MagicMock()
|
||||
cfg.id = 1
|
||||
cfg.apprise_url = "json://localhost"
|
||||
db = _make_db(configs=[cfg])
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(side_effect=RuntimeError("oops")),
|
||||
):
|
||||
count = await send_admin_notification(db, title="T", body="B")
|
||||
assert count == 0
|
||||
|
||||
|
||||
# ── test_notification ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTestNotification:
|
||||
async def test_success(self):
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
ok, msg = await _test_notification("json://localhost")
|
||||
assert ok is True
|
||||
assert "success" in msg.lower()
|
||||
|
||||
async def test_failure_from_apprise(self):
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(return_value=False),
|
||||
):
|
||||
ok, msg = await _test_notification("json://localhost")
|
||||
assert ok is False
|
||||
|
||||
async def test_exception_returns_false(self):
|
||||
with patch(
|
||||
"app.services.notification_service._send_apprise",
|
||||
new=AsyncMock(side_effect=Exception("network error")),
|
||||
):
|
||||
ok, msg = await _test_notification("json://localhost")
|
||||
assert ok is False
|
||||
assert "error" in msg.lower()
|
||||
|
||||
|
||||
# ── _send_apprise internal helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSendApprise:
|
||||
async def test_invalid_url_returns_false(self):
|
||||
# Apprise.add() returns False for unrecognised schemes
|
||||
with patch("app.services.notification_service.apprise") as mock_apprise_module:
|
||||
ap_instance = MagicMock()
|
||||
ap_instance.add.return_value = False
|
||||
mock_apprise_module.Apprise.return_value = ap_instance
|
||||
result = await _send_apprise("not-a-valid-url://", "T", "B")
|
||||
assert result is False
|
||||
|
||||
async def test_valid_url_returns_true(self):
|
||||
with patch("app.services.notification_service.apprise") as mock_apprise_module:
|
||||
ap_instance = MagicMock()
|
||||
ap_instance.add.return_value = True
|
||||
ap_instance.async_notify = AsyncMock(return_value=True)
|
||||
mock_apprise_module.Apprise.return_value = ap_instance
|
||||
result = await _send_apprise("json://localhost", "T", "B")
|
||||
assert result is True
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Unit tests for notification config endpoints (api/v1/endpoints/notifications.py).
|
||||
|
||||
All database interactions and auth dependencies are mocked.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.models.database_models import (
|
||||
User,
|
||||
NotificationConfig,
|
||||
NotificationChannel,
|
||||
SubscriptionTier,
|
||||
)
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
u = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(u, k, v)
|
||||
return u
|
||||
|
||||
|
||||
def _make_notification_config(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
user_id=1,
|
||||
name="Test Notification",
|
||||
apprise_url="json://localhost",
|
||||
channel=NotificationChannel.WEBHOOK,
|
||||
is_enabled=True,
|
||||
config={},
|
||||
notify_on_errors=True,
|
||||
notify_on_success=False,
|
||||
notify_threshold=3,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
cfg = MagicMock(spec=NotificationConfig)
|
||||
for k, v in defaults.items():
|
||||
setattr(cfg, k, v)
|
||||
return cfg
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
r = MagicMock()
|
||||
r.scalar_one_or_none.return_value = value
|
||||
return r
|
||||
|
||||
|
||||
def _scalars_all(values):
|
||||
r = MagicMock()
|
||||
scalars = MagicMock()
|
||||
scalars.all.return_value = values
|
||||
r.scalars.return_value = scalars
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.delete = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user():
|
||||
return _make_user()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(app, current_user, mock_db):
|
||||
async def _override_user():
|
||||
return current_user
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_current_active_user] = _override_user
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── POST /notifications ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateNotificationConfig:
|
||||
async def test_creates_config_201(self, auth_client, mock_db, current_user):
|
||||
created_cfg = _make_notification_config()
|
||||
|
||||
# db.refresh must populate the object returned from the endpoint
|
||||
async def _refresh(obj):
|
||||
for k, v in vars(created_cfg).items():
|
||||
if not k.startswith("_"):
|
||||
try:
|
||||
setattr(obj, k, v)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_refresh)
|
||||
|
||||
response = await auth_client.post(
|
||||
"/api/v1/notifications",
|
||||
json={
|
||||
"name": "My Webhook",
|
||||
"apprise_url": "json://localhost",
|
||||
"channel": "webhook",
|
||||
"is_enabled": True,
|
||||
"config": {},
|
||||
"notify_on_errors": True,
|
||||
"notify_on_success": False,
|
||||
"notify_threshold": 3,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
async def test_unauthenticated_401(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/notifications",
|
||||
json={
|
||||
"name": "x",
|
||||
"apprise_url": "json://localhost",
|
||||
"channel": "webhook",
|
||||
"config": {},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ── GET /notifications ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListNotificationConfigs:
|
||||
async def test_returns_list(self, auth_client, mock_db):
|
||||
cfg1 = _make_notification_config(id=1)
|
||||
cfg2 = _make_notification_config(id=2)
|
||||
mock_db.execute = AsyncMock(return_value=_scalars_all([cfg1, cfg2]))
|
||||
response = await auth_client.get("/api/v1/notifications")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
assert len(response.json()) == 2
|
||||
|
||||
async def test_returns_empty_list(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalars_all([]))
|
||||
response = await auth_client.get("/api/v1/notifications")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
# ── GET /notifications/{id} ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetNotificationConfig:
|
||||
async def test_returns_config(self, auth_client, mock_db):
|
||||
cfg = _make_notification_config(id=5)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
|
||||
response = await auth_client.get("/api/v1/notifications/5")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_404_when_not_found(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
response = await auth_client.get("/api/v1/notifications/999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ── PUT /notifications/{id} ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpdateNotificationConfig:
|
||||
async def test_updates_config(self, auth_client, mock_db):
|
||||
cfg = _make_notification_config(id=5)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
response = await auth_client.put(
|
||||
"/api/v1/notifications/5",
|
||||
json={"name": "Updated Name"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_404_when_not_found(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
response = await auth_client.put(
|
||||
"/api/v1/notifications/999",
|
||||
json={"name": "Updated"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ── DELETE /notifications/{id} ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteNotificationConfig:
|
||||
async def test_deletes_config_204(self, auth_client, mock_db):
|
||||
cfg = _make_notification_config(id=5)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
|
||||
response = await auth_client.delete("/api/v1/notifications/5")
|
||||
assert response.status_code == 204
|
||||
mock_db.delete.assert_awaited_once_with(cfg)
|
||||
|
||||
async def test_404_when_not_found(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
response = await auth_client.delete("/api/v1/notifications/999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ── POST /notifications/test ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTestNotificationConfig:
|
||||
async def test_test_success(self, auth_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.notifications.test_notification",
|
||||
new=AsyncMock(return_value=(True, "sent successfully")),
|
||||
):
|
||||
response = await auth_client.post(
|
||||
"/api/v1/notifications/test",
|
||||
json={"apprise_url": "json://localhost"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
async def test_test_failure(self, auth_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.notifications.test_notification",
|
||||
new=AsyncMock(return_value=(False, "delivery failed")),
|
||||
):
|
||||
response = await auth_client.post(
|
||||
"/api/v1/notifications/test",
|
||||
json={"apprise_url": "invalid://url"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
@@ -0,0 +1,709 @@
|
||||
"""
|
||||
Unit tests for provider endpoints (backend/app/api/v1/endpoints/providers.py).
|
||||
|
||||
All tests mock the database session, auth dependencies, and external services
|
||||
so no real PostgreSQL instance or Google API access is required.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.models.database_models import User, GmailCredential
|
||||
from app.services.gmail_service import GmailInjectionError
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a User ORM instance."""
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
hashed_password="hashed",
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
user = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(user, k, v)
|
||||
return user
|
||||
|
||||
|
||||
def _make_gmail_credential(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a GmailCredential ORM instance."""
|
||||
defaults = dict(
|
||||
id=1,
|
||||
user_id=1,
|
||||
gmail_email="user@gmail.com",
|
||||
encrypted_access_token="encrypted_access",
|
||||
encrypted_refresh_token="encrypted_refresh",
|
||||
token_expiry=datetime.now(timezone.utc),
|
||||
scopes={
|
||||
"granted_scopes": ["scope1"],
|
||||
"import_label_templates": ["imported"],
|
||||
},
|
||||
is_valid=True,
|
||||
last_verified_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
import_label_templates=["imported"],
|
||||
default_import_label_templates=["{{source_email}}", "imported"],
|
||||
granted_scopes=["scope1"],
|
||||
)
|
||||
defaults.update(overrides)
|
||||
cred = MagicMock(spec=GmailCredential)
|
||||
for k, v in defaults.items():
|
||||
setattr(cred, k, v)
|
||||
return cred
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
"""Create a mock result whose .scalar_one_or_none() returns *value*."""
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
# ── fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user():
|
||||
return _make_user()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.delete = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(app, test_user, mock_db):
|
||||
"""AsyncClient where the caller is an authenticated active user."""
|
||||
|
||||
async def _override_user():
|
||||
return test_user
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_current_active_user] = _override_user
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Provider Presets
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListProviderPresets:
|
||||
async def test_returns_all_presets(self, auth_client):
|
||||
resp = await auth_client.get("/api/v1/providers/presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "providers" in data
|
||||
assert len(data["providers"]) == 13
|
||||
ids = [p["id"] for p in data["providers"]]
|
||||
assert "gmail" in ids
|
||||
assert "outlook" in ids
|
||||
assert "icloud" in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetProviderPreset:
|
||||
async def test_known_preset(self, auth_client):
|
||||
resp = await auth_client.get("/api/v1/providers/presets/gmail")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "gmail"
|
||||
assert data["name"] == "Gmail"
|
||||
assert "gmail.com" in data["domains"]
|
||||
|
||||
async def test_unknown_preset_404(self, auth_client):
|
||||
resp = await auth_client.get("/api/v1/providers/presets/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Save Gmail Credential (POST /gmail-credential)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSaveGmailCredential:
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_create_new_credential(
|
||||
self, mock_gmail_cls, mock_encrypt, mock_build_scopes, auth_client, mock_db
|
||||
):
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
mock_encrypt.return_value = "encrypted_token"
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": [],
|
||||
"import_label_templates": ["{{source_email}}", "imported"],
|
||||
}
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async def _populate_on_refresh(obj):
|
||||
"""Simulate what the DB does after INSERT + refresh."""
|
||||
obj.id = 1
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_populate_on_refresh)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail-credential",
|
||||
json={
|
||||
"access_token": "test_access",
|
||||
"refresh_token": "test_refresh",
|
||||
"gmail_email": "user@gmail.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
assert data["is_valid"] is True
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_update_existing_credential(
|
||||
self, mock_gmail_cls, mock_encrypt, mock_build_scopes, auth_client, mock_db
|
||||
):
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
mock_encrypt.return_value = "encrypted_token"
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": ["scope1"],
|
||||
"import_label_templates": ["imported"],
|
||||
}
|
||||
|
||||
existing = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail-credential",
|
||||
json={
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "new_refresh",
|
||||
"gmail_email": "user@gmail.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_invalid_credentials_400(self, mock_gmail_cls, auth_client, mock_db):
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=False)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail-credential",
|
||||
json={
|
||||
"access_token": "bad_token",
|
||||
"refresh_token": "bad_refresh",
|
||||
"gmail_email": "user@gmail.com",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "invalid" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Get Gmail Credential (GET /gmail-credential)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetGmailCredential:
|
||||
async def test_found(self, auth_client, mock_db):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
|
||||
resp = await auth_client.get("/api/v1/providers/gmail-credential")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
assert data["is_valid"] is True
|
||||
|
||||
async def test_not_found_404(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await auth_client.get("/api/v1/providers/gmail-credential")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Delete Gmail Credential (DELETE /gmail-credential)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteGmailCredential:
|
||||
async def test_delete_success(self, auth_client, mock_db):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
|
||||
resp = await auth_client.delete("/api/v1/providers/gmail-credential")
|
||||
assert resp.status_code == 204
|
||||
mock_db.delete.assert_awaited_once_with(cred)
|
||||
mock_db.commit.assert_awaited()
|
||||
|
||||
async def test_delete_not_found_404(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await auth_client.delete("/api/v1/providers/gmail-credential")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Update Import Labels (PUT /gmail-credential/labels)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateImportLabels:
|
||||
@patch("app.api.v1.endpoints.providers.extract_granted_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.normalize_import_label_templates")
|
||||
async def test_update_labels_success(
|
||||
self,
|
||||
mock_normalize,
|
||||
mock_build_scopes,
|
||||
mock_extract_scopes,
|
||||
auth_client,
|
||||
mock_db,
|
||||
):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
mock_normalize.return_value = ["custom-label"]
|
||||
mock_extract_scopes.return_value = ["scope1"]
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": ["scope1"],
|
||||
"import_label_templates": ["custom-label"],
|
||||
}
|
||||
|
||||
resp = await auth_client.put(
|
||||
"/api/v1/providers/gmail-credential/labels",
|
||||
json={"import_label_templates": ["custom-label"]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
|
||||
async def test_update_labels_not_found_404(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await auth_client.put(
|
||||
"/api/v1/providers/gmail-credential/labels",
|
||||
json={"import_label_templates": ["test"]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Get Gmail Authorize URL (GET /gmail/authorize-url)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetGmailAuthorizeUrl:
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_success(self, mock_settings, auth_client):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
|
||||
|
||||
resp = await auth_client.get(
|
||||
"/api/v1/providers/gmail/authorize-url",
|
||||
params={"redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "authorization_url" in data
|
||||
assert "test-client-id" in data["authorization_url"]
|
||||
assert "redirect_uri=http" in data["authorization_url"]
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_google_not_configured_501(self, mock_settings, auth_client):
|
||||
mock_settings.GOOGLE_CLIENT_ID = None
|
||||
|
||||
resp = await auth_client.get(
|
||||
"/api/v1/providers/gmail/authorize-url",
|
||||
params={"redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 501
|
||||
assert "not configured" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Send Debug Email (POST /gmail/debug-email)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSendDebugEmail:
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.decrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_success(
|
||||
self, mock_gmail_cls, mock_decrypt, mock_encrypt, auth_client, mock_db
|
||||
):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
mock_decrypt.return_value = "decrypted_token"
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_instance.inject_debug_email = AsyncMock(
|
||||
return_value={
|
||||
"message_id": "msg1",
|
||||
"thread_id": "t1",
|
||||
"label_ids": ["INBOX"],
|
||||
}
|
||||
)
|
||||
mock_gmail_instance.get_refreshed_token = MagicMock(return_value=None)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
resp = await auth_client.post("/api/v1/providers/gmail/debug-email")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message_id"] == "msg1"
|
||||
assert data["thread_id"] == "t1"
|
||||
assert "INBOX" in data["label_ids"]
|
||||
|
||||
async def test_no_credentials_400(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await auth_client.post("/api/v1/providers/gmail/debug-email")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "no valid gmail credentials" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.decrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
async def test_injection_failure_502(
|
||||
self, mock_gmail_cls, mock_decrypt, auth_client, mock_db
|
||||
):
|
||||
cred = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred))
|
||||
mock_decrypt.return_value = "decrypted_token"
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.inject_debug_email = AsyncMock(
|
||||
side_effect=GmailInjectionError("API error")
|
||||
)
|
||||
mock_gmail_instance.get_refreshed_token = MagicMock(return_value=None)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
resp = await auth_client.post("/api/v1/providers/gmail/debug-email")
|
||||
|
||||
assert resp.status_code == 502
|
||||
assert "injection failed" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Gmail OAuth Callback (POST /gmail/callback)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _mock_httpx_context_manager(mock_http_client):
|
||||
"""Build an httpx.AsyncClient mock that works as an async context manager."""
|
||||
mock_httpx_cls = MagicMock()
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
return mock_httpx_cls
|
||||
|
||||
|
||||
def _make_token_response(status_code=200, json_data=None):
|
||||
"""Create a mock httpx response for the token exchange."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data or {
|
||||
"access_token": "new_access_token",
|
||||
"refresh_token": "new_refresh_token",
|
||||
"expires_in": 3600,
|
||||
"scope": "openid email https://www.googleapis.com/auth/gmail.insert",
|
||||
}
|
||||
resp.text = "error" if status_code != 200 else "ok"
|
||||
return resp
|
||||
|
||||
|
||||
def _make_profile_response(status_code=200, email="user@gmail.com"):
|
||||
"""Create a mock httpx response for the userinfo endpoint."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = {"email": email}
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGmailCallback:
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_new_credential_success(
|
||||
self,
|
||||
mock_settings,
|
||||
mock_httpx_cls,
|
||||
mock_gmail_cls,
|
||||
mock_encrypt,
|
||||
mock_build_scopes,
|
||||
auth_client,
|
||||
mock_db,
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
token_resp = _make_token_response()
|
||||
profile_resp = _make_profile_response()
|
||||
mock_http_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_http_client.get = AsyncMock(return_value=profile_resp)
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
mock_encrypt.return_value = "encrypted"
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": ["openid", "email"],
|
||||
"import_label_templates": ["{{source_email}}", "imported"],
|
||||
}
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async def _populate_on_refresh(obj):
|
||||
"""Simulate what the DB does after INSERT + refresh."""
|
||||
obj.id = 1
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_populate_on_refresh)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
assert data["is_valid"] is True
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes")
|
||||
@patch("app.api.v1.endpoints.providers.encrypt_credential")
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_update_existing_credential(
|
||||
self,
|
||||
mock_settings,
|
||||
mock_httpx_cls,
|
||||
mock_gmail_cls,
|
||||
mock_encrypt,
|
||||
mock_build_scopes,
|
||||
auth_client,
|
||||
mock_db,
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
token_resp = _make_token_response()
|
||||
profile_resp = _make_profile_response()
|
||||
mock_http_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_http_client.get = AsyncMock(return_value=profile_resp)
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=True)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
mock_encrypt.return_value = "encrypted"
|
||||
mock_build_scopes.return_value = {
|
||||
"granted_scopes": ["openid"],
|
||||
"import_label_templates": ["imported"],
|
||||
}
|
||||
|
||||
existing = _make_gmail_credential()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["gmail_email"] == "user@gmail.com"
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_google_not_configured_501(self, mock_settings, auth_client):
|
||||
mock_settings.GOOGLE_CLIENT_ID = None
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = None
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 501
|
||||
assert "not configured" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_token_exchange_fails_400(
|
||||
self, mock_settings, mock_httpx_cls, auth_client
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.post = AsyncMock(
|
||||
return_value=_make_token_response(status_code=400, json_data={})
|
||||
)
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "bad_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "exchange" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_no_access_token_returned_400(
|
||||
self, mock_settings, mock_httpx_cls, auth_client
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
# Token response OK but missing access_token
|
||||
token_resp = _make_token_response(
|
||||
json_data={"refresh_token": "rt", "expires_in": 3600}
|
||||
)
|
||||
mock_http_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "access token" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.v1.endpoints.providers.GmailService")
|
||||
@patch("app.api.v1.endpoints.providers.httpx.AsyncClient")
|
||||
@patch("app.api.v1.endpoints.providers.settings")
|
||||
async def test_verification_fails_400(
|
||||
self, mock_settings, mock_httpx_cls, mock_gmail_cls, auth_client
|
||||
):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "client-id"
|
||||
mock_settings.GOOGLE_CLIENT_SECRET = "client-secret"
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.post = AsyncMock(return_value=_make_token_response())
|
||||
mock_http_client.get = AsyncMock(return_value=_make_profile_response())
|
||||
mock_httpx_cls.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_http_client
|
||||
)
|
||||
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_gmail_instance = MagicMock()
|
||||
mock_gmail_instance.verify_access = AsyncMock(return_value=False)
|
||||
mock_gmail_cls.return_value = mock_gmail_instance
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/providers/gmail/callback",
|
||||
json={
|
||||
"code": "auth_code",
|
||||
"redirect_uri": "http://localhost/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "verify" in resp.json()["detail"].lower()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Unit tests for user profile and SMTP config endpoints (api/v1/endpoints/users.py).
|
||||
|
||||
All database interactions and auth dependencies are mocked.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.models.database_models import User, UserSmtpConfig, SubscriptionTier
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
u = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(u, k, v)
|
||||
return u
|
||||
|
||||
|
||||
def _make_smtp_config(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
user_id=1,
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="user@example.com",
|
||||
encrypted_password="encrypted",
|
||||
use_tls=True,
|
||||
sender_email="",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
cfg = MagicMock(spec=UserSmtpConfig)
|
||||
for k, v in defaults.items():
|
||||
setattr(cfg, k, v)
|
||||
return cfg
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
r = MagicMock()
|
||||
r.scalar_one_or_none.return_value = value
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.delete = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user():
|
||||
return _make_user()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(app, current_user, mock_db):
|
||||
async def _override_user():
|
||||
return current_user
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_current_active_user] = _override_user
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── GET /me ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetMe:
|
||||
async def test_returns_user_data(self, auth_client, current_user):
|
||||
response = await auth_client.get("/api/v1/users/me")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == current_user.email
|
||||
|
||||
async def test_unauthenticated_401(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/users/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ── PUT /me ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpdateMe:
|
||||
async def test_update_full_name(self, auth_client, mock_db, current_user):
|
||||
mock_db.refresh = AsyncMock(
|
||||
side_effect=lambda obj: setattr(obj, "full_name", "Updated Name")
|
||||
)
|
||||
response = await auth_client.put(
|
||||
"/api/v1/users/me", json={"full_name": "Updated Name"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_update_email(self, auth_client, mock_db, current_user):
|
||||
mock_db.refresh = AsyncMock(
|
||||
side_effect=lambda obj: setattr(obj, "email", "new@example.com")
|
||||
)
|
||||
response = await auth_client.put(
|
||||
"/api/v1/users/me", json={"email": "new@example.com"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── GET /smtp-config ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetSmtpConfig:
|
||||
async def test_returns_config_when_exists(self, auth_client, mock_db):
|
||||
cfg = _make_smtp_config()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
|
||||
response = await auth_client.get("/api/v1/users/smtp-config")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["host"] == "smtp.example.com"
|
||||
assert data["port"] == 587
|
||||
assert "has_password" in data
|
||||
# Password must not be exposed
|
||||
assert "encrypted_password" not in data
|
||||
|
||||
async def test_returns_404_when_no_config(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
response = await auth_client.get("/api/v1/users/smtp-config")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ── PUT /smtp-config ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpsertSmtpConfig:
|
||||
async def test_creates_new_config(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
# db.refresh populates the new config object with required fields
|
||||
async def _refresh(obj):
|
||||
obj.id = 1
|
||||
obj.user_id = 1
|
||||
obj.host = "smtp.example.com"
|
||||
obj.port = 587
|
||||
obj.username = "user@example.com"
|
||||
obj.encrypted_password = "encrypted"
|
||||
obj.use_tls = True
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
obj.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_refresh)
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"app.api.v1.endpoints.users.encrypt_credential", return_value="encrypted"
|
||||
):
|
||||
response = await auth_client.put(
|
||||
"/api/v1/users/smtp-config",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "user@example.com",
|
||||
"password": "secret",
|
||||
"use_tls": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_updates_existing_config(self, auth_client, mock_db):
|
||||
existing = _make_smtp_config()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"app.api.v1.endpoints.users.encrypt_credential", return_value="encrypted"
|
||||
):
|
||||
response = await auth_client.put(
|
||||
"/api/v1/users/smtp-config",
|
||||
json={
|
||||
"host": "newsmtp.example.com",
|
||||
"port": 465,
|
||||
"username": "newuser@example.com",
|
||||
"password": "newpass",
|
||||
"use_tls": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── DELETE /smtp-config ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteSmtpConfig:
|
||||
async def test_deletes_existing_config(self, auth_client, mock_db):
|
||||
existing = _make_smtp_config()
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
response = await auth_client.delete("/api/v1/users/smtp-config")
|
||||
assert response.status_code == 204
|
||||
mock_db.delete.assert_awaited_once_with(existing)
|
||||
|
||||
async def test_no_config_is_noop(self, auth_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
response = await auth_client.delete("/api/v1/users/smtp-config")
|
||||
assert response.status_code == 204
|
||||
mock_db.delete.assert_not_awaited()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Unit tests for the version endpoint (api/v1/endpoints/version.py).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
class TestVersionEndpoint:
|
||||
async def test_get_version_returns_200(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/version")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_get_version_has_version_key(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/version")
|
||||
data = response.json()
|
||||
assert "version" in data
|
||||
|
||||
async def test_get_version_has_build_date_key(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/version")
|
||||
data = response.json()
|
||||
assert "build_date" in data
|
||||
|
||||
async def test_version_is_string_or_none(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/version")
|
||||
data = response.json()
|
||||
assert isinstance(data["version"], str) or data["version"] is None
|
||||
@@ -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,103 @@
|
||||
|
||||
Comprehensive task breakdown for repository improvements and production readiness.
|
||||
|
||||
## ✅ Recently Completed
|
||||
|
||||
- [x] **IMAP/POP3 connection stability — IPv4 preference + DNS cache**: All
|
||||
POP3 and IMAP connections now prefer IPv4 by pre-resolving hostnames with
|
||||
`AF_INET` before connecting. This eliminates `ENETUNREACH` / `Network is
|
||||
unreachable` and sporadic TLS timeouts caused by Docker containers attempting
|
||||
IPv6 connections to servers that block IPv6. Successfully resolved IPv4
|
||||
addresses are cached in-process; when `DNS_CACHE_FALLBACK_ENABLED=true`
|
||||
(default) the cached address is used as a fallback if DNS subsequently fails
|
||||
(`EAI_AGAIN`), keeping mail delivery alive through transient resolver outages.
|
||||
Added `_resolve_ipv4` / `_resolve_ipv4_sync`, `_IMAP4SSLwithSNI`,
|
||||
`_POP3SSLWithIPv4Pref`, `_POP3WithIPv4Pref`, `_make_pop3_conn`, and
|
||||
`_make_imap_client` helpers. Transient DNS errors now report "Temporary DNS
|
||||
failure" rather than "check that the server address is correct". Replaced
|
||||
four deprecated `asyncio.get_event_loop()` calls with `get_running_loop()`.
|
||||
|
||||
- [x] **IMAP/POP3 diagnostics — Step 1: Friendly error messages**: Added
|
||||
`_format_connection_error()` helper that converts raw OS/socket/SSL/POP3/IMAP
|
||||
exceptions into human-readable sentences with host:port context. Applied at
|
||||
every `raise MailFetchError`/`MailConnectionError` site. Fixes blank "IMAP
|
||||
fetch error:" messages and cryptic DNS errno strings.
|
||||
|
||||
- [x] **IMAP/POP3 diagnostics — Step 2: Auto-clear stale error state**: Successful
|
||||
fetches now always clear `last_error_message`/`last_error_at` and set
|
||||
`status=ACTIVE`, even when some individual email forwards fail. Added
|
||||
`POST /mail-accounts/{id}/clear-error` endpoint and "Clear" buttons on the
|
||||
Accounts and Mailbox Activity pages.
|
||||
|
||||
- [x] **IMAP/POP3 diagnostics — Step 3: Per-account debug logging**: Added
|
||||
`debug_logging` boolean column (migration `0002`), `MailDebugRecorder` class,
|
||||
instrumented all connection phases (connect, auth, select, search, fetch UIDs,
|
||||
per-message fetch, logout), persisted as `ProcessingLog[level=DEBUG]`.
|
||||
Auto-disables after 5 runs **from when the flag was last enabled** (migration
|
||||
`0003` adds `debug_logging_run_count` counter; the old 24-hour window is
|
||||
replaced). Toggle in account edit form. Connection trace viewer in Mailbox
|
||||
Activity logs page.
|
||||
|
||||
- [x] **POP3/IMAP retry + DNS 8.8.8.8 fallback**: Transient errors (`-ERR EOF`,
|
||||
timeout, connection-reset) are retried up to 3 times (5 s fixed delay) in both
|
||||
POP3 and IMAP paths. DNS resolution now falls back to a raw UDP query to
|
||||
8.8.8.8:53 when both the system resolver and the in-process cache fail, keeping
|
||||
connections alive even if the container's resolver is temporarily unavailable.
|
||||
|
||||
- [x] **Notification backoff**: Error notifications fire only once per consecutive
|
||||
failure streak (`error_notification_sent` flag, migration `0003`). A recovery
|
||||
notice is sent when the account processes successfully again, then the flag
|
||||
resets so the next failure streak triggers a fresh alert.
|
||||
|
||||
- [x] **Google OAuth consent screen legal compliance**: Added English Privacy Policy (`/privacy`) with Google API Limited Use Disclosure, Terms of Service (`/terms`), legal footer links on the home page (resolves Google's "homepage has no privacy policy link" verification rejection), login page, and register page (consent text). Cross-link from `/datenschutz` to `/privacy` added.
|
||||
|
||||
- [x] **Fix Pydantic V2 deprecation warnings**: Replaced `.dict()` with `.model_dump()` in `admin.py` and `notifications.py`. Fixed `RuntimeWarning: coroutine never awaited` for `db.add()` in test mocks (`test_tasks.py`, `test_config_service.py`).
|
||||
|
||||
- [x] **Expanded backend test coverage**: Added 150 new unit tests across 10 new test files, increasing the backend test count from 361 to 511. New coverage includes `core/gdpr.py`, `utils/gmail_labels.py`, `services/notification_service.py`, `services/auth_service.py`, and API endpoints for auth, users, notifications, processing-runs/logs, app-settings, and version.
|
||||
|
||||
- [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 +108,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 +169,19 @@ 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.
|
||||
- [x] **AddMailAccountModal test coverage**: 28 tests covering create/edit mode rendering, provider wizard flow, form fields, auto-detect, test connection, submit mutations, error extraction, and modal interactions. Total frontend: 147 tests across 9 suites.
|
||||
- [x] **Expanded `api.ts` test coverage**: Added 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`). Every exported method now has at least one test verifying correct HTTP method, URL, parameters, and return value. Total: 65 tests in `api.test.ts`.
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Write unit tests for authentication (target 80%+ coverage)
|
||||
- [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions)
|
||||
- [x] Write unit tests for mail account endpoints (30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs/logs)
|
||||
- [x] Write unit tests for provider endpoints (23 tests covering presets, Gmail credential CRUD, import labels, authorize URL, debug email, OAuth callback)
|
||||
- [x] **Expanded `gmail_service.py` test coverage**: 10 new tests covering `inject_email` HttpError branch, `get_or_create_label` (existing/create/HttpError/generic-error), `inject_debug_email` full flow, `get_refreshed_token` (changed/unchanged), and `service` property lazy initialization — 24 total tests in `test_gmail_service.py`
|
||||
- [ ] 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 +268,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 +297,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 +333,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 +460,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",
|
||||
|
||||
@@ -19,7 +19,8 @@ COPY . .
|
||||
# Set environment variable for build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
# Turbopack is not supported for this ARM64 Alpine/QEMU build path.
|
||||
RUN npm run build:docker
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
|
||||
@@ -13,6 +13,15 @@ const eslintConfig = defineConfig([
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
// Specify explicit React version to avoid eslint-plugin-react calling the
|
||||
// removed context.getFilename() API when using version: 'detect' with ESLint 10.
|
||||
{
|
||||
settings: {
|
||||
react: {
|
||||
version: "19",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -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);
|
||||
@@ -5,25 +5,38 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:docker": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --coverage --coverageReporters=lcov --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.95.0",
|
||||
"axios": "^1.13.6",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "^5.0.12"
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"axios": "^1.16.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "5.0.6",
|
||||
"postcss": "8.5.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@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": "^25",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint": "^10",
|
||||
"eslint-config-next": "16.2.5",
|
||||
"jest": "^30.3.0",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"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 |