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
|
# POP3_ACCOUNT_2_USE_SSL=true
|
||||||
|
|
||||||
# ── Bootstrap Settings (always from env, never from database) ──────
|
# ── Bootstrap Settings (always from env, never from database) ──────
|
||||||
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder
|
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/inbox_converge
|
||||||
# SECRET_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
|
# SECRET_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
|
||||||
# ENCRYPTION_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
|
# ENCRYPTION_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
|
||||||
|
|
||||||
@@ -41,5 +41,10 @@ MAX_EMAILS_PER_RUN=50
|
|||||||
# Throttling (emails per minute)
|
# Throttling (emails per minute)
|
||||||
THROTTLE_EMAILS_PER_MINUTE=10
|
THROTTLE_EMAILS_PER_MINUTE=10
|
||||||
|
|
||||||
|
# Application identity (used by frontend server components at runtime)
|
||||||
|
APP_NAME=InboxConverge
|
||||||
|
APP_URL=https://inboxconverge.com
|
||||||
|
CONTACT_EMAIL=christian@inboxconverge.com
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ assignees: ''
|
|||||||
<!-- What actually happened -->
|
<!-- What actually happened -->
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
- **Component**: [e.g., Backend API, Worker, Docker, pop3_forwarder.py]
|
- **Component**: [e.g., Backend API, Worker, Docker, inbox_converge.py]
|
||||||
- **Version**: [e.g., v1.0.0, main branch]
|
- **Version**: [e.g., v1.0.0, main branch]
|
||||||
- **Deployment**: [e.g., Docker, Kubernetes, local]
|
- **Deployment**: [e.g., Docker, Kubernetes, local]
|
||||||
- **OS**: [e.g., Ubuntu 22.04, macOS, Windows]
|
- **OS**: [e.g., Ubuntu 22.04, macOS, Windows]
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ on:
|
|||||||
env:
|
env:
|
||||||
GHCR_REGISTRY: ghcr.io
|
GHCR_REGISTRY: ghcr.io
|
||||||
PRIVATE_REGISTRY: registry.cklnet.com
|
PRIVATE_REGISTRY: registry.cklnet.com
|
||||||
|
K8S_STATE_REPO: christianlouis/k8s-cluster-state
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Phase 1: Lint ──────────────────────────────────────────────────────
|
# ── Phase 1: Lint ──────────────────────────────────────────────────────
|
||||||
@@ -24,7 +26,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
@@ -77,7 +79,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: pop3_forwarder_test
|
POSTGRES_DB: inbox_converge_test
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
@@ -97,7 +99,7 @@ jobs:
|
|||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
@@ -113,7 +115,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/pop3_forwarder_test
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/inbox_converge_test
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
SECRET_KEY: test-secret-key-for-ci-cd-at-least-32-chars
|
SECRET_KEY: test-secret-key-for-ci-cd-at-least-32-chars
|
||||||
ENCRYPTION_KEY: test-encryption-key-for-ci-cd-at-least-32-chars
|
ENCRYPTION_KEY: test-encryption-key-for-ci-cd-at-least-32-chars
|
||||||
@@ -121,12 +123,37 @@ jobs:
|
|||||||
cd backend
|
cd backend
|
||||||
pytest tests/ -v --cov=app --cov-report=xml --cov-report=term
|
pytest tests/ -v --cov=app --cov-report=xml --cov-report=term
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Set up Node.js
|
||||||
uses: codecov/codecov-action@v5
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
file: ./backend/coverage.xml
|
node-version: '20.19.0'
|
||||||
flags: unittests
|
cache: 'npm'
|
||||||
name: codecov-umbrella
|
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
|
fail_ci_if_error: false
|
||||||
|
|
||||||
# ── Phase 3: Security ──────────────────────────────────────────────────
|
# ── Phase 3: Security ──────────────────────────────────────────────────
|
||||||
@@ -138,7 +165,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
@@ -148,7 +175,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install bandit safety
|
pip install bandit pip-audit
|
||||||
pip install -r backend/requirements.txt
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
- name: Run Bandit security scan
|
- name: Run Bandit security scan
|
||||||
@@ -156,7 +183,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Check dependencies for known vulnerabilities
|
- name: Check dependencies for known vulnerabilities
|
||||||
run: safety check --json
|
run: pip-audit -r backend/requirements.txt
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# ── Phase 4: Build ─────────────────────────────────────────────────────
|
# ── Phase 4: Build ─────────────────────────────────────────────────────
|
||||||
@@ -173,39 +200,30 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- context: ./backend
|
- context: ./backend
|
||||||
image_name: gmail-puller/backend
|
image_name: inboxconverge/backend
|
||||||
- context: ./frontend
|
- context: ./frontend
|
||||||
image_name: gmail-puller/frontend
|
image_name: inboxconverge/frontend
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.GHCR_REGISTRY }}
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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)
|
- name: Extract metadata (tags, labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image_name }}
|
${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image_name }}
|
||||||
${{ env.PRIVATE_REGISTRY }}/${{ matrix.image_name }}
|
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
@@ -215,7 +233,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-push
|
id: build-push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
push: true
|
push: true
|
||||||
@@ -223,4 +241,87 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
logs/
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -55,7 +54,6 @@ htmlcov/
|
|||||||
|
|
||||||
# Backend specific
|
# Backend specific
|
||||||
backend/.env
|
backend/.env
|
||||||
backend/alembic/versions/*_*.py
|
|
||||||
backend/*.db
|
backend/*.db
|
||||||
backend/*.sqlite
|
backend/*.sqlite
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ repos:
|
|||||||
- id: black
|
- id: black
|
||||||
language_version: python3.11
|
language_version: python3.11
|
||||||
args: [--line-length=100]
|
args: [--line-length=100]
|
||||||
files: ^(backend/|pop3_forwarder\.py)
|
files: ^(backend/|inboxconverge\.py)
|
||||||
|
|
||||||
# Python linting with Ruff (replaces flake8, isort, etc.)
|
# Python linting with Ruff (replaces flake8, isort, etc.)
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
@@ -41,7 +41,7 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [--fix, --exit-non-zero-on-fix]
|
args: [--fix, --exit-non-zero-on-fix]
|
||||||
files: ^(backend/|pop3_forwarder\.py)
|
files: ^(backend/|inboxconverge\.py)
|
||||||
|
|
||||||
# Type checking with mypy
|
# Type checking with mypy
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
|||||||
@@ -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.
|
Thank you for your interest in contributing! This document provides guidelines for contributing to the project.
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ Be respectful and inclusive. We welcome contributions from everyone.
|
|||||||
|
|
||||||
### Reporting Bugs
|
### Reporting Bugs
|
||||||
|
|
||||||
1. Check if the bug has already been reported in [Issues](https://github.com/christianlouis/pop_puller_to_gmail/issues)
|
1. Check if the bug has already been reported in [Issues](https://github.com/christianlouis/inboxconverge/issues)
|
||||||
2. If not, create a new issue with:
|
2. If not, create a new issue with:
|
||||||
- Clear title and description
|
- Clear title and description
|
||||||
- Steps to reproduce
|
- Steps to reproduce
|
||||||
@@ -77,8 +77,8 @@ Be respectful and inclusive. We welcome contributions from everyone.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone your fork
|
# Clone your fork
|
||||||
git clone https://github.com/YOUR-USERNAME/pop_puller_to_gmail.git
|
git clone https://github.com/YOUR-USERNAME/inboxconverge.git
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
|
|
||||||
# Install all development dependencies
|
# Install all development dependencies
|
||||||
make install-dev
|
make install-dev
|
||||||
@@ -88,7 +88,7 @@ cp .env.example .env
|
|||||||
# Edit .env with test credentials
|
# Edit .env with test credentials
|
||||||
|
|
||||||
# Run the legacy forwarder script directly
|
# Run the legacy forwarder script directly
|
||||||
python pop3_forwarder.py
|
python inbox_converge.py
|
||||||
|
|
||||||
# Or start the SaaS backend in dev mode
|
# Or start the SaaS backend in dev mode
|
||||||
make run-dev
|
make run-dev
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY pop3_forwarder.py .
|
COPY inboxconverge.py .
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN useradd -m -u 1000 forwarder && \
|
RUN useradd -m -u 1000 inboxconverge && \
|
||||||
chown -R forwarder:forwarder /app
|
chown -R inboxconverge:inboxconverge /app
|
||||||
|
|
||||||
USER forwarder
|
USER inboxconverge
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "-u", "pop3_forwarder.py"]
|
CMD ["python", "-u", "inboxconverge.py"]
|
||||||
|
|||||||
@@ -120,12 +120,12 @@ run-beat: ## Run Celery beat scheduler
|
|||||||
run-flower: ## Run Flower (Celery monitoring)
|
run-flower: ## Run Flower (Celery monitoring)
|
||||||
cd backend && celery -A app.core.celery_app flower --port=5555
|
cd backend && celery -A app.core.celery_app flower --port=5555
|
||||||
|
|
||||||
run-legacy: ## Run legacy pop3_forwarder script
|
run-legacy: ## Run legacy inboxconverge script
|
||||||
python pop3_forwarder.py
|
python inboxconverge.py
|
||||||
|
|
||||||
# Database Shell
|
# Database Shell
|
||||||
shell: ## Open database shell
|
shell: ## Open database shell
|
||||||
docker-compose exec db psql -U postgres -d pop3_forwarder
|
docker-compose exec db psql -U postgres -d inbox_converge
|
||||||
|
|
||||||
shell-python: ## Open Python shell with app context
|
shell-python: ## Open Python shell with app context
|
||||||
cd backend && python -c "from app.core.database import SessionLocal; db = SessionLocal(); print('Database session available as db')"
|
cd backend && python -c "from app.core.database import SessionLocal; db = SessionLocal(); print('Database session available as db')"
|
||||||
|
|||||||
@@ -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/InboxConverge/actions/workflows/ci.yml)
|
||||||
[](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/lint.yml)
|
[](https://github.com/christianlouis/InboxConverge/releases/latest)
|
||||||
[](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://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.python.org/downloads/)
|
|
||||||
[](https://www.docker.com/)
|
[](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
|
> **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."*
|
||||||
- **Dual Delivery** — inject emails via **Gmail API** (preferred) or forward via **SMTP**
|
>
|
||||||
- **Hybrid Configuration** — configure via environment variables, `.env` files, **or** the database
|
> InboxConverge puts that option back — permanently, on your own terms.
|
||||||
- **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
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Pull and configure
|
# 1. Grab the config files
|
||||||
curl -O https://raw.githubusercontent.com/christianlouis/pop_puller_to_gmail/main/docker-compose.yml
|
curl -O https://raw.githubusercontent.com/christianlouis/InboxConverge/main/docker-compose.yml
|
||||||
curl -o .env https://raw.githubusercontent.com/christianlouis/pop_puller_to_gmail/main/.env.example
|
curl -o .env https://raw.githubusercontent.com/christianlouis/InboxConverge/main/.env.example
|
||||||
|
|
||||||
# Edit .env with your credentials
|
# 2. Fill in your credentials
|
||||||
nano .env
|
nano .env
|
||||||
|
|
||||||
# Start
|
# 3. Launch
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building from Source
|
Minimum `.env` to get going:
|
||||||
|
|
||||||
```bash
|
```dotenv
|
||||||
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
|
# Source mailbox — add _2_, _3_, … for additional accounts
|
||||||
cd pop_puller_to_gmail
|
POP3_ACCOUNT_1_HOST=pop.yourprovider.com
|
||||||
cp .env.example .env # then edit .env
|
POP3_ACCOUNT_1_USER=you@yourprovider.com
|
||||||
docker-compose up -d
|
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 |
|
## Key settings
|
||||||
|--------|----------|---------|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
**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.
|
All settings except the three bootstrap secrets can be changed at runtime in the admin web UI — no restart needed.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Detailed documentation lives in the [`docs/`](docs/) directory:
|
| | |
|
||||||
|
|---|---|
|
||||||
| Document | Description |
|
| [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) | System design and component overview |
|
| [Architecture](docs/ARCHITECTURE.md) | How the pieces fit together |
|
||||||
| [Quick Start](docs/QUICKSTART.md) | Step-by-step setup guide |
|
| [Migration Guide](docs/MIGRATION_GUIDE.md) | Upgrading from an older version |
|
||||||
| [Migration Guide](docs/MIGRATION_GUIDE.md) | Upgrading from v1 to v2 |
|
| [Roadmap](docs/ROADMAP.md) | What's coming next |
|
||||||
| [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 |
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on:
|
Bug reports, feature requests, and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
- Reporting bugs and suggesting features
|
|
||||||
- Development setup and code style
|
|
||||||
- Pull request process
|
|
||||||
|
|
||||||
## Security
|
## 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
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License — see [LICENSE](LICENSE) for details.
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- [Issue Tracker](https://github.com/christianlouis/pop_puller_to_gmail/issues)
|
|
||||||
- [Discussions](https://github.com/christianlouis/pop_puller_to_gmail/discussions)
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
If you discover a security vulnerability, please report it responsibly:
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
1. **Email**: Send details to the repository maintainer via the email listed on the [GitHub profile](https://github.com/christianlouis).
|
1. **Email**: Send details to the repository maintainer via the email listed on the [GitHub profile](https://github.com/christianlouis).
|
||||||
2. **GitHub Private Vulnerability Reporting**: Use [GitHub's security advisory feature](https://github.com/christianlouis/pop_puller_to_gmail/security/advisories/new) to report privately.
|
2. **GitHub Private Vulnerability Reporting**: Use [GitHub's security advisory feature](https://github.com/christianlouis/inboxconverge/security/advisories/new) to report privately.
|
||||||
|
|
||||||
### What to Include
|
### What to Include
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/pop3_forwarder
|
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/inbox_converge
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
SECRET_KEY=change-this-to-a-secure-random-secret-key-minimum-32-characters
|
SECRET_KEY=change-this-to-a-secure-random-secret-key-minimum-32-characters
|
||||||
@@ -41,12 +41,14 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Admin Account (created on first startup)
|
# Admin Account (created on first startup)
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=christian@inboxconverge.com
|
||||||
ADMIN_PASSWORD=change-this-secure-password
|
ADMIN_PASSWORD=change-this-secure-password
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
APP_NAME=POP3 Forwarder SaaS
|
APP_NAME=InboxConverge
|
||||||
APP_VERSION=2.0.0
|
APP_VERSION=2.0.0
|
||||||
|
APP_URL=https://inboxconverge.com
|
||||||
|
CONTACT_EMAIL=christian@inboxconverge.com
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Inject build metadata
|
||||||
|
ARG BUILD_DATE=""
|
||||||
|
ENV BUILD_DATE=${BUILD_DATE}
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN useradd -m -u 1000 appuser && \
|
RUN useradd -m -u 1000 appuser && \
|
||||||
chown -R appuser:appuser /app
|
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,
|
admin,
|
||||||
providers,
|
providers,
|
||||||
app_settings,
|
app_settings,
|
||||||
|
logs,
|
||||||
|
version,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(admin.router, prefix="/admin", tags=["Admin"])
|
||||||
api_router.include_router(app_settings.router, prefix="/settings", tags=["Settings"])
|
api_router.include_router(app_settings.router, prefix="/settings", tags=["Settings"])
|
||||||
|
api_router.include_router(
|
||||||
|
logs.router, prefix="/processing-runs", tags=["Processing Logs"]
|
||||||
|
)
|
||||||
|
api_router.include_router(version.router, prefix="/version", tags=["Version"])
|
||||||
|
|||||||
@@ -1,12 +1,41 @@
|
|||||||
"""Admin endpoints"""
|
"""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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_superuser
|
from app.core.deps import get_current_superuser
|
||||||
from app.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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -35,3 +64,508 @@ async def get_admin_stats(
|
|||||||
"total_mail_accounts": total_accounts,
|
"total_mail_accounts": total_accounts,
|
||||||
"total_processing_runs": total_runs,
|
"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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import quote as urlquote
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import verify_password, get_password_hash
|
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.database_models import User, SubscriptionTier
|
||||||
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
|
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
|
||||||
from app.services.auth_service import oauth_service
|
from app.services.auth_service import oauth_service
|
||||||
@@ -18,6 +25,57 @@ from app.services.auth_service import oauth_service
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
@router.post(
|
||||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
"/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"
|
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
|
# Create new user
|
||||||
user = User(
|
user = User(
|
||||||
email=user_in.email,
|
email=user_in.email,
|
||||||
@@ -41,14 +102,16 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
hashed_password=(
|
hashed_password=(
|
||||||
get_password_hash(user_in.password) if user_in.password else None
|
get_password_hash(user_in.password) if user_in.password else None
|
||||||
),
|
),
|
||||||
subscription_tier=SubscriptionTier.FREE,
|
subscription_tier=_default_tier(),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
|
is_superuser=_is_admin_email(user_in.email),
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
AUTH_REGISTRATIONS_TOTAL.labels(method="password", status="success").inc()
|
||||||
logger.info(f"New user registered: {user.email}")
|
logger.info(f"New user registered: {user.email}")
|
||||||
|
|
||||||
return user
|
return user
|
||||||
@@ -65,6 +128,7 @@ async def login(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user or not user.hashed_password:
|
if not user or not user.hashed_password:
|
||||||
|
AUTH_LOGINS_TOTAL.labels(method="password", status="failure").inc()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect email or password",
|
detail="Incorrect email or password",
|
||||||
@@ -73,6 +137,7 @@ async def login(
|
|||||||
|
|
||||||
# Verify password
|
# Verify password
|
||||||
if not verify_password(form_data.password, user.hashed_password): # type: ignore[arg-type]
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect email or password",
|
detail="Incorrect email or password",
|
||||||
@@ -81,17 +146,29 @@ async def login(
|
|||||||
|
|
||||||
# Check if user is active
|
# Check if user is active
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
|
AUTH_LOGINS_TOTAL.labels(method="password", status="failure").inc()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
|
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
|
# Update last login
|
||||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
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()
|
await db.commit()
|
||||||
|
|
||||||
# Create tokens
|
# Create tokens
|
||||||
tokens = oauth_service.create_tokens_for_user(user)
|
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}")
|
logger.info(f"User logged in: {user.email}")
|
||||||
|
|
||||||
return tokens
|
return tokens
|
||||||
@@ -105,73 +182,126 @@ async def google_oauth(
|
|||||||
Authenticate with Google OAuth2.
|
Authenticate with Google OAuth2.
|
||||||
Exchange authorization code for access token and user info.
|
Exchange authorization code for access token and user info.
|
||||||
"""
|
"""
|
||||||
|
logger.debug(
|
||||||
# Get user info from Google
|
"OAuth [Google sign-in]: callback received (redirect_uri=%s)",
|
||||||
user_info = await oauth_service.get_google_user_info(
|
auth_request.redirect_uri,
|
||||||
code=auth_request.code, redirect_uri=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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Email not verified with Google",
|
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")
|
@router.get("/google/authorize-url")
|
||||||
async def get_google_authorize_url(redirect_uri: str):
|
async def get_google_authorize_url(redirect_uri: str):
|
||||||
"""Get Google OAuth2 authorization URL"""
|
"""Get Google OAuth2 authorization URL for sign-in (profile scopes only)."""
|
||||||
from app.core.config import settings
|
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 = (
|
auth_url = (
|
||||||
f"https://accounts.google.com/o/oauth2/v2/auth?"
|
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
f"client_id={settings.GOOGLE_CLIENT_ID}&"
|
f"?client_id={settings.GOOGLE_CLIENT_ID}"
|
||||||
f"response_type=code&"
|
"&response_type=code"
|
||||||
f"scope=openid%20email%20profile&"
|
f"&scope={scope}"
|
||||||
f"redirect_uri={redirect_uri}&"
|
f"&redirect_uri={redirect_uri}"
|
||||||
f"access_type=offline"
|
"&prompt=select_account"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"authorization_url": auth_url}
|
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"""
|
"""Mail account management endpoints"""
|
||||||
|
|
||||||
from typing import List
|
import math
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc, func
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_active_user
|
from app.core.deps import get_current_active_user
|
||||||
from app.core.security import encrypt_credential
|
from app.core.security import encrypt_credential, decrypt_credential
|
||||||
from app.models.database_models import User, MailAccount, AccountStatus
|
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 (
|
from app.models.schemas import (
|
||||||
MailAccountCreate,
|
MailAccountCreate,
|
||||||
MailAccountResponse,
|
MailAccountResponse,
|
||||||
@@ -17,6 +26,10 @@ from app.models.schemas import (
|
|||||||
MailAccountTestResponse,
|
MailAccountTestResponse,
|
||||||
MailAccountAutoDetectRequest,
|
MailAccountAutoDetectRequest,
|
||||||
MailAccountAutoDetectResponse,
|
MailAccountAutoDetectResponse,
|
||||||
|
PaginatedProcessingRunsResponse,
|
||||||
|
PaginatedProcessingLogsResponse,
|
||||||
|
ProcessingRunDetailResponse,
|
||||||
|
ProcessingLogDetailResponse,
|
||||||
)
|
)
|
||||||
from app.services.mail_processor import MailProcessor, MailServerAutoDetect
|
from app.services.mail_processor import MailProcessor, MailServerAutoDetect
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -34,26 +47,41 @@ async def create_mail_account(
|
|||||||
):
|
):
|
||||||
"""Create a new mail account"""
|
"""Create a new mail account"""
|
||||||
|
|
||||||
# Check subscription limits
|
# Superusers are not subject to subscription limits
|
||||||
result = await db.execute(
|
if not current_user.is_superuser:
|
||||||
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
# Count existing accounts for this user
|
||||||
)
|
result = await db.execute(
|
||||||
existing_accounts = result.scalars().all()
|
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
||||||
|
|
||||||
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.",
|
|
||||||
)
|
)
|
||||||
|
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
|
# Encrypt password
|
||||||
encrypted_password = encrypt_credential(account_in.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,
|
check_interval_minutes=account_in.check_interval_minutes,
|
||||||
max_emails_per_check=account_in.max_emails_per_check,
|
max_emails_per_check=account_in.max_emails_per_check,
|
||||||
delete_after_forward=account_in.delete_after_forward,
|
delete_after_forward=account_in.delete_after_forward,
|
||||||
|
debug_logging=account_in.debug_logging,
|
||||||
|
provider_name=account_in.provider_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(account)
|
db.add(account)
|
||||||
@@ -146,9 +176,14 @@ async def update_mail_account(
|
|||||||
update_data = account_update.model_dump(exclude_unset=True)
|
update_data = account_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
if "password" in update_data:
|
if "password" in update_data:
|
||||||
update_data["encrypted_password"] = encrypt_credential(
|
password = update_data.pop("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():
|
for field, value in update_data.items():
|
||||||
setattr(account, field, value)
|
setattr(account, field, value)
|
||||||
@@ -214,6 +249,72 @@ async def toggle_mail_account(
|
|||||||
return 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)
|
@router.post("/test", response_model=MailAccountTestResponse)
|
||||||
async def test_mail_connection(
|
async def test_mail_connection(
|
||||||
test_request: MailAccountTestRequest,
|
test_request: MailAccountTestRequest,
|
||||||
@@ -242,6 +343,39 @@ async def test_mail_connection(
|
|||||||
return MailAccountTestResponse(success=success, message=message)
|
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)
|
@router.post("/auto-detect", response_model=MailAccountAutoDetectResponse)
|
||||||
async def auto_detect_mail_settings(
|
async def auto_detect_mail_settings(
|
||||||
detect_request: MailAccountAutoDetectRequest,
|
detect_request: MailAccountAutoDetectRequest,
|
||||||
@@ -254,3 +388,154 @@ async def auto_detect_mail_settings(
|
|||||||
return MailAccountAutoDetectResponse(
|
return MailAccountAutoDetectResponse(
|
||||||
success=len(suggestions) > 0, suggestions=suggestions
|
success=len(suggestions) > 0, suggestions=suggestions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-account processing runs & logs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{account_id}/processing-runs",
|
||||||
|
response_model=PaginatedProcessingRunsResponse,
|
||||||
|
summary="List processing runs for a specific mail account",
|
||||||
|
)
|
||||||
|
async def list_account_runs(
|
||||||
|
account_id: int,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
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"""
|
"""Notification configuration endpoints"""
|
||||||
|
|
||||||
from typing import List
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
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.database_models import User, NotificationConfig
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
NotificationConfigCreate,
|
NotificationConfigCreate,
|
||||||
|
NotificationConfigUpdate,
|
||||||
NotificationConfigResponse,
|
NotificationConfigResponse,
|
||||||
|
NotificationTestRequest,
|
||||||
|
NotificationTestResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.notification_service import test_notification
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -24,8 +28,8 @@ async def create_notification_config(
|
|||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Create notification configuration"""
|
"""Create a new notification configuration"""
|
||||||
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
|
config = NotificationConfig(user_id=current_user.id, **config_in.model_dump())
|
||||||
db.add(config)
|
db.add(config)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(config)
|
await db.refresh(config)
|
||||||
@@ -37,8 +41,94 @@ async def list_notification_configs(
|
|||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""List all notification configurations"""
|
"""List all notification configurations for the current user"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(NotificationConfig).where(NotificationConfig.user_id == current_user.id)
|
select(NotificationConfig).where(NotificationConfig.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
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 datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from urllib.parse import quote as urlquote
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -10,7 +11,7 @@ import logging
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_active_user
|
from app.core.deps import get_current_active_user
|
||||||
from app.core.security import encrypt_credential
|
from app.core.security import encrypt_credential, decrypt_credential
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.database_models import User, GmailCredential
|
from app.models.database_models import User, GmailCredential
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
@@ -20,18 +21,37 @@ from app.models.schemas import (
|
|||||||
GmailCredentialResponse,
|
GmailCredentialResponse,
|
||||||
GmailAuthorizeResponse,
|
GmailAuthorizeResponse,
|
||||||
GmailCallbackRequest,
|
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()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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 = [
|
GMAIL_API_SCOPES = [
|
||||||
"openid",
|
"openid",
|
||||||
"email",
|
"email",
|
||||||
"https://www.googleapis.com/auth/gmail.insert",
|
*GMAIL_SCOPES,
|
||||||
"https://www.googleapis.com/auth/gmail.labels",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Provider presets with server configurations
|
# Provider presets with server configurations
|
||||||
@@ -135,6 +155,15 @@ PROVIDER_PRESETS: List[ProviderPreset] = [
|
|||||||
pop3_ssl={"host": "pop.mail.de", "port": 995},
|
pop3_ssl={"host": "pop.mail.de", "port": 995},
|
||||||
notes="Use your mail.de email credentials.",
|
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(
|
ProviderPreset(
|
||||||
id="icloud",
|
id="icloud",
|
||||||
name="iCloud Mail",
|
name="iCloud Mail",
|
||||||
@@ -188,6 +217,13 @@ async def save_gmail_credential(
|
|||||||
Save Gmail API OAuth2 credentials for the current user.
|
Save Gmail API OAuth2 credentials for the current user.
|
||||||
These are used to inject emails directly into Gmail via the API.
|
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
|
# Verify the credentials work
|
||||||
gmail_service = GmailService(
|
gmail_service = GmailService(
|
||||||
access_token=credential_in.access_token,
|
access_token=credential_in.access_token,
|
||||||
@@ -198,6 +234,12 @@ async def save_gmail_credential(
|
|||||||
|
|
||||||
is_valid = await gmail_service.verify_access()
|
is_valid = await gmail_service.verify_access()
|
||||||
if not is_valid:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Gmail API credentials are invalid or expired",
|
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.gmail_email = credential_in.gmail_email # type: ignore[assignment]
|
||||||
existing.encrypted_access_token = encrypted_access # type: ignore[assignment]
|
existing.encrypted_access_token = encrypted_access # type: ignore[assignment]
|
||||||
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
|
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
|
||||||
|
existing.scopes = build_gmail_credential_scopes(
|
||||||
|
existing.granted_scopes,
|
||||||
|
existing.import_label_templates,
|
||||||
|
) # type: ignore[assignment]
|
||||||
existing.is_valid = True # type: ignore[assignment]
|
existing.is_valid = True # type: ignore[assignment]
|
||||||
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(existing)
|
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
|
return existing
|
||||||
else:
|
else:
|
||||||
# Create new
|
# Create new
|
||||||
@@ -233,12 +284,18 @@ async def save_gmail_credential(
|
|||||||
gmail_email=credential_in.gmail_email,
|
gmail_email=credential_in.gmail_email,
|
||||||
encrypted_access_token=encrypted_access,
|
encrypted_access_token=encrypted_access,
|
||||||
encrypted_refresh_token=encrypted_refresh,
|
encrypted_refresh_token=encrypted_refresh,
|
||||||
|
scopes=build_gmail_credential_scopes(granted_scopes=[]),
|
||||||
is_valid=True,
|
is_valid=True,
|
||||||
last_verified_at=datetime.now(timezone.utc),
|
last_verified_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.add(credential)
|
db.add(credential)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(credential)
|
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
|
return credential
|
||||||
|
|
||||||
|
|
||||||
@@ -279,10 +336,50 @@ async def delete_gmail_credential(
|
|||||||
detail="No Gmail credentials found",
|
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.delete(credential)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/gmail-credential/labels", response_model=GmailCredentialResponse)
|
||||||
|
async def update_gmail_import_labels(
|
||||||
|
labels_in: GmailImportLabelsUpdate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update the Gmail labels applied to imported messages."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
credential = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not credential:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No Gmail credentials found. Connect Gmail first.",
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
@router.get("/gmail/authorize-url", response_model=GmailAuthorizeResponse)
|
||||||
async def get_gmail_authorize_url(
|
async def get_gmail_authorize_url(
|
||||||
redirect_uri: str,
|
redirect_uri: str,
|
||||||
@@ -302,7 +399,14 @@ async def get_gmail_authorize_url(
|
|||||||
detail="Google OAuth2 is not configured on this server.",
|
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 = (
|
url = (
|
||||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
f"?client_id={settings.GOOGLE_CLIENT_ID}"
|
f"?client_id={settings.GOOGLE_CLIENT_ID}"
|
||||||
@@ -311,10 +415,119 @@ async def get_gmail_authorize_url(
|
|||||||
f"&redirect_uri={redirect_uri}"
|
f"&redirect_uri={redirect_uri}"
|
||||||
"&access_type=offline"
|
"&access_type=offline"
|
||||||
"&prompt=consent"
|
"&prompt=consent"
|
||||||
|
"&include_granted_scopes=true"
|
||||||
|
"&state=gmail_connect"
|
||||||
)
|
)
|
||||||
return GmailAuthorizeResponse(authorization_url=url)
|
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(
|
@router.post(
|
||||||
"/gmail/callback",
|
"/gmail/callback",
|
||||||
response_model=GmailCredentialResponse,
|
response_model=GmailCredentialResponse,
|
||||||
@@ -347,7 +560,18 @@ async def gmail_oauth_callback(
|
|||||||
detail="Google OAuth2 is not configured on this server.",
|
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
|
# 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:
|
async with httpx.AsyncClient() as client:
|
||||||
token_resp = await client.post(
|
token_resp = await client.post(
|
||||||
"https://oauth2.googleapis.com/token",
|
"https://oauth2.googleapis.com/token",
|
||||||
@@ -361,7 +585,13 @@ async def gmail_oauth_callback(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if token_resp.status_code != 200:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Failed to exchange authorization code with Google.",
|
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")
|
access_token: Optional[str] = token_data.get("access_token")
|
||||||
refresh_token: Optional[str] = token_data.get("refresh_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:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Google did not return an access token.",
|
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
|
# 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:
|
async with httpx.AsyncClient() as client:
|
||||||
profile_resp = await client.get(
|
profile_resp = await client.get(
|
||||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
@@ -387,6 +645,20 @@ async def gmail_oauth_callback(
|
|||||||
gmail_email = current_user.email # fallback
|
gmail_email = current_user.email # fallback
|
||||||
if profile_resp.status_code == 200:
|
if profile_resp.status_code == 200:
|
||||||
gmail_email = profile_resp.json().get("email", current_user.email)
|
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)
|
# Calculate token expiry (Google access tokens last 1 hour)
|
||||||
token_expiry = datetime.now(timezone.utc) + timedelta(
|
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
|
# 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(
|
gmail_service = GmailService(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
@@ -402,6 +679,12 @@ async def gmail_oauth_callback(
|
|||||||
)
|
)
|
||||||
is_valid = await gmail_service.verify_access()
|
is_valid = await gmail_service.verify_access()
|
||||||
if not is_valid:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Obtained tokens but could not verify Gmail API access. "
|
detail="Obtained tokens but could not verify Gmail API access. "
|
||||||
@@ -423,11 +706,23 @@ async def gmail_oauth_callback(
|
|||||||
if encrypted_refresh:
|
if encrypted_refresh:
|
||||||
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
|
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
|
||||||
existing.token_expiry = token_expiry # type: ignore[assignment]
|
existing.token_expiry = token_expiry # type: ignore[assignment]
|
||||||
existing.scopes = token_data.get("scope", "").split() # type: ignore[assignment]
|
existing.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
|
||||||
|
token_data.get("scope", "").split(),
|
||||||
|
existing.import_label_templates,
|
||||||
|
)
|
||||||
existing.is_valid = True # type: ignore[assignment]
|
existing.is_valid = True # type: ignore[assignment]
|
||||||
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(existing)
|
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
|
return existing
|
||||||
else:
|
else:
|
||||||
credential = GmailCredential(
|
credential = GmailCredential(
|
||||||
@@ -436,11 +731,20 @@ async def gmail_oauth_callback(
|
|||||||
encrypted_access_token=encrypted_access,
|
encrypted_access_token=encrypted_access,
|
||||||
encrypted_refresh_token=encrypted_refresh,
|
encrypted_refresh_token=encrypted_refresh,
|
||||||
token_expiry=token_expiry,
|
token_expiry=token_expiry,
|
||||||
scopes=token_data.get("scope", "").split(),
|
scopes=build_gmail_credential_scopes(token_data.get("scope", "").split()),
|
||||||
is_valid=True,
|
is_valid=True,
|
||||||
last_verified_at=datetime.now(timezone.utc),
|
last_verified_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.add(credential)
|
db.add(credential)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(credential)
|
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
|
return credential
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
||||||
async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
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(
|
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()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -25,10 +34,22 @@ async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
|||||||
@router.get("/current")
|
@router.get("/current")
|
||||||
async def get_current_subscription(
|
async def get_current_subscription(
|
||||||
current_user: User = Depends(get_current_active_user),
|
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 {
|
return {
|
||||||
"tier": current_user.subscription_tier,
|
"tier": current_user.subscription_tier,
|
||||||
"status": current_user.subscription_status,
|
"status": current_user.subscription_status,
|
||||||
"expires_at": current_user.subscription_expires_at,
|
"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"""
|
"""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 fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_active_user
|
from app.core.deps import get_current_active_user
|
||||||
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.database_models import User, UserSmtpConfig
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
UserDetailResponse,
|
UserDetailResponse,
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
UserSmtpConfigUpdate,
|
UserSmtpConfigUpdate,
|
||||||
UserSmtpConfigResponse,
|
UserSmtpConfigResponse,
|
||||||
|
SmtpTestResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -66,6 +72,7 @@ async def get_smtp_config(
|
|||||||
host=config.host, # type: ignore[arg-type]
|
host=config.host, # type: ignore[arg-type]
|
||||||
port=config.port, # type: ignore[arg-type]
|
port=config.port, # type: ignore[arg-type]
|
||||||
username=config.username, # 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]
|
use_tls=config.use_tls, # type: ignore[arg-type]
|
||||||
has_password=bool(config.encrypted_password),
|
has_password=bool(config.encrypted_password),
|
||||||
created_at=config.created_at, # type: ignore[arg-type]
|
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.host = config_in.host # type: ignore[assignment]
|
||||||
config.port = config_in.port # type: ignore[assignment]
|
config.port = config_in.port # type: ignore[assignment]
|
||||||
config.username = config_in.username # 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]
|
config.use_tls = config_in.use_tls # type: ignore[assignment]
|
||||||
if config_in.password is not None:
|
if config_in.password is not None:
|
||||||
config.encrypted_password = encrypt_credential(config_in.password) # type: ignore[assignment]
|
config.encrypted_password = encrypt_credential(config_in.password) # type: ignore[assignment]
|
||||||
@@ -98,6 +106,7 @@ async def upsert_smtp_config(
|
|||||||
host=config_in.host,
|
host=config_in.host,
|
||||||
port=config_in.port,
|
port=config_in.port,
|
||||||
username=config_in.username,
|
username=config_in.username,
|
||||||
|
sender_email=config_in.sender_email,
|
||||||
encrypted_password=(
|
encrypted_password=(
|
||||||
encrypt_credential(config_in.password) if config_in.password else ""
|
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]
|
host=config.host, # type: ignore[arg-type]
|
||||||
port=config.port, # type: ignore[arg-type]
|
port=config.port, # type: ignore[arg-type]
|
||||||
username=config.username, # 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]
|
use_tls=config.use_tls, # type: ignore[arg-type]
|
||||||
has_password=bool(config.encrypted_password),
|
has_password=bool(config.encrypted_password),
|
||||||
created_at=config.created_at, # type: ignore[arg-type]
|
created_at=config.created_at, # type: ignore[arg-type]
|
||||||
@@ -134,3 +144,87 @@ async def delete_smtp_config(
|
|||||||
if config:
|
if config:
|
||||||
await db.delete(config)
|
await db.delete(config)
|
||||||
await db.commit()
|
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
|
# Application
|
||||||
APP_NAME: str = "POP3 Forwarder SaaS"
|
APP_NAME: str = "InboxConverge"
|
||||||
APP_VERSION: str = "2.0.0"
|
APP_VERSION: str = "2.0.0"
|
||||||
|
BUILD_DATE: str = ""
|
||||||
|
APP_URL: str = "https://inboxconverge.com"
|
||||||
|
CONTACT_EMAIL: str = "christian@inboxconverge.com"
|
||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
API_V1_PREFIX: str = "/api/v1"
|
API_V1_PREFIX: str = "/api/v1"
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
HOST: str = "0.0.0.0"
|
HOST: str = (
|
||||||
|
"0.0.0.0" # nosec B104 – intentional: containerised service binds all interfaces
|
||||||
|
)
|
||||||
PORT: int = 8000
|
PORT: int = 8000
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: str = (
|
DATABASE_URL: str = (
|
||||||
"postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder"
|
"postgresql+asyncpg://user:password@localhost:5432/inbox_converge"
|
||||||
)
|
)
|
||||||
DATABASE_POOL_SIZE: int = 20
|
DATABASE_POOL_SIZE: int = 20
|
||||||
DATABASE_MAX_OVERFLOW: int = 10
|
DATABASE_MAX_OVERFLOW: int = 10
|
||||||
@@ -90,13 +95,31 @@ class Settings(BaseSettings):
|
|||||||
# Apprise (notifications)
|
# Apprise (notifications)
|
||||||
APPRISE_ENABLED: bool = True
|
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
|
# Logging
|
||||||
LOG_LEVEL: str = "INFO"
|
LOG_LEVEL: str = "INFO"
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_EMAIL: Optional[str] = None
|
ADMIN_EMAIL: Optional[str] = "christian@inboxconverge.com"
|
||||||
ADMIN_PASSWORD: Optional[str] = None
|
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
|
||||||
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json"
|
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 [i.strip() for i in v.split(",")]
|
||||||
return v
|
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")
|
@field_validator("SECRET_KEY")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_secret_key(cls, v: str) -> str:
|
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.
|
Main FastAPI application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
|
||||||
import logging
|
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.config import settings
|
||||||
from app.core.middleware import SecurityHeadersMiddleware, CSRFProtectionMiddleware
|
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
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, settings.LOG_LEVEL.upper()),
|
level=getattr(logging, settings.LOG_LEVEL.upper()),
|
||||||
@@ -22,7 +36,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
"""Application lifespan handler for startup and shutdown events."""
|
"""Application lifespan handler for startup and shutdown events."""
|
||||||
# Startup
|
# Startup
|
||||||
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
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,
|
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)
|
# Seed default database-backed settings (no-op if they already exist)
|
||||||
try:
|
try:
|
||||||
from app.core.database import async_session_maker
|
from app.core.database import async_session_maker
|
||||||
@@ -54,6 +87,34 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Could not seed default settings: %s", exc, exc_info=True)
|
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
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down application")
|
logger.info("Shutting down application")
|
||||||
@@ -65,7 +126,7 @@ def create_application() -> FastAPI:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.APP_NAME,
|
title=settings.APP_NAME,
|
||||||
version=settings.APP_VERSION,
|
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",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
@@ -88,11 +149,35 @@ def create_application() -> FastAPI:
|
|||||||
# Include API router
|
# Include API router
|
||||||
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
|
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("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint"""
|
"""Root endpoint"""
|
||||||
return {
|
return {
|
||||||
"message": "POP3 Forwarder SaaS API",
|
"message": "InboxConverge API",
|
||||||
"version": settings.APP_VERSION,
|
"version": settings.APP_VERSION,
|
||||||
"docs": "/api/docs",
|
"docs": "/api/docs",
|
||||||
}
|
}
|
||||||
@@ -102,6 +187,12 @@ def create_application() -> FastAPI:
|
|||||||
"""Health check endpoint for container orchestration"""
|
"""Health check endpoint for container orchestration"""
|
||||||
return {"status": "healthy"}
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ from sqlalchemy import (
|
|||||||
Index,
|
Index,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.utils.gmail_labels import (
|
||||||
|
DEFAULT_IMPORT_LABEL_TEMPLATES,
|
||||||
|
extract_granted_scopes,
|
||||||
|
extract_import_label_templates,
|
||||||
|
)
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -165,6 +171,18 @@ class MailAccount(Base):
|
|||||||
provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX"
|
provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX"
|
||||||
auto_detected = Column(Boolean, default=False)
|
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
|
# Statistics
|
||||||
total_emails_processed = Column(Integer, default=0)
|
total_emails_processed = Column(Integer, default=0)
|
||||||
total_emails_failed = 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
|
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 details
|
||||||
channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False)
|
channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False)
|
||||||
is_enabled = Column(Boolean, default=True)
|
is_enabled = Column(Boolean, default=True)
|
||||||
@@ -461,6 +484,7 @@ class UserSmtpConfig(Base):
|
|||||||
username = Column(String(255), nullable=False, default="")
|
username = Column(String(255), nullable=False, default="")
|
||||||
encrypted_password = Column(Text, nullable=False, default="")
|
encrypted_password = Column(Text, nullable=False, default="")
|
||||||
use_tls = Column(Boolean, default=True)
|
use_tls = Column(Boolean, default=True)
|
||||||
|
sender_email = Column(String(255), nullable=False, default="")
|
||||||
|
|
||||||
created_at = Column(
|
created_at = Column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
@@ -557,6 +581,18 @@ class GmailCredential(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
user = relationship("User", backref="gmail_credential")
|
user = relationship("User", backref="gmail_credential")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def granted_scopes(self) -> list[str]:
|
||||||
|
return extract_granted_scopes(self.scopes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def import_label_templates(self) -> list[str]:
|
||||||
|
return extract_import_label_templates(self.scopes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_import_label_templates(self) -> list[str]:
|
||||||
|
return DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
|
||||||
|
|
||||||
|
|
||||||
class AppSetting(Base):
|
class AppSetting(Base):
|
||||||
"""
|
"""
|
||||||
@@ -590,3 +626,29 @@ class AppSetting(Base):
|
|||||||
onupdate=lambda: datetime.now(timezone.utc),
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
nullable=False,
|
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
|
stripe_customer_id: Optional[str] = None
|
||||||
subscription_expires_at: Optional[datetime] = None
|
subscription_expires_at: Optional[datetime] = None
|
||||||
last_login_at: Optional[datetime] = None
|
last_login_at: Optional[datetime] = None
|
||||||
|
is_superuser: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -112,6 +113,8 @@ class MailAccountBase(BaseModel):
|
|||||||
check_interval_minutes: int = Field(default=5, gt=0, le=1440)
|
check_interval_minutes: int = Field(default=5, gt=0, le=1440)
|
||||||
max_emails_per_check: int = Field(default=50, gt=0, le=1000)
|
max_emails_per_check: int = Field(default=50, gt=0, le=1000)
|
||||||
delete_after_forward: bool = True
|
delete_after_forward: bool = True
|
||||||
|
provider_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
debug_logging: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MailAccountCreate(MailAccountBase):
|
class MailAccountCreate(MailAccountBase):
|
||||||
@@ -120,6 +123,13 @@ class MailAccountCreate(MailAccountBase):
|
|||||||
|
|
||||||
class MailAccountUpdate(BaseModel):
|
class MailAccountUpdate(BaseModel):
|
||||||
name: Optional[str] = Field(None, max_length=255)
|
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
|
password: Optional[str] = None
|
||||||
forward_to: Optional[EmailStr] = None
|
forward_to: Optional[EmailStr] = None
|
||||||
delivery_method: Optional[DeliveryMethod] = None
|
delivery_method: Optional[DeliveryMethod] = None
|
||||||
@@ -127,6 +137,8 @@ class MailAccountUpdate(BaseModel):
|
|||||||
check_interval_minutes: Optional[int] = Field(None, gt=0, le=1440)
|
check_interval_minutes: Optional[int] = Field(None, gt=0, le=1440)
|
||||||
max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000)
|
max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000)
|
||||||
delete_after_forward: Optional[bool] = None
|
delete_after_forward: Optional[bool] = None
|
||||||
|
provider_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
debug_logging: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class MailAccountResponse(MailAccountBase):
|
class MailAccountResponse(MailAccountBase):
|
||||||
@@ -145,9 +157,8 @@ class MailAccountResponse(MailAccountBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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="")
|
password: str = Field(exclude=True, default="")
|
||||||
username: str = Field(exclude=True, default="")
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -197,6 +208,15 @@ class ProcessingRunResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingRunDetailResponse(ProcessingRunResponse):
|
||||||
|
"""ProcessingRunResponse with optional account metadata."""
|
||||||
|
|
||||||
|
account_name: Optional[str] = None
|
||||||
|
account_email: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# Processing Log Schemas
|
# Processing Log Schemas
|
||||||
class ProcessingLogResponse(BaseModel):
|
class ProcessingLogResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -210,11 +230,81 @@ class ProcessingLogResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingLogDetailResponse(ProcessingLogResponse):
|
||||||
|
"""ProcessingLogResponse with additional fields."""
|
||||||
|
|
||||||
|
mail_account_id: int
|
||||||
|
processing_run_id: Optional[int] = None
|
||||||
|
email_size_bytes: Optional[int] = None
|
||||||
|
error_details: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedProcessingRunsResponse(BaseModel):
|
||||||
|
items: List[ProcessingRunDetailResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedProcessingLogsResponse(BaseModel):
|
||||||
|
items: List[ProcessingLogDetailResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminProcessingRunResponse(ProcessingRunDetailResponse):
|
||||||
|
"""ProcessingRunDetailResponse with user info for admin views."""
|
||||||
|
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
user_email: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedAdminRunsResponse(BaseModel):
|
||||||
|
items: List[AdminProcessingRunResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminProcessingLogResponse(ProcessingLogDetailResponse):
|
||||||
|
"""ProcessingLogDetailResponse with user info for admin views."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
user_email: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedAdminLogsResponse(BaseModel):
|
||||||
|
items: List[AdminProcessingLogResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
# Notification Config Schemas
|
# Notification Config Schemas
|
||||||
class NotificationConfigBase(BaseModel):
|
class NotificationConfigBase(BaseModel):
|
||||||
|
name: str = Field(
|
||||||
|
default="My Notification",
|
||||||
|
max_length=255,
|
||||||
|
description="Friendly name for this notification channel",
|
||||||
|
)
|
||||||
channel: NotificationChannel
|
channel: NotificationChannel
|
||||||
|
apprise_url: Optional[str] = Field(None, description="Apprise notification URL")
|
||||||
is_enabled: bool = True
|
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_errors: bool = True
|
||||||
notify_on_success: bool = False
|
notify_on_success: bool = False
|
||||||
notify_threshold: int = Field(default=3, gt=0, le=100)
|
notify_threshold: int = Field(default=3, gt=0, le=100)
|
||||||
@@ -225,6 +315,9 @@ class NotificationConfigCreate(NotificationConfigBase):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationConfigUpdate(BaseModel):
|
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
|
is_enabled: Optional[bool] = None
|
||||||
config: Optional[Dict[str, Any]] = None
|
config: Optional[Dict[str, Any]] = None
|
||||||
notify_on_errors: Optional[bool] = None
|
notify_on_errors: Optional[bool] = None
|
||||||
@@ -241,6 +334,46 @@ class NotificationConfigResponse(NotificationConfigBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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
|
# Subscription Schemas
|
||||||
class SubscriptionPlanResponse(BaseModel):
|
class SubscriptionPlanResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -319,6 +452,8 @@ class GmailCredentialResponse(BaseModel):
|
|||||||
user_id: int
|
user_id: int
|
||||||
gmail_email: str
|
gmail_email: str
|
||||||
is_valid: bool
|
is_valid: bool
|
||||||
|
import_label_templates: List[str] = Field(default_factory=list)
|
||||||
|
default_import_label_templates: List[str] = Field(default_factory=list)
|
||||||
last_verified_at: Optional[datetime] = None
|
last_verified_at: Optional[datetime] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -346,6 +481,7 @@ class UserSmtpConfigBase(BaseModel):
|
|||||||
host: str = "smtp.gmail.com"
|
host: str = "smtp.gmail.com"
|
||||||
port: int = Field(587, gt=0, lt=65536)
|
port: int = Field(587, gt=0, lt=65536)
|
||||||
username: str = ""
|
username: str = ""
|
||||||
|
sender_email: str = ""
|
||||||
use_tls: bool = True
|
use_tls: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -363,6 +499,11 @@ class UserSmtpConfigResponse(UserSmtpConfigBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpTestResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
# Gmail OAuth Schemas
|
# Gmail OAuth Schemas
|
||||||
class GmailAuthorizeResponse(BaseModel):
|
class GmailAuthorizeResponse(BaseModel):
|
||||||
authorization_url: str
|
authorization_url: str
|
||||||
@@ -371,3 +512,63 @@ class GmailAuthorizeResponse(BaseModel):
|
|||||||
class GmailCallbackRequest(BaseModel):
|
class GmailCallbackRequest(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
redirect_uri: 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):
|
def _register_google(self):
|
||||||
"""Register Google OAuth2 provider"""
|
"""Register Google OAuth2 provider"""
|
||||||
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
|
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
|
||||||
|
scope = "openid email profile"
|
||||||
self.oauth.register(
|
self.oauth.register(
|
||||||
name="google",
|
name="google",
|
||||||
client_id=settings.GOOGLE_CLIENT_ID,
|
client_id=settings.GOOGLE_CLIENT_ID,
|
||||||
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
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(
|
async def get_google_user_info(
|
||||||
@@ -48,6 +49,11 @@ class OAuthService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Exchange code for token
|
# 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:
|
async with httpx.AsyncClient() as client:
|
||||||
token_response = await client.post(
|
token_response = await client.post(
|
||||||
"https://oauth2.googleapis.com/token",
|
"https://oauth2.googleapis.com/token",
|
||||||
@@ -61,7 +67,12 @@ class OAuthService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if token_response.status_code != 200:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Failed to exchange authorization code",
|
detail="Failed to exchange authorization code",
|
||||||
@@ -71,12 +82,26 @@ class OAuthService:
|
|||||||
access_token = token_data.get("access_token")
|
access_token = token_data.get("access_token")
|
||||||
|
|
||||||
if not 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="No access token received",
|
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
|
# Get user info
|
||||||
|
logger.debug("OAuth [Google sign-in]: fetching Google user profile")
|
||||||
user_info_response = await client.get(
|
user_info_response = await client.get(
|
||||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
@@ -84,7 +109,10 @@ class OAuthService:
|
|||||||
|
|
||||||
if user_info_response.status_code != 200:
|
if user_info_response.status_code != 200:
|
||||||
logger.error(
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -92,6 +120,12 @@ class OAuthService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_info = user_info_response.json()
|
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 {
|
return {
|
||||||
"email": user_info.get("email"),
|
"email": user_info.get("email"),
|
||||||
@@ -99,12 +133,20 @@ class OAuthService:
|
|||||||
"google_id": user_info.get("id"),
|
"google_id": user_info.get("id"),
|
||||||
"picture": user_info.get("picture"),
|
"picture": user_info.get("picture"),
|
||||||
"verified_email": user_info.get("verified_email", False),
|
"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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="OAuth authentication failed",
|
detail="OAuth authentication failed",
|
||||||
@@ -121,6 +163,7 @@ class OAuthService:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with access_token, refresh_token, and token_type
|
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)})
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
refresh_token = create_refresh_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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -338,4 +338,87 @@ class ConfigService:
|
|||||||
if created:
|
if created:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Seeded %d default settings into the database", created)
|
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
|
return created
|
||||||
|
|||||||
@@ -9,18 +9,33 @@ This is preferred over SMTP forwarding as it doesn't modify the email.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import logging
|
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
|
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 google.oauth2.credentials import Credentials
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
from googleapiclient.errors import HttpError
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Gmail API scopes needed for email injection
|
# Gmail API scopes needed for email injection
|
||||||
GMAIL_SCOPES = [
|
GMAIL_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/gmail.insert",
|
"https://www.googleapis.com/auth/gmail.insert",
|
||||||
"https://www.googleapis.com/auth/gmail.labels",
|
"https://www.googleapis.com/auth/gmail.labels",
|
||||||
|
"https://www.googleapis.com/auth/gmail.readonly",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +45,12 @@ class GmailInjectionError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GmailAuthError(GmailInjectionError):
|
||||||
|
"""Raised when Gmail OAuth2 authentication fails (token expired or revoked)."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GmailService:
|
class GmailService:
|
||||||
"""
|
"""
|
||||||
Service for injecting emails into Gmail via the Gmail API.
|
Service for injecting emails into Gmail via the Gmail API.
|
||||||
@@ -66,12 +87,21 @@ class GmailService:
|
|||||||
scopes=GMAIL_SCOPES,
|
scopes=GMAIL_SCOPES,
|
||||||
)
|
)
|
||||||
self._service = None
|
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
|
@property
|
||||||
def service(self):
|
def service(self):
|
||||||
"""Lazy-initialize the Gmail API service."""
|
"""Lazy-initialize the Gmail API service."""
|
||||||
if self._service is None:
|
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
|
return self._service
|
||||||
|
|
||||||
async def inject_email(
|
async def inject_email(
|
||||||
@@ -111,6 +141,7 @@ class GmailService:
|
|||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
_start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
@@ -120,6 +151,10 @@ class GmailService:
|
|||||||
.execute(),
|
.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(
|
logger.info(
|
||||||
f"Injected email into Gmail: id={result.get('id')}"
|
f"Injected email into Gmail: id={result.get('id')}"
|
||||||
f"{f' from {source_account_name}' if source_account_name else ''}"
|
f"{f' from {source_account_name}' if source_account_name else ''}"
|
||||||
@@ -132,13 +167,29 @@ class GmailService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
except HttpError as e:
|
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 = (
|
error_msg = (
|
||||||
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
|
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
|
||||||
)
|
)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
# Surface 401 so callers can mark credentials as invalid
|
# Surface 401 so callers can mark credentials as invalid
|
||||||
raise GmailInjectionError(error_msg)
|
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:
|
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)}"
|
error_msg = f"Failed to inject email into Gmail: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise GmailInjectionError(error_msg)
|
raise GmailInjectionError(error_msg)
|
||||||
@@ -152,15 +203,31 @@ class GmailService:
|
|||||||
"""
|
"""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
_start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: self.service.users().getProfile(userId="me").execute(),
|
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")
|
email = result.get("emailAddress", "unknown")
|
||||||
logger.info(f"Gmail API access verified for: {email}")
|
logger.info(f"Gmail API access verified for: {email}")
|
||||||
return True
|
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:
|
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}")
|
logger.error(f"Gmail API access verification failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -173,16 +240,286 @@ class GmailService:
|
|||||||
"""
|
"""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
_start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: self.service.users().getProfile(userId="me").execute(),
|
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:
|
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}")
|
logger.error(f"Failed to get Gmail email address: {e}")
|
||||||
return None
|
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]]:
|
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Return the current access token and expiry if the token was refreshed
|
Return the current access token and expiry if the token was refreshed
|
||||||
@@ -198,8 +535,14 @@ class GmailService:
|
|||||||
"""
|
"""
|
||||||
current_token = self.credentials.token
|
current_token = self.credentials.token
|
||||||
if current_token and current_token != self._initial_access_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 {
|
return {
|
||||||
"access_token": current_token,
|
"access_token": current_token,
|
||||||
"expiry": self.credentials.expiry,
|
"expiry": self._tz_aware_expiry(self.credentials.expiry),
|
||||||
}
|
}
|
||||||
return None
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Suppress noisy INFO-level "ignored untagged response" messages from aioimaplib
|
||||||
|
logging.getLogger("aioimaplib").setLevel(logging.WARNING)
|
||||||
|
|
||||||
# Create Celery app
|
# Create Celery app
|
||||||
celery_app = Celery(
|
celery_app = Celery(
|
||||||
"pop3_forwarder",
|
"inboxconverge",
|
||||||
broker=settings.CELERY_BROKER_URL,
|
broker=settings.CELERY_BROKER_URL,
|
||||||
backend=settings.CELERY_RESULT_BACKEND,
|
backend=settings.CELERY_RESULT_BACKEND,
|
||||||
include=["app.workers.tasks"],
|
include=["app.workers.tasks"],
|
||||||
@@ -36,7 +39,13 @@ celery_app.conf.update(
|
|||||||
celery_app.conf.beat_schedule = {
|
celery_app.conf.beat_schedule = {
|
||||||
"process-all-mail-accounts": {
|
"process-all-mail-accounts": {
|
||||||
"task": "app.workers.tasks.process_all_enabled_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": {
|
"cleanup-old-logs": {
|
||||||
"task": "app.workers.tasks.cleanup_old_logs",
|
"task": "app.workers.tasks.cleanup_old_logs",
|
||||||
|
|||||||
@@ -3,13 +3,25 @@ Celery tasks for background email processing.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import email as email_lib
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
from celery import Task
|
from celery import Task
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.workers.celery_app import celery_app
|
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.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 (
|
from app.models.database_models import (
|
||||||
MailAccount,
|
MailAccount,
|
||||||
ProcessingRun,
|
ProcessingRun,
|
||||||
@@ -20,22 +32,42 @@ from app.models.database_models import (
|
|||||||
DownloadedMessageId,
|
DownloadedMessageId,
|
||||||
UserSmtpConfig,
|
UserSmtpConfig,
|
||||||
)
|
)
|
||||||
from app.services.mail_processor import MailProcessor
|
from app.services.mail_processor import MailProcessor, MailDebugRecorder
|
||||||
from app.services.gmail_service import GmailService
|
from app.services.gmail_service import GmailService, GmailAuthError
|
||||||
from app.services.config_service import ConfigService
|
from app.services.config_service import ConfigService
|
||||||
|
from app.services.notification_service import send_user_notification
|
||||||
from app.core.config import settings
|
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__)
|
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):
|
class AsyncTask(Task):
|
||||||
"""Base task class that handles async operations"""
|
"""Base task class that handles async operations"""
|
||||||
|
|
||||||
def __call__(self, *args, **kwargs):
|
def __call__(self, *args, **kwargs):
|
||||||
"""Run async task in event loop"""
|
"""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")
|
@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:
|
Args:
|
||||||
account_id: ID of mail account to process
|
account_id: ID of mail account to process
|
||||||
"""
|
"""
|
||||||
|
_task_start = time.monotonic()
|
||||||
async with async_session_maker() as db:
|
async with async_session_maker() as db:
|
||||||
try:
|
try:
|
||||||
# Get account
|
# Get account
|
||||||
@@ -58,10 +91,15 @@ async def process_mail_account(account_id: int):
|
|||||||
logger.warning(f"Account {account_id} not found or disabled")
|
logger.warning(f"Account {account_id} not found or disabled")
|
||||||
return
|
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
|
# Create processing run
|
||||||
run = ProcessingRun(
|
run = ProcessingRun(
|
||||||
mail_account_id=account.id,
|
mail_account_id=account.id,
|
||||||
started_at=datetime.now(timezone.utc),
|
started_at=_run_started_at,
|
||||||
status="running",
|
status="running",
|
||||||
)
|
)
|
||||||
db.add(run)
|
db.add(run)
|
||||||
@@ -79,8 +117,13 @@ async def process_mail_account(account_id: int):
|
|||||||
)
|
)
|
||||||
already_seen_uids = set(seen_result.scalars().all())
|
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
|
# Create processor
|
||||||
processor = MailProcessor(account, password)
|
processor = MailProcessor(account, password, debug_recorder=_debug_recorder)
|
||||||
|
|
||||||
# Fetch emails (returns raw bytes + new UIDs)
|
# Fetch emails (returns raw bytes + new UIDs)
|
||||||
emails, new_uids = await processor.fetch_emails(
|
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]
|
run.emails_fetched = len(emails) # type: ignore[assignment]
|
||||||
|
MAIL_PROCESSING_EMAILS_TOTAL.labels(operation="fetched").inc(len(emails))
|
||||||
|
|
||||||
# Forward emails
|
# Forward emails
|
||||||
emails_forwarded = 0
|
emails_forwarded = 0
|
||||||
@@ -99,6 +143,7 @@ async def process_mail_account(account_id: int):
|
|||||||
|
|
||||||
gmail_service = None
|
gmail_service = None
|
||||||
smtp_config = None
|
smtp_config = None
|
||||||
|
gmail_cred = None
|
||||||
|
|
||||||
if use_gmail_api:
|
if use_gmail_api:
|
||||||
# Get user's Gmail credentials
|
# Get user's Gmail credentials
|
||||||
@@ -117,6 +162,27 @@ async def process_mail_account(account_id: int):
|
|||||||
if gmail_cred.encrypted_refresh_token
|
if gmail_cred.encrypted_refresh_token
|
||||||
else None
|
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(
|
gmail_service = GmailService(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
@@ -144,6 +210,7 @@ async def process_mail_account(account_id: int):
|
|||||||
"host": user_smtp.host,
|
"host": user_smtp.host,
|
||||||
"port": user_smtp.port,
|
"port": user_smtp.port,
|
||||||
"username": user_smtp.username,
|
"username": user_smtp.username,
|
||||||
|
"sender_email": user_smtp.sender_email or "",
|
||||||
"password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type]
|
"password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type]
|
||||||
"use_tls": user_smtp.use_tls,
|
"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.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.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()
|
await db.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
successfully_forwarded_uids: list[str] = []
|
successfully_forwarded_uids: list[str] = []
|
||||||
|
skipped_empty_uids: list[str] = []
|
||||||
|
|
||||||
if len(emails) != len(new_uids):
|
if len(emails) != len(new_uids):
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -167,15 +240,95 @@ async def process_mail_account(account_id: int):
|
|||||||
f"for account {account.id}; truncating to shorter list"
|
f"for account {account.id}; truncating to shorter list"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Field length limits matching the DB column definitions
|
||||||
|
_MAX_SUBJECT_LEN = 500
|
||||||
|
_MAX_FROM_LEN = 255
|
||||||
|
|
||||||
for email_data, uid in zip(emails, new_uids):
|
for email_data, uid in zip(emails, new_uids):
|
||||||
|
# ── Parse email metadata for logging ───────────────────────
|
||||||
|
email_subject: str | None = None
|
||||||
|
email_from: str | None = None
|
||||||
try:
|
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)
|
# Inject via Gmail API (preferred)
|
||||||
|
label_ids = await gmail_service.build_import_label_ids(
|
||||||
|
import_label_templates=gmail_cred.import_label_templates,
|
||||||
|
source_email=account.email_address, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
await gmail_service.inject_email(
|
await gmail_service.inject_email(
|
||||||
raw_email=email_data,
|
raw_email=email_data,
|
||||||
label_ids=["INBOX"],
|
label_ids=label_ids,
|
||||||
source_account_name=account.name, # type: ignore[arg-type]
|
source_account_name=account.name, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
forwarded_ok = True
|
||||||
emails_forwarded += 1
|
emails_forwarded += 1
|
||||||
successfully_forwarded_uids.append(uid)
|
successfully_forwarded_uids.append(uid)
|
||||||
else:
|
else:
|
||||||
@@ -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]
|
email_data, account.name, account.forward_to, smtp_config # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
|
forwarded_ok = True
|
||||||
emails_forwarded += 1
|
emails_forwarded += 1
|
||||||
successfully_forwarded_uids.append(uid)
|
successfully_forwarded_uids.append(uid)
|
||||||
else:
|
else:
|
||||||
|
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
|
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:
|
except Exception as e:
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
# If Gmail returns 401/403 the refresh token was revoked –
|
# Catch any remaining auth-style errors that slipped through
|
||||||
# mark credentials invalid so the user gets notified.
|
# (e.g. HttpError 401/403 returned after a successful refresh).
|
||||||
if (
|
if (
|
||||||
use_gmail_api
|
use_gmail_api
|
||||||
and gmail_cred
|
and gmail_cred
|
||||||
@@ -203,13 +402,90 @@ async def process_mail_account(account_id: int):
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
gmail_cred.is_valid = False # type: ignore[assignment]
|
gmail_cred.is_valid = False # type: ignore[assignment]
|
||||||
|
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Gmail credentials revoked for user {account.user_id}. "
|
"Gmail credentials invalidated for user %s "
|
||||||
"User must re-authorise."
|
"(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
|
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
|
# Persist new message UIDs so they are not processed again
|
||||||
for uid in successfully_forwarded_uids:
|
for uid in successfully_forwarded_uids:
|
||||||
if uid not in already_seen_uids:
|
if uid not in already_seen_uids:
|
||||||
@@ -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
|
# 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.
|
# the DB so the next run doesn't need an extra token-refresh call.
|
||||||
if use_gmail_api and gmail_service and gmail_cred:
|
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_forwarded = emails_forwarded # type: ignore[assignment]
|
||||||
run.emails_failed = emails_failed # type: ignore[assignment]
|
run.emails_failed = emails_failed # type: ignore[assignment]
|
||||||
run.completed_at = datetime.now(timezone.utc) # 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]
|
run.status = "completed" if emails_failed == 0 else "partial_failure" # type: ignore[assignment]
|
||||||
|
|
||||||
# Update account
|
# Update account
|
||||||
@@ -245,16 +534,99 @@ async def process_mail_account(account_id: int):
|
|||||||
account.total_emails_failed += emails_failed # type: ignore[assignment]
|
account.total_emails_failed += emails_failed # type: ignore[assignment]
|
||||||
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
|
|
||||||
if emails_failed == 0:
|
# The fetch/connection succeeded: always clear any connection-level error
|
||||||
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
# and mark the account ACTIVE regardless of per-email forwarding failures.
|
||||||
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
# Per-email failures are already tracked in ProcessingLog and the
|
||||||
else:
|
# run's emails_failed counter so the user can drill into them without
|
||||||
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
# having the account badge stuck in ERROR indefinitely.
|
||||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
# Capture pre-mutation state for the post-commit notification logic.
|
||||||
account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment]
|
# 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()
|
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(
|
logger.info(
|
||||||
f"Processed account {account.id}: "
|
f"Processed account {account.id}: "
|
||||||
f"{emails_forwarded} forwarded, {emails_failed} failed"
|
f"{emails_forwarded} forwarded, {emails_failed} failed"
|
||||||
@@ -263,13 +635,31 @@ async def process_mail_account(account_id: int):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing account {account_id}: {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():
|
if "run" in locals():
|
||||||
run.status = "failed" # type: ignore[assignment]
|
run.status = "failed" # type: ignore[assignment]
|
||||||
run.error_message = str(e) # type: ignore[assignment]
|
run.error_message = str(e) # type: ignore[assignment]
|
||||||
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
run.duration_seconds = (
|
# Use the locally-captured start time to avoid accessing an
|
||||||
run.completed_at - run.started_at
|
# expired ORM attribute after the session rollback above.
|
||||||
|
run.duration_seconds = ( # type: ignore[assignment]
|
||||||
|
run.completed_at - _run_started_at
|
||||||
).total_seconds()
|
).total_seconds()
|
||||||
|
|
||||||
# Update account error status
|
# Update account error status
|
||||||
@@ -277,8 +667,55 @@ async def process_mail_account(account_id: int):
|
|||||||
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
||||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
account.last_error_message = str(e) # 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")
|
@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.
|
Process all enabled mail accounts.
|
||||||
This task is scheduled to run periodically.
|
This task is scheduled to run periodically.
|
||||||
"""
|
"""
|
||||||
|
_task_start = time.monotonic()
|
||||||
async with async_session_maker() as db:
|
async with async_session_maker() as db:
|
||||||
try:
|
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
|
# Fetch all enabled accounts regardless of operational status so
|
||||||
# that accounts in ERROR state are retried automatically.
|
# that accounts in ERROR state are retried automatically.
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -299,13 +763,14 @@ async def process_all_enabled_accounts():
|
|||||||
accounts = result.scalars().all()
|
accounts = result.scalars().all()
|
||||||
|
|
||||||
logger.info(f"Processing {len(accounts)} enabled mail accounts")
|
logger.info(f"Processing {len(accounts)} enabled mail accounts")
|
||||||
|
ACTIVE_MAIL_ACCOUNTS.set(len(accounts))
|
||||||
|
|
||||||
# Process each account
|
# Process each account
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
# Check if it's time to check this account
|
# Check if it's time to check this account
|
||||||
if account.last_check_at:
|
if account.last_check_at:
|
||||||
time_since_last_check = (
|
time_since_last_check = datetime.now(timezone.utc) - _as_utc(
|
||||||
datetime.now(timezone.utc) - account.last_check_at
|
account.last_check_at
|
||||||
)
|
)
|
||||||
if time_since_last_check.total_seconds() < (
|
if time_since_last_check.total_seconds() < (
|
||||||
account.check_interval_minutes * 60
|
account.check_interval_minutes * 60
|
||||||
@@ -316,22 +781,64 @@ async def process_all_enabled_accounts():
|
|||||||
# Queue processing task
|
# Queue processing task
|
||||||
process_mail_account.delay(account.id)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error processing accounts: {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")
|
@celery_app.task(base=AsyncTask, name="app.workers.tasks.cleanup_old_logs")
|
||||||
async def cleanup_old_logs(days_to_keep: int = 30):
|
async def cleanup_old_logs(days_to_keep: int = 30):
|
||||||
"""
|
"""
|
||||||
Clean up old processing logs, runs, and downloaded message ID records.
|
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:
|
Args:
|
||||||
days_to_keep: Number of days of data to retain
|
days_to_keep: Number of days of data to retain
|
||||||
"""
|
"""
|
||||||
|
_task_start = time.monotonic()
|
||||||
async with async_session_maker() as db:
|
async with async_session_maker() as db:
|
||||||
try:
|
try:
|
||||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
|
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
|
# Delete old processing runs
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ProcessingRun).where(ProcessingRun.started_at < cutoff_date)
|
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"
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up logs: {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
|
# Core Framework
|
||||||
fastapi==0.109.1 # Updated: Fixed ReDoS vulnerability (was 0.109.0)
|
fastapi==0.136.3 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0
|
||||||
uvicorn[standard]==0.27.0
|
uvicorn[standard]==0.48.0
|
||||||
pydantic==2.12.5
|
pydantic==2.13.4
|
||||||
pydantic-settings==2.13.1
|
pydantic-settings==2.14.1
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlalchemy==2.0.48 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
|
sqlalchemy==2.0.50 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
|
||||||
alembic==1.13.1
|
alembic==1.18.4
|
||||||
psycopg2-binary==2.9.11
|
psycopg2-binary==2.9.12
|
||||||
asyncpg==0.31.0
|
asyncpg==0.31.0
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
python-jose[cryptography]==3.3.0
|
|
||||||
bcrypt==4.3.0
|
python-jose[cryptography]==3.5.0 # Updated: Fixed algorithm confusion with OpenSSH ECDSA keys (was 3.3.0)
|
||||||
python-multipart==0.0.22 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
|
bcrypt==5.0.0
|
||||||
authlib==1.6.9 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
|
python-multipart==0.0.29 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
|
||||||
httpx==0.26.0
|
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
|
# Payment Processing
|
||||||
stripe==14.4.1
|
stripe==15.1.0
|
||||||
|
|
||||||
# Email & Mail Processing
|
# Email & Mail Processing
|
||||||
aiosmtplib==3.0.1
|
aiosmtplib==5.1.0
|
||||||
aiohttp==3.13.3 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
|
aiohttp==3.13.5 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
|
||||||
aioimaplib==2.0.1
|
aioimaplib==2.0.1
|
||||||
email-validator==2.1.0.post1
|
email-validator==2.3.0
|
||||||
|
|
||||||
# Gmail API (for direct email injection)
|
# Gmail API (for direct email injection)
|
||||||
google-api-python-client==2.193.0
|
google-api-python-client==2.196.0
|
||||||
google-auth==2.49.1
|
google-auth==2.53.0
|
||||||
google-auth-oauthlib==1.2.0
|
google-auth-oauthlib==1.4.0
|
||||||
google-auth-httplib2==0.3.0
|
google-auth-httplib2==0.4.0
|
||||||
|
|
||||||
# Job Queue & Cache
|
# Job Queue & Cache
|
||||||
celery==5.6.2
|
celery==5.6.3
|
||||||
redis==7.3.0
|
redis==7.4.0
|
||||||
|
|
||||||
# Security & Encryption
|
# 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
|
# Notifications
|
||||||
apprise==1.7.1
|
apprise==1.10.0
|
||||||
|
|
||||||
# Monitoring & Logging
|
# Monitoring & Logging
|
||||||
prometheus-client==0.19.0
|
prometheus-client==0.25.0
|
||||||
python-json-logger==2.0.7
|
python-json-logger==2.0.7
|
||||||
|
|
||||||
# Development & Testing
|
# Development & Testing
|
||||||
pytest==7.4.4
|
pytest==9.0.3
|
||||||
pytest-asyncio==0.23.3
|
pytest-asyncio==1.3.0
|
||||||
pytest-cov==4.1.0
|
pytest-cov==7.1.0
|
||||||
faker==22.6.0
|
faker==40.19.1
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.2.2
|
||||||
schedule==1.2.0
|
schedule==1.2.2
|
||||||
tenacity==9.1.4
|
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 (use different database for tests)
|
||||||
TEST_DATABASE_URL = settings.DATABASE_URL.replace(
|
TEST_DATABASE_URL = settings.DATABASE_URL.replace(
|
||||||
"/pop3_forwarder", "/pop3_forwarder_test"
|
"/inbox_converge", "/inbox_converge_test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +82,6 @@ async def test_user(db_session: AsyncSession) -> User:
|
|||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
hashed_password=get_password_hash("testpassword123"),
|
hashed_password=get_password_hash("testpassword123"),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_verified=True,
|
|
||||||
)
|
)
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
@@ -97,8 +96,7 @@ async def test_admin_user(db_session: AsyncSession) -> User:
|
|||||||
email="admin@example.com",
|
email="admin@example.com",
|
||||||
hashed_password=get_password_hash("adminpassword123"),
|
hashed_password=get_password_hash("adminpassword123"),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_verified=True,
|
is_superuser=True,
|
||||||
is_admin=True,
|
|
||||||
)
|
)
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
@@ -109,14 +107,14 @@ async def test_admin_user(db_session: AsyncSession) -> User:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_headers(test_user: User) -> dict:
|
def auth_headers(test_user: User) -> dict:
|
||||||
"""Generate authentication headers for test user"""
|
"""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}"}
|
return {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_auth_headers(test_admin_user: User) -> dict:
|
def admin_auth_headers(test_admin_user: User) -> dict:
|
||||||
"""Generate authentication headers for admin user"""
|
"""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}"}
|
return {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
|
|
||||||
@@ -131,8 +129,7 @@ def user_factory(db_session: AsyncSession):
|
|||||||
email: str = None,
|
email: str = None,
|
||||||
password: str = "testpassword123",
|
password: str = "testpassword123",
|
||||||
is_active: bool = True,
|
is_active: bool = True,
|
||||||
is_verified: bool = True,
|
is_superuser: bool = False,
|
||||||
is_admin: bool = False,
|
|
||||||
) -> User:
|
) -> User:
|
||||||
if email is None:
|
if email is None:
|
||||||
import uuid
|
import uuid
|
||||||
@@ -143,8 +140,7 @@ def user_factory(db_session: AsyncSession):
|
|||||||
email=email,
|
email=email,
|
||||||
hashed_password=get_password_hash(password),
|
hashed_password=get_password_hash(password),
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
is_verified=is_verified,
|
is_superuser=is_superuser,
|
||||||
is_admin=is_admin,
|
|
||||||
)
|
)
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
await db_session.commit()
|
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):
|
async def test_app_title(self, app):
|
||||||
"""Test that app has correct title"""
|
"""Test that app has correct title"""
|
||||||
assert app.title == "POP3 Forwarder SaaS"
|
assert app.title == "InboxConverge"
|
||||||
|
|
||||||
async def test_app_version(self, app):
|
async def test_app_version(self, app):
|
||||||
"""Test that app has a version"""
|
"""Test that app has a version"""
|
||||||
|
|||||||
@@ -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_result.scalar_one_or_none.return_value = None
|
||||||
|
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
|
mock_db.add = MagicMock()
|
||||||
mock_db.execute.return_value = mock_result
|
mock_db.execute.return_value = mock_result
|
||||||
|
|
||||||
# The method will call db.add() and db.commit()
|
|
||||||
await ConfigService.set(
|
await ConfigService.set(
|
||||||
"SMTP_HOST", "new.host.com", db=mock_db, category="smtp"
|
"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.
|
Unit tests for Gmail service module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock
|
from googleapiclient.errors import HttpError
|
||||||
|
|
||||||
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
|
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
|
||||||
|
from app.utils.gmail_labels import (
|
||||||
|
DEFAULT_IMPORT_LABEL_TEMPLATES,
|
||||||
|
SOURCE_EMAIL_LABEL_TEMPLATE,
|
||||||
|
build_gmail_credential_scopes,
|
||||||
|
extract_granted_scopes,
|
||||||
|
extract_import_label_templates,
|
||||||
|
render_import_labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestGmailService:
|
class TestGmailService:
|
||||||
@@ -153,3 +166,325 @@ class TestGmailService:
|
|||||||
|
|
||||||
email = await service.get_email_address()
|
email = await service.get_email_address()
|
||||||
assert email is None
|
assert email is None
|
||||||
|
|
||||||
|
def test_gmail_label_metadata_helpers(self):
|
||||||
|
"""Test Gmail metadata extraction remains backward compatible."""
|
||||||
|
scopes = build_gmail_credential_scopes(
|
||||||
|
["scope-a", "scope-b"],
|
||||||
|
[SOURCE_EMAIL_LABEL_TEMPLATE, "Imported", " imported "],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert extract_granted_scopes(scopes) == ["scope-a", "scope-b"]
|
||||||
|
assert extract_import_label_templates(scopes) == [
|
||||||
|
SOURCE_EMAIL_LABEL_TEMPLATE,
|
||||||
|
"Imported",
|
||||||
|
]
|
||||||
|
assert (
|
||||||
|
extract_import_label_templates(["legacy-scope"])
|
||||||
|
== DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_render_import_labels_uses_source_email_template(self):
|
||||||
|
"""Test that source email templates render to the source mailbox address."""
|
||||||
|
rendered = render_import_labels(
|
||||||
|
[SOURCE_EMAIL_LABEL_TEMPLATE, "Imported", ""],
|
||||||
|
"source@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rendered == ["source@example.com", "Imported"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_import_label_ids_creates_configured_labels(self):
|
||||||
|
"""Test that configured import labels are created and added alongside INBOX."""
|
||||||
|
service = GmailService(access_token="test-access-token")
|
||||||
|
service.get_or_create_label = AsyncMock(
|
||||||
|
side_effect=["Label-source", "Label-imported"]
|
||||||
|
) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
label_ids = await service.build_import_label_ids(
|
||||||
|
import_label_templates=[SOURCE_EMAIL_LABEL_TEMPLATE, "imported"],
|
||||||
|
source_email="source@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert label_ids == ["INBOX", "Label-source", "Label-imported"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 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
|
# PostgreSQL Database
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: pop3-postgres
|
container_name: inboxconverge-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: password
|
POSTGRES_PASSWORD: password
|
||||||
POSTGRES_DB: pop3_forwarder
|
POSTGRES_DB: inbox_converge
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -22,7 +22,7 @@ services:
|
|||||||
# Redis for caching and Celery
|
# Redis for caching and Celery
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: pop3-redis
|
container_name: inboxconverge-redis
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-backend
|
container_name: inboxconverge-backend
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
env_file:
|
env_file:
|
||||||
@@ -58,7 +58,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-celery-worker
|
container_name: inboxconverge-celery-worker
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -75,7 +75,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-celery-beat
|
container_name: inboxconverge-celery-beat
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -92,15 +92,56 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-frontend
|
container_name: inboxconverge-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=http://backend:8000
|
- BACKEND_URL=http://backend:8000
|
||||||
|
- CONTACT_EMAIL=christian@inboxconverge.com
|
||||||
|
- APP_URL=https://inboxconverge.com
|
||||||
|
- APP_NAME=InboxConverge
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# 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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
prometheus_data:
|
||||||
|
grafana_data:
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
pop3-forwarder:
|
inboxconverge:
|
||||||
# Option 1: Build from source (default)
|
# Option 1: Build from source (default)
|
||||||
build: .
|
build: .
|
||||||
# Option 2: Use pre-built image from GitHub Container Registry
|
# Option 2: Use pre-built image from GitHub Container Registry
|
||||||
# Uncomment the line below and comment out 'build: .' to use pre-built image
|
# Uncomment the line below and comment out 'build: .' to use pre-built image
|
||||||
# image: ghcr.io/christianlouis/pop_puller_to_gmail:latest
|
# image: ghcr.io/christianlouis/inboxconverge:latest
|
||||||
container_name: pop3-gmail-forwarder
|
container_name: inboxconverge
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# POP3 Forwarder SaaS - Multi-Tenant Architecture
|
# InboxConverge - Multi-Tenant Architecture
|
||||||
|
|
||||||
This document describes the new multi-tenant SaaS architecture for the POP3/IMAP email forwarder.
|
This document describes the new multi-tenant SaaS architecture for the POP3/IMAP email forwarder.
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ The project has been transformed from a single-user Docker application into a fu
|
|||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
pop_puller_to_gmail/
|
inboxconverge/
|
||||||
├── backend/ # FastAPI backend application
|
├── backend/ # FastAPI backend application
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/ # API endpoints
|
│ │ ├── api/ # API endpoints
|
||||||
@@ -50,7 +50,7 @@ pop_puller_to_gmail/
|
|||||||
│ └── .env.example # Environment template
|
│ └── .env.example # Environment template
|
||||||
├── frontend/ # React/Next.js frontend (to be implemented)
|
├── frontend/ # React/Next.js frontend (to be implemented)
|
||||||
├── docker-compose.new.yml # Docker Compose for all services
|
├── docker-compose.new.yml # Docker Compose for all services
|
||||||
├── pop3_forwarder.py # Legacy single-user script
|
├── inbox_converge.py # Legacy single-user script
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -68,8 +68,8 @@ pop_puller_to_gmail/
|
|||||||
|
|
||||||
1. **Clone and navigate to repository**
|
1. **Clone and navigate to repository**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
|
git clone https://github.com/christianlouis/inboxconverge.git
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure backend environment**
|
2. **Configure backend environment**
|
||||||
@@ -329,7 +329,7 @@ Coming soon: Kubernetes manifests and Helm charts.
|
|||||||
|
|
||||||
## 🔄 Migration from Legacy Version
|
## 🔄 Migration from Legacy Version
|
||||||
|
|
||||||
To migrate from the single-user `pop3_forwarder.py`:
|
To migrate from the single-user `inbox_converge.py`:
|
||||||
|
|
||||||
1. **Export existing configuration** from `.env` file
|
1. **Export existing configuration** from `.env` file
|
||||||
2. **Create user account** via API or admin panel
|
2. **Create user account** via API or admin panel
|
||||||
@@ -404,8 +404,8 @@ MIT License - See [LICENSE](../LICENSE) file
|
|||||||
|
|
||||||
## 🆘 Support
|
## 🆘 Support
|
||||||
|
|
||||||
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues
|
- **Issues**: https://github.com/christianlouis/inboxconverge/issues
|
||||||
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions
|
- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
|
||||||
- **Email**: support@example.com
|
- **Email**: support@example.com
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
## 🙏 Acknowledgments
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Coding Patterns and Best Practices
|
# Coding Patterns and Best Practices
|
||||||
|
|
||||||
This document outlines the coding patterns, conventions, and best practices for the POP3 to Gmail Forwarder project.
|
This document outlines the coding patterns, conventions, and best practices for the InboxConverge project.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
- [General Principles](#general-principles)
|
- [General Principles](#general-principles)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Deployment Checklist and Next Steps
|
# Deployment Checklist and Next Steps
|
||||||
|
|
||||||
This document provides a checklist for deploying the multi-tenant POP3 Forwarder with web interface.
|
This document provides a checklist for deploying the multi-tenant InboxConverge with web interface.
|
||||||
|
|
||||||
## 🚀 Pre-Deployment Checklist
|
## 🚀 Pre-Deployment Checklist
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ This document provides a checklist for deploying the multi-tenant POP3 Forwarder
|
|||||||
|
|
||||||
#### Database
|
#### Database
|
||||||
- [ ] PostgreSQL 15+ instance running
|
- [ ] PostgreSQL 15+ instance running
|
||||||
- [ ] Database created: `pop3_forwarder`
|
- [ ] Database created: `inbox_converge`
|
||||||
- [ ] Connection details configured in backend/.env
|
- [ ] Connection details configured in backend/.env
|
||||||
- [ ] Backups configured
|
- [ ] Backups configured
|
||||||
|
|
||||||
@@ -89,8 +89,8 @@ sudo certbot --nginx -d yourdomain.com -d api.yourdomain.com
|
|||||||
```bash
|
```bash
|
||||||
# On production server
|
# On production server
|
||||||
cd /opt
|
cd /opt
|
||||||
sudo git clone https://github.com/christianlouis/pop_puller_to_gmail.git
|
sudo git clone https://github.com/christianlouis/inboxconverge.git
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Configure Environment
|
### Step 2: Configure Environment
|
||||||
@@ -195,9 +195,9 @@ curl -X POST https://api.yourdomain.com/api/v1/auth/register \
|
|||||||
# Automated daily backup script
|
# Automated daily backup script
|
||||||
cat > /usr/local/bin/backup-pop3-db.sh << 'EOF'
|
cat > /usr/local/bin/backup-pop3-db.sh << 'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
BACKUP_DIR=/var/backups/pop3_forwarder
|
BACKUP_DIR=/var/backups/inbox_converge
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
docker exec pop3-postgres pg_dump -U postgres pop3_forwarder | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
|
docker exec inboxconverge-postgres pg_dump -U postgres inbox_converge | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
|
||||||
find $BACKUP_DIR -type f -mtime +30 -delete
|
find $BACKUP_DIR -type f -mtime +30 -delete
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -394,4 +394,4 @@ Use this checklist after deployment:
|
|||||||
|
|
||||||
## 🎉 Congratulations!
|
## 🎉 Congratulations!
|
||||||
|
|
||||||
If all checkboxes above are complete, your multi-tenant POP3 Forwarder with web interface is successfully deployed and ready to serve users!
|
If all checkboxes above are complete, your multi-tenant InboxConverge with web interface is successfully deployed and ready to serve users!
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Deployment Guide
|
# Deployment Guide
|
||||||
|
|
||||||
This guide walks you through deploying **POP3 to Gmail Forwarder** from scratch — whether you just want a single container pulling emails, a full multi-service SaaS stack with Docker Compose, or a production-grade Kubernetes setup.
|
This guide walks you through deploying **InboxConverge** from scratch — whether you just want a single container pulling emails, a full multi-service SaaS stack with Docker Compose, or a production-grade Kubernetes setup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,13 +52,13 @@ You will also need:
|
|||||||
|
|
||||||
## Option 1 — Legacy Single-Container Deployment
|
## Option 1 — Legacy Single-Container Deployment
|
||||||
|
|
||||||
The legacy mode runs a single Python script (`pop3_forwarder.py`) that polls POP3 mailboxes and forwards email via SMTP. No database, no web UI — just a container and an `.env` file.
|
The legacy mode runs a single Python script (`inbox_converge.py`) that polls POP3 mailboxes and forwards email via SMTP. No database, no web UI — just a container and an `.env` file.
|
||||||
|
|
||||||
### 1. Create the environment file
|
### 1. Create the environment file
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
|
git clone https://github.com/christianlouis/inboxconverge.git
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,12 +97,12 @@ The repository ships `docker-compose.yml` for this mode. Here is the content for
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
pop3-forwarder:
|
inbox-converge:
|
||||||
# Build from source
|
# Build from source
|
||||||
build: .
|
build: .
|
||||||
# Or use the pre-built image:
|
# Or use the pre-built image:
|
||||||
# image: ghcr.io/christianlouis/pop_puller_to_gmail:latest
|
# image: ghcr.io/christianlouis/inboxconverge:latest
|
||||||
container_name: pop3-gmail-forwarder
|
container_name: inboxconverge
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -149,7 +149,7 @@ Save both values — you will need them below.
|
|||||||
### 2. Create the backend environment file
|
### 2. Create the backend environment file
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ Edit `backend/.env`:
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
# ── Database ──────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:change-me@postgres:5432/pop3_forwarder
|
DATABASE_URL=postgresql+asyncpg://postgres:change-me@postgres:5432/inbox_converge
|
||||||
|
|
||||||
# ── Security (paste the values you generated above) ──────
|
# ── Security (paste the values you generated above) ──────
|
||||||
SECRET_KEY=<your-64-char-hex-secret>
|
SECRET_KEY=<your-64-char-hex-secret>
|
||||||
@@ -198,12 +198,12 @@ services:
|
|||||||
# ── PostgreSQL ───────────────────────────────────────────
|
# ── PostgreSQL ───────────────────────────────────────────
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: pop3-postgres
|
container_name: inboxconverge-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: change-me # must match DATABASE_URL
|
POSTGRES_PASSWORD: change-me # must match DATABASE_URL
|
||||||
POSTGRES_DB: pop3_forwarder
|
POSTGRES_DB: inbox_converge
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -219,7 +219,7 @@ services:
|
|||||||
# ── Redis ────────────────────────────────────────────────
|
# ── Redis ────────────────────────────────────────────────
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: pop3-redis
|
container_name: inboxconverge-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes
|
||||||
volumes:
|
volumes:
|
||||||
@@ -235,7 +235,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-backend
|
container_name: inboxconverge-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -260,7 +260,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-celery-worker
|
container_name: inboxconverge-celery-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
@@ -278,7 +278,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-celery-beat
|
container_name: inboxconverge-celery-beat
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
@@ -296,7 +296,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-frontend
|
container_name: inboxconverge-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -358,7 +358,7 @@ Below is a set of example Kubernetes manifests to get you started. Adapt namespa
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
metadata:
|
||||||
name: pop3-forwarder
|
name: inbox-converge
|
||||||
```
|
```
|
||||||
|
|
||||||
### Secrets
|
### Secrets
|
||||||
@@ -369,13 +369,13 @@ Store sensitive values in a Kubernetes Secret. In production, consider using an
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: pop3-forwarder-secrets
|
name: inbox-converge-secrets
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
SECRET_KEY: "<your-64-char-hex-secret>"
|
SECRET_KEY: "<your-64-char-hex-secret>"
|
||||||
ENCRYPTION_KEY: "<your-64-char-hex-encryption-key>"
|
ENCRYPTION_KEY: "<your-64-char-hex-encryption-key>"
|
||||||
DATABASE_URL: "postgresql+asyncpg://postgres:change-me@postgres:5432/pop3_forwarder"
|
DATABASE_URL: "postgresql+asyncpg://postgres:change-me@postgres:5432/inbox_converge"
|
||||||
REDIS_URL: "redis://redis:6379/0"
|
REDIS_URL: "redis://redis:6379/0"
|
||||||
CELERY_BROKER_URL: "redis://redis:6379/0"
|
CELERY_BROKER_URL: "redis://redis:6379/0"
|
||||||
CELERY_RESULT_BACKEND: "redis://redis:6379/0"
|
CELERY_RESULT_BACKEND: "redis://redis:6379/0"
|
||||||
@@ -396,7 +396,7 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: postgres
|
name: postgres
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
@@ -416,11 +416,11 @@ spec:
|
|||||||
- name: POSTGRES_USER
|
- name: POSTGRES_USER
|
||||||
value: postgres
|
value: postgres
|
||||||
- name: POSTGRES_DB
|
- name: POSTGRES_DB
|
||||||
value: pop3_forwarder
|
value: inbox_converge
|
||||||
- name: POSTGRES_PASSWORD
|
- name: POSTGRES_PASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: pop3-forwarder-secrets
|
name: inbox-converge-secrets
|
||||||
key: POSTGRES_PASSWORD
|
key: POSTGRES_PASSWORD
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: pgdata
|
- name: pgdata
|
||||||
@@ -439,7 +439,7 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: postgres
|
name: postgres
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: postgres
|
app: postgres
|
||||||
@@ -451,7 +451,7 @@ apiVersion: v1
|
|||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: postgres-pvc
|
name: postgres-pvc
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
accessModes: [ReadWriteOnce]
|
accessModes: [ReadWriteOnce]
|
||||||
resources:
|
resources:
|
||||||
@@ -466,7 +466,7 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: redis
|
name: redis
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
@@ -493,7 +493,7 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: redis
|
name: redis
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: redis
|
app: redis
|
||||||
@@ -509,7 +509,7 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: backend
|
name: backend
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
replicas: 2
|
replicas: 2
|
||||||
selector:
|
selector:
|
||||||
@@ -522,22 +522,22 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: run-migrations
|
- name: run-migrations
|
||||||
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest
|
image: ghcr.io/christianlouis/inboxconverge-backend:latest
|
||||||
command: ["alembic", "upgrade", "head"]
|
command: ["alembic", "upgrade", "head"]
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: pop3-forwarder-secrets
|
name: inbox-converge-secrets
|
||||||
env:
|
env:
|
||||||
- name: DEBUG
|
- name: DEBUG
|
||||||
value: "false"
|
value: "false"
|
||||||
containers:
|
containers:
|
||||||
- name: backend
|
- name: backend
|
||||||
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest
|
image: ghcr.io/christianlouis/inboxconverge-backend:latest
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: pop3-forwarder-secrets
|
name: inbox-converge-secrets
|
||||||
env:
|
env:
|
||||||
- name: HOST
|
- name: HOST
|
||||||
value: "0.0.0.0"
|
value: "0.0.0.0"
|
||||||
@@ -567,7 +567,7 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: backend
|
name: backend
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: backend
|
app: backend
|
||||||
@@ -583,7 +583,7 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: celery-worker
|
name: celery-worker
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
replicas: 2
|
replicas: 2
|
||||||
selector:
|
selector:
|
||||||
@@ -596,7 +596,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: worker
|
- name: worker
|
||||||
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest
|
image: ghcr.io/christianlouis/inboxconverge-backend:latest
|
||||||
command:
|
command:
|
||||||
- celery
|
- celery
|
||||||
- -A
|
- -A
|
||||||
@@ -606,7 +606,7 @@ spec:
|
|||||||
- --concurrency=2
|
- --concurrency=2
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: pop3-forwarder-secrets
|
name: inbox-converge-secrets
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 250m
|
cpu: 250m
|
||||||
@@ -625,7 +625,7 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: celery-beat
|
name: celery-beat
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
replicas: 1 # Must be exactly 1
|
replicas: 1 # Must be exactly 1
|
||||||
strategy:
|
strategy:
|
||||||
@@ -640,7 +640,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: beat
|
- name: beat
|
||||||
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest
|
image: ghcr.io/christianlouis/inboxconverge-backend:latest
|
||||||
command:
|
command:
|
||||||
- celery
|
- celery
|
||||||
- -A
|
- -A
|
||||||
@@ -649,7 +649,7 @@ spec:
|
|||||||
- --loglevel=info
|
- --loglevel=info
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: pop3-forwarder-secrets
|
name: inbox-converge-secrets
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 100m
|
||||||
@@ -666,7 +666,7 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: frontend
|
name: frontend
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
replicas: 2
|
replicas: 2
|
||||||
selector:
|
selector:
|
||||||
@@ -679,7 +679,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
image: ghcr.io/christianlouis/pop_puller_to_gmail-frontend:latest
|
image: ghcr.io/christianlouis/inboxconverge-frontend:latest
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
env:
|
env:
|
||||||
@@ -697,7 +697,7 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: frontend
|
name: frontend
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: frontend
|
app: frontend
|
||||||
@@ -714,8 +714,8 @@ The Ingress below assumes you have an Ingress controller installed (e.g., [ingre
|
|||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: pop3-forwarder-ingress
|
name: inbox-converge-ingress
|
||||||
namespace: pop3-forwarder
|
namespace: inbox-converge
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||||
@@ -725,7 +725,7 @@ spec:
|
|||||||
- hosts:
|
- hosts:
|
||||||
- your-domain.com
|
- your-domain.com
|
||||||
- api.your-domain.com
|
- api.your-domain.com
|
||||||
secretName: pop3-forwarder-tls
|
secretName: inbox-converge-tls
|
||||||
rules:
|
rules:
|
||||||
- host: your-domain.com
|
- host: your-domain.com
|
||||||
http:
|
http:
|
||||||
@@ -754,7 +754,7 @@ spec:
|
|||||||
If you manage many environments (staging, production, etc.) consider wrapping the manifests above into a Helm chart:
|
If you manage many environments (staging, production, etc.) consider wrapping the manifests above into a Helm chart:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
helm/pop3-forwarder/
|
helm/inbox-converge/
|
||||||
├── Chart.yaml
|
├── Chart.yaml
|
||||||
├── values.yaml # defaults for all environments
|
├── values.yaml # defaults for all environments
|
||||||
├── values-staging.yaml
|
├── values-staging.yaml
|
||||||
@@ -782,8 +782,8 @@ replicaCount:
|
|||||||
frontend: 2
|
frontend: 2
|
||||||
|
|
||||||
image:
|
image:
|
||||||
backend: ghcr.io/christianlouis/pop_puller_to_gmail-backend
|
backend: ghcr.io/christianlouis/inboxconverge-backend
|
||||||
frontend: ghcr.io/christianlouis/pop_puller_to_gmail-frontend
|
frontend: ghcr.io/christianlouis/inboxconverge-frontend
|
||||||
tag: latest
|
tag: latest
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
@@ -862,7 +862,7 @@ In production you should place a reverse proxy in front of the backend and front
|
|||||||
### Example: nginx
|
### Example: nginx
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# /etc/nginx/sites-available/pop3-forwarder
|
# /etc/nginx/sites-available/inbox-converge
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
server {
|
server {
|
||||||
@@ -969,7 +969,7 @@ If you prefer Traefik, add it as a service in your Compose file and use labels o
|
|||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
|
|
||||||
# Pull latest code
|
# Pull latest code
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Error Codes and Messages
|
# Error Codes and Messages
|
||||||
|
|
||||||
This document catalogs all error codes used in the POP3 to Gmail Forwarder application.
|
This document catalogs all error codes used in the InboxConverge application.
|
||||||
|
|
||||||
## Error Code Format
|
## Error Code Format
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 🎯 Mission Accomplished
|
## 🎯 Mission Accomplished
|
||||||
|
|
||||||
This document summarizes the completion of the web interface and multitenancy features for the POP3 to Gmail Forwarder project.
|
This document summarizes the completion of the web interface and multitenancy features for the InboxConverge project.
|
||||||
|
|
||||||
## 📦 What Was Delivered
|
## 📦 What Was Delivered
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ These are potential future improvements outside the current task:
|
|||||||
### What Was Accomplished
|
### What Was Accomplished
|
||||||
✅ **Complete implementation of web interface and multitenancy features**
|
✅ **Complete implementation of web interface and multitenancy features**
|
||||||
|
|
||||||
The POP3 to Gmail Forwarder now has:
|
The InboxConverge now has:
|
||||||
- A modern, responsive web interface
|
- A modern, responsive web interface
|
||||||
- Complete user authentication system
|
- Complete user authentication system
|
||||||
- Full mail account management capabilities
|
- Full mail account management capabilities
|
||||||
@@ -356,7 +356,7 @@ The implementation is **complete and ready for**:
|
|||||||
|
|
||||||
## 👏 Thank You
|
## 👏 Thank You
|
||||||
|
|
||||||
This implementation represents a significant milestone in transforming the POP3 Forwarder from a simple script into a production-ready multi-tenant SaaS application. The web interface makes the service accessible to users of all technical levels, while maintaining the robust backend infrastructure.
|
This implementation represents a significant milestone in transforming the InboxConverge from a simple script into a production-ready multi-tenant SaaS application. The web interface makes the service accessible to users of all technical levels, while maintaining the robust backend infrastructure.
|
||||||
|
|
||||||
**The multitenancy and web interface implementation is now complete and ready for deployment!** 🎉
|
**The multitenancy and web interface implementation is now complete and ready for deployment!** 🎉
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Implementation Guide
|
# Implementation Guide
|
||||||
|
|
||||||
This guide provides step-by-step instructions for setting up and deploying the multi-tenant POP3 Forwarder SaaS application.
|
This guide provides step-by-step instructions for setting up and deploying the multi-tenant InboxConverge application.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ This guide provides step-by-step instructions for setting up and deploying the m
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
|
git clone https://github.com/christianlouis/inboxconverge.git
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
|
|
||||||
# Create backend environment file
|
# Create backend environment file
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
@@ -49,7 +49,7 @@ Edit `backend/.env` with your settings:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Minimum required for development
|
# Minimum required for development
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/pop3_forwarder
|
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/inbox_converge
|
||||||
SECRET_KEY=$(openssl rand -hex 32)
|
SECRET_KEY=$(openssl rand -hex 32)
|
||||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||||
GOOGLE_CLIENT_ID=your-client-id
|
GOOGLE_CLIENT_ID=your-client-id
|
||||||
@@ -176,7 +176,7 @@ ADMIN_EMAIL=admin@yourdomain.com
|
|||||||
Use nginx or Traefik as reverse proxy:
|
Use nginx or Traefik as reverse proxy:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# /etc/nginx/sites-available/pop3-forwarder
|
# /etc/nginx/sites-available/inbox-converge
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name api.yourdomain.com;
|
server_name api.yourdomain.com;
|
||||||
@@ -202,7 +202,7 @@ cat > /etc/cron.daily/backup-postgres << 'EOF'
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
BACKUP_DIR=/var/backups/postgres
|
BACKUP_DIR=/var/backups/postgres
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
docker exec pop3-postgres pg_dump -U postgres pop3_forwarder | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
|
docker exec inboxconverge-postgres pg_dump -U postgres inbox_converge | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
|
||||||
find $BACKUP_DIR -type f -mtime +7 -delete # Keep 7 days
|
find $BACKUP_DIR -type f -mtime +7 -delete # Keep 7 days
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -348,10 +348,10 @@ docker-compose -f docker-compose.new.yml exec celery-worker celery -A app.worker
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check connections
|
# Check connections
|
||||||
docker exec pop3-postgres psql -U postgres -d pop3_forwarder -c "SELECT count(*) FROM pg_stat_activity;"
|
docker exec inboxconverge-postgres psql -U postgres -d inbox_converge -c "SELECT count(*) FROM pg_stat_activity;"
|
||||||
|
|
||||||
# Check table sizes
|
# Check table sizes
|
||||||
docker exec pop3-postgres psql -U postgres -d pop3_forwarder -c "
|
docker exec inboxconverge-postgres psql -U postgres -d inbox_converge -c "
|
||||||
SELECT
|
SELECT
|
||||||
schemaname,
|
schemaname,
|
||||||
tablename,
|
tablename,
|
||||||
@@ -376,7 +376,7 @@ docker-compose -f docker-compose.new.yml ps postgres
|
|||||||
docker-compose -f docker-compose.new.yml logs postgres
|
docker-compose -f docker-compose.new.yml logs postgres
|
||||||
|
|
||||||
# Test connection
|
# Test connection
|
||||||
docker exec pop3-postgres psql -U postgres -c "SELECT version();"
|
docker exec inboxconverge-postgres psql -U postgres -c "SELECT version();"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Celery Worker Not Processing
|
#### Celery Worker Not Processing
|
||||||
@@ -480,8 +480,8 @@ redis:
|
|||||||
For additional help:
|
For additional help:
|
||||||
|
|
||||||
- **Documentation**: See [ARCHITECTURE.md](ARCHITECTURE.md)
|
- **Documentation**: See [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||||
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues
|
- **Issues**: https://github.com/christianlouis/inboxconverge/issues
|
||||||
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions
|
- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Migration Guide: Single-User to Multi-Tenant SaaS
|
# Migration Guide: Single-User to Multi-Tenant SaaS
|
||||||
|
|
||||||
This guide helps you migrate from the legacy single-user `pop3_forwarder.py` script to the new multi-tenant SaaS application.
|
This guide helps you migrate from the legacy single-user `inbox_converge.py` script to the new multi-tenant SaaS application.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ docker-compose -f docker-compose.yml down
|
|||||||
|
|
||||||
# Archive old configuration
|
# Archive old configuration
|
||||||
mkdir -p archive
|
mkdir -p archive
|
||||||
mv pop3_forwarder.py archive/
|
mv inbox_converge.py archive/
|
||||||
mv .env.legacy.backup archive/
|
mv .env.legacy.backup archive/
|
||||||
mv docker-compose.yml archive/docker-compose.legacy.yml
|
mv docker-compose.yml archive/docker-compose.legacy.yml
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Quick Start Guide
|
# Quick Start Guide
|
||||||
|
|
||||||
Get your POP3 to Gmail forwarder running in under 10 minutes!
|
Get your InboxConverge instance running in under 10 minutes!
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ Get your POP3 to Gmail forwarder running in under 10 minutes!
|
|||||||
### 1. Clone the Repository
|
### 1. Clone the Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
|
git clone https://github.com/christianlouis/inboxconverge.git
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Generate Gmail App Password
|
### 2. Generate Gmail App Password
|
||||||
@@ -23,7 +23,7 @@ cd pop_puller_to_gmail
|
|||||||
2. Sign in to your Google Account
|
2. Sign in to your Google Account
|
||||||
3. Select "App passwords" under Security
|
3. Select "App passwords" under Security
|
||||||
4. Choose "Mail" and "Other (Custom name)"
|
4. Choose "Mail" and "Other (Custom name)"
|
||||||
5. Enter "POP3 Forwarder" as the name
|
5. Enter "InboxConverge" as the name
|
||||||
6. Click "Generate"
|
6. Click "Generate"
|
||||||
7. **Copy the 16-character password** (you'll need this in step 3)
|
7. **Copy the 16-character password** (you'll need this in step 3)
|
||||||
|
|
||||||
@@ -66,10 +66,10 @@ docker-compose logs -f
|
|||||||
|
|
||||||
You should see:
|
You should see:
|
||||||
```
|
```
|
||||||
pop3-gmail-forwarder | INFO - POP3 to Gmail Forwarder starting...
|
inboxconverge | INFO - InboxConverge starting...
|
||||||
pop3-gmail-forwarder | INFO - Loaded POP3 account: ...
|
inboxconverge | INFO - Loaded POP3 account: ...
|
||||||
pop3-gmail-forwarder | INFO - Configuration validated successfully
|
inboxconverge | INFO - Configuration validated successfully
|
||||||
pop3-gmail-forwarder | INFO - Starting email processing cycle
|
inboxconverge | INFO - Starting email processing cycle
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Test the Forwarder
|
### 6. Test the Forwarder
|
||||||
@@ -192,13 +192,13 @@ Restart after changes: `docker-compose restart`
|
|||||||
|
|
||||||
## Need Help?
|
## Need Help?
|
||||||
|
|
||||||
- Open an issue: https://github.com/christianlouis/pop_puller_to_gmail/issues
|
- Open an issue: https://github.com/christianlouis/inboxconverge/issues
|
||||||
- Check existing discussions
|
- Check existing discussions
|
||||||
- Review troubleshooting section in README.md
|
- Review troubleshooting section in README.md
|
||||||
|
|
||||||
## Success! 🎉
|
## Success! 🎉
|
||||||
|
|
||||||
Your POP3 to Gmail forwarder is now running. Emails will be automatically forwarded every 5 minutes (or your configured interval).
|
Your InboxConverge instance is now running. Emails will be automatically forwarded every 5 minutes (or your configured interval).
|
||||||
|
|
||||||
**Remember**:
|
**Remember**:
|
||||||
- The forwarder deletes emails from POP3 after successful forwarding
|
- The forwarder deletes emails from POP3 after successful forwarding
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ This project has been **completely transformed** from a single-user Docker scrip
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
|
git clone https://github.com/christianlouis/inboxconverge.git
|
||||||
cd pop_puller_to_gmail
|
cd inboxconverge
|
||||||
|
|
||||||
# Configure environment
|
# Configure environment
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
@@ -330,8 +330,8 @@ MIT License - See [LICENSE](../LICENSE) file for details.
|
|||||||
## 🆘 Support
|
## 🆘 Support
|
||||||
|
|
||||||
- **Documentation**: See docs in repository
|
- **Documentation**: See docs in repository
|
||||||
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues
|
- **Issues**: https://github.com/christianlouis/inboxconverge/issues
|
||||||
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions
|
- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
|
||||||
- **Email**: support@example.com (for Enterprise customers)
|
- **Email**: support@example.com (for Enterprise customers)
|
||||||
|
|
||||||
## 🎉 Acknowledgments
|
## 🎉 Acknowledgments
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
Create a robust, scalable, and user-friendly POP3 to Gmail forwarding solution that serves as a complete replacement for Gmail's discontinued POP3 import feature.
|
Create a robust, scalable, and user-friendly InboxConverge email-forwarding solution that serves as a complete replacement for Gmail's discontinued POP3 import feature.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Security analysis completed on February 1, 2026 for the Multi-Tenant POP3 Forwarder SaaS application.
|
Security analysis completed on February 1, 2026 for the Multi-Tenant InboxConverge application.
|
||||||
|
|
||||||
## CodeQL Security Scan
|
## CodeQL Security Scan
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ This guide will help you test the complete multi-tenant web interface with the b
|
|||||||
3. Edit the `.env` file and update the following critical values:
|
3. Edit the `.env` file and update the following critical values:
|
||||||
```bash
|
```bash
|
||||||
# Database - should point to Docker service
|
# Database - should point to Docker service
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/pop3_forwarder
|
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/inbox_converge
|
||||||
|
|
||||||
# Redis - should point to Docker service
|
# Redis - should point to Docker service
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|||||||
@@ -2,6 +2,103 @@
|
|||||||
|
|
||||||
Comprehensive task breakdown for repository improvements and production readiness.
|
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)
|
## 🔴 Critical - Security (In Progress)
|
||||||
|
|
||||||
### Completed ✅
|
### Completed ✅
|
||||||
@@ -11,11 +108,13 @@ Comprehensive task breakdown for repository improvements and production readines
|
|||||||
- [x] Implement CSRF protection middleware
|
- [x] Implement CSRF protection middleware
|
||||||
- [x] Document all error codes in docs/ERRORS.md
|
- [x] Document all error codes in docs/ERRORS.md
|
||||||
- [x] Create security ADR (Architecture Decision Records)
|
- [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 🔨
|
### In Progress 🔨
|
||||||
- [ ] Enable rate limiting per user/tier
|
- [ ] Enable rate limiting per user/tier
|
||||||
- [x] Fix bare exception handlers throughout codebase
|
- [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] 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
|
- [ ] Validate redirect_uri to prevent open redirect vulnerabilities
|
||||||
- [ ] Add per-user random salt for encryption (currently deterministic)
|
- [ ] 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 schemas and validation
|
||||||
- [x] Write unit tests for application factory and core endpoints
|
- [x] Write unit tests for application factory and core endpoints
|
||||||
- [x] Reach 50%+ test coverage (currently 59%)
|
- [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 🔨
|
### 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 unit tests for mail processing
|
||||||
- [ ] Write integration tests for API endpoints
|
- [ ] Write integration tests for API endpoints
|
||||||
- [ ] Write tests for Celery tasks
|
|
||||||
|
|
||||||
### Not Started 📋
|
### Not Started 📋
|
||||||
- [ ] Add end-to-end tests
|
- [ ] Add end-to-end tests
|
||||||
@@ -162,12 +268,21 @@ Comprehensive task breakdown for repository improvements and production readines
|
|||||||
|
|
||||||
## 📊 Medium Priority - Observability
|
## 📊 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 📋
|
### Not Started 📋
|
||||||
- [ ] Add Prometheus metrics endpoints
|
|
||||||
- [ ] Integrate Sentry for error tracking
|
- [ ] Integrate Sentry for error tracking
|
||||||
- [ ] Add structured logging with correlation IDs
|
- [x] Add structured logging with correlation IDs (per-email ProcessingLog entries now captured in DB)
|
||||||
- [ ] Create Grafana dashboard templates
|
|
||||||
- [ ] Document monitoring setup
|
|
||||||
- [ ] Add APM (Application Performance Monitoring)
|
- [ ] Add APM (Application Performance Monitoring)
|
||||||
- [ ] Set up uptime monitoring
|
- [ ] Set up uptime monitoring
|
||||||
- [ ] Create runbook for common issues
|
- [ ] Create runbook for common issues
|
||||||
@@ -182,9 +297,13 @@ Comprehensive task breakdown for repository improvements and production readines
|
|||||||
- [x] Account enable/disable toggle (UX + backend)
|
- [x] Account enable/disable toggle (UX + backend)
|
||||||
- [x] Per-user SMTP configuration (UX + backend)
|
- [x] Per-user SMTP configuration (UX + backend)
|
||||||
- [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling
|
- [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling
|
||||||
|
- [x] Configurable Gmail import labels (default `{{source_email}}` + `imported`, editable in Settings with reset-to-default action)
|
||||||
|
- [x] 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] 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
|
- [ ] Implement GDPR data export endpoint
|
||||||
- [ ] Complete notification service integration (Apprise)
|
- [x] Complete notification service integration (Apprise)
|
||||||
- [ ] Add advanced email filtering
|
- [ ] Add advanced email filtering
|
||||||
- [ ] Implement OAuth2 for Gmail (instead of App Passwords)
|
- [ ] Implement OAuth2 for Gmail (instead of App Passwords)
|
||||||
- [ ] Add attachment handling improvements
|
- [ ] 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] Mail accounts list with CRUD operations + enable/disable toggle
|
||||||
- [x] Settings page — Profile, Gmail API connection, SMTP relay, Account info, Security
|
- [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] `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] `DashboardLayout` with responsive sidebar
|
||||||
- [x] `AuthGuard` for protected routes
|
- [x] `AuthGuard` for protected routes
|
||||||
- [x] Fix wizard grey screen (Tailwind v4 `bg-opacity` → `/75` syntax, modal restructure)
|
- [x] Fix wizard grey screen (Tailwind v4 `bg-opacity` → `/75` syntax, modal restructure)
|
||||||
- [x] `/auth/gmail-callback` page for Gmail OAuth one-click flow
|
- [x] `/auth/gmail-callback` page for Gmail OAuth one-click flow
|
||||||
|
- [x] **`/logs` page** — user processing history: paginated runs table with expandable per-email log panel (subject, sender, size, status)
|
||||||
|
- [x] **Dashboard** — "Recent Processing Runs" now wired to real `/processing-runs` endpoint; shows account name and links to `/logs`
|
||||||
|
|
||||||
### Not Started 📋
|
### Not Started 📋
|
||||||
- [ ] End-to-end testing of frontend against backend API
|
- [ ] End-to-end testing of frontend against backend API
|
||||||
- [ ] Error boundary components
|
- [ ] Error boundary components
|
||||||
- [ ] Loading skeletons / proper loading states
|
- [ ] Loading skeletons / proper loading states
|
||||||
- [ ] Notification preferences UI
|
- [ ] 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
|
- [ ] 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
|
## 📅 Milestone Timeline
|
||||||
@@ -324,7 +460,8 @@ because the API client layer is missing.
|
|||||||
1. **Immediate** (Today):
|
1. **Immediate** (Today):
|
||||||
- [x] Create `frontend/src/lib/api.ts` (frontend is broken without it)
|
- [x] Create `frontend/src/lib/api.ts` (frontend is broken without it)
|
||||||
- [x] Fix remaining security issues (bare excepts, datetime, redirect_uri)
|
- [x] Fix remaining security issues (bare excepts, datetime, redirect_uri)
|
||||||
- [ ] Add backend endpoint for processing runs (needed by dashboard)
|
- [x] Add backend endpoint for processing runs (needed by dashboard)
|
||||||
|
- [x] Build logging & reporting: per-email ProcessingLog capture, user `/logs` page, admin `/admin/logs` page, GDPR masking
|
||||||
|
|
||||||
2. **This Week**:
|
2. **This Week**:
|
||||||
- [ ] Enable rate limiting
|
- [ ] Enable rate limiting
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Web Interface Quick Start Guide
|
# Web Interface Quick Start Guide
|
||||||
|
|
||||||
The POP3 to Gmail Forwarder now includes a modern web interface built with Next.js, making it easy to manage your email forwarding without API calls.
|
The InboxConverge now includes a modern web interface built with Next.js, making it easy to manage your email forwarding without API calls.
|
||||||
|
|
||||||
## 🌐 Accessing the Web Interface
|
## 🌐 Accessing the Web Interface
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ frontend:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pop3-frontend
|
container_name: inboxconverge-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ elif version == 'v2':
|
|||||||
def get_or_create_user_salt(user_id: int) -> bytes:
|
def get_or_create_user_salt(user_id: int) -> bytes:
|
||||||
# Use deterministic salt based on user_id + global salt
|
# Use deterministic salt based on user_id + global salt
|
||||||
# OR store random salt in database per user
|
# OR store random salt in database per user
|
||||||
return hashlib.sha256(f'pop3_forwarder_user_{user_id}'.encode()).digest()
|
return hashlib.sha256(f'inbox_converge_user_{user_id}'.encode()).digest()
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ async def lifespan(app: FastAPI):
|
|||||||
# Shutdown: cleanup
|
# Shutdown: cleanup
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="POP3 Forwarder API",
|
title="InboxConverge API",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ COPY . .
|
|||||||
# Set environment variable for build
|
# Set environment variable for build
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
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
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ const eslintConfig = defineConfig([
|
|||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"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;
|
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": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"build:docker": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "jest",
|
||||||
|
"test:ci": "jest --coverage --coverageReporters=lcov --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.95.0",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.16.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^1.16.0",
|
||||||
"next": "16.1.7",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.5",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.5",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.13"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"brace-expansion": "5.0.6",
|
||||||
|
"postcss": "8.5.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@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": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^10",
|
||||||
"eslint-config-next": "16.2.1",
|
"eslint-config-next": "16.2.5",
|
||||||
|
"jest": "^30.3.0",
|
||||||
|
"jest-environment-jsdom": "^30.4.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"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 |