Compare commits
229 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 | |||
| d79f9caf9e | |||
| 4d1451ecde | |||
| 65318b8c1b | |||
| 4d07d04c63 | |||
| 55536c964f |
+17
-26
@@ -26,7 +26,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
|
||||
- name: Upload backend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./backend/coverage.xml
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./frontend/coverage/lcov.info
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -206,33 +206,24 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Requires PRIVATE_REGISTRY_USERNAME and PRIVATE_REGISTRY_PASSWORD secrets
|
||||
- name: Log in to private registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.PRIVATE_REGISTRY }}
|
||||
username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.PRIVATE_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image_name }}
|
||||
${{ env.PRIVATE_REGISTRY }}/${{ matrix.image_name }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
@@ -242,7 +233,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
push: true
|
||||
@@ -250,7 +241,7 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
||||
|
||||
@@ -268,8 +259,8 @@ jobs:
|
||||
id: tag
|
||||
run: |
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
echo "backend_image=${{ env.PRIVATE_REGISTRY }}/inboxconverge/backend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=${{ env.PRIVATE_REGISTRY }}/inboxconverge/frontend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "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
|
||||
@@ -294,7 +285,7 @@ jobs:
|
||||
|
||||
- name: Checkout k8s-cluster-state
|
||||
if: steps.pat-check.outputs.available == 'true'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.K8S_STATE_REPO }}
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
@@ -303,22 +294,22 @@ jobs:
|
||||
|
||||
- name: Update backend image tag in preprod manifest
|
||||
if: steps.pat-check.outputs.available == 'true'
|
||||
uses: mikefarah/yq@v4.44.6
|
||||
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/inboxconverge/backend:"))) = strenv(IMAGE)' \
|
||||
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.44.6
|
||||
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/inboxconverge/frontend:"))) = strenv(IMAGE)' \
|
||||
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
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+609
@@ -7,6 +7,614 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
<!-- version list -->
|
||||
|
||||
## v0.10.7 (2026-05-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Pin patched postcss dependency
|
||||
([`97ee4d0`](https://github.com/christianlouis/InboxConverge/commit/97ee4d0e3cdef837d822429432c9ed3cd5843ce1))
|
||||
|
||||
### Chores
|
||||
|
||||
- **deps**: Bump @tailwindcss/postcss from 4.1.18 to 4.3.0 in /frontend
|
||||
([#249](https://github.com/christianlouis/InboxConverge/pull/249),
|
||||
[`83b53ac`](https://github.com/christianlouis/InboxConverge/commit/83b53aca9f9e43b070204545642f74b56d843f4b))
|
||||
|
||||
- **deps**: Bump @tanstack/react-query in /frontend
|
||||
([#239](https://github.com/christianlouis/InboxConverge/pull/239),
|
||||
[`f7f4745`](https://github.com/christianlouis/InboxConverge/commit/f7f4745f55cbdb2903d3590925690a743546e8c8))
|
||||
|
||||
- **deps**: Bump @types/node from 25.6.0 to 25.6.2 in /frontend
|
||||
([#253](https://github.com/christianlouis/InboxConverge/pull/253),
|
||||
[`64b06bd`](https://github.com/christianlouis/InboxConverge/commit/64b06bd1edb52972bcc602693eeccd338aaff9c9))
|
||||
|
||||
- **deps**: Bump apprise from 1.9.9 to 1.10.0 in /backend
|
||||
([#236](https://github.com/christianlouis/InboxConverge/pull/236),
|
||||
[`3b123d9`](https://github.com/christianlouis/InboxConverge/commit/3b123d974f123e526a9b17eae5d8a3ffcefdf90c))
|
||||
|
||||
- **deps**: Bump authlib from 1.6.12 to 1.7.2 in /backend
|
||||
([#258](https://github.com/christianlouis/InboxConverge/pull/258),
|
||||
[`4b09a3b`](https://github.com/christianlouis/InboxConverge/commit/4b09a3beabf848f6d41b7945f696253b1883b841))
|
||||
|
||||
- **deps**: Bump axios from 1.15.0 to 1.16.1 in /frontend
|
||||
([#233](https://github.com/christianlouis/InboxConverge/pull/233),
|
||||
[`bcca5a9`](https://github.com/christianlouis/InboxConverge/commit/bcca5a9192c3ee394830c79f906f4443c0c1dcf5))
|
||||
|
||||
- **deps**: Bump cryptography from 46.0.7 to 48.0.0 in /backend
|
||||
([#245](https://github.com/christianlouis/InboxConverge/pull/245),
|
||||
[`4eaeb1a`](https://github.com/christianlouis/InboxConverge/commit/4eaeb1ad8d61c6ffe925c3e7336f1b768b44985d))
|
||||
|
||||
- **deps**: Bump eslint from 9.39.4 to 10.4.0 in /frontend
|
||||
([#230](https://github.com/christianlouis/InboxConverge/pull/230),
|
||||
[`a364748`](https://github.com/christianlouis/InboxConverge/commit/a364748df7eeb0c0786b0010bd5b6c726ea79dbd))
|
||||
|
||||
- **deps**: Bump faker from 40.12.0 to 40.18.0 in /backend
|
||||
([#262](https://github.com/christianlouis/InboxConverge/pull/262),
|
||||
[`9cc9af2`](https://github.com/christianlouis/InboxConverge/commit/9cc9af2735da6c5b992f9bb6fca12eadebce6606))
|
||||
|
||||
- **deps**: Bump google-api-python-client to 2.196.0
|
||||
([#248](https://github.com/christianlouis/InboxConverge/pull/248),
|
||||
[`34f6f7b`](https://github.com/christianlouis/InboxConverge/commit/34f6f7b8e91496a7472f33aaf8836c0b5a18f969))
|
||||
|
||||
- **deps**: Bump google-auth from 2.49.2 to 2.52.0 in /backend
|
||||
([#243](https://github.com/christianlouis/InboxConverge/pull/243),
|
||||
[`60a1a9b`](https://github.com/christianlouis/InboxConverge/commit/60a1a9b2aeec60fb35a8cf84206928d462cd2f09))
|
||||
|
||||
- **deps**: Bump google-auth from 2.52.0 to 2.53.0 in /backend
|
||||
([#261](https://github.com/christianlouis/InboxConverge/pull/261),
|
||||
[`42b18e1`](https://github.com/christianlouis/InboxConverge/commit/42b18e10a6ab46b9a0a7c45ccb01c919bf51adb9))
|
||||
|
||||
- **deps**: Bump google-auth-httplib2 from 0.3.1 to 0.4.0 in /backend
|
||||
([#247](https://github.com/christianlouis/InboxConverge/pull/247),
|
||||
[`58a2474`](https://github.com/christianlouis/InboxConverge/commit/58a2474255d1ac7dc2e2692bb84f47f69845f67a))
|
||||
|
||||
- **deps**: Bump google-auth-oauthlib from 1.3.1 to 1.4.0 in /backend
|
||||
([#259](https://github.com/christianlouis/InboxConverge/pull/259),
|
||||
[`7d84f43`](https://github.com/christianlouis/InboxConverge/commit/7d84f4395ecee212609c4608aa01f662aa1ea3c8))
|
||||
|
||||
- **deps**: Bump jest-environment-jsdom in /frontend
|
||||
([#252](https://github.com/christianlouis/InboxConverge/pull/252),
|
||||
[`ebb8839`](https://github.com/christianlouis/InboxConverge/commit/ebb88391db4c72281e3b567f75f73d7137b24286))
|
||||
|
||||
- **deps**: Bump lucide-react to 1.16.0
|
||||
([#237](https://github.com/christianlouis/InboxConverge/pull/237),
|
||||
[`311c1bc`](https://github.com/christianlouis/InboxConverge/commit/311c1bc408ec8645bcb9c20efcfab387f9c2054d))
|
||||
|
||||
- **deps**: Bump next from 16.2.5 to 16.2.6 in /frontend
|
||||
([#251](https://github.com/christianlouis/InboxConverge/pull/251),
|
||||
[`fb8a11f`](https://github.com/christianlouis/InboxConverge/commit/fb8a11ffba62801d0d9a31964a11af883393011a))
|
||||
|
||||
- **deps**: Bump psycopg2-binary to 2.9.12
|
||||
([#238](https://github.com/christianlouis/InboxConverge/pull/238),
|
||||
[`a1ff9e7`](https://github.com/christianlouis/InboxConverge/commit/a1ff9e70f79d35d5736da8af22f8b0e8535c6025))
|
||||
|
||||
- **deps**: Bump pydantic from 2.12.5 to 2.13.4 in /backend
|
||||
([#244](https://github.com/christianlouis/InboxConverge/pull/244),
|
||||
[`d4a2160`](https://github.com/christianlouis/InboxConverge/commit/d4a2160e010b658d1555555d08060d70d6c7c4cb))
|
||||
|
||||
- **deps**: Bump pydantic-settings from 2.13.1 to 2.14.1 in /backend
|
||||
([#260](https://github.com/christianlouis/InboxConverge/pull/260),
|
||||
[`8130a6f`](https://github.com/christianlouis/InboxConverge/commit/8130a6f81ccdf81ea7efc3a5d385aa11f2b42960))
|
||||
|
||||
- **deps**: Bump python-multipart from 0.0.26 to 0.0.28 in /backend
|
||||
([#246](https://github.com/christianlouis/InboxConverge/pull/246),
|
||||
[`e602779`](https://github.com/christianlouis/InboxConverge/commit/e6027792c68c77e10bae0d2577d4439bf7154396))
|
||||
|
||||
- **deps**: Bump python-multipart from 0.0.28 to 0.0.29 in /backend
|
||||
([#257](https://github.com/christianlouis/InboxConverge/pull/257),
|
||||
[`fdc8721`](https://github.com/christianlouis/InboxConverge/commit/fdc87218af75276444451cf0d268900db2bf4d7b))
|
||||
|
||||
- **deps**: Bump stripe to 15.1.0 ([#232](https://github.com/christianlouis/InboxConverge/pull/232),
|
||||
[`ec5b2e1`](https://github.com/christianlouis/InboxConverge/commit/ec5b2e12f423b76b057e35c05c2fbfa9b7769077))
|
||||
|
||||
- **deps**: Bump the pip group across 1 directory with 2 updates
|
||||
([#242](https://github.com/christianlouis/InboxConverge/pull/242),
|
||||
[`cde6458`](https://github.com/christianlouis/InboxConverge/commit/cde645844cd922eec71e209567632ad0026df45b))
|
||||
|
||||
- **deps**: Bump uvicorn from 0.43.0 to 0.47.0 in /backend
|
||||
([#256](https://github.com/christianlouis/InboxConverge/pull/256),
|
||||
[`37b48e1`](https://github.com/christianlouis/InboxConverge/commit/37b48e1c3782e8e9974bf263dbafb0e36efbb8fa))
|
||||
|
||||
- **deps**: Bump zustand from 5.0.12 to 5.0.13 in /frontend
|
||||
([#250](https://github.com/christianlouis/InboxConverge/pull/250),
|
||||
[`9b0448c`](https://github.com/christianlouis/InboxConverge/commit/9b0448cd3bad8cbff7a7ea92f4ed635f3495b961))
|
||||
|
||||
|
||||
## v0.10.6 (2026-05-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Linearize alembic migrations
|
||||
([`8295b7a`](https://github.com/christianlouis/InboxConverge/commit/8295b7aaf247bb8c6f5adee093368ac45df72d75))
|
||||
|
||||
|
||||
## v0.10.5 (2026-05-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add sender_email to _make_smtp_config test helper
|
||||
([`1420149`](https://github.com/christianlouis/InboxConverge/commit/1420149d8b7214ef34d90152b0ef0541e9cb9866))
|
||||
|
||||
|
||||
## v0.10.4 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve all lint errors (black, ruff, mypy)
|
||||
([`fcdd51e`](https://github.com/christianlouis/InboxConverge/commit/fcdd51e6aa571da1c5716e90f8b15dacc23a3d78))
|
||||
|
||||
|
||||
## v0.10.3 (2026-05-03)
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Lint**: Removed unused imports (`pytest` in `test_dns_fallback.py`, `asyncio` in `test_mail_processor_pop3.py`); reformatted three test files with Black; resolved mypy type errors in `mail_processor.py` (IPv4 address tuple indexing, `_create_socket` override) and suppressed spurious `alembic.command` attr-defined error in `main.py`.
|
||||
|
||||
- **POP3/IMAP connectivity**: Added retry logic (up to 3 attempts, 5 s delay) for
|
||||
transient errors (`-ERR EOF`, timeout, connection-reset) in both POP3 and IMAP
|
||||
fetch paths. Most `-ERR EOF` and "timed out" failures now self-heal without
|
||||
surfacing as errors.
|
||||
|
||||
- **DNS fallback to 8.8.8.8**: When both the system resolver and the in-process
|
||||
DNS cache fail (e.g. transient resolver outage), mail-processor now sends a raw
|
||||
UDP A-record query directly to Google's public resolver (8.8.8.8:53). The
|
||||
result is cached in the same way as system-resolver results. No new runtime
|
||||
dependencies.
|
||||
|
||||
- **Debug-logging auto-disable**: Counter now counts 5 runs *from when the checkbox
|
||||
was last ticked*, not in a rolling 24-hour window. New `debug_logging_run_count`
|
||||
column (migration `0003`) is reset to 0 each time `debug_logging` is enabled via
|
||||
the API, so users always get exactly 5 debugged runs per enable.
|
||||
|
||||
- **Notification backoff**: Error notifications (both connection-level failures and
|
||||
per-email forwarding failures) are now sent only once per consecutive failure
|
||||
streak. A `error_notification_sent` flag (migration `0003`) tracks whether the
|
||||
current streak has already been notified. When the account recovers the flag is
|
||||
cleared and a single recovery notification is sent; the next failure streak will
|
||||
then fire a fresh alert.
|
||||
## v0.10.2 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add sender_email to UserSmtpConfig to fix SMTP From header (501 bad sender address)
|
||||
([`a21e2f7`](https://github.com/christianlouis/InboxConverge/commit/a21e2f7df62ef79271efab172a88d1ead06bfe67))
|
||||
|
||||
|
||||
## v0.10.1 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Normalise naive UTC expiry from google-auth to tz-aware in GmailService
|
||||
([`01a85eb`](https://github.com/christianlouis/InboxConverge/commit/01a85ebedef1508b13353c55aec073562ba367d6))
|
||||
|
||||
|
||||
## v0.10.0 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **SMTP Fallback sender address**: Add `sender_email` field to `UserSmtpConfig` so the SMTP `From:` header uses a real email address instead of the SMTP authentication token. Providers like Postmark use a UUID API token as the username, which caused a `501 Bad sender address syntax` error. The new field is optional; when left blank the `username` value is used as a fallback (preserving existing behaviour for providers where username == email address). Includes a new Alembic migration (`0003`) and a Sender Email input in the Settings page.
|
||||
|
||||
|
||||
## v0.9.2 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Address code review comments (type hints, loop var name, em dashes)
|
||||
([`28a7592`](https://github.com/christianlouis/InboxConverge/commit/28a75927317a30aace2c8fda1db3c6afe4792440))
|
||||
|
||||
- IPv4 preference + DNS cache fallback for stable POP3/IMAP connections
|
||||
([`45a2dd8`](https://github.com/christianlouis/InboxConverge/commit/45a2dd8be294dbf0b9c69a19c285ca56ec736fde))
|
||||
|
||||
|
||||
## v0.9.1 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add separate sender/recipient email fields for SMTP notification channel
|
||||
([`c3e608d`](https://github.com/christianlouis/InboxConverge/commit/c3e608d82fc8d7459ca02865700ba99283cf348b))
|
||||
|
||||
|
||||
## v0.9.0 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Apply black formatting to test_format_connection_error.py (--target-version py312)
|
||||
([`79d664c`](https://github.com/christianlouis/InboxConverge/commit/79d664c0e4907159e6f2558f9449522411ecda73))
|
||||
|
||||
- Remove unused imports and bare f-string in test_format_connection_error.py (ruff F401/F541)
|
||||
([`b1dd171`](https://github.com/christianlouis/InboxConverge/commit/b1dd1714f7e2d40826e03ccfd6e6868002f2699e))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Send Test Email button for SMTP Fallback**: a new "Send Test Email" button
|
||||
appears on the Settings page next to the Save/Remove SMTP controls once a
|
||||
configuration is saved. It calls a new `POST /users/smtp-config/test`
|
||||
endpoint that sends a test message to the user's registered email address
|
||||
using the stored SMTP credentials, and displays an inline success or error
|
||||
banner with the result.
|
||||
- **IPv4 preference for all POP3 and IMAP connections**: The app now resolves
|
||||
every mail-server hostname to an IPv4 address before connecting. This
|
||||
prevents `ENETUNREACH` / `Network is unreachable` errors and IMAP/POP3
|
||||
connection timeouts that occur on Docker hosts where IPv6 traffic is not
|
||||
routed to the internet but dual-stack DNS returns AAAA records first.
|
||||
|
||||
- **In-process DNS cache with automatic fallback** (`DNS_CACHE_FALLBACK_ENABLED`,
|
||||
enabled by default): The last successfully resolved IPv4 address for each
|
||||
mail-server hostname is stored in memory. When a subsequent DNS lookup fails
|
||||
with `EAI_AGAIN` (Temporary failure in name resolution) the cached address is
|
||||
used instead, keeping mail delivery alive through transient resolver outages.
|
||||
Set `DNS_CACHE_FALLBACK_ENABLED=false` in the environment to disable.
|
||||
|
||||
- **Friendly error messages**: Introduced `_format_connection_error()` helper in
|
||||
`mail_processor.py` that translates raw OS/socket/SSL/POP3/IMAP exceptions into
|
||||
human-readable sentences including the host:port and actionable guidance (DNS
|
||||
failure, TLS error, connection timeout, authentication rejection, etc.). The
|
||||
helper is applied at every `raise MailFetchError` / `raise MailConnectionError`
|
||||
site and in both `_test_pop3_connection` and `_test_imap_connection`.
|
||||
|
||||
- **Per-account debug logging** (`debug_logging` column on `MailAccount`): when
|
||||
enabled, the next processing run records a structured connection trace
|
||||
(connect timing, TLS details, auth, INBOX selection, message UIDs/sizes,
|
||||
elapsed milliseconds per phase) via the new `MailDebugRecorder` class. The
|
||||
trace is persisted as a `ProcessingLog` row with `level="DEBUG"` and surfaced
|
||||
in the "Mailbox Activity" logs page as a collapsible "Connection trace" panel.
|
||||
Debug logging auto-disables after 5 completed runs in a 24-hour window.
|
||||
|
||||
- **"Clear error" button**: new `POST /api/v1/mail-accounts/{id}/clear-error`
|
||||
endpoint that nulls `last_error_message`/`last_error_at` and resets `status`
|
||||
to `ACTIVE` when currently `ERROR`. Wired into the error banners on both the
|
||||
Accounts page and the Mailbox Activity (Logs) page.
|
||||
|
||||
- **Debug-logging toggle** in the account edit form (Add/Edit Account modal):
|
||||
checkbox labelled "Debug logging (auto-disables after 5 runs)".
|
||||
|
||||
- Alembic migration `0002_add_debug_logging.py` adding the `debug_logging`
|
||||
boolean column to `mail_accounts` (idempotent via `ADD COLUMN IF NOT EXISTS`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Transient DNS errors now produce a clear, accurate message**: `EAI_AGAIN`
|
||||
errors (errno -3 – resolver temporarily unavailable) are now reported as
|
||||
"Temporary DNS failure while resolving '…'" instead of the misleading "check
|
||||
that the server address is correct" message that was shown for all DNS errors.
|
||||
|
||||
- **`asyncio.get_event_loop()` replaced with `asyncio.get_running_loop()`** in
|
||||
`_test_pop3_connection`, `_fetch_pop3_emails`, `post_process_pop3`, and
|
||||
`forward_email`, silencing deprecation warnings in Python ≥ 3.10 and avoiding
|
||||
potential `DeprecationWarning → RuntimeError` in a future Python release.
|
||||
|
||||
- **Empty IMAP error messages** — `IMAP fetch error:` with a blank suffix was
|
||||
caused by `asyncio.TimeoutError` and `aioimaplib.Abort` having an empty
|
||||
`str()`. The new `_format_connection_error()` helper always produces a
|
||||
non-empty, human-readable message.
|
||||
|
||||
- **Cryptic DNS error** — `POP3 fetch error: [Errno -5] No address associated
|
||||
with hostname` is now surfaced as `Could not resolve hostname 'pop.web.de' —
|
||||
check that the server address is correct (DNS lookup failed: …)`.
|
||||
|
||||
- **Sticky ERROR status after transient fetch failures**: the `tasks.py`
|
||||
processing loop previously set `account.status = ERROR` and
|
||||
`last_error_message = "{N} emails failed to forward"` even when the
|
||||
connection and fetch succeeded but some individual email-forward operations
|
||||
failed. Now, a successful fetch (no exception from `fetch_emails`) always
|
||||
clears `last_error_message`/`last_error_at` and sets `status = ACTIVE`,
|
||||
regardless of per-email forwarding failures. Per-email failures continue to
|
||||
be tracked in `ProcessingLog` and the run's `emails_failed` counter.
|
||||
### Fixed
|
||||
|
||||
- OAuth Google sign-in: added `exc_info=True` to the catch-all exception handler in `auth_service.py` so the full traceback is always emitted to the log instead of only `str(e)`.
|
||||
- OAuth Google sign-in: added an endpoint-level try/except in the `/auth/google` handler to catch and log any unexpected errors (e.g. database failures) that occurred after the Google token exchange, which previously surfaced as silent 500s.
|
||||
|
||||
## v0.8.1 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Downgrade eslint to ^9 and typescript to ^5 for eslint-config-next compatibility
|
||||
([`f39241a`](https://github.com/christianlouis/InboxConverge/commit/f39241a547662fa8ae4ad408d2565ff0e50d9d55))
|
||||
|
||||
### Chores
|
||||
|
||||
- **deps**: Bump react and @types/react in /frontend
|
||||
([`00d06c6`](https://github.com/christianlouis/InboxConverge/commit/00d06c63de26f0bbbb5184ba219ad6b02eec4ec8))
|
||||
|
||||
|
||||
## v0.8.0 (2026-05-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Bump react to 19.2.5 to match react-dom, resolve npm ci ERESOLVE
|
||||
([`be812dd`](https://github.com/christianlouis/InboxConverge/commit/be812dda791fe36cb1cba5e4dce5de759de9153b))
|
||||
|
||||
- Combine f-strings, align product name in notification titles
|
||||
([`3470213`](https://github.com/christianlouis/InboxConverge/commit/3470213fb3374fec1101b04447ba6d2a26c1ec41))
|
||||
|
||||
### Chores
|
||||
|
||||
- **deps**: Bump @tanstack/react-query in /frontend
|
||||
([`b20da89`](https://github.com/christianlouis/InboxConverge/commit/b20da8940f682a83c37b9b80626eb6772f7ad246))
|
||||
|
||||
- **deps**: Bump @types/node from 25.5.2 to 25.6.0 in /frontend
|
||||
([`641007d`](https://github.com/christianlouis/InboxConverge/commit/641007d51cdef8b0f01b9326a93a16d1197eaf0e))
|
||||
|
||||
- **deps**: Bump aiohttp from 3.13.4 to 3.13.5 in /backend
|
||||
([`4c03352`](https://github.com/christianlouis/InboxConverge/commit/4c03352650d174a1c7903f45d18ff49a6e592c0d))
|
||||
|
||||
- **deps**: Bump axios from 1.14.0 to 1.15.0 in /frontend
|
||||
([`0ed6256`](https://github.com/christianlouis/InboxConverge/commit/0ed6256fb7ac1d06376a12ba2d58e3405904ba3a))
|
||||
|
||||
- **deps**: Bump cryptography
|
||||
([`222fc96`](https://github.com/christianlouis/InboxConverge/commit/222fc96effced50974e1f8cc4ffa1903461bda39))
|
||||
|
||||
- **deps**: Bump eslint-config-next from 16.2.2 to 16.2.3 in /frontend
|
||||
([`45a237b`](https://github.com/christianlouis/InboxConverge/commit/45a237bdcb070ecd527709fc466e27497b12bb9d))
|
||||
|
||||
- **deps**: Bump fastapi from 0.135.2 to 0.135.3 in /backend
|
||||
([`56a85e9`](https://github.com/christianlouis/InboxConverge/commit/56a85e9c9eab8cc8005c9a45aecb88c08cea4f28))
|
||||
|
||||
- **deps**: Bump google-auth from 2.49.1 to 2.49.2 in /backend
|
||||
([`197d7fe`](https://github.com/christianlouis/InboxConverge/commit/197d7fe060c0eed423fa060f7ad19c0a87da3eac))
|
||||
|
||||
- **deps**: Bump lucide-react from 1.7.0 to 1.8.0 in /frontend
|
||||
([`095ee10`](https://github.com/christianlouis/InboxConverge/commit/095ee1059c23e8f25ed43d2ea7def3f85d3f05ef))
|
||||
|
||||
- **deps**: Bump next
|
||||
([`54ecd7f`](https://github.com/christianlouis/InboxConverge/commit/54ecd7f95c7b41492b3e5777e38a66ffadab5d3a))
|
||||
|
||||
- **deps**: Bump prometheus-client from 0.24.1 to 0.25.0 in /backend
|
||||
([`8df1465`](https://github.com/christianlouis/InboxConverge/commit/8df1465a64ee25618ebc930e20d040c9f287de8b))
|
||||
|
||||
- **deps**: Bump pytest from 9.0.2 to 9.0.3 in /backend
|
||||
([`8441f73`](https://github.com/christianlouis/InboxConverge/commit/8441f734784bb5279f2521334a5284e24bbcb95d))
|
||||
|
||||
- **deps**: Bump pytest-cov from 4.1.0 to 7.1.0 in /backend
|
||||
([`24a85d6`](https://github.com/christianlouis/InboxConverge/commit/24a85d6554e213b4316de5abaf7826efe6b7e2b4))
|
||||
|
||||
- **deps**: Bump python-multipart from 0.0.24 to 0.0.26 in /backend
|
||||
([`42c5900`](https://github.com/christianlouis/InboxConverge/commit/42c5900c0677ece8e094616881f1add17d7e20b2))
|
||||
|
||||
- **deps**: Bump react-dom from 19.2.4 to 19.2.5 in /frontend
|
||||
([`d6d4df2`](https://github.com/christianlouis/InboxConverge/commit/d6d4df2df1d00940e8f0800235d650deffa5b834))
|
||||
|
||||
- **deps**: Bump schedule from 1.2.0 to 1.2.2 in /backend
|
||||
([`06ea195`](https://github.com/christianlouis/InboxConverge/commit/06ea1958aa7bda8c6d127c82fc751a0b3ea059e7))
|
||||
|
||||
### Features
|
||||
|
||||
- Add in-depth OAuth logging across auth_service, auth, providers, gmail_service
|
||||
([`075ba92`](https://github.com/christianlouis/InboxConverge/commit/075ba92f09f048c71d3f20ae70c8af0c69ac2339))
|
||||
|
||||
- Proactive Gmail token refresh, GmailAuthError, improved logging
|
||||
([`992e157`](https://github.com/christianlouis/InboxConverge/commit/992e157f022ae334d938575e7cb63a5135fc12ec))
|
||||
|
||||
|
||||
## v0.7.0 (2026-04-18)
|
||||
|
||||
### Features
|
||||
|
||||
- Add English privacy policy, ToS, and legal footer links for Google OAuth verification
|
||||
([`78610ef`](https://github.com/christianlouis/InboxConverge/commit/78610ef09da6fa21db8c9c82190f85c400de541f))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **English Privacy Policy** (`/privacy`): comprehensive public privacy policy in English including a Google API Services Limited Use Disclosure, explaining how `gmail.insert`, `gmail.labels`, and `gmail.readonly` scopes are used. Required for Google OAuth consent screen verification.
|
||||
- **Terms of Service** (`/terms`): full English terms of service covering acceptable use, Google API compliance, data, liability, and governing law.
|
||||
- **Home page footer legal links**: Privacy Policy, Terms of Service, Impressum, and Datenschutz links added to the public landing page footer — directly resolves the Google verification rejection ("homepage does not contain a link to your privacy policy").
|
||||
- **Login page footer**: now includes Privacy Policy and Terms of Service links alongside the existing Impressum / Datenschutz links.
|
||||
- **Register page consent text**: "By creating an account you agree to our Terms of Service and Privacy Policy" notice added below the sign-up form.
|
||||
- **Datenschutz cross-link**: German privacy page now links to the English `/privacy` page in section 2 and the bottom footer bar.
|
||||
- **Proactive Gmail token refresh task** (`refresh_gmail_tokens`): new Celery Beat task running every 45 minutes that refreshes any Gmail access token expiring within the next 30 minutes. Tokens with unknown expiry are also refreshed. Revoked tokens are immediately detected, marked invalid, and the user is notified. This prevents the first email delivery after a long idle period from triggering a synchronous in-band token exchange.
|
||||
- **In-depth OAuth logging**: added `DEBUG`/`INFO`/`WARNING`/`ERROR` log lines at every significant step of the OAuth flow across `auth_service.py`, `auth.py`, `providers.py`, and `gmail_service.py`. Logged events include: authorize-URL generation, code exchange (with scopes and `has_refresh_token` flag), user-profile fetch, API access verification, credential create/update/delete, label updates, debug-email injection, auto-refresh detection, and proactive refresh. Token values are never logged; only metadata (email, expiry, boolean presence, scopes) is recorded.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`GmailAuthError` exception class** added to `gmail_service.py` (subclass of `GmailInjectionError`): raised specifically when `google.auth.exceptions.RefreshError` is caught (i.e. the refresh token was revoked or invalid). This gives callers a typed signal distinct from ordinary API errors.
|
||||
- **Improved error logging** for Gmail token failures: log messages now include the token expiry timestamp and distinguish between "refresh token revoked" and "API HTTP error" scenarios, making it much easier to diagnose authentication problems in the application logs.
|
||||
- **Structured exception handling** in `process_mail_account`: `GmailAuthError` is now caught separately from generic exceptions so that revocation is detected reliably even when the error does not contain "401", "403", or "invalid_grant" in its string representation.
|
||||
|
||||
## v0.6.5 (2026-04-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove duplicate MailProcessor import in test_mail_processor_imap.py
|
||||
([`74ef362`](https://github.com/christianlouis/InboxConverge/commit/74ef362833824c844bb34daa4a8ceb8f0aa3bc36))
|
||||
|
||||
- Restore missing forward_email signature and reformat with black
|
||||
([`3dd71cd`](https://github.com/christianlouis/InboxConverge/commit/3dd71cd61d069776df2c3908392057e49f2f29f7))
|
||||
|
||||
- Retry failed forwarded emails; only post-process successfully forwarded messages
|
||||
([`ff7b50d`](https://github.com/christianlouis/InboxConverge/commit/ff7b50d944960d4da419ede988c2b1f24316b88d))
|
||||
|
||||
|
||||
## v0.6.4 (2026-04-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Replace deprecated .dict() with .model_dump() and fix db.add() AsyncMock warnings
|
||||
([`31dc059`](https://github.com/christianlouis/InboxConverge/commit/31dc059274f0e75f6ea46581b4b4ec0dca994d37))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Replace deprecated Pydantic V2 `.dict()` calls with `.model_dump()` in `admin.py` and `notifications.py` endpoints.
|
||||
- Fix `RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited` in `test_tasks.py` and `test_config_service.py` by marking `db.add` as a synchronous `MagicMock` in test helpers.
|
||||
|
||||
## v0.6.3 (2026-04-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **lint**: Specify explicit React version to fix ESLint 10 compatibility
|
||||
([`efa553c`](https://github.com/christianlouis/InboxConverge/commit/efa553c8d52b2e7595318b5e4897baf13b4d847d))
|
||||
|
||||
### Chores
|
||||
|
||||
- **ci**: Bump docker/login-action from 3 to 4
|
||||
([`6fe2f9a`](https://github.com/christianlouis/InboxConverge/commit/6fe2f9ad951a75d7c43bbb596cce9af552b82ece))
|
||||
|
||||
- **deps**: Bump @tanstack/react-query in /frontend
|
||||
([`466e852`](https://github.com/christianlouis/InboxConverge/commit/466e852c8bb587ed9f98198c8ef7a570e4cb1aec))
|
||||
|
||||
- **deps**: Bump @types/node from 25.5.0 to 25.5.2 in /frontend
|
||||
([`cc1edd8`](https://github.com/christianlouis/InboxConverge/commit/cc1edd8f5d1893eeecc83afb7795983fe5181b71))
|
||||
|
||||
- **deps**: Bump aiosmtplib from 3.0.1 to 5.1.0 in /backend
|
||||
([`284fbba`](https://github.com/christianlouis/InboxConverge/commit/284fbba47f1082cc52a3d23ec82ef24d443f1c17))
|
||||
|
||||
- **deps**: Bump alembic from 1.13.1 to 1.18.4 in /backend
|
||||
([`bd51a5b`](https://github.com/christianlouis/InboxConverge/commit/bd51a5b541db60610976d784e3518b00b04d9b65))
|
||||
|
||||
- **deps**: Bump email-validator from 2.1.0.post1 to 2.3.0 in /backend
|
||||
([`6f414e1`](https://github.com/christianlouis/InboxConverge/commit/6f414e175a117aa1faa57844797be33b258ac380))
|
||||
|
||||
- **deps**: Bump eslint from 9.39.4 to 10.2.0 in /frontend
|
||||
([`4bb92ac`](https://github.com/christianlouis/InboxConverge/commit/4bb92ac9d030aef180b2b358f4e824068a6c5c74))
|
||||
|
||||
- **deps**: Bump eslint-config-next from 16.2.1 to 16.2.2 in /frontend
|
||||
([`9d53b11`](https://github.com/christianlouis/InboxConverge/commit/9d53b117d4c10282c1525312e048a509fba4aa00))
|
||||
|
||||
- **deps**: Bump faker from 22.6.0 to 40.12.0 in /backend
|
||||
([`9553fae`](https://github.com/christianlouis/InboxConverge/commit/9553fae6b7a06a1780672a8c3ecd1d98b945731d))
|
||||
|
||||
- **deps**: Bump google-auth-httplib2 from 0.3.0 to 0.3.1 in /backend
|
||||
([`233595e`](https://github.com/christianlouis/InboxConverge/commit/233595e5ddf3caf135ff36ab15a702955868b131))
|
||||
|
||||
- **deps**: Bump google-auth-oauthlib from 1.2.0 to 1.3.1 in /backend
|
||||
([`3bb7a7c`](https://github.com/christianlouis/InboxConverge/commit/3bb7a7cd460d01356c89914681d3bb1344231dae))
|
||||
|
||||
- **deps**: Bump lucide-react from 0.577.0 to 1.7.0 in /frontend
|
||||
([`bfaa639`](https://github.com/christianlouis/InboxConverge/commit/bfaa639eedf97df6e81620590b1a21e6e780b1e3))
|
||||
|
||||
- **deps**: Bump next from 16.2.1 to 16.2.2 in /frontend
|
||||
([`a5d8700`](https://github.com/christianlouis/InboxConverge/commit/a5d87001194b9f0764875975e0d792b85e073ceb))
|
||||
|
||||
- **deps**: Bump python-multipart from 0.0.22 to 0.0.24 in /backend
|
||||
([`f479974`](https://github.com/christianlouis/InboxConverge/commit/f479974a5a432183de01a5c58264ff9e35376d7d))
|
||||
|
||||
- **deps**: Bump sqlalchemy from 2.0.48 to 2.0.49 in /backend
|
||||
([`6283d1c`](https://github.com/christianlouis/InboxConverge/commit/6283d1c4b4a46afb43cbafbfbb570e7be23cdef8))
|
||||
|
||||
- **deps**: Bump stripe from 14.4.1 to 15.0.1 in /backend
|
||||
([`582311a`](https://github.com/christianlouis/InboxConverge/commit/582311a18e6ec02ef90b6d049c709ef0057bb837))
|
||||
|
||||
- **deps**: Bump uvicorn from 0.42.0 to 0.43.0 in /backend
|
||||
([`b2d044d`](https://github.com/christianlouis/InboxConverge/commit/b2d044d8c209853f5ee2e58ab7e6b701ba462015))
|
||||
|
||||
|
||||
## v0.6.2 (2026-04-05)
|
||||
|
||||
### Code Style
|
||||
|
||||
- Reformat test_gdpr.py and test_gmail_labels.py to fix black CI check
|
||||
([`7afa33f`](https://github.com/christianlouis/InboxConverge/commit/7afa33f3758d289bb0971e3f83bcc30b39e436f1))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Expanded `gmail_service.py` unit 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`
|
||||
- Expanded `api.ts` test coverage from 20% to near-complete: 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`) — 65 total tests in `api.test.ts`
|
||||
- Unit tests for `AddMailAccountModal` component (`AddMailAccountModal.test.tsx`): 28 tests covering create/edit mode rendering, provider wizard flow, form field changes, checkbox toggles, auto-detect, test connection, submit mutations, error extraction, and modal close interactions
|
||||
- Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback
|
||||
- Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs
|
||||
- Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions
|
||||
- **Backend test coverage expanded** (+150 tests, 361 → 511 total): added `test_gdpr.py` (GDPR masking utilities), `test_gmail_labels.py` (Gmail label helpers), `test_notification_service.py` (Apprise notification service), `test_auth_service.py` (OAuth service token exchange and token creation), `test_version_endpoint.py`, `test_auth_endpoints.py` (register, login, Google OAuth, authorize-url, domain helpers), `test_users_endpoints.py` (profile CRUD, SMTP config upsert/delete), `test_notifications_endpoints.py` (full CRUD + test-send), `test_logs_endpoints.py` (processing-runs pagination/filtering + run log retrieval), and `test_app_settings_endpoints.py` (list, upsert, delete, seed-defaults with bootstrap key guards).
|
||||
## v0.6.1 (2026-04-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Upgrade pytest-asyncio to 1.3.0 for pytest 9.x compatibility
|
||||
([`39052af`](https://github.com/christianlouis/InboxConverge/commit/39052af4e2a5df1dd29062b7dcced9b1f2f17baf))
|
||||
|
||||
### Chores
|
||||
|
||||
- **ci**: Bump actions/checkout from 4 to 6
|
||||
([`68a6163`](https://github.com/christianlouis/InboxConverge/commit/68a6163fef8e81e250d6959d94be2174bf9fba31))
|
||||
|
||||
- **ci**: Bump codecov/codecov-action from 5 to 6
|
||||
([`594e380`](https://github.com/christianlouis/InboxConverge/commit/594e3803013363c0360d32fc329e9b427fa62835))
|
||||
|
||||
- **ci**: Bump docker/build-push-action from 5 to 7
|
||||
([`2c26c15`](https://github.com/christianlouis/InboxConverge/commit/2c26c156f63cc7335cf7799a443c63d734c047f7))
|
||||
|
||||
- **ci**: Bump docker/metadata-action from 5 to 6
|
||||
([`0e87da8`](https://github.com/christianlouis/InboxConverge/commit/0e87da85a61b9c1a764313231b69882b9aa55137))
|
||||
|
||||
- **ci**: Bump mikefarah/yq from 4.44.6 to 4.52.5
|
||||
([`f4c8e4a`](https://github.com/christianlouis/InboxConverge/commit/f4c8e4a1dc98d221eabd137bb71459ebec9de254))
|
||||
|
||||
- **deps**: Bump @tanstack/react-query in /frontend
|
||||
([`348c347`](https://github.com/christianlouis/InboxConverge/commit/348c3477dcbb0c504681cc71df6d7d098ae378df))
|
||||
|
||||
- **deps**: Bump @types/node from 20.19.30 to 25.5.0 in /frontend
|
||||
([`917dacc`](https://github.com/christianlouis/InboxConverge/commit/917dacc7cb9f1fa35a8dc2a72664b7d022039f2c))
|
||||
|
||||
- **deps**: Bump aiohttp from 3.13.3 to 3.13.4 in /backend
|
||||
([`d919939`](https://github.com/christianlouis/InboxConverge/commit/d91993964783c6bfde48b6f115abb6d02b0eec5c))
|
||||
|
||||
- **deps**: Bump apprise from 1.7.1 to 1.9.9 in /backend
|
||||
([`094b1e1`](https://github.com/christianlouis/InboxConverge/commit/094b1e177fcefc77b417a6715446fa13527c1a16))
|
||||
|
||||
- **deps**: Bump axios from 1.13.6 to 1.14.0 in /frontend
|
||||
([`3b580c3`](https://github.com/christianlouis/InboxConverge/commit/3b580c36c751d962e4c164596ce65ef4e7ce5048))
|
||||
|
||||
- **deps**: Bump bcrypt from 4.3.0 to 5.0.0 in /backend
|
||||
([`f0213a0`](https://github.com/christianlouis/InboxConverge/commit/f0213a0b03b05195329323d68a870a51e0a97150))
|
||||
|
||||
- **deps**: Bump celery from 5.6.2 to 5.6.3 in /backend
|
||||
([`5992a9e`](https://github.com/christianlouis/InboxConverge/commit/5992a9e802686227675867a259771dcb3ecf41bb))
|
||||
|
||||
- **deps**: Bump httpx from 0.26.0 to 0.28.1 in /backend
|
||||
([`c50cf6e`](https://github.com/christianlouis/InboxConverge/commit/c50cf6e0b32f2d115e9345054b777d3ab8a63f2b))
|
||||
|
||||
- **deps**: Bump next from 16.1.7 to 16.2.1 in /frontend
|
||||
([`ec90cd6`](https://github.com/christianlouis/InboxConverge/commit/ec90cd600b03fd0aea64a9c395b11b7faaa0d52a))
|
||||
|
||||
- **deps**: Bump prometheus-client from 0.19.0 to 0.24.1 in /backend
|
||||
([`77c8426`](https://github.com/christianlouis/InboxConverge/commit/77c84266fd5ea93f9c59e4ef3d9b37990f232883))
|
||||
|
||||
- **deps**: Bump pytest from 7.4.4 to 9.0.2 in /backend
|
||||
([`4a6f93e`](https://github.com/christianlouis/InboxConverge/commit/4a6f93e5acf5a2f7766e25444a20e11a5918a2b6))
|
||||
|
||||
- **deps**: Bump python-dotenv from 1.0.0 to 1.2.2 in /backend
|
||||
([`4dbe30b`](https://github.com/christianlouis/InboxConverge/commit/4dbe30b0eadb829e0d112b2c9ce877353b9d903e))
|
||||
|
||||
- **deps**: Bump redis from 7.3.0 to 7.4.0 in /backend
|
||||
([`b914ac0`](https://github.com/christianlouis/InboxConverge/commit/b914ac0ecd8a14bb1c3a96a232de330622c93ac7))
|
||||
|
||||
- **deps**: Bump typescript from 5.9.3 to 6.0.2 in /frontend
|
||||
([`726e089`](https://github.com/christianlouis/InboxConverge/commit/726e089c464b81e33ee872cd200b346cb996dcf6))
|
||||
|
||||
- **deps**: Bump uvicorn from 0.27.0 to 0.42.0 in /backend
|
||||
([`ac6a837`](https://github.com/christianlouis/InboxConverge/commit/ac6a837522c1dcfee3fdc9b6e9dca2f94460053f))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix CHANGELOG entry to only reference pytest-asyncio bump
|
||||
([`0521c97`](https://github.com/christianlouis/InboxConverge/commit/0521c97394ff3f4e909f9b463bd15958ac06b76f))
|
||||
|
||||
- Fix secret key generation syntax in README quickstart
|
||||
([`9c12555`](https://github.com/christianlouis/InboxConverge/commit/9c12555a8116e1db605d3fc3042de48d292b2be2))
|
||||
|
||||
- Rewrite README focused on Google 2026 POP/Gmailify shutdown replacement
|
||||
([`06efeed`](https://github.com/christianlouis/InboxConverge/commit/06efeed7be70a2d1da3a9e55a63cb8c9556b8c31))
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Bumped `pytest-asyncio` from 0.23.3 to 1.3.0 to restore compatibility with pytest 9.0.2 in the backend test suite.
|
||||
|
||||
## v0.6.0 (2026-03-29)
|
||||
|
||||
### Chores
|
||||
@@ -312,6 +920,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Duration display rounding bug**: `formatDuration` in the frontend Processing Logs page now
|
||||
uses `Math.floor` instead of `Math.round` for the seconds component, eliminating the "60s"
|
||||
artefact that appeared for durations very close to a whole minute boundary.
|
||||
- **Frontend dependency conflict**: Bumped `react` from `19.2.4` to `19.2.5` in `frontend/package.json` to match `react-dom@19.2.5`, resolving the `ERESOLVE` peer-dependency conflict that broke `npm ci`.
|
||||
|
||||
### Changed
|
||||
- **Decoupled Gmail permissions from Google Sign-In**: The "Sign in with Google" OAuth flow now only requests basic profile scopes (`openid`, `email`, `profile`) instead of also requesting Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`). Users can grant Gmail access separately via the "Connect Gmail" button in Settings. This results in a simpler, permission-free login experience.
|
||||
|
||||
@@ -0,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")
|
||||
@@ -322,7 +322,7 @@ async def create_admin_notification_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new admin notification configuration (admin only)"""
|
||||
config = AdminNotificationConfig(**config_in.dict())
|
||||
config = AdminNotificationConfig(**config_in.model_dump())
|
||||
db.add(config)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
@@ -370,7 +370,7 @@ async def update_admin_notification_config(
|
||||
detail="Admin notification config not found",
|
||||
)
|
||||
|
||||
update_data = config_in.dict(exclude_unset=True)
|
||||
update_data = config_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(config, field, value)
|
||||
|
||||
|
||||
@@ -182,81 +182,118 @@ async def google_oauth(
|
||||
Authenticate with Google OAuth2.
|
||||
Exchange authorization code for access token and user info.
|
||||
"""
|
||||
|
||||
# Get user info from Google
|
||||
user_info = await oauth_service.get_google_user_info(
|
||||
code=auth_request.code, redirect_uri=auth_request.redirect_uri
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: callback received (redirect_uri=%s)",
|
||||
auth_request.redirect_uri,
|
||||
)
|
||||
|
||||
if not user_info.get("verified_email"):
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="error").inc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email not verified with Google",
|
||||
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
|
||||
)
|
||||
|
||||
email = user_info["email"]
|
||||
google_id = user_info["google_id"]
|
||||
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",
|
||||
)
|
||||
|
||||
# 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()
|
||||
email = user_info["email"]
|
||||
google_id = user_info["google_id"]
|
||||
|
||||
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]
|
||||
# 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()
|
||||
|
||||
# Update last login
|
||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
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,
|
||||
)
|
||||
|
||||
# Domain restriction — superusers always bypass
|
||||
if not user.is_superuser:
|
||||
# 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)
|
||||
|
||||
# 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}")
|
||||
# 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"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)
|
||||
logger.info(f"New user registered with Google: {user.email}")
|
||||
AUTH_REGISTRATIONS_TOTAL.labels(method="google", status="success").inc()
|
||||
|
||||
# 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),
|
||||
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,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
logger.info(f"New user registered with Google: {user.email}")
|
||||
AUTH_REGISTRATIONS_TOTAL.labels(method="google", status="success").inc()
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="success").inc()
|
||||
return tokens
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Create tokens
|
||||
tokens = oauth_service.create_tokens_for_user(user)
|
||||
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="success").inc()
|
||||
return tokens
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="error").inc()
|
||||
logger.error(
|
||||
"OAuth [Google sign-in]: unhandled error during sign-in flow",
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="OAuth authentication failed",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/google/authorize-url")
|
||||
async def get_google_authorize_url(redirect_uri: str):
|
||||
"""Get Google OAuth2 authorization URL for sign-in (profile scopes only)."""
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: authorization URL requested "
|
||||
"(redirect_uri=%s, scopes=%s)",
|
||||
redirect_uri,
|
||||
GOOGLE_LOGIN_SCOPES,
|
||||
)
|
||||
scope = urlquote(" ".join(GOOGLE_LOGIN_SCOPES))
|
||||
auth_url = (
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
|
||||
@@ -104,6 +104,7 @@ async def create_mail_account(
|
||||
check_interval_minutes=account_in.check_interval_minutes,
|
||||
max_emails_per_check=account_in.max_emails_per_check,
|
||||
delete_after_forward=account_in.delete_after_forward,
|
||||
debug_logging=account_in.debug_logging,
|
||||
provider_name=account_in.provider_name,
|
||||
)
|
||||
|
||||
@@ -179,6 +180,11 @@ async def update_mail_account(
|
||||
if password: # Only update when a non-empty password is provided
|
||||
update_data["encrypted_password"] = encrypt_credential(password)
|
||||
|
||||
# When debug logging is (re-)enabled, reset the run counter so the user
|
||||
# always gets exactly 5 debugged runs from the moment they check the box.
|
||||
if update_data.get("debug_logging") is True:
|
||||
update_data["debug_logging_run_count"] = 0
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(account, field, value)
|
||||
|
||||
@@ -243,6 +249,42 @@ async def toggle_mail_account(
|
||||
return account
|
||||
|
||||
|
||||
@router.post("/{account_id}/clear-error", response_model=MailAccountResponse)
|
||||
async def clear_account_error(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Clear the error status of a mail account.
|
||||
|
||||
Resets last_error_message, last_error_at and sets status to ACTIVE
|
||||
if the account is currently in ERROR state. Use this after fixing the
|
||||
underlying problem (e.g. wrong password, DNS issue) to immediately remove
|
||||
the error indicator without waiting for the next successful fetch.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(MailAccount).where(
|
||||
MailAccount.id == account_id, MailAccount.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
account = result.scalar_one_or_none()
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
|
||||
)
|
||||
|
||||
account.last_error_message = None # type: ignore[assignment]
|
||||
account.last_error_at = None # type: ignore[assignment]
|
||||
if account.status == AccountStatus.ERROR:
|
||||
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
|
||||
return account
|
||||
|
||||
|
||||
@router.post("/{account_id}/pull-now", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def pull_now(
|
||||
account_id: int,
|
||||
|
||||
@@ -29,7 +29,7 @@ async def create_notification_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""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)
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
@@ -91,7 +91,7 @@ async def update_notification_config(
|
||||
detail="Notification config not found",
|
||||
)
|
||||
|
||||
update_data = config_in.dict(exclude_unset=True)
|
||||
update_data = config_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(config, field, value)
|
||||
|
||||
|
||||
@@ -217,6 +217,13 @@ async def save_gmail_credential(
|
||||
Save Gmail API OAuth2 credentials for the current user.
|
||||
These are used to inject emails directly into Gmail via the API.
|
||||
"""
|
||||
logger.debug(
|
||||
"OAuth [Gmail credential]: verifying credentials for user_id=%s "
|
||||
"(gmail=%s, has_refresh_token=%s)",
|
||||
current_user.id,
|
||||
credential_in.gmail_email,
|
||||
bool(credential_in.refresh_token),
|
||||
)
|
||||
# Verify the credentials work
|
||||
gmail_service = GmailService(
|
||||
access_token=credential_in.access_token,
|
||||
@@ -227,6 +234,12 @@ async def save_gmail_credential(
|
||||
|
||||
is_valid = await gmail_service.verify_access()
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
"OAuth [Gmail credential]: credential verification failed for user_id=%s "
|
||||
"(gmail=%s)",
|
||||
current_user.id,
|
||||
credential_in.gmail_email,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Gmail API credentials are invalid or expired",
|
||||
@@ -258,6 +271,11 @@ async def save_gmail_credential(
|
||||
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
"OAuth [Gmail credential]: updated credential for user_id=%s (gmail=%s)",
|
||||
current_user.id,
|
||||
credential_in.gmail_email,
|
||||
)
|
||||
return existing
|
||||
else:
|
||||
# Create new
|
||||
@@ -273,6 +291,11 @@ async def save_gmail_credential(
|
||||
db.add(credential)
|
||||
await db.commit()
|
||||
await db.refresh(credential)
|
||||
logger.info(
|
||||
"OAuth [Gmail credential]: created new credential for user_id=%s (gmail=%s)",
|
||||
current_user.id,
|
||||
credential_in.gmail_email,
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
@@ -313,6 +336,11 @@ async def delete_gmail_credential(
|
||||
detail="No Gmail credentials found",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"OAuth [Gmail credential]: deleting credential for user_id=%s (gmail=%s)",
|
||||
current_user.id,
|
||||
credential.gmail_email,
|
||||
)
|
||||
await db.delete(credential)
|
||||
await db.commit()
|
||||
|
||||
@@ -335,9 +363,17 @@ async def update_gmail_import_labels(
|
||||
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_import_label_templates(labels_in.import_label_templates),
|
||||
validated,
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(credential)
|
||||
@@ -363,6 +399,13 @@ async def get_gmail_authorize_url(
|
||||
detail="Google OAuth2 is not configured on this server.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"OAuth [Gmail connect]: authorization URL requested for user_id=%s "
|
||||
"(redirect_uri=%s, scopes=%s)",
|
||||
current_user.id,
|
||||
redirect_uri,
|
||||
GMAIL_API_SCOPES,
|
||||
)
|
||||
scope = urlquote(" ".join(GMAIL_API_SCOPES))
|
||||
url = (
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
@@ -393,6 +436,10 @@ async def send_gmail_debug_email(
|
||||
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,
|
||||
@@ -402,11 +449,23 @@ async def send_gmail_debug_email(
|
||||
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]
|
||||
@@ -427,6 +486,12 @@ async def send_gmail_debug_email(
|
||||
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}",
|
||||
@@ -435,6 +500,12 @@ async def send_gmail_debug_email(
|
||||
# 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"]
|
||||
)
|
||||
@@ -442,6 +513,13 @@ async def send_gmail_debug_email(
|
||||
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"),
|
||||
@@ -482,7 +560,18 @@ async def gmail_oauth_callback(
|
||||
detail="Google OAuth2 is not configured on this server.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"OAuth [Gmail connect]: callback received for user_id=%s (redirect_uri=%s)",
|
||||
current_user.id,
|
||||
callback_in.redirect_uri,
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: exchanging authorization code for tokens "
|
||||
"(user_id=%s)",
|
||||
current_user.id,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_resp = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
@@ -496,7 +585,13 @@ async def gmail_oauth_callback(
|
||||
)
|
||||
|
||||
if token_resp.status_code != 200:
|
||||
logger.error(f"Gmail token exchange failed: {token_resp.text}")
|
||||
logger.error(
|
||||
"OAuth [Gmail connect]: token exchange failed for user_id=%s "
|
||||
"(status=%s, body=%s)",
|
||||
current_user.id,
|
||||
token_resp.status_code,
|
||||
token_resp.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code with Google.",
|
||||
@@ -506,13 +601,41 @@ async def gmail_oauth_callback(
|
||||
access_token: Optional[str] = token_data.get("access_token")
|
||||
refresh_token: Optional[str] = token_data.get("refresh_token")
|
||||
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: token exchange succeeded for user_id=%s — "
|
||||
"scopes=%s, has_refresh_token=%s, expires_in=%s",
|
||||
current_user.id,
|
||||
token_data.get("scope", ""),
|
||||
bool(refresh_token),
|
||||
token_data.get("expires_in"),
|
||||
)
|
||||
|
||||
if not access_token:
|
||||
logger.error(
|
||||
"OAuth [Gmail connect]: Google response contained no access_token "
|
||||
"for user_id=%s (keys_present=%s)",
|
||||
current_user.id,
|
||||
list(token_data.keys()),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Google did not return an access token.",
|
||||
)
|
||||
|
||||
if not refresh_token:
|
||||
logger.warning(
|
||||
"OAuth [Gmail connect]: Google did not return a refresh_token for "
|
||||
"user_id=%s — token refresh may fail after 1 hour. "
|
||||
"This can happen if the user has previously authorised the app and "
|
||||
"access_type=offline was not honoured.",
|
||||
current_user.id,
|
||||
)
|
||||
|
||||
# Fetch the Gmail email address to associate with this credential
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: fetching Google profile for user_id=%s",
|
||||
current_user.id,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
profile_resp = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
@@ -522,6 +645,20 @@ async def gmail_oauth_callback(
|
||||
gmail_email = current_user.email # fallback
|
||||
if profile_resp.status_code == 200:
|
||||
gmail_email = profile_resp.json().get("email", current_user.email)
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: Google profile retrieved for user_id=%s "
|
||||
"(gmail=%s)",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"OAuth [Gmail connect]: could not fetch Google profile for user_id=%s "
|
||||
"(status=%s); falling back to account email=%s",
|
||||
current_user.id,
|
||||
profile_resp.status_code,
|
||||
current_user.email,
|
||||
)
|
||||
|
||||
# Calculate token expiry (Google access tokens last 1 hour)
|
||||
token_expiry = datetime.now(timezone.utc) + timedelta(
|
||||
@@ -529,6 +666,11 @@ async def gmail_oauth_callback(
|
||||
)
|
||||
|
||||
# Verify the credentials actually work with the Gmail API
|
||||
logger.debug(
|
||||
"OAuth [Gmail connect]: verifying Gmail API access for user_id=%s (gmail=%s)",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
)
|
||||
gmail_service = GmailService(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -537,6 +679,12 @@ async def gmail_oauth_callback(
|
||||
)
|
||||
is_valid = await gmail_service.verify_access()
|
||||
if not is_valid:
|
||||
logger.error(
|
||||
"OAuth [Gmail connect]: Gmail API access verification failed for "
|
||||
"user_id=%s (gmail=%s) — ensure gmail.insert scope was granted",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Obtained tokens but could not verify Gmail API access. "
|
||||
@@ -566,6 +714,15 @@ async def gmail_oauth_callback(
|
||||
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
"OAuth [Gmail connect]: updated credential for user_id=%s "
|
||||
"(gmail=%s, token_expiry=%s, has_refresh_token=%s, scopes=%s)",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
token_expiry,
|
||||
bool(encrypted_refresh),
|
||||
token_data.get("scope", ""),
|
||||
)
|
||||
return existing
|
||||
else:
|
||||
credential = GmailCredential(
|
||||
@@ -581,4 +738,13 @@ async def gmail_oauth_callback(
|
||||
db.add(credential)
|
||||
await db.commit()
|
||||
await db.refresh(credential)
|
||||
logger.info(
|
||||
"OAuth [Gmail connect]: created new credential for user_id=%s "
|
||||
"(gmail=%s, token_expiry=%s, has_refresh_token=%s, scopes=%s)",
|
||||
current_user.id,
|
||||
gmail_email,
|
||||
token_expiry,
|
||||
bool(encrypted_refresh),
|
||||
token_data.get("scope", ""),
|
||||
)
|
||||
return credential
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
"""User management endpoints"""
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formatdate, make_msgid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_active_user
|
||||
from app.core.security import encrypt_credential
|
||||
from app.core.security import encrypt_credential, decrypt_credential
|
||||
from app.models.database_models import User, UserSmtpConfig
|
||||
from app.models.schemas import (
|
||||
UserDetailResponse,
|
||||
UserUpdate,
|
||||
UserSmtpConfigUpdate,
|
||||
UserSmtpConfigResponse,
|
||||
SmtpTestResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -66,6 +72,7 @@ async def get_smtp_config(
|
||||
host=config.host, # type: ignore[arg-type]
|
||||
port=config.port, # type: ignore[arg-type]
|
||||
username=config.username, # type: ignore[arg-type]
|
||||
sender_email=config.sender_email, # type: ignore[arg-type]
|
||||
use_tls=config.use_tls, # type: ignore[arg-type]
|
||||
has_password=bool(config.encrypted_password),
|
||||
created_at=config.created_at, # type: ignore[arg-type]
|
||||
@@ -89,6 +96,7 @@ async def upsert_smtp_config(
|
||||
config.host = config_in.host # type: ignore[assignment]
|
||||
config.port = config_in.port # type: ignore[assignment]
|
||||
config.username = config_in.username # type: ignore[assignment]
|
||||
config.sender_email = config_in.sender_email # type: ignore[assignment]
|
||||
config.use_tls = config_in.use_tls # type: ignore[assignment]
|
||||
if config_in.password is not None:
|
||||
config.encrypted_password = encrypt_credential(config_in.password) # type: ignore[assignment]
|
||||
@@ -98,6 +106,7 @@ async def upsert_smtp_config(
|
||||
host=config_in.host,
|
||||
port=config_in.port,
|
||||
username=config_in.username,
|
||||
sender_email=config_in.sender_email,
|
||||
encrypted_password=(
|
||||
encrypt_credential(config_in.password) if config_in.password else ""
|
||||
),
|
||||
@@ -114,6 +123,7 @@ async def upsert_smtp_config(
|
||||
host=config.host, # type: ignore[arg-type]
|
||||
port=config.port, # type: ignore[arg-type]
|
||||
username=config.username, # type: ignore[arg-type]
|
||||
sender_email=config.sender_email, # type: ignore[arg-type]
|
||||
use_tls=config.use_tls, # type: ignore[arg-type]
|
||||
has_password=bool(config.encrypted_password),
|
||||
created_at=config.created_at, # type: ignore[arg-type]
|
||||
@@ -134,3 +144,87 @@ async def delete_smtp_config(
|
||||
if config:
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/smtp-config/test", response_model=SmtpTestResponse)
|
||||
async def test_smtp_config(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send a test email using the current user's saved SMTP configuration"""
|
||||
result = await db.execute(
|
||||
select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No SMTP configuration found. Save one first.",
|
||||
)
|
||||
|
||||
if not config.encrypted_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No SMTP password stored. Save the configuration with a password first.",
|
||||
)
|
||||
|
||||
password = decrypt_credential(config.encrypted_password) # type: ignore[arg-type]
|
||||
recipient = current_user.email # type: ignore[arg-type]
|
||||
|
||||
def _send_test() -> None:
|
||||
msg = MIMEText(
|
||||
"This is a test email sent by InboxConverge to verify your SMTP settings.",
|
||||
"plain",
|
||||
"utf-8",
|
||||
)
|
||||
msg["From"] = config.sender_email or config.username # type: ignore[index]
|
||||
msg["To"] = recipient
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid()
|
||||
msg["Subject"] = "InboxConverge – SMTP Test"
|
||||
|
||||
if config.use_tls: # type: ignore[union-attr]
|
||||
server: smtplib.SMTP = smtplib.SMTP(
|
||||
config.host, config.port, timeout=30 # type: ignore[arg-type]
|
||||
)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP_SSL(
|
||||
config.host, config.port, timeout=30 # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
try:
|
||||
server.login(config.username, password) # type: ignore[arg-type]
|
||||
server.send_message(msg)
|
||||
finally:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _send_test)
|
||||
return SmtpTestResponse(
|
||||
success=True,
|
||||
message=f"Test email sent successfully to {recipient}.",
|
||||
)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
smtp_err = exc.smtp_error
|
||||
detail = (
|
||||
smtp_err.decode(errors="replace")
|
||||
if isinstance(smtp_err, bytes)
|
||||
else str(exc)
|
||||
)
|
||||
return SmtpTestResponse(
|
||||
success=False,
|
||||
message=f"Authentication failed: {detail}",
|
||||
)
|
||||
except smtplib.SMTPException as exc:
|
||||
return SmtpTestResponse(success=False, message=f"SMTP error: {exc}")
|
||||
except OSError as exc:
|
||||
return SmtpTestResponse(
|
||||
success=False,
|
||||
message=f"Connection error: {exc}",
|
||||
)
|
||||
|
||||
@@ -95,6 +95,15 @@ class Settings(BaseSettings):
|
||||
# Apprise (notifications)
|
||||
APPRISE_ENABLED: bool = True
|
||||
|
||||
# Mail connectivity resilience
|
||||
# When True the app caches the last successfully resolved IPv4 address for
|
||||
# every mail host and uses that address as a fallback if DNS subsequently
|
||||
# fails (e.g. EAI_AGAIN / Temporary failure in name resolution). This also
|
||||
# makes every outgoing POP3/IMAP connection prefer IPv4, avoiding
|
||||
# ENETUNREACH errors on Docker hosts where IPv6 is not routed to the
|
||||
# internet.
|
||||
DNS_CACHE_FALLBACK_ENABLED: bool = True
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
|
||||
import logging
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from alembic import command as alembic_command
|
||||
from alembic import command as alembic_command # type: ignore[attr-defined]
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.middleware import SecurityHeadersMiddleware, CSRFProtectionMiddleware
|
||||
|
||||
@@ -171,6 +171,18 @@ class MailAccount(Base):
|
||||
provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX"
|
||||
auto_detected = Column(Boolean, default=False)
|
||||
|
||||
# Debug logging
|
||||
debug_logging = Column(Boolean, default=False)
|
||||
# Number of completed/partial_failure runs since debug_logging was last
|
||||
# enabled. Resets to 0 when debug_logging is toggled True via the API.
|
||||
# debug_logging is auto-disabled once this reaches 5.
|
||||
debug_logging_run_count = Column(Integer, default=0)
|
||||
|
||||
# Notification backoff: True once an error notification has been sent for
|
||||
# the current consecutive failure streak. Cleared (with a recovery notice)
|
||||
# when a run succeeds so the next new failure streak triggers a fresh alert.
|
||||
error_notification_sent = Column(Boolean, default=False)
|
||||
|
||||
# Statistics
|
||||
total_emails_processed = Column(Integer, default=0)
|
||||
total_emails_failed = Column(Integer, default=0)
|
||||
@@ -472,6 +484,7 @@ class UserSmtpConfig(Base):
|
||||
username = Column(String(255), nullable=False, default="")
|
||||
encrypted_password = Column(Text, nullable=False, default="")
|
||||
use_tls = Column(Boolean, default=True)
|
||||
sender_email = Column(String(255), nullable=False, default="")
|
||||
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
@@ -114,6 +114,7 @@ class MailAccountBase(BaseModel):
|
||||
max_emails_per_check: int = Field(default=50, gt=0, le=1000)
|
||||
delete_after_forward: bool = True
|
||||
provider_name: Optional[str] = Field(None, max_length=100)
|
||||
debug_logging: bool = False
|
||||
|
||||
|
||||
class MailAccountCreate(MailAccountBase):
|
||||
@@ -137,6 +138,7 @@ class MailAccountUpdate(BaseModel):
|
||||
max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000)
|
||||
delete_after_forward: Optional[bool] = None
|
||||
provider_name: Optional[str] = Field(None, max_length=100)
|
||||
debug_logging: Optional[bool] = None
|
||||
|
||||
|
||||
class MailAccountResponse(MailAccountBase):
|
||||
@@ -479,6 +481,7 @@ class UserSmtpConfigBase(BaseModel):
|
||||
host: str = "smtp.gmail.com"
|
||||
port: int = Field(587, gt=0, lt=65536)
|
||||
username: str = ""
|
||||
sender_email: str = ""
|
||||
use_tls: bool = True
|
||||
|
||||
|
||||
@@ -496,6 +499,11 @@ class UserSmtpConfigResponse(UserSmtpConfigBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SmtpTestResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# Gmail OAuth Schemas
|
||||
class GmailAuthorizeResponse(BaseModel):
|
||||
authorization_url: str
|
||||
|
||||
@@ -49,6 +49,11 @@ class OAuthService:
|
||||
"""
|
||||
try:
|
||||
# Exchange code for token
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: exchanging authorization code for tokens "
|
||||
"(redirect_uri=%s)",
|
||||
redirect_uri,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
@@ -62,7 +67,12 @@ class OAuthService:
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
logger.error(f"Google token exchange failed: {token_response.text}")
|
||||
logger.error(
|
||||
"OAuth [Google sign-in]: token exchange failed "
|
||||
"(status=%s, body=%s)",
|
||||
token_response.status_code,
|
||||
token_response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code",
|
||||
@@ -72,12 +82,26 @@ class OAuthService:
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
logger.error(
|
||||
"OAuth [Google sign-in]: token exchange response contained "
|
||||
"no access_token (keys_present=%s)",
|
||||
list(token_data.keys()),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No access token received",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: token exchange succeeded — "
|
||||
"scopes=%s, has_refresh_token=%s, expires_in=%s",
|
||||
token_data.get("scope", ""),
|
||||
bool(token_data.get("refresh_token")),
|
||||
token_data.get("expires_in"),
|
||||
)
|
||||
|
||||
# Get user info
|
||||
logger.debug("OAuth [Google sign-in]: fetching Google user profile")
|
||||
user_info_response = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
@@ -85,7 +109,10 @@ class OAuthService:
|
||||
|
||||
if user_info_response.status_code != 200:
|
||||
logger.error(
|
||||
f"Google user info fetch failed: {user_info_response.text}"
|
||||
"OAuth [Google sign-in]: user-info fetch failed "
|
||||
"(status=%s, body=%s)",
|
||||
user_info_response.status_code,
|
||||
user_info_response.text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -93,6 +120,12 @@ class OAuthService:
|
||||
)
|
||||
|
||||
user_info = user_info_response.json()
|
||||
logger.debug(
|
||||
"OAuth [Google sign-in]: user profile retrieved — "
|
||||
"email=%s, verified=%s",
|
||||
user_info.get("email"),
|
||||
user_info.get("verified_email"),
|
||||
)
|
||||
|
||||
return {
|
||||
"email": user_info.get("email"),
|
||||
@@ -109,7 +142,11 @@ class OAuthService:
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth error: {e}")
|
||||
logger.error(
|
||||
"OAuth [Google sign-in]: unexpected error: %s",
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="OAuth authentication failed",
|
||||
@@ -126,6 +163,7 @@ class OAuthService:
|
||||
Returns:
|
||||
Dict with access_token, refresh_token, and token_type
|
||||
"""
|
||||
logger.debug("OAuth: issuing application JWT tokens for user_id=%s", user.id)
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ import base64
|
||||
import logging
|
||||
import textwrap
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import format_datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import google.auth.exceptions
|
||||
from google.auth.transport.requests import Request as GoogleAuthRequest
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
@@ -43,6 +45,12 @@ class GmailInjectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GmailAuthError(GmailInjectionError):
|
||||
"""Raised when Gmail OAuth2 authentication fails (token expired or revoked)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GmailService:
|
||||
"""
|
||||
Service for injecting emails into Gmail via the Gmail API.
|
||||
@@ -79,6 +87,13 @@ class GmailService:
|
||||
scopes=GMAIL_SCOPES,
|
||||
)
|
||||
self._service = None
|
||||
logger.debug(
|
||||
"OAuth [GmailService]: initialized — has_refresh_token=%s, "
|
||||
"has_client_id=%s, has_client_secret=%s",
|
||||
bool(refresh_token),
|
||||
bool(client_id),
|
||||
bool(client_secret),
|
||||
)
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
@@ -161,6 +176,16 @@ class GmailService:
|
||||
logger.error(error_msg)
|
||||
# Surface 401 so callers can mark credentials as invalid
|
||||
raise GmailInjectionError(error_msg)
|
||||
except google.auth.exceptions.RefreshError as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="error").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="inject").observe(_dur)
|
||||
error_msg = (
|
||||
f"Gmail token refresh failed — the refresh token may have been revoked. "
|
||||
f"The user must re-authorise. Detail: {e}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise GmailAuthError(error_msg)
|
||||
except Exception as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="error").inc()
|
||||
@@ -190,6 +215,15 @@ class GmailService:
|
||||
email = result.get("emailAddress", "unknown")
|
||||
logger.info(f"Gmail API access verified for: {email}")
|
||||
return True
|
||||
except google.auth.exceptions.RefreshError as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="verify", status="error").inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="verify").observe(_dur)
|
||||
logger.error(
|
||||
f"Gmail token refresh failed during access verification — "
|
||||
f"refresh token may be revoked. Detail: {e}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(operation="verify", status="error").inc()
|
||||
@@ -217,7 +251,21 @@ class GmailService:
|
||||
operation="get_profile", status="success"
|
||||
).inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="get_profile").observe(_dur)
|
||||
return result.get("emailAddress")
|
||||
email = result.get("emailAddress")
|
||||
logger.debug("OAuth [GmailService]: fetched email address=%s", email)
|
||||
return email
|
||||
except google.auth.exceptions.RefreshError as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(
|
||||
operation="get_profile", status="error"
|
||||
).inc()
|
||||
GMAIL_API_DURATION_SECONDS.labels(operation="get_profile").observe(_dur)
|
||||
logger.error(
|
||||
"OAuth [GmailService]: token refresh failed while fetching email "
|
||||
"address — refresh token may be revoked. Detail: %s",
|
||||
e,
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
_dur = time.perf_counter() - _start
|
||||
GMAIL_API_REQUESTS_TOTAL.labels(
|
||||
@@ -286,6 +334,16 @@ class GmailService:
|
||||
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()
|
||||
@@ -376,6 +434,92 @@ class GmailService:
|
||||
|
||||
return label_ids
|
||||
|
||||
@staticmethod
|
||||
def _tz_aware_expiry(expiry: Optional[datetime]) -> Optional[datetime]:
|
||||
"""Return *expiry* with UTC tzinfo attached if it is naive.
|
||||
|
||||
google-auth sets ``credentials.expiry`` as a naive UTC datetime
|
||||
(``datetime.utcnow() + timedelta(...)``). Storing a naive datetime
|
||||
into a ``DateTime(timezone=True)`` column causes silent tz-mismatch
|
||||
bugs in comparisons and storage, so we always normalise before
|
||||
returning expiry values to callers.
|
||||
"""
|
||||
if expiry is not None and expiry.tzinfo is None:
|
||||
return expiry.replace(tzinfo=timezone.utc)
|
||||
return expiry
|
||||
|
||||
def is_token_expiring_soon(self, within_minutes: int = 30) -> bool:
|
||||
"""
|
||||
Return True if the access token has already expired or will expire
|
||||
within *within_minutes* minutes.
|
||||
|
||||
When ``credentials.expiry`` is None the expiry is unknown, which is
|
||||
treated as expiring soon so a proactive refresh is performed.
|
||||
|
||||
Args:
|
||||
within_minutes: Threshold in minutes before which a token is
|
||||
considered "expiring soon".
|
||||
|
||||
Returns:
|
||||
True if the token needs refreshing, False otherwise.
|
||||
"""
|
||||
if self.credentials.expiry is None:
|
||||
return True
|
||||
threshold = datetime.now(timezone.utc) + timedelta(minutes=within_minutes)
|
||||
expiry = self.credentials.expiry
|
||||
# google-auth stores expiry as a naive UTC datetime; make it tz-aware.
|
||||
if expiry.tzinfo is None:
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
return expiry <= threshold
|
||||
|
||||
async def proactive_refresh(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Explicitly refresh the access token using the stored refresh token.
|
||||
|
||||
Unlike the lazy refresh that happens automatically during API calls,
|
||||
this method triggers a refresh regardless of whether the current
|
||||
access token has expired. Use this from a scheduled task to keep
|
||||
tokens fresh and to detect revocation early.
|
||||
|
||||
Returns:
|
||||
Dict with ``access_token`` and ``expiry`` (datetime | None)
|
||||
representing the newly obtained access token.
|
||||
|
||||
Raises:
|
||||
GmailAuthError: If the refresh token is missing, has been revoked,
|
||||
or the refresh request fails for an auth-related reason.
|
||||
GmailInjectionError: For unexpected non-auth errors.
|
||||
"""
|
||||
if not self.credentials.refresh_token:
|
||||
raise GmailAuthError(
|
||||
"Cannot refresh: no refresh token stored. "
|
||||
"Re-authorise Gmail to obtain a new refresh token."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.credentials.refresh(GoogleAuthRequest()),
|
||||
)
|
||||
GMAIL_TOKEN_REFRESHES_TOTAL.inc()
|
||||
logger.info(
|
||||
"Gmail access token refreshed proactively; new expiry: %s",
|
||||
self.credentials.expiry,
|
||||
)
|
||||
return {
|
||||
"access_token": self.credentials.token,
|
||||
"expiry": self._tz_aware_expiry(self.credentials.expiry),
|
||||
}
|
||||
except google.auth.exceptions.RefreshError as e:
|
||||
error_msg = f"Gmail refresh token has been revoked or is invalid — the user must re-authorise. Detail: {e}"
|
||||
logger.error(error_msg)
|
||||
raise GmailAuthError(error_msg)
|
||||
except Exception as e:
|
||||
raise GmailInjectionError(
|
||||
f"Unexpected error during Gmail token refresh: {e}"
|
||||
)
|
||||
|
||||
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return the current access token and expiry if the token was refreshed
|
||||
@@ -392,8 +536,13 @@ class GmailService:
|
||||
current_token = self.credentials.token
|
||||
if current_token and current_token != self._initial_access_token:
|
||||
GMAIL_TOKEN_REFRESHES_TOTAL.inc()
|
||||
logger.debug(
|
||||
"OAuth [GmailService]: access token was auto-refreshed during API "
|
||||
"call; new expiry=%s",
|
||||
self.credentials.expiry,
|
||||
)
|
||||
return {
|
||||
"access_token": current_token,
|
||||
"expiry": self.credentials.expiry,
|
||||
"expiry": self._tz_aware_expiry(self.credentials.expiry),
|
||||
}
|
||||
return None
|
||||
|
||||
+1103
-143
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,10 @@ celery_app.conf.beat_schedule = {
|
||||
minute="*"
|
||||
), # Every minute (per-account interval gates actual work)
|
||||
},
|
||||
"refresh-gmail-tokens": {
|
||||
"task": "app.workers.tasks.refresh_gmail_tokens",
|
||||
"schedule": crontab(minute="*/45"), # Every 45 minutes
|
||||
},
|
||||
"cleanup-old-logs": {
|
||||
"task": "app.workers.tasks.cleanup_old_logs",
|
||||
"schedule": crontab(hour=3, minute=0), # Daily at 3 AM
|
||||
|
||||
+371
-30
@@ -6,6 +6,7 @@ import asyncio
|
||||
import email as email_lib
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from celery import Task
|
||||
import logging
|
||||
|
||||
@@ -31,12 +32,12 @@ from app.models.database_models import (
|
||||
DownloadedMessageId,
|
||||
UserSmtpConfig,
|
||||
)
|
||||
from app.services.mail_processor import MailProcessor
|
||||
from app.services.gmail_service import GmailService
|
||||
from app.services.mail_processor import MailProcessor, MailDebugRecorder
|
||||
from app.services.gmail_service import GmailService, GmailAuthError
|
||||
from app.services.config_service import ConfigService
|
||||
from app.services.notification_service import send_user_notification
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy import select, delete, or_, update as sa_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -116,8 +117,13 @@ async def process_mail_account(account_id: int):
|
||||
)
|
||||
already_seen_uids = set(seen_result.scalars().all())
|
||||
|
||||
# Create debug recorder if debug logging is enabled for this account
|
||||
_debug_recorder: Optional[MailDebugRecorder] = (
|
||||
MailDebugRecorder() if account.debug_logging else None # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
# Create processor
|
||||
processor = MailProcessor(account, password)
|
||||
processor = MailProcessor(account, password, debug_recorder=_debug_recorder)
|
||||
|
||||
# Fetch emails (returns raw bytes + new UIDs)
|
||||
emails, new_uids = await processor.fetch_emails(
|
||||
@@ -156,6 +162,27 @@ async def process_mail_account(account_id: int):
|
||||
if gmail_cred.encrypted_refresh_token
|
||||
else None
|
||||
)
|
||||
# Log token expiry to help diagnose timeout issues.
|
||||
if gmail_cred.token_expiry:
|
||||
_now = datetime.now(timezone.utc)
|
||||
_expiry = gmail_cred.token_expiry
|
||||
if _expiry.tzinfo is None:
|
||||
_expiry = _expiry.replace(tzinfo=timezone.utc)
|
||||
_secs = (_expiry - _now).total_seconds()
|
||||
if _secs < 0:
|
||||
logger.info(
|
||||
"Gmail access token for user %s expired %.0f s ago; "
|
||||
"google-auth will refresh automatically using the refresh token.",
|
||||
account.user_id,
|
||||
-_secs,
|
||||
)
|
||||
elif _secs < 300:
|
||||
logger.info(
|
||||
"Gmail access token for user %s expires in %.0f s; "
|
||||
"will be refreshed on the first API call.",
|
||||
account.user_id,
|
||||
_secs,
|
||||
)
|
||||
gmail_service = GmailService(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -183,6 +210,7 @@ async def process_mail_account(account_id: int):
|
||||
"host": user_smtp.host,
|
||||
"port": user_smtp.port,
|
||||
"username": user_smtp.username,
|
||||
"sender_email": user_smtp.sender_email or "",
|
||||
"password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type]
|
||||
"use_tls": user_smtp.use_tls,
|
||||
}
|
||||
@@ -313,12 +341,57 @@ async def process_mail_account(account_id: int):
|
||||
emails_forwarded += 1
|
||||
successfully_forwarded_uids.append(uid)
|
||||
else:
|
||||
logger.warning(
|
||||
"Email delivery returned False for uid=%s "
|
||||
"on account %s (subject=%r, from=%r); "
|
||||
"message will be retried on next run",
|
||||
uid,
|
||||
account.id,
|
||||
email_subject,
|
||||
email_from,
|
||||
)
|
||||
emails_failed += 1
|
||||
|
||||
except GmailAuthError as e:
|
||||
# Token refresh failed: refresh token is likely revoked.
|
||||
# Mark the credential invalid so the user is prompted to
|
||||
# re-authorise, and stop retrying for this run.
|
||||
if use_gmail_api and gmail_cred:
|
||||
gmail_cred.is_valid = False # type: ignore[assignment]
|
||||
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
|
||||
logger.warning(
|
||||
"Gmail credentials revoked for user %s "
|
||||
"(account %s, token_expiry=%s). "
|
||||
"User must re-authorise. Error: %s",
|
||||
account.user_id,
|
||||
account.id,
|
||||
getattr(gmail_cred, "token_expiry", "unknown"),
|
||||
e,
|
||||
)
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
await send_user_notification(
|
||||
db=notif_db,
|
||||
user_id=int(account.user_id),
|
||||
title="InboxRescue: Gmail Authorization Expired",
|
||||
body=(
|
||||
f"Your Gmail credentials for account '{account.name}' "
|
||||
f"have been revoked or have expired. "
|
||||
f"Please re-authorize Gmail access in Settings."
|
||||
),
|
||||
notify_on_error=True,
|
||||
)
|
||||
except Exception as notify_exc:
|
||||
logger.warning(
|
||||
"Failed to send revocation notification: %s", notify_exc
|
||||
)
|
||||
error_msg = str(e)
|
||||
emails_failed += 1
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
# If Gmail returns 401/403 the refresh token was revoked –
|
||||
# mark credentials invalid so the user gets notified.
|
||||
# Catch any remaining auth-style errors that slipped through
|
||||
# (e.g. HttpError 401/403 returned after a successful refresh).
|
||||
if (
|
||||
use_gmail_api
|
||||
and gmail_cred
|
||||
@@ -331,8 +404,13 @@ async def process_mail_account(account_id: int):
|
||||
gmail_cred.is_valid = False # type: ignore[assignment]
|
||||
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
|
||||
logger.warning(
|
||||
f"Gmail credentials revoked for user {account.user_id}. "
|
||||
"User must re-authorise."
|
||||
"Gmail credentials invalidated for user %s "
|
||||
"(account %s, token_expiry=%s) due to HTTP auth error. "
|
||||
"User must re-authorise. Error: %s",
|
||||
account.user_id,
|
||||
account.id,
|
||||
getattr(gmail_cred, "token_expiry", "unknown"),
|
||||
e,
|
||||
)
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
@@ -340,14 +418,23 @@ async def process_mail_account(account_id: int):
|
||||
db=notif_db,
|
||||
user_id=int(account.user_id),
|
||||
title="InboxRescue: Gmail Authorization Expired",
|
||||
body=f"Your Gmail credentials for account '{account.name}' have been revoked. Please re-authorize Gmail access in Settings.",
|
||||
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(f"Error delivering email: {e}")
|
||||
logger.error(
|
||||
"Error delivering email (account %s, uid=%s): %s",
|
||||
account.id,
|
||||
uid,
|
||||
e,
|
||||
)
|
||||
error_msg = str(e)
|
||||
emails_failed += 1
|
||||
|
||||
@@ -371,6 +458,34 @@ async def process_mail_account(account_id: int):
|
||||
)
|
||||
)
|
||||
|
||||
# Post-process: mark successfully forwarded messages as \Seen (IMAP)
|
||||
# and/or delete them from the source mailbox. This is done AFTER
|
||||
# the forwarding loop so that any message that failed to forward is
|
||||
# left untouched in the source and will be retried on the next run.
|
||||
if successfully_forwarded_uids:
|
||||
try:
|
||||
await processor.post_process_messages(successfully_forwarded_uids)
|
||||
except Exception as post_exc:
|
||||
logger.warning(
|
||||
"Failed to post-process messages for account %s: %s",
|
||||
account.id,
|
||||
post_exc,
|
||||
)
|
||||
|
||||
# Persist debug connection trace (if debug logging was enabled)
|
||||
if _debug_recorder and _debug_recorder.has_entries():
|
||||
db.add(
|
||||
ProcessingLog(
|
||||
user_id=account.user_id,
|
||||
mail_account_id=account.id,
|
||||
processing_run_id=run.id,
|
||||
level="DEBUG",
|
||||
message=f"Connection trace ({len(_debug_recorder)} entries)",
|
||||
success=True,
|
||||
error_details=_debug_recorder.as_details(),
|
||||
)
|
||||
)
|
||||
|
||||
# Persist new message UIDs so they are not processed again
|
||||
for uid in successfully_forwarded_uids:
|
||||
if uid not in already_seen_uids:
|
||||
@@ -419,24 +534,73 @@ async def process_mail_account(account_id: int):
|
||||
account.total_emails_failed += emails_failed # type: ignore[assignment]
|
||||
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
|
||||
if emails_failed == 0:
|
||||
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
||||
account.last_error_message = None # type: ignore[assignment]
|
||||
account.last_error_at = None # type: ignore[assignment]
|
||||
else:
|
||||
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment]
|
||||
# The fetch/connection succeeded: always clear any connection-level error
|
||||
# and mark the account ACTIVE regardless of per-email forwarding failures.
|
||||
# Per-email failures are already tracked in ProcessingLog and the
|
||||
# run's emails_failed counter so the user can drill into them without
|
||||
# having the account badge stuck in ERROR indefinitely.
|
||||
# Capture pre-mutation state for the post-commit notification logic.
|
||||
# These must be read before the account status and flag are cleared below.
|
||||
_was_in_error = account.status == AccountStatus.ERROR # type: ignore[comparison-overlap]
|
||||
_had_notified = bool(account.error_notification_sent) # type: ignore[attr-defined]
|
||||
|
||||
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
||||
account.last_error_message = None # type: ignore[assignment]
|
||||
account.last_error_at = None # type: ignore[assignment]
|
||||
# Clear the notification-sent flag so a future error streak fires a fresh alert.
|
||||
account.error_notification_sent = False # type: ignore[assignment]
|
||||
|
||||
# Auto-disable debug logging after 5 runs since it was last enabled.
|
||||
# The counter resets to 0 each time the user turns the flag on via
|
||||
# the API, so "5 runs" is always counted from the moment of enabling.
|
||||
if account.debug_logging: # type: ignore[attr-defined]
|
||||
account.debug_logging_run_count = (account.debug_logging_run_count or 0) + 1 # type: ignore[attr-defined,assignment]
|
||||
if account.debug_logging_run_count >= 5: # type: ignore[attr-defined]
|
||||
account.debug_logging = False # type: ignore[assignment]
|
||||
account.debug_logging_run_count = 0 # type: ignore[assignment]
|
||||
logger.info(
|
||||
"Auto-disabled debug logging for account %s after 5 runs",
|
||||
account.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Send failure notification after the commit so the status is
|
||||
# persisted even if the notification fails. Use a fresh session
|
||||
# to avoid interfering with the (now-committed) main transaction.
|
||||
if emails_failed > 0:
|
||||
#
|
||||
# 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),
|
||||
@@ -520,18 +684,38 @@ async def process_mail_account(account_id: int):
|
||||
# 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:
|
||||
try:
|
||||
async with async_session_maker() as notif_db:
|
||||
await send_user_notification(
|
||||
db=notif_db,
|
||||
user_id=int(account.user_id),
|
||||
title="InboxRescue: Mail Processing Error",
|
||||
body=f"Error processing mail account '{account.name}': {e}",
|
||||
notify_on_error=True,
|
||||
_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}"
|
||||
)
|
||||
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")
|
||||
@@ -705,3 +889,160 @@ async def cleanup_old_logs(days_to_keep: int = 30):
|
||||
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)
|
||||
|
||||
+30
-30
@@ -1,59 +1,59 @@
|
||||
# Core Framework
|
||||
fastapi==0.135.2 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.13.1
|
||||
fastapi==0.136.3 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0
|
||||
uvicorn[standard]==0.48.0
|
||||
pydantic==2.13.4
|
||||
pydantic-settings==2.14.1
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.48 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
|
||||
alembic==1.13.1
|
||||
psycopg2-binary==2.9.11
|
||||
sqlalchemy==2.0.50 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
|
||||
alembic==1.18.4
|
||||
psycopg2-binary==2.9.12
|
||||
asyncpg==0.31.0
|
||||
|
||||
# Authentication
|
||||
|
||||
python-jose[cryptography]==3.5.0 # Updated: Fixed algorithm confusion with OpenSSH ECDSA keys (was 3.3.0)
|
||||
bcrypt==4.3.0
|
||||
python-multipart==0.0.22 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
|
||||
authlib==1.6.9 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
|
||||
httpx==0.26.0
|
||||
bcrypt==5.0.0
|
||||
python-multipart==0.0.29 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
|
||||
authlib==1.7.2 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
|
||||
httpx==0.28.1
|
||||
|
||||
# Payment Processing
|
||||
stripe==14.4.1
|
||||
stripe==15.1.0
|
||||
|
||||
# Email & Mail Processing
|
||||
aiosmtplib==3.0.1
|
||||
aiohttp==3.13.3 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
|
||||
aiosmtplib==5.1.0
|
||||
aiohttp==3.13.5 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
|
||||
aioimaplib==2.0.1
|
||||
email-validator==2.1.0.post1
|
||||
email-validator==2.3.0
|
||||
|
||||
# Gmail API (for direct email injection)
|
||||
google-api-python-client==2.193.0
|
||||
google-auth==2.49.1
|
||||
google-auth-oauthlib==1.2.0
|
||||
google-auth-httplib2==0.3.0
|
||||
google-api-python-client==2.196.0
|
||||
google-auth==2.53.0
|
||||
google-auth-oauthlib==1.4.0
|
||||
google-auth-httplib2==0.4.0
|
||||
|
||||
# Job Queue & Cache
|
||||
celery==5.6.2
|
||||
redis==7.3.0
|
||||
celery==5.6.3
|
||||
redis==7.4.0
|
||||
|
||||
# Security & Encryption
|
||||
cryptography==46.0.6 # Updated: Fixed NULL pointer dereference (was 42.0.0)
|
||||
cryptography==48.0.0 # Updated: Fixed NULL pointer dereference (was 42.0.0)
|
||||
|
||||
# Notifications
|
||||
apprise==1.7.1
|
||||
apprise==1.10.0
|
||||
|
||||
# Monitoring & Logging
|
||||
prometheus-client==0.19.0
|
||||
prometheus-client==0.25.0
|
||||
python-json-logger==2.0.7
|
||||
|
||||
# Development & Testing
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
pytest-cov==4.1.0
|
||||
faker==22.6.0
|
||||
pytest==9.0.3
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==7.1.0
|
||||
faker==40.19.1
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
schedule==1.2.0
|
||||
python-dotenv==1.2.2
|
||||
schedule==1.2.2
|
||||
tenacity==9.1.4
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Unit tests for app settings endpoints (api/v1/endpoints/app_settings.py).
|
||||
|
||||
All database interactions and auth dependencies are mocked.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_superuser, get_current_user
|
||||
from app.models.database_models import User, SubscriptionTier
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_admin_user(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="admin@example.com",
|
||||
full_name="Admin",
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
u = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(u, k, v)
|
||||
return u
|
||||
|
||||
|
||||
def _make_setting(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
key="SOME_SETTING",
|
||||
value="some_value",
|
||||
value_type="string",
|
||||
description="A setting",
|
||||
is_secret=False,
|
||||
category="general",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
s = MagicMock()
|
||||
for k, v in defaults.items():
|
||||
setattr(s, k, v)
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
db.delete = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user():
|
||||
return _make_admin_user()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(app, admin_user, mock_db):
|
||||
async def _override_superuser():
|
||||
return admin_user
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_current_superuser] = _override_superuser
|
||||
app.dependency_overrides[get_current_user] = _override_superuser
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── GET /app-settings ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListSettings:
|
||||
async def test_returns_list(self, admin_client):
|
||||
setting = _make_setting()
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
|
||||
new=AsyncMock(return_value=[setting]),
|
||||
):
|
||||
response = await admin_client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["key"] == "SOME_SETTING"
|
||||
assert data[0]["value"] == "some_value"
|
||||
|
||||
async def test_secret_value_masked(self, admin_client):
|
||||
setting = _make_setting(key="SECRET_KEY", value="supersecret", is_secret=True)
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
|
||||
new=AsyncMock(return_value=[setting]),
|
||||
):
|
||||
response = await admin_client.get("/api/v1/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data[0]["value"] == "********"
|
||||
|
||||
async def test_filters_by_category(self, admin_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
|
||||
new=AsyncMock(return_value=[]),
|
||||
):
|
||||
response = await admin_client.get("/api/v1/settings?category=general")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_unauthenticated_401(self, app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/settings")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ── PUT /app-settings/{key} ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpsertSetting:
|
||||
async def test_upsert_creates_or_updates(self, admin_client):
|
||||
setting = _make_setting(key="MY_KEY", value="myval")
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.set",
|
||||
new=AsyncMock(return_value=setting),
|
||||
):
|
||||
response = await admin_client.put(
|
||||
"/api/v1/settings/MY_KEY",
|
||||
json={
|
||||
"key": "MY_KEY",
|
||||
"value": "myval",
|
||||
"value_type": "string",
|
||||
"is_secret": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["key"] == "MY_KEY"
|
||||
assert data["value"] == "myval"
|
||||
|
||||
async def test_bootstrap_key_rejected_400(self, admin_client):
|
||||
response = await admin_client.put(
|
||||
"/api/v1/settings/SECRET_KEY",
|
||||
json={"key": "SECRET_KEY", "value": "new_secret"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "bootstrap" in response.json()["detail"].lower()
|
||||
|
||||
async def test_database_url_rejected(self, admin_client):
|
||||
response = await admin_client.put(
|
||||
"/api/v1/settings/DATABASE_URL",
|
||||
json={"key": "DATABASE_URL", "value": "postgresql://..."},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_secret_value_masked_in_response(self, admin_client):
|
||||
setting = _make_setting(key="API_KEY", value="secret!", is_secret=True)
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.set",
|
||||
new=AsyncMock(return_value=setting),
|
||||
):
|
||||
response = await admin_client.put(
|
||||
"/api/v1/settings/API_KEY",
|
||||
json={
|
||||
"key": "API_KEY",
|
||||
"value": "secret!",
|
||||
"is_secret": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["value"] == "********"
|
||||
|
||||
|
||||
# ── DELETE /app-settings/{key} ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteSetting:
|
||||
async def test_deletes_existing_setting(self, admin_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.delete",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
response = await admin_client.delete("/api/v1/settings/MY_KEY")
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_404_when_not_found(self, admin_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.delete",
|
||||
new=AsyncMock(return_value=False),
|
||||
):
|
||||
response = await admin_client.delete("/api/v1/settings/MISSING_KEY")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_bootstrap_key_rejected_400(self, admin_client):
|
||||
response = await admin_client.delete("/api/v1/settings/SECRET_KEY")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ── POST /app-settings/seed-defaults ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSeedDefaultSettings:
|
||||
async def test_seeds_defaults(self, admin_client):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.app_settings.ConfigService.seed_defaults",
|
||||
new=AsyncMock(return_value=5),
|
||||
):
|
||||
response = await admin_client.post("/api/v1/settings/seed-defaults")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["created"] == 5
|
||||
assert "Seeded 5" in data["message"]
|
||||
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
Unit tests for auth endpoints (backend/app/api/v1/endpoints/auth.py).
|
||||
|
||||
All tests mock the database session, security functions, and OAuth service
|
||||
so no real PostgreSQL instance or external API is required.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import quote as urlquote
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.models.database_models import User, SubscriptionTier
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
"""Return a MagicMock that behaves like a User ORM instance."""
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
hashed_password="hashedpw",
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
user = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(user, k, v)
|
||||
return user
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
"""Create a mock result whose .scalar_one_or_none() returns *value*."""
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
def _fake_tokens():
|
||||
return {
|
||||
"access_token": "fake-access-token",
|
||||
"refresh_token": "fake-refresh-token",
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
# ── fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.delete = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app, mock_db):
|
||||
"""AsyncClient with only get_db overridden (no auth required)."""
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Helper functions
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
"""Unit tests for private helper functions in auth.py."""
|
||||
|
||||
def test_domain_of(self):
|
||||
from app.api.v1.endpoints.auth import _domain_of
|
||||
|
||||
assert _domain_of("alice@Example.COM") == "example.com"
|
||||
assert _domain_of("bob@sub.domain.org") == "sub.domain.org"
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_check_domain_allowed_no_restriction(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _check_domain_allowed
|
||||
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
_check_domain_allowed("anyone@whatever.com") # should not raise
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_check_domain_allowed_passes(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _check_domain_allowed
|
||||
|
||||
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
||||
_check_domain_allowed("alice@acme.com") # should not raise
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_check_domain_allowed_blocks(self, mock_settings):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.endpoints.auth import _check_domain_allowed
|
||||
|
||||
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_domain_allowed("alice@blocked.com")
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_default_tier_valid(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
|
||||
mock_settings.DEFAULT_USER_TIER = "pro"
|
||||
assert _default_tier() == SubscriptionTier.PRO
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_default_tier_invalid_falls_back(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
|
||||
mock_settings.DEFAULT_USER_TIER = "invalid_tier"
|
||||
assert _default_tier() == SubscriptionTier.FREE
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_is_admin_email_match(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
mock_settings.ADMIN_EMAIL = "Admin@Example.com"
|
||||
assert _is_admin_email("admin@example.com") is True
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_is_admin_email_no_match(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
mock_settings.ADMIN_EMAIL = "admin@example.com"
|
||||
assert _is_admin_email("other@example.com") is False
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
def test_is_admin_email_none(self, mock_settings):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
assert _is_admin_email("anyone@example.com") is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# POST /api/v1/auth/register
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRegister:
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch("app.api.v1.endpoints.auth.get_password_hash", return_value="hashed123")
|
||||
async def test_register_success(self, _mock_hash, mock_settings, client, mock_db):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
# Simulate db.refresh assigning an id and required fields
|
||||
def _set_id(obj):
|
||||
obj.id = 42
|
||||
obj.email = "new@example.com"
|
||||
obj.full_name = "New User"
|
||||
obj.is_active = True
|
||||
obj.subscription_tier = SubscriptionTier.FREE
|
||||
obj.subscription_status = "active"
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_set_id)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "new@example.com",
|
||||
"full_name": "New User",
|
||||
"password": "secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["email"] == "new@example.com"
|
||||
assert body["id"] == 42
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_awaited_once()
|
||||
|
||||
async def test_register_duplicate_email(self, client, mock_db):
|
||||
existing = _make_user(email="dup@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "dup@example.com", "full_name": "Dup", "password": "pw"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "already registered" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
async def test_register_domain_restricted(self, mock_settings, client, mock_db):
|
||||
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "user@blocked.com",
|
||||
"full_name": "Blocked",
|
||||
"password": "pw",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "not authorised" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# POST /api/v1/auth/login
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestLogin:
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value=_fake_tokens(),
|
||||
)
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
||||
async def test_login_success(
|
||||
self, _mock_verify, _mock_tokens, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
user = _make_user(email="login@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "login@example.com", "password": "correct"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["access_token"] == "fake-access-token"
|
||||
assert body["token_type"] == "bearer"
|
||||
mock_db.commit.assert_awaited_once()
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=False)
|
||||
async def test_login_user_not_found(self, _mock_verify, client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "nobody@example.com", "password": "pw"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect email or password" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=False)
|
||||
async def test_login_wrong_password(self, _mock_verify, client, mock_db):
|
||||
user = _make_user(email="login@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "login@example.com", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert "Incorrect email or password" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
||||
async def test_login_inactive_user(self, _mock_verify, client, mock_db):
|
||||
user = _make_user(email="inactive@example.com", is_active=False)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "inactive@example.com", "password": "pw"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "inactive" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value=_fake_tokens(),
|
||||
)
|
||||
@patch("app.api.v1.endpoints.auth.verify_password", return_value=True)
|
||||
async def test_login_admin_auto_promotion(
|
||||
self, _mock_verify, _mock_tokens, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.ADMIN_EMAIL = "admin@example.com"
|
||||
|
||||
user = _make_user(email="admin@example.com", is_superuser=False, is_active=True)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin@example.com", "password": "pw"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# The endpoint should have set is_superuser = True on the user mock
|
||||
assert user.is_superuser is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# POST /api/v1/auth/google
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGoogleOAuth:
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value=_fake_tokens(),
|
||||
)
|
||||
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
||||
async def test_google_existing_user(
|
||||
self, mock_google_info, _mock_tokens, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
mock_google_info.return_value = {
|
||||
"email": "existing@example.com",
|
||||
"google_id": "g-123",
|
||||
"full_name": "Existing User",
|
||||
"verified_email": True,
|
||||
}
|
||||
|
||||
user = _make_user(email="existing@example.com", google_id="g-123")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["access_token"] == "fake-access-token"
|
||||
mock_db.commit.assert_awaited_once()
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value=_fake_tokens(),
|
||||
)
|
||||
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
||||
async def test_google_new_user(
|
||||
self, mock_google_info, _mock_tokens, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = []
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
mock_google_info.return_value = {
|
||||
"email": "brand-new@example.com",
|
||||
"google_id": "g-456",
|
||||
"full_name": "Brand New",
|
||||
"verified_email": True,
|
||||
}
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
def _set_id(obj):
|
||||
obj.id = 99
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_set_id)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["access_token"] == "fake-access-token"
|
||||
mock_db.add.assert_called_once()
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
||||
async def test_google_email_not_verified(self, mock_google_info, client, mock_db):
|
||||
mock_google_info.return_value = {
|
||||
"email": "unverified@example.com",
|
||||
"google_id": "g-789",
|
||||
"verified_email": False,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "not verified" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
@patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info")
|
||||
async def test_google_domain_restricted_new_user(
|
||||
self, mock_google_info, mock_settings, client, mock_db
|
||||
):
|
||||
mock_settings.ALLOWED_DOMAINS = ["acme.com"]
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
|
||||
mock_google_info.return_value = {
|
||||
"email": "person@blocked.com",
|
||||
"google_id": "g-block",
|
||||
"full_name": "Blocked",
|
||||
"verified_email": True,
|
||||
}
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "auth-code", "redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert "not authorised" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# GET /api/v1/auth/google/authorize-url
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGoogleAuthorizeUrl:
|
||||
|
||||
@patch("app.api.v1.endpoints.auth.settings")
|
||||
async def test_returns_correct_url(self, mock_settings, client):
|
||||
mock_settings.GOOGLE_CLIENT_ID = "test-client-id"
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/auth/google/authorize-url",
|
||||
params={"redirect_uri": "http://localhost:3000/callback"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
url = body["authorization_url"]
|
||||
assert "accounts.google.com" in url
|
||||
assert "client_id=test-client-id" in url
|
||||
assert "redirect_uri=http://localhost:3000/callback" in url
|
||||
expected_scope = urlquote("openid email profile")
|
||||
assert f"scope={expected_scope}" in url
|
||||
assert "prompt=select_account" in url
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Unit tests for authentication endpoints (api/v1/endpoints/auth.py).
|
||||
|
||||
All database interactions and the oauth_service are mocked so no real
|
||||
PostgreSQL instance or Google credentials are needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.models.database_models import User, SubscriptionTier
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
hashed_password=None,
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
u = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(u, k, v)
|
||||
return u
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
r = MagicMock()
|
||||
r.scalar_one_or_none.return_value = value
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def anon_client(app, mock_db):
|
||||
"""Client with no auth (db mocked)."""
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── /register ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegisterEndpoint:
|
||||
async def test_register_new_user_201(self, anon_client, mock_db):
|
||||
# DB returns no existing user
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
# db.refresh will be called with the new User object; we simulate it
|
||||
# by setting the required response fields on that object.
|
||||
from datetime import datetime, timezone
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
async def _refresh(obj):
|
||||
obj.id = 99
|
||||
obj.email = "new@example.com"
|
||||
obj.full_name = None
|
||||
obj.is_active = True
|
||||
obj.subscription_tier = SubscriptionTier.FREE
|
||||
obj.subscription_status = "active"
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
obj.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_refresh)
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "new@example.com", "password": "secretpassword"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
async def test_register_existing_user_400(self, anon_client, mock_db):
|
||||
existing = _make_user(email="taken@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "taken@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already registered" in response.json()["detail"]
|
||||
|
||||
async def test_register_blocked_domain_403(self, app, mock_db):
|
||||
"""When ALLOWED_DOMAINS is set, unknown domains should get 403."""
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
with patch("app.api.v1.endpoints.auth.settings") as mock_settings:
|
||||
mock_settings.ALLOWED_DOMAINS = ["allowed.com"]
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "user@blocked.com", "password": "pass"},
|
||||
)
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ── /login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLoginEndpoint:
|
||||
async def test_login_success_returns_tokens(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("correctpassword")
|
||||
user = _make_user(email="user@example.com", hashed_password=hashed)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "correctpassword"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
async def test_login_wrong_password_401(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("correctpassword")
|
||||
user = _make_user(email="user@example.com", hashed_password=hashed)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_login_user_not_found_401(self, anon_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "unknown@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_login_inactive_user_403(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("password")
|
||||
user = _make_user(
|
||||
email="user@example.com", hashed_password=hashed, is_active=False
|
||||
)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "password"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_login_no_password_hash_401(self, anon_client, mock_db):
|
||||
"""OAuth-only users have no hashed_password – login should fail."""
|
||||
user = _make_user(hashed_password=None)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ── /google ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGoogleOAuthEndpoint:
|
||||
async def test_google_oauth_unverified_email_400(self, anon_client, mock_db):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "user@gmail.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Test",
|
||||
"verified_email": False,
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_google_oauth_new_user_created(self, anon_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "google@example.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Google User",
|
||||
"verified_email": True,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value={
|
||||
"access_token": "tok",
|
||||
"refresh_token": "ref",
|
||||
"token_type": "bearer",
|
||||
},
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_token"] == "tok"
|
||||
|
||||
async def test_google_oauth_existing_user_logs_in(self, anon_client, mock_db):
|
||||
existing = _make_user(email="google@example.com", google_id="g123")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "google@example.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Google User",
|
||||
"verified_email": True,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value={
|
||||
"access_token": "tok2",
|
||||
"refresh_token": "ref2",
|
||||
"token_type": "bearer",
|
||||
},
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── /google/authorize-url ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGoogleAuthorizeUrl:
|
||||
async def test_returns_authorization_url(self, anon_client):
|
||||
response = await anon_client.get(
|
||||
"/api/v1/auth/google/authorize-url",
|
||||
params={"redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "authorization_url" in data
|
||||
assert data["authorization_url"].startswith(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
)
|
||||
|
||||
async def test_url_contains_redirect_uri(self, anon_client):
|
||||
redirect = "http://myapp.example.com/callback"
|
||||
response = await anon_client.get(
|
||||
"/api/v1/auth/google/authorize-url",
|
||||
params={"redirect_uri": redirect},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── helper functions (domain checks, tier, admin email) ───────────────────────
|
||||
|
||||
|
||||
class TestAuthHelpers:
|
||||
def test_domain_of(self):
|
||||
from app.api.v1.endpoints.auth import _domain_of
|
||||
|
||||
assert _domain_of("user@Example.COM") == "example.com"
|
||||
assert _domain_of("a@b.de") == "b.de"
|
||||
|
||||
def test_default_tier_fallback(self):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.DEFAULT_USER_TIER = "invalid_tier"
|
||||
tier = _default_tier()
|
||||
assert tier == SubscriptionTier.FREE
|
||||
|
||||
def test_default_tier_valid(self):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.DEFAULT_USER_TIER = "pro"
|
||||
tier = _default_tier()
|
||||
assert tier == SubscriptionTier.PRO
|
||||
|
||||
def test_is_admin_email_match(self):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.ADMIN_EMAIL = "admin@example.com"
|
||||
assert _is_admin_email("ADMIN@EXAMPLE.COM") is True
|
||||
assert _is_admin_email("other@example.com") is False
|
||||
|
||||
def test_is_admin_email_none_config(self):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.ADMIN_EMAIL = None
|
||||
assert _is_admin_email("admin@example.com") is False
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Unit tests for the OAuth / auth service (services/auth_service.py).
|
||||
|
||||
External HTTP calls are mocked via httpx. No real network or DB needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.auth_service import OAuthService
|
||||
from app.models.database_models import User
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(id: int = 1, email: str = "user@example.com") -> MagicMock:
|
||||
u = MagicMock(spec=User)
|
||||
u.id = id
|
||||
u.email = email
|
||||
return u
|
||||
|
||||
|
||||
def _mock_token_response(
|
||||
access_token: str = "access123", refresh_token: str = "refresh456"
|
||||
):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"expires_in": 3600,
|
||||
"scope": "openid email profile",
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _mock_user_info_response(
|
||||
email: str = "user@google.com",
|
||||
name: str = "Test User",
|
||||
google_id: str = "g123",
|
||||
verified: bool = True,
|
||||
):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {
|
||||
"email": email,
|
||||
"name": name,
|
||||
"id": google_id,
|
||||
"picture": "https://example.com/pic.jpg",
|
||||
"verified_email": verified,
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
# ── OAuthService.get_google_user_info ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetGoogleUserInfo:
|
||||
async def test_success(self):
|
||||
svc = OAuthService()
|
||||
token_resp = _mock_token_response()
|
||||
user_info_resp = _mock_user_info_response()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_client.get = AsyncMock(return_value=user_info_resp)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
result = await svc.get_google_user_info(
|
||||
code="authcode", redirect_uri="http://localhost/callback"
|
||||
)
|
||||
|
||||
assert result["email"] == "user@google.com"
|
||||
assert result["google_id"] == "g123"
|
||||
assert result["verified_email"] is True
|
||||
assert result["access_token"] == "access123"
|
||||
|
||||
async def test_token_exchange_fails(self):
|
||||
svc = OAuthService()
|
||||
bad_resp = MagicMock()
|
||||
bad_resp.status_code = 400
|
||||
bad_resp.text = "bad_request"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=bad_resp)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.get_google_user_info(
|
||||
code="bad", redirect_uri="http://localhost"
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
async def test_no_access_token_in_response(self):
|
||||
svc = OAuthService()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {} # no access_token
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=resp)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.get_google_user_info(
|
||||
code="c", redirect_uri="http://localhost"
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
async def test_user_info_fetch_fails(self):
|
||||
svc = OAuthService()
|
||||
token_resp = _mock_token_response()
|
||||
bad_info = MagicMock()
|
||||
bad_info.status_code = 500
|
||||
bad_info.text = "server error"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=token_resp)
|
||||
mock_client.get = AsyncMock(return_value=bad_info)
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.get_google_user_info(
|
||||
code="c", redirect_uri="http://localhost"
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
async def test_unexpected_exception_becomes_500(self):
|
||||
svc = OAuthService()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(side_effect=RuntimeError("network down"))
|
||||
|
||||
with patch(
|
||||
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.get_google_user_info(
|
||||
code="c", redirect_uri="http://localhost"
|
||||
)
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
# ── OAuthService.create_tokens_for_user ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateTokensForUser:
|
||||
def test_returns_all_fields(self):
|
||||
user = _make_user(id=7)
|
||||
result = OAuthService.create_tokens_for_user(user)
|
||||
assert "access_token" in result
|
||||
assert "refresh_token" in result
|
||||
assert result["token_type"] == "bearer"
|
||||
|
||||
def test_access_token_is_string(self):
|
||||
user = _make_user(id=3)
|
||||
result = OAuthService.create_tokens_for_user(user)
|
||||
assert isinstance(result["access_token"], str)
|
||||
assert len(result["access_token"]) > 0
|
||||
|
||||
def test_refresh_token_is_string(self):
|
||||
user = _make_user(id=5)
|
||||
result = OAuthService.create_tokens_for_user(user)
|
||||
assert isinstance(result["refresh_token"], str)
|
||||
assert len(result["refresh_token"]) > 0
|
||||
|
||||
def test_different_users_get_different_tokens(self):
|
||||
u1 = _make_user(id=1)
|
||||
u2 = _make_user(id=2)
|
||||
tokens1 = OAuthService.create_tokens_for_user(u1)
|
||||
tokens2 = OAuthService.create_tokens_for_user(u2)
|
||||
assert tokens1["access_token"] != tokens2["access_token"]
|
||||
@@ -172,9 +172,8 @@ class TestConfigServiceSet:
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.execute.return_value = mock_result
|
||||
|
||||
# The method will call db.add() and db.commit()
|
||||
await ConfigService.set(
|
||||
"SMTP_HOST", "new.host.com", db=mock_db, category="smtp"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Unit tests for the DNS 8.8.8.8 fallback resolver in mail_processor.py.
|
||||
|
||||
Covers:
|
||||
- _build_dns_query() produces a valid DNS A-record packet
|
||||
- _parse_dns_a_response() extracts the first A record correctly
|
||||
- _query_google_dns_sync() sends UDP query to 8.8.8.8 and returns an IPv4
|
||||
- _resolve_ipv4_sync() falls through to 8.8.8.8 when system DNS and cache fail
|
||||
- _resolve_ipv4_sync() caches the 8.8.8.8 result for subsequent calls
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from app.services.mail_processor import (
|
||||
_build_dns_query,
|
||||
_parse_dns_a_response,
|
||||
_query_google_dns_sync,
|
||||
_resolve_ipv4_sync,
|
||||
_dns_cache,
|
||||
_dns_cache_lock,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_dns_response(txid: int, ip: str) -> bytes:
|
||||
"""Craft a minimal valid DNS A-record response for the given IP."""
|
||||
flags = 0x8180 # response, recursion available
|
||||
qdcount = 1
|
||||
ancount = 1
|
||||
header = struct.pack(">HHHHHH", txid, flags, qdcount, ancount, 0, 0)
|
||||
# Question: dummy single-label name "x" + QTYPE=A + QCLASS=IN
|
||||
qname = b"\x01x\x00"
|
||||
question = qname + struct.pack(">HH", 1, 1)
|
||||
# Answer: pointer to question name (0xC00C), TYPE=A, CLASS=IN, TTL, RDLEN=4, IP
|
||||
octets = tuple(int(o) for o in ip.split("."))
|
||||
answer = struct.pack(">HHHiH", 0xC00C, 1, 1, 300, 4) + bytes(octets)
|
||||
return header + question + answer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_dns_query
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildDnsQuery:
|
||||
def test_contains_hostname_labels(self):
|
||||
data = _build_dns_query("pop.example.com")
|
||||
# "pop" label should appear: length byte 3 followed by b"pop"
|
||||
assert b"\x03pop" in data
|
||||
|
||||
def test_qtype_a_and_class_in(self):
|
||||
data = _build_dns_query("mail.example.com")
|
||||
# Last 4 bytes of question: QTYPE=0x0001, QCLASS=0x0001
|
||||
assert data[-4:] == b"\x00\x01\x00\x01"
|
||||
|
||||
def test_transaction_id_is_0xAB12(self):
|
||||
data = _build_dns_query("x.example.com")
|
||||
txid = struct.unpack(">H", data[:2])[0]
|
||||
assert txid == 0xAB12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_dns_a_response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseDnsAResponse:
|
||||
def test_extracts_ip_from_valid_response(self):
|
||||
response = _make_dns_response(0xAB12, "1.2.3.4")
|
||||
result = _parse_dns_a_response(response, "example.com")
|
||||
assert result == "1.2.3.4"
|
||||
|
||||
def test_returns_none_for_wrong_txid(self):
|
||||
response = _make_dns_response(0x1234, "1.2.3.4")
|
||||
result = _parse_dns_a_response(response, "example.com")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_short_data(self):
|
||||
assert _parse_dns_a_response(b"\x00\x01", "example.com") is None
|
||||
|
||||
def test_returns_none_when_no_answers(self):
|
||||
# Build a header with ancount=0
|
||||
header = struct.pack(">HHHHHH", 0xAB12, 0x8180, 0, 0, 0, 0)
|
||||
assert _parse_dns_a_response(header, "x") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _query_google_dns_sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryGoogleDnsSync:
|
||||
def test_returns_ip_on_success(self):
|
||||
response = _make_dns_response(0xAB12, "5.6.7.8")
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.recvfrom.return_value = (response, ("8.8.8.8", 53))
|
||||
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = _query_google_dns_sync("pop.example.com")
|
||||
|
||||
assert result == "5.6.7.8"
|
||||
mock_sock.sendto.assert_called_once()
|
||||
|
||||
def test_returns_none_on_socket_error(self):
|
||||
with patch("socket.socket", side_effect=OSError("network down")):
|
||||
result = _query_google_dns_sync("pop.example.com")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_on_timeout(self):
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.recvfrom.side_effect = socket.timeout("timed out")
|
||||
with patch("socket.socket", return_value=mock_sock):
|
||||
result = _query_google_dns_sync("pop.example.com")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_ipv4_sync — 8.8.8.8 fallback path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveIpv4SyncGoogleFallback:
|
||||
def setup_method(self):
|
||||
"""Clear the DNS cache before each test to avoid state bleed."""
|
||||
with _dns_cache_lock:
|
||||
_dns_cache.clear()
|
||||
|
||||
def test_falls_through_to_google_when_system_dns_and_cache_fail(self):
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=OSError("Name or service not known"),
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor._query_google_dns_sync",
|
||||
return_value="9.10.11.12",
|
||||
) as mock_google,
|
||||
patch(
|
||||
"app.services.mail_processor.settings.DNS_CACHE_FALLBACK_ENABLED",
|
||||
False,
|
||||
),
|
||||
):
|
||||
result = _resolve_ipv4_sync("pop.web.de", 995)
|
||||
|
||||
assert result == "9.10.11.12"
|
||||
mock_google.assert_called_once_with("pop.web.de")
|
||||
|
||||
def test_google_result_is_cached(self):
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=OSError("Name or service not known"),
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor._query_google_dns_sync",
|
||||
return_value="9.10.11.12",
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor.settings.DNS_CACHE_FALLBACK_ENABLED",
|
||||
True,
|
||||
),
|
||||
):
|
||||
_resolve_ipv4_sync("pop.web.de", 995)
|
||||
|
||||
with _dns_cache_lock:
|
||||
cached = _dns_cache.get(("pop.web.de", 995))
|
||||
assert cached == "9.10.11.12"
|
||||
|
||||
def test_returns_none_when_all_strategies_fail(self):
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
side_effect=OSError("Name or service not known"),
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor._query_google_dns_sync",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor.settings.DNS_CACHE_FALLBACK_ENABLED",
|
||||
False,
|
||||
),
|
||||
):
|
||||
result = _resolve_ipv4_sync("nonexistent.invalid", 995)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_system_dns_success_skips_google(self):
|
||||
with (
|
||||
patch(
|
||||
"socket.getaddrinfo",
|
||||
return_value=[(None, None, None, None, ("1.2.3.4", 995))],
|
||||
),
|
||||
patch(
|
||||
"app.services.mail_processor._query_google_dns_sync",
|
||||
) as mock_google,
|
||||
patch(
|
||||
"app.services.mail_processor.settings.DNS_CACHE_FALLBACK_ENABLED",
|
||||
False,
|
||||
),
|
||||
):
|
||||
result = _resolve_ipv4_sync("pop.example.com", 995)
|
||||
|
||||
assert result == "1.2.3.4"
|
||||
mock_google.assert_not_called()
|
||||
@@ -0,0 +1,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,8 +2,13 @@
|
||||
Unit tests for Gmail service module.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
|
||||
from app.utils.gmail_labels import (
|
||||
DEFAULT_IMPORT_LABEL_TEMPLATES,
|
||||
@@ -202,3 +207,284 @@ class TestGmailService:
|
||||
)
|
||||
|
||||
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
|
||||
@@ -163,7 +163,7 @@ class TestFetchImapEmailsUidCommands:
|
||||
# The fetch call should use UID FETCH
|
||||
fetch_call = [c for c in mock_imap.uid.call_args_list if c.args[0] == "fetch"]
|
||||
assert len(fetch_call) == 1
|
||||
assert fetch_call[0] == call("fetch", "42", "(RFC822)")
|
||||
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."""
|
||||
@@ -260,8 +260,12 @@ class TestFetchImapEmailsUidCommands:
|
||||
# RFC 3501 parenthesised flag syntax
|
||||
assert store_calls[0].args[3] == "(\\Seen)"
|
||||
|
||||
async def test_delete_after_forward_uses_single_batch_store(self):
|
||||
"""delete_after_forward=True must issue one UID STORE \\Deleted command."""
|
||||
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)
|
||||
@@ -295,17 +299,15 @@ class TestFetchImapEmailsUidCommands:
|
||||
|
||||
assert new_uids == ["7", "8"]
|
||||
|
||||
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
|
||||
# Exactly one STORE for deletion covering both UIDs
|
||||
assert len(store_calls) == 1
|
||||
uid_set_arg = store_calls[0].args[1]
|
||||
parts = set(uid_set_arg.split(","))
|
||||
assert parts == {"7", "8"}
|
||||
assert store_calls[0].args[2] == "+FLAGS"
|
||||
# RFC 3501 parenthesised flag syntax
|
||||
assert store_calls[0].args[3] == "(\\Deleted)"
|
||||
# expunge must also be called
|
||||
mock_imap.expunge.assert_awaited_once()
|
||||
# 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."""
|
||||
@@ -709,3 +711,176 @@ class TestFetchImapEdgeCases:
|
||||
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()
|
||||
|
||||
@@ -172,7 +172,7 @@ class TestTestPop3Connection:
|
||||
success, msg = await proc._test_pop3_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Authentication failed" in msg
|
||||
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):
|
||||
@@ -189,7 +189,7 @@ class TestTestPop3Connection:
|
||||
success, msg = await proc._test_pop3_connection()
|
||||
|
||||
assert success is False
|
||||
assert "POP3 protocol error" in msg
|
||||
assert "POP3" in msg and "error" in msg.lower()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_pop3_generic_exception(self, mock_poplib):
|
||||
@@ -204,7 +204,7 @@ class TestTestPop3Connection:
|
||||
success, msg = await proc._test_pop3_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Connection failed" in msg
|
||||
assert msg # must be non-empty
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -280,7 +280,7 @@ class TestTestImapConnection:
|
||||
success, msg = await proc._test_imap_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Authentication failed" in msg
|
||||
assert "Authentication" in msg or "rejected" in msg
|
||||
|
||||
@patch("app.services.mail_processor.aioimaplib")
|
||||
async def test_imap_generic_exception(self, mock_aioimaplib):
|
||||
@@ -292,7 +292,7 @@ class TestTestImapConnection:
|
||||
success, msg = await proc._test_imap_connection()
|
||||
|
||||
assert success is False
|
||||
assert "IMAP connection failed" in msg
|
||||
assert msg # must be non-empty
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -438,8 +438,12 @@ class TestFetchPop3Emails:
|
||||
assert len(uids) == 2
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_delete_after_forward(self, mock_poplib):
|
||||
"""delete_after_forward=True issues dele() for fetched messages."""
|
||||
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
|
||||
|
||||
@@ -447,9 +451,7 @@ class TestFetchPop3Emails:
|
||||
proc = MailProcessor(account, "secret")
|
||||
await proc._fetch_pop3_emails(10, set())
|
||||
|
||||
assert mock_conn.dele.call_count == 2
|
||||
mock_conn.dele.assert_any_call(1)
|
||||
mock_conn.dele.assert_any_call(2)
|
||||
mock_conn.dele.assert_not_called()
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_no_delete_when_disabled(self, mock_poplib):
|
||||
@@ -483,27 +485,31 @@ class TestFetchPop3Emails:
|
||||
assert "b" not in uids
|
||||
|
||||
@patch("app.services.mail_processor.poplib")
|
||||
async def test_delete_error_does_not_abort(self, mock_poplib):
|
||||
"""A dele() error is logged but doesn't raise."""
|
||||
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_conn.dele.side_effect = Exception("delete failed")
|
||||
mock_poplib.POP3_SSL.return_value = mock_conn
|
||||
|
||||
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
|
||||
proc = MailProcessor(account, "secret")
|
||||
# Should not raise
|
||||
emails, uids = await proc._fetch_pop3_emails(10, set())
|
||||
assert len(emails) == 1
|
||||
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."""
|
||||
"""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, match="POP3 fetch error"):
|
||||
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):
|
||||
@@ -559,10 +565,78 @@ class TestFetchPop3Emails:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# forward_email
|
||||
# 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()."""
|
||||
|
||||
@@ -780,3 +854,87 @@ class TestForwardEmail:
|
||||
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()
|
||||
@@ -77,6 +77,8 @@ def _mock_session_maker():
|
||||
is usable as ``async with session_maker_mock() as db:``.
|
||||
"""
|
||||
session = AsyncMock()
|
||||
# db.add() is synchronous in SQLAlchemy; prevent "coroutine never awaited" warnings
|
||||
session.add = MagicMock()
|
||||
|
||||
# Make the session usable as an async context manager
|
||||
ctx = AsyncMock()
|
||||
@@ -589,6 +591,7 @@ class TestProcessMailAccount:
|
||||
session.refresh = AsyncMock()
|
||||
|
||||
mock_processor = AsyncMock()
|
||||
mock_processor.post_process_messages = AsyncMock()
|
||||
# Two emails: first succeeds, second fails
|
||||
mock_processor.fetch_emails.return_value = (
|
||||
[raw_email, raw_email],
|
||||
@@ -612,6 +615,8 @@ class TestProcessMailAccount:
|
||||
await process_mail_account.run(1)
|
||||
|
||||
assert session.commit.await_count >= 2
|
||||
# Only the successfully forwarded UID should be post-processed
|
||||
mock_processor.post_process_messages.assert_awaited_once_with(["uid-1"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gmail_credential_revocation_on_401(self):
|
||||
@@ -1817,3 +1822,254 @@ class TestCleanupOldLogs:
|
||||
|
||||
# Should not raise
|
||||
await cleanup_old_logs.run(days_to_keep=30)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug-logging counter auto-disable tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDebugLoggingCounter:
|
||||
"""debug_logging is auto-disabled after exactly 5 runs since it was enabled."""
|
||||
|
||||
def _make_account_with_debug(self, run_count: int = 0) -> MagicMock:
|
||||
account = _make_account(delivery_method=DeliveryMethod.GMAIL_API)
|
||||
account.debug_logging = True
|
||||
account.debug_logging_run_count = run_count
|
||||
account.error_notification_sent = False
|
||||
account.status = MagicMock(value="active")
|
||||
return account
|
||||
|
||||
def _build_task_mocks(self, account):
|
||||
maker, session = _mock_session_maker()
|
||||
gmail_cred = _make_gmail_cred(user_id=account.user_id)
|
||||
|
||||
account_result = MagicMock()
|
||||
account_result.scalar_one_or_none.return_value = account
|
||||
seen_result = MagicMock()
|
||||
seen_result.scalars.return_value.all.return_value = []
|
||||
gmail_cred_result = MagicMock()
|
||||
gmail_cred_result.scalar_one_or_none.return_value = gmail_cred
|
||||
session.execute = AsyncMock(
|
||||
side_effect=[account_result, seen_result, gmail_cred_result]
|
||||
)
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
return maker, session, gmail_cred
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counter_increments_each_run(self):
|
||||
"""debug_logging_run_count increments from 0 to 1 after one successful run."""
|
||||
account = self._make_account_with_debug(run_count=0)
|
||||
maker, session, gmail_cred = self._build_task_mocks(account)
|
||||
|
||||
mock_processor = AsyncMock()
|
||||
mock_processor.fetch_emails.return_value = ([], [])
|
||||
mock_gmail_svc = _make_gmail_service()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.async_session_maker", maker),
|
||||
patch(f"{MODULE}.engine", AsyncMock()),
|
||||
patch(f"{MODULE}.decrypt_credential", return_value="pw"),
|
||||
patch(f"{MODULE}.MailProcessor", return_value=mock_processor),
|
||||
patch(f"{MODULE}.GmailService", return_value=mock_gmail_svc),
|
||||
patch(f"{MODULE}.send_user_notification", new_callable=AsyncMock),
|
||||
):
|
||||
from app.workers.tasks import process_mail_account
|
||||
|
||||
await process_mail_account.run(1)
|
||||
|
||||
assert account.debug_logging_run_count == 1
|
||||
assert account.debug_logging is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_disabled_at_run_5(self):
|
||||
"""debug_logging turns off and counter resets when it reaches 5."""
|
||||
account = self._make_account_with_debug(run_count=4)
|
||||
maker, session, gmail_cred = self._build_task_mocks(account)
|
||||
|
||||
mock_processor = AsyncMock()
|
||||
mock_processor.fetch_emails.return_value = ([], [])
|
||||
mock_gmail_svc = _make_gmail_service()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.async_session_maker", maker),
|
||||
patch(f"{MODULE}.engine", AsyncMock()),
|
||||
patch(f"{MODULE}.decrypt_credential", return_value="pw"),
|
||||
patch(f"{MODULE}.MailProcessor", return_value=mock_processor),
|
||||
patch(f"{MODULE}.GmailService", return_value=mock_gmail_svc),
|
||||
patch(f"{MODULE}.send_user_notification", new_callable=AsyncMock),
|
||||
):
|
||||
from app.workers.tasks import process_mail_account
|
||||
|
||||
await process_mail_account.run(1)
|
||||
|
||||
assert account.debug_logging is False
|
||||
assert account.debug_logging_run_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_not_disabled_at_run_4(self):
|
||||
"""debug_logging stays on at run 4 (need one more)."""
|
||||
account = self._make_account_with_debug(run_count=3)
|
||||
maker, session, gmail_cred = self._build_task_mocks(account)
|
||||
|
||||
mock_processor = AsyncMock()
|
||||
mock_processor.fetch_emails.return_value = ([], [])
|
||||
mock_gmail_svc = _make_gmail_service()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.async_session_maker", maker),
|
||||
patch(f"{MODULE}.engine", AsyncMock()),
|
||||
patch(f"{MODULE}.decrypt_credential", return_value="pw"),
|
||||
patch(f"{MODULE}.MailProcessor", return_value=mock_processor),
|
||||
patch(f"{MODULE}.GmailService", return_value=mock_gmail_svc),
|
||||
patch(f"{MODULE}.send_user_notification", new_callable=AsyncMock),
|
||||
):
|
||||
from app.workers.tasks import process_mail_account
|
||||
|
||||
await process_mail_account.run(1)
|
||||
|
||||
assert account.debug_logging is True
|
||||
assert account.debug_logging_run_count == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification backoff tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNotificationBackoff:
|
||||
"""Failure notifications should only fire once per error streak."""
|
||||
|
||||
def _make_smtp_account(self, **overrides):
|
||||
account = _make_account(delivery_method=DeliveryMethod.SMTP, **overrides)
|
||||
account.debug_logging = False
|
||||
account.debug_logging_run_count = 0
|
||||
account.error_notification_sent = overrides.get(
|
||||
"error_notification_sent", False
|
||||
)
|
||||
account.status = MagicMock(value="active")
|
||||
return account
|
||||
|
||||
def _smtp_session(self, account):
|
||||
maker, session = _mock_session_maker()
|
||||
user_smtp = MagicMock()
|
||||
user_smtp.host = "smtp.x.com"
|
||||
user_smtp.port = 587
|
||||
user_smtp.username = "u"
|
||||
user_smtp.encrypted_password = "ep"
|
||||
user_smtp.use_tls = True
|
||||
|
||||
account_result = MagicMock()
|
||||
account_result.scalar_one_or_none.return_value = account
|
||||
seen_result = MagicMock()
|
||||
seen_result.scalars.return_value.all.return_value = []
|
||||
smtp_result = MagicMock()
|
||||
smtp_result.scalar_one_or_none.return_value = user_smtp
|
||||
session.execute = AsyncMock(
|
||||
side_effect=[account_result, seen_result, smtp_result]
|
||||
)
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
return maker, session
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_failure_sends_notification(self):
|
||||
"""First email-forwarding failure in a streak sends a notification."""
|
||||
raw_email = _build_raw_email()
|
||||
account = self._make_smtp_account(error_notification_sent=False)
|
||||
maker, session = self._smtp_session(account)
|
||||
|
||||
mock_processor = AsyncMock()
|
||||
mock_processor.post_process_messages = AsyncMock()
|
||||
mock_processor.fetch_emails.return_value = ([raw_email], ["uid-1"])
|
||||
|
||||
mock_notif_session = MagicMock()
|
||||
mock_notif_ctx = MagicMock()
|
||||
mock_notif_ctx.__aenter__ = AsyncMock(return_value=mock_notif_session)
|
||||
mock_notif_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_notif_ctx.begin = MagicMock(return_value=mock_notif_ctx)
|
||||
mock_notif_session.execute = AsyncMock()
|
||||
|
||||
def session_maker_side_effect():
|
||||
return mock_notif_ctx
|
||||
|
||||
mock_send_notification = AsyncMock(return_value=1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{MODULE}.async_session_maker",
|
||||
side_effect=[maker(), session_maker_side_effect()],
|
||||
),
|
||||
patch(f"{MODULE}.engine", AsyncMock()),
|
||||
patch(f"{MODULE}.decrypt_credential", return_value="pw"),
|
||||
patch(f"{MODULE}.MailProcessor", return_value=mock_processor),
|
||||
patch(
|
||||
f"{MODULE}.MailProcessor.forward_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
patch(f"{MODULE}.send_user_notification", mock_send_notification),
|
||||
):
|
||||
from app.workers.tasks import process_mail_account
|
||||
|
||||
await process_mail_account.run(1)
|
||||
|
||||
mock_send_notification.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_failure_suppressed(self):
|
||||
"""When error_notification_sent is already True, no new notification fires."""
|
||||
raw_email = _build_raw_email()
|
||||
account = self._make_smtp_account(error_notification_sent=True)
|
||||
maker, session = self._smtp_session(account)
|
||||
|
||||
mock_processor = AsyncMock()
|
||||
mock_processor.post_process_messages = AsyncMock()
|
||||
mock_processor.fetch_emails.return_value = ([raw_email], ["uid-1"])
|
||||
|
||||
mock_send_notification = AsyncMock(return_value=0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.async_session_maker", maker),
|
||||
patch(f"{MODULE}.engine", AsyncMock()),
|
||||
patch(f"{MODULE}.decrypt_credential", return_value="pw"),
|
||||
patch(f"{MODULE}.MailProcessor", return_value=mock_processor),
|
||||
patch(
|
||||
f"{MODULE}.MailProcessor.forward_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
),
|
||||
patch(f"{MODULE}.send_user_notification", mock_send_notification),
|
||||
):
|
||||
from app.workers.tasks import process_mail_account
|
||||
|
||||
await process_mail_account.run(1)
|
||||
|
||||
mock_send_notification.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovery_clears_flag(self):
|
||||
"""A successful run clears error_notification_sent."""
|
||||
account = self._make_smtp_account(error_notification_sent=False)
|
||||
# Put account in "active" status – recovery notice fires when was ERROR
|
||||
account.status = MagicMock(value="active")
|
||||
maker, session = self._smtp_session(account)
|
||||
|
||||
mock_processor = AsyncMock()
|
||||
mock_processor.fetch_emails.return_value = ([], [])
|
||||
mock_processor.post_process_messages = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.async_session_maker", maker),
|
||||
patch(f"{MODULE}.engine", AsyncMock()),
|
||||
patch(f"{MODULE}.decrypt_credential", return_value="pw"),
|
||||
patch(f"{MODULE}.MailProcessor", return_value=mock_processor),
|
||||
patch(f"{MODULE}.send_user_notification", new_callable=AsyncMock),
|
||||
):
|
||||
from app.workers.tasks import process_mail_account
|
||||
|
||||
await process_mail_account.run(1)
|
||||
|
||||
# Flag is reset to False after successful connection
|
||||
assert account.error_notification_sent is False
|
||||
|
||||
@@ -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
|
||||
+58
-1
@@ -4,6 +4,58 @@ Comprehensive task breakdown for repository improvements and production readines
|
||||
|
||||
## ✅ 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()`.
|
||||
@@ -120,9 +172,14 @@ Comprehensive task breakdown for repository improvements and production readines
|
||||
- [x] Write tests for Celery tasks (96% coverage for `tasks.py`)
|
||||
- [x] Write unit tests for admin endpoints (87 tests, 100% coverage on admin.py)
|
||||
- [x] **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components and utility functions. Installed `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. New suites: `date-utils` (30 tests), API interceptors (9 tests), `AuthGuard` (6 tests), `QueryProvider` (2 tests), `DashboardLayout` (14 tests), `NotificationWizard` (32 tests), `ProviderWizard` (20 tests). Total frontend: 119 tests across 8 suites.
|
||||
- [x] **AddMailAccountModal test coverage**: 28 tests covering create/edit mode rendering, provider wizard flow, form fields, auto-detect, test connection, submit mutations, error extraction, and modal interactions. Total frontend: 147 tests across 9 suites.
|
||||
- [x] **Expanded `api.ts` test coverage**: Added 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`). Every exported method now has at least one test verifying correct HTTP method, URL, parameters, and return value. Total: 65 tests in `api.test.ts`.
|
||||
|
||||
### In Progress 🔨
|
||||
- [ ] Write unit tests for authentication (target 80%+ coverage)
|
||||
- [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions)
|
||||
- [x] Write unit tests for mail account endpoints (30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs/logs)
|
||||
- [x] Write unit tests for provider endpoints (23 tests covering presets, Gmail credential CRUD, import labels, authorize URL, debug email, OAuth callback)
|
||||
- [x] **Expanded `gmail_service.py` test coverage**: 10 new tests covering `inject_email` HttpError branch, `get_or_create_label` (existing/create/HttpError/generic-error), `inject_debug_email` full flow, `get_refreshed_token` (changed/unchanged), and `service` property lazy initialization — 24 total tests in `test_gmail_service.py`
|
||||
- [ ] Write unit tests for mail processing
|
||||
- [ ] Write integration tests for API endpoints
|
||||
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ COPY . .
|
||||
# Set environment variable for build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
# Turbopack is not supported for this ARM64 Alpine/QEMU build path.
|
||||
RUN npm run build:docker
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
|
||||
@@ -13,6 +13,15 @@ const eslintConfig = defineConfig([
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
// Specify explicit React version to avoid eslint-plugin-react calling the
|
||||
// removed context.getFilename() API when using version: 'detect' with ESLint 10.
|
||||
{
|
||||
settings: {
|
||||
react: {
|
||||
version: "19",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
Generated
+1025
-903
File diff suppressed because it is too large
Load Diff
+16
-11
@@ -5,19 +5,24 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:docker": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --coverage --coverageReporters=lcov --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.95.0",
|
||||
"axios": "^1.13.6",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "^5.0.12"
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"axios": "^1.16.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "5.0.6",
|
||||
"postcss": "8.5.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -25,13 +30,13 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint": "^10",
|
||||
"eslint-config-next": "16.2.5",
|
||||
"jest": "^30.3.0",
|
||||
"jest-environment-jsdom": "^30.3.0",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { mailAccountsApi, MailAccount } from '@/lib/api';
|
||||
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power, RefreshCw } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { AddMailAccountModal } from '@/components/AddMailAccountModal';
|
||||
@@ -76,7 +76,7 @@ function ProviderLogoBanner({ providerName, email }: { providerName?: string | n
|
||||
if (!icon) return null;
|
||||
const label = providerName ?? email?.split('@')[1] ?? 'provider';
|
||||
return (
|
||||
<div className="relative h-16 w-full bg-gray-50 border-b border-gray-100 overflow-hidden">
|
||||
<div className="relative h-16 w-full overflow-hidden border-b border-[#edf2f8] bg-[#f7faff]">
|
||||
<Image
|
||||
src={`/providers/${icon}.svg`}
|
||||
alt={`${label} logo`}
|
||||
@@ -115,6 +115,13 @@ export default function AccountsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const clearErrorMutation = useMutation({
|
||||
mutationFn: mailAccountsApi.clearError,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (account: MailAccount) => {
|
||||
setEditingAccount(account);
|
||||
setIsModalOpen(true);
|
||||
@@ -170,11 +177,14 @@ export default function AccountsPage() {
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mail Accounts</h1>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-extrabold text-slate-950">Mail Accounts</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Connect and monitor the external inboxes feeding Gmail.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
className="ic-button-primary"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Add Account
|
||||
@@ -183,15 +193,15 @@ export default function AccountsPage() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-[#0b63f6]"></div>
|
||||
</div>
|
||||
) : accounts && accounts.length > 0 ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className={`bg-white rounded-lg shadow-md border overflow-hidden transition-opacity ${
|
||||
account.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
|
||||
className={`ic-card overflow-hidden transition-opacity ${
|
||||
account.is_enabled ? '' : 'opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Provider logo banner – full-width strip that accommodates any aspect ratio */}
|
||||
@@ -200,19 +210,19 @@ export default function AccountsPage() {
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1 truncate">
|
||||
<h3 className="text-lg font-bold text-slate-950 mb-1 truncate">
|
||||
{account.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate">{account.email_address}</p>
|
||||
<p className="text-sm text-slate-500 truncate">{account.email_address}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2 flex-shrink-0">
|
||||
{account.is_enabled ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-[#e8f7ef] px-2 py-0.5 text-xs font-bold text-[#11834d]">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-0.5 text-xs font-bold text-slate-500">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Disabled
|
||||
</span>
|
||||
@@ -222,53 +232,64 @@ export default function AccountsPage() {
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="text-gray-500 w-20">Protocol:</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
<span className="w-20 text-slate-500">Protocol:</span>
|
||||
<span className="font-semibold text-slate-950">
|
||||
{account.protocol.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="text-gray-500 w-20">Host:</span>
|
||||
<span className="text-gray-900">{account.host}:{account.port}</span>
|
||||
<span className="w-20 text-slate-500">Host:</span>
|
||||
<span className="text-slate-950">{account.host}:{account.port}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="text-gray-500 w-20">SSL:</span>
|
||||
<span className="text-gray-900">
|
||||
<span className="w-20 text-slate-500">SSL:</span>
|
||||
<span className="text-slate-950">
|
||||
{account.use_ssl ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="text-gray-500 w-20">Interval:</span>
|
||||
<span className="text-gray-900">
|
||||
<span className="w-20 text-slate-500">Interval:</span>
|
||||
<span className="text-slate-950">
|
||||
Every {account.check_interval_minutes} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{account.last_check_at && (
|
||||
<div className="mb-4 text-xs text-gray-500">
|
||||
<div className="mb-4 text-xs text-slate-500">
|
||||
Last checked: {new Date(account.last_check_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{account.last_error_message && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-red-700">{account.last_error_message}</p>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start min-w-0">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-red-700">{account.last_error_message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => clearErrorMutation.mutate(account.id)}
|
||||
disabled={clearErrorMutation.isPending}
|
||||
title="Clear error status"
|
||||
className="flex-shrink-0 flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 bg-red-100 hover:bg-red-200 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-2 border-t border-[#edf2f8] pt-4">
|
||||
<button
|
||||
onClick={() => handleToggle(account.id)}
|
||||
disabled={toggleMutation.isPending}
|
||||
title={account.is_enabled ? 'Disable account' : 'Enable account'}
|
||||
className={`flex items-center justify-center px-3 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 ${
|
||||
account.is_enabled
|
||||
? 'text-yellow-600 bg-yellow-50 hover:bg-yellow-100'
|
||||
: 'text-green-600 bg-green-50 hover:bg-green-100'
|
||||
? 'text-yellow-700 bg-yellow-50 hover:bg-yellow-100'
|
||||
: 'text-[#11834d] bg-[#e8f7ef] hover:bg-green-100'
|
||||
}`}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
@@ -281,7 +302,7 @@ export default function AccountsPage() {
|
||||
className={`flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 ${
|
||||
successIds.has(account.id)
|
||||
? 'text-green-600 bg-green-50 hover:bg-green-100'
|
||||
: 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100'
|
||||
: 'text-[#0b63f6] bg-[#e7f0ff] hover:bg-blue-100'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${pullingIds.has(account.id) ? 'animate-spin' : ''}`} />
|
||||
@@ -295,7 +316,7 @@ export default function AccountsPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(account)}
|
||||
className="flex-1 flex items-center justify-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
|
||||
className="flex-1 flex items-center justify-center px-3 py-2 text-sm font-bold text-[#0b63f6] bg-[#e7f0ff] rounded-md hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Edit2 className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
@@ -314,11 +335,11 @@ export default function AccountsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow">
|
||||
<p className="text-gray-500 mb-4">No mail accounts configured yet</p>
|
||||
<div className="ic-card py-12 text-center">
|
||||
<p className="text-slate-500 mb-4">No mail accounts configured yet</p>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
className="ic-button-primary"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Add Your First Account
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { mailAccountsApi, MailAccount } from '@/lib/api';
|
||||
import { formatRelative } from '@/lib/date-utils';
|
||||
import Link from 'next/link';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Inbox,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
@@ -26,13 +27,13 @@ interface StatCardProps {
|
||||
|
||||
function StatCard({ title, value, icon: Icon, iconColor }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="ic-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
|
||||
<p className="text-sm font-semibold text-slate-500">{title}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-slate-950">{value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-full ${iconColor}`}>
|
||||
<div className={`rounded-lg p-3 ${iconColor}`}>
|
||||
<Icon className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,38 +44,46 @@ function StatCard({ title, value, icon: Icon, iconColor }: StatCardProps) {
|
||||
function AccountStatusRow({ account }: { account: MailAccount }) {
|
||||
const hasError = !!account.last_error_message;
|
||||
const lastChecked = account.last_check_at;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const clearErrorMutation = useMutation({
|
||||
mutationFn: () => mailAccountsApi.clearError(account.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 border-b border-gray-100 last:border-b-0">
|
||||
<div className="px-5 py-4 border-b border-[#edf2f8] last:border-b-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left: name + email */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Inbox className="h-4 w-4 text-blue-400 shrink-0" />
|
||||
<Inbox className="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">{account.name}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{account.email_address}</p>
|
||||
<p className="text-sm font-semibold text-slate-950 truncate">{account.name}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{account.email_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: status badge + last check */}
|
||||
<div className="text-right shrink-0">
|
||||
{hasError ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-bold text-red-600">
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
Error
|
||||
</span>
|
||||
) : lastChecked ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-bold text-[#11834d]">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
OK
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-400">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-bold text-slate-400">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{formatRelative(lastChecked)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,15 +91,24 @@ function AccountStatusRow({ account }: { account: MailAccount }) {
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && (
|
||||
<div className="mt-2 flex items-start gap-1.5 p-2 bg-red-50 border border-red-200 rounded">
|
||||
<div className="mt-2 flex items-start gap-1.5 rounded-md border border-red-200 bg-red-50 p-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-700 line-clamp-2">{account.last_error_message}</p>
|
||||
<p className="text-xs text-red-700 line-clamp-2 flex-1">{account.last_error_message}</p>
|
||||
<button
|
||||
onClick={() => clearErrorMutation.mutate()}
|
||||
disabled={clearErrorMutation.isPending}
|
||||
title="Clear error status"
|
||||
className="flex-shrink-0 flex items-center gap-0.5 px-1.5 py-0.5 text-xs text-red-600 bg-red-100 hover:bg-red-200 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lifetime counters (only when there's activity) */}
|
||||
{(account.total_emails_processed > 0 || account.total_emails_failed > 0) && (
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-slate-500">
|
||||
<span>{account.total_emails_processed.toLocaleString()} processed</span>
|
||||
{account.total_emails_failed > 0 && (
|
||||
<span className="text-red-500">{account.total_emails_failed.toLocaleString()} failed</span>
|
||||
@@ -124,40 +142,40 @@ export default function DashboardPage() {
|
||||
title="Total Accounts"
|
||||
value={stats.totalAccounts}
|
||||
icon={Mail}
|
||||
iconColor="bg-blue-500"
|
||||
iconColor="bg-[#0b63f6]"
|
||||
/>
|
||||
<StatCard
|
||||
title="Emails Processed"
|
||||
value={stats.totalProcessed.toLocaleString()}
|
||||
icon={Send}
|
||||
iconColor="bg-green-500"
|
||||
iconColor="bg-[#11834d]"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Accounts"
|
||||
value={stats.activeAccounts}
|
||||
icon={CheckCircle}
|
||||
iconColor="bg-purple-500"
|
||||
iconColor="bg-[#10213f]"
|
||||
/>
|
||||
<StatCard
|
||||
title="Accounts with Errors"
|
||||
value={stats.accountsWithErrors}
|
||||
icon={AlertCircle}
|
||||
iconColor={stats.accountsWithErrors > 0 ? 'bg-red-500' : 'bg-gray-400'}
|
||||
iconColor={stats.accountsWithErrors > 0 ? 'bg-red-500' : 'bg-slate-400'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mailbox Status Overview */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Mailbox Status</h3>
|
||||
<Link href="/logs" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
|
||||
<div className="ic-card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-[#d9e3f2] bg-[#fbfdff] px-6 py-4">
|
||||
<h3 className="text-lg font-bold text-slate-950">Mailbox Status</h3>
|
||||
<Link href="/logs" className="text-sm font-bold text-[#0b63f6] hover:text-[#0649bf]">
|
||||
View activity & history →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{accountsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-[#0b63f6]" />
|
||||
</div>
|
||||
) : accounts && accounts.length > 0 ? (
|
||||
<div>
|
||||
@@ -167,11 +185,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Clock className="mx-auto h-10 w-10 text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-500">No mail accounts configured yet.</p>
|
||||
<Clock className="mx-auto h-10 w-10 text-slate-300 mb-3" />
|
||||
<p className="text-sm text-slate-500">No mail accounts configured yet.</p>
|
||||
<Link
|
||||
href="/accounts"
|
||||
className="mt-3 inline-block text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
className="mt-3 inline-block text-sm font-bold text-[#0b63f6] hover:text-[#0649bf]"
|
||||
>
|
||||
Add your first account →
|
||||
</Link>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function DatenschutzPage() {
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
2. Geltungsbereich dieser Datenschutzerklärung
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
<p className="text-gray-700 mb-3">
|
||||
Dieser Hinweis gilt für die {APP_NAME}-Webanwendung. Er gilt
|
||||
für alle Nutzer weltweit, einschließlich derjenigen in der
|
||||
Europäischen Union (EU), dem Europäischen Wirtschaftsraum (EWR),
|
||||
@@ -91,6 +91,14 @@ export default function DatenschutzPage() {
|
||||
Ukraine, den Vereinigten Staaten (US), Kanada, Lateinamerika,
|
||||
dem asiatisch-pazifischen Raum und Japan.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Eine englischsprachige Fassung dieser Datenschutzerklärung ist
|
||||
verfügbar unter:{' '}
|
||||
<Link href="/privacy" className="text-blue-600 hover:text-blue-500">
|
||||
/privacy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="section-3" className="mb-8">
|
||||
@@ -372,7 +380,13 @@ export default function DatenschutzPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 flex gap-4 text-sm text-gray-500">
|
||||
<div className="border-t border-gray-200 pt-4 flex flex-wrap gap-4 text-sm text-gray-500">
|
||||
<Link href="/privacy" className="hover:text-blue-600">
|
||||
Privacy Policy (EN)
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-blue-600">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link href="/impressum" className="hover:text-blue-600">
|
||||
Impressum
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--foreground: #10213f;
|
||||
--ic-bg-soft: #f7faff;
|
||||
--ic-bg-blue: #eef5ff;
|
||||
--ic-ink: #10213f;
|
||||
--ic-ink-strong: #07162f;
|
||||
--ic-muted: #52617a;
|
||||
--ic-line: #d9e3f2;
|
||||
--ic-line-strong: #b8c8df;
|
||||
--ic-blue: #0b63f6;
|
||||
--ic-blue-deep: #0649bf;
|
||||
--ic-green: #11834d;
|
||||
--ic-green-soft: #e8f7ef;
|
||||
--ic-shadow: 0 24px 70px rgba(16, 33, 63, 0.11);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -12,15 +24,89 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
body {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(238, 245, 255, 0.72), rgba(255, 255, 255, 0) 420px),
|
||||
var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist-sans), Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(11, 99, 246, 0.16);
|
||||
}
|
||||
|
||||
.ic-panel {
|
||||
border: 1px solid var(--ic-line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: var(--ic-shadow);
|
||||
}
|
||||
|
||||
.ic-card {
|
||||
border: 1px solid var(--ic-line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 40px rgba(16, 33, 63, 0.07);
|
||||
}
|
||||
|
||||
.ic-button-primary {
|
||||
display: inline-flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--ic-blue);
|
||||
padding: 0.625rem 1rem;
|
||||
color: #ffffff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 750;
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.ic-button-primary:hover {
|
||||
background: var(--ic-blue-deep);
|
||||
box-shadow: 0 14px 28px rgba(11, 99, 246, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ic-button-secondary {
|
||||
display: inline-flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--ic-line-strong);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 0.625rem 1rem;
|
||||
color: var(--ic-ink);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 750;
|
||||
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.ic-button-secondary:hover {
|
||||
border-color: #8aa5ca;
|
||||
background: #f9fbff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ic-focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ic-focus:focus-visible {
|
||||
outline-color: var(--ic-blue);
|
||||
box-shadow: 0 0 0 4px rgba(11, 99, 246, 0.14);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -42,25 +43,31 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#f7faff] px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<Link href="/">
|
||||
<BrandMark />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ic-panel p-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
InboxConverge
|
||||
<h2 className="text-center text-3xl font-extrabold text-slate-950">
|
||||
Sign in
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
<p className="mt-2 text-center text-sm text-slate-500">
|
||||
Continue to your mailbox delivery dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
<label htmlFor="email" className="block text-sm font-bold text-slate-700">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
@@ -71,12 +78,12 @@ export default function LoginPage() {
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
className="ic-focus mt-1 relative block w-full rounded-md border border-[#b8c8df] px-3 py-2.5 text-slate-950 placeholder-slate-400 focus:border-[#0b63f6] sm:text-sm"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
<label htmlFor="password" className="block text-sm font-bold text-slate-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -87,7 +94,7 @@ export default function LoginPage() {
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
className="ic-focus mt-1 relative block w-full rounded-md border border-[#b8c8df] px-3 py-2.5 text-slate-950 placeholder-slate-400 focus:border-[#0b63f6] sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
@@ -97,7 +104,7 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
className="ic-button-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
@@ -105,10 +112,10 @@ export default function LoginPage() {
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
<div className="w-full border-t border-[#d9e3f2]" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-gray-50 text-gray-500">Or continue with</span>
|
||||
<span className="bg-white px-2 text-slate-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +123,7 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
className="w-full flex justify-center items-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
className="ic-button-secondary w-full"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -141,20 +148,27 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-slate-600">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
<Link href="/register" className="font-bold text-[#0b63f6] hover:text-[#0649bf]">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center gap-4 text-xs text-gray-400">
|
||||
<Link href="/impressum" className="hover:text-gray-600 transition-colors">
|
||||
<div className="mt-6 flex justify-center gap-4 text-xs text-slate-400">
|
||||
<Link href="/privacy" className="hover:text-slate-600 transition-colors">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-slate-600 transition-colors">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/impressum" className="hover:text-slate-600 transition-colors">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="hover:text-gray-600 transition-colors">
|
||||
<Link href="/datenschutz" className="hover:text-slate-600 transition-colors">
|
||||
Datenschutz
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { processingRunsApi, mailAccountsApi, MailAccount, ProcessingRun, ProcessingLog } from '@/lib/api';
|
||||
import { formatRelative, formatDate, formatDuration } from '@/lib/date-utils';
|
||||
import {
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
Bug,
|
||||
} from 'lucide-react';
|
||||
|
||||
function RunDetailRow({ run }: { run: ProcessingRun }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [logsPage, setLogsPage] = useState(1);
|
||||
const [traceExpanded, setTraceExpanded] = useState(false);
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['run-logs', run.id, logsPage],
|
||||
@@ -29,6 +32,10 @@ function RunDetailRow({ run }: { run: ProcessingRun }) {
|
||||
enabled: expanded,
|
||||
});
|
||||
|
||||
// Separate DEBUG-level connection trace entries from email-level logs
|
||||
const debugLogs = (logsData?.items ?? []).filter((l: ProcessingLog) => l.level === 'DEBUG');
|
||||
const emailLogs = (logsData?.items ?? []).filter((l: ProcessingLog) => l.level !== 'DEBUG');
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
@@ -62,11 +69,47 @@ function RunDetailRow({ run }: { run: ProcessingRun }) {
|
||||
{run.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection trace (DEBUG-level logs) */}
|
||||
{!logsLoading && debugLogs.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setTraceExpanded((v) => !v); }}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-purple-700 hover:text-purple-900 mb-1"
|
||||
>
|
||||
<Bug className="h-3.5 w-3.5" />
|
||||
Connection trace
|
||||
{traceExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
{traceExpanded && debugLogs.map((log: ProcessingLog) => {
|
||||
const trace = (log.error_details as { trace?: unknown[]; truncated?: boolean } | null)?.trace ?? [];
|
||||
const truncated = (log.error_details as { trace?: unknown[]; truncated?: boolean } | null)?.truncated ?? false;
|
||||
return (
|
||||
<div key={log.id} className="bg-gray-900 text-gray-100 rounded p-3 text-xs font-mono overflow-x-auto max-h-80 overflow-y-auto">
|
||||
{(trace as { ts: string; phase: string; msg: string; data?: Record<string, unknown> }[]).map((entry, i) => (
|
||||
<div key={i} className="flex gap-2 mb-0.5">
|
||||
<span className="text-gray-400 flex-shrink-0">{new Date(entry.ts).toISOString().slice(11, 23)}</span>
|
||||
<span className={`flex-shrink-0 ${entry.phase === 'error' || entry.phase === 'fetch_error' ? 'text-red-400' : entry.phase === 'truncated' ? 'text-yellow-400' : 'text-green-400'}`}>[{entry.phase}]</span>
|
||||
<span>{entry.msg}</span>
|
||||
{entry.data && (
|
||||
<span className="text-gray-500 ml-1">{JSON.stringify(entry.data)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{truncated && (
|
||||
<div className="text-yellow-400 mt-1">⚠ Trace was truncated (size limit reached)</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 py-1">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : logsData && logsData.items.length > 0 ? (
|
||||
) : emailLogs.length > 0 ? (
|
||||
<>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
@@ -78,7 +121,7 @@ function RunDetailRow({ run }: { run: ProcessingRun }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logsData.items.map((log: ProcessingLog) => (
|
||||
{emailLogs.map((log: ProcessingLog) => (
|
||||
<tr key={log.id} className="border-t border-gray-100">
|
||||
<td className="py-1 pr-4 text-gray-500 whitespace-nowrap">
|
||||
{formatDate(log.timestamp)}
|
||||
@@ -100,7 +143,7 @@ function RunDetailRow({ run }: { run: ProcessingRun }) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{logsData.pages > 1 && (
|
||||
{logsData && logsData.pages > 1 && (
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -126,9 +169,9 @@ function RunDetailRow({ run }: { run: ProcessingRun }) {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
) : !logsLoading && debugLogs.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 py-1">No per-email logs for this run.</p>
|
||||
)}
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -146,6 +189,14 @@ function MailboxCard({
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const hasError = !!account.last_error_message;
|
||||
const lastChecked = account.last_check_at;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const clearErrorMutation = useMutation({
|
||||
mutationFn: () => mailAccountsApi.clearError(account.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
@@ -186,7 +237,16 @@ function MailboxCard({
|
||||
{hasError && (
|
||||
<div className="mx-5 mb-3 p-2 bg-red-50 border border-red-200 rounded flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-700">{account.last_error_message}</p>
|
||||
<p className="text-xs text-red-700 flex-1">{account.last_error_message}</p>
|
||||
<button
|
||||
onClick={() => clearErrorMutation.mutate()}
|
||||
disabled={clearErrorMutation.isPending}
|
||||
title="Clear error status"
|
||||
className="flex-shrink-0 flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 bg-red-100 hover:bg-red-200 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+67
-56
@@ -7,7 +7,8 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { userApi, SubscriptionPlan } from '@/lib/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { Mail, ArrowRight, Shield, Zap, Clock, Check } from 'lucide-react';
|
||||
import { ArrowRight, Shield, Zap, Clock, Check } from 'lucide-react';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
|
||||
async function fetchPublicPlans(): Promise<SubscriptionPlan[]> {
|
||||
const res = await api.get<SubscriptionPlan[]>('/subscriptions/plans');
|
||||
@@ -20,39 +21,39 @@ function PricingCard({ plan }: { plan: SubscriptionPlan }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-md p-8 flex flex-col border border-gray-100 hover:shadow-lg transition-shadow">
|
||||
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 flex-1">{plan.description}</p>
|
||||
<div className="ic-card flex flex-col p-8 transition-transform hover:-translate-y-0.5">
|
||||
<h3 className="text-xl font-bold text-slate-950">{plan.name}</h3>
|
||||
<p className="mt-2 text-sm text-slate-500 flex-1">{plan.description}</p>
|
||||
<div className="mt-6">
|
||||
<span className="text-4xl font-extrabold text-gray-900">
|
||||
<span className="text-4xl font-extrabold text-slate-950">
|
||||
€{plan.price_monthly.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-gray-500">/month</span>
|
||||
<span className="text-slate-500">/month</span>
|
||||
{yearlyMonthly && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
<p className="text-xs text-[#11834d] mt-1">
|
||||
or €{yearlyMonthly}/mo billed yearly (€{plan.price_yearly?.toFixed(2)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-6 space-y-2 text-sm text-gray-600">
|
||||
<ul className="mt-6 space-y-2 text-sm text-slate-600">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
<Check className="h-4 w-4 text-[#11834d] shrink-0" />
|
||||
{plan.max_mail_accounts === 1
|
||||
? '1 mailbox'
|
||||
: `Up to ${plan.max_mail_accounts} mailboxes`}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
<Check className="h-4 w-4 text-[#11834d] shrink-0" />
|
||||
Checked every {plan.check_interval_minutes} minute{plan.check_interval_minutes > 1 ? 's' : ''}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
<Check className="h-4 w-4 text-[#11834d] shrink-0" />
|
||||
Up to {plan.max_emails_per_day.toLocaleString()} emails/day
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="/register"
|
||||
className="mt-8 block text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
className="ic-button-primary mt-8 w-full"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
@@ -99,25 +100,22 @@ export default function Home() {
|
||||
const hasPaidPlans = plans && plans.length > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
<header className="border-b border-gray-200 bg-white">
|
||||
<header className="border-b border-[#d9e3f2] bg-white/90 backdrop-blur-xl">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<div className="flex items-center">
|
||||
<Mail className="h-8 w-8 text-blue-600 mr-2" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">InboxConverge</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<BrandMark />
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-gray-700 hover:text-gray-900 font-medium"
|
||||
className="font-semibold text-slate-700 hover:text-slate-950"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
className="ic-button-primary"
|
||||
>
|
||||
Sign Up Free
|
||||
</Link>
|
||||
@@ -129,32 +127,31 @@ export default function Home() {
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
|
||||
{/* Hero */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
|
||||
Your old inboxes,{' '}
|
||||
<span className="text-blue-600">delivered to Gmail.</span>
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h2 className="mb-6 text-4xl font-extrabold leading-tight text-slate-950 sm:text-6xl">
|
||||
Your old inboxes, delivered to Gmail.
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto">
|
||||
<p className="mx-auto mb-4 max-w-2xl text-xl leading-8 text-slate-600">
|
||||
You know the ones — that GMX account from 2009, the old ISP address your
|
||||
bank still sends to, the Hotmail you gave out in school. InboxConverge
|
||||
quietly polls them all and drops everything into your Gmail. Set it once,
|
||||
forget it exists.
|
||||
</p>
|
||||
<p className="text-base text-gray-500 mb-8 max-w-xl mx-auto">
|
||||
<p className="text-base text-slate-500 mb-8 max-w-xl mx-auto">
|
||||
No forwarding rules to configure. No email clients to keep open.
|
||||
Just your mail, where you actually read it.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 flex-wrap">
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-lg font-medium"
|
||||
className="ic-button-primary min-h-12 px-6 text-base"
|
||||
>
|
||||
Get Started — it's free
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-6 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-lg font-medium"
|
||||
className="ic-button-secondary min-h-12 px-6 text-base"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
@@ -163,40 +160,40 @@ export default function Home() {
|
||||
|
||||
{/* Features */}
|
||||
<div className="mt-20 grid md:grid-cols-3 gap-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mb-4">
|
||||
<div className="ic-card p-6">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-md bg-[#0b63f6] text-white mb-4">
|
||||
<Zap className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-bold text-slate-950 mb-2">
|
||||
Auto-detects everything
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-slate-600">
|
||||
Type your old email address and InboxConverge figures out the server
|
||||
settings. No Googling port numbers required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-md bg-green-500 text-white mb-4">
|
||||
<div className="ic-card p-6">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-md bg-[#11834d] text-white mb-4">
|
||||
<Clock className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-bold text-slate-950 mb-2">
|
||||
Runs in the background
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-slate-600">
|
||||
Checks your old inboxes on a schedule you choose — from every minute
|
||||
to once a day. New mail appears in Gmail as if it was always there.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-md bg-purple-500 text-white mb-4">
|
||||
<div className="ic-card p-6">
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-md bg-[#10213f] text-white mb-4">
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-xl font-bold text-slate-950 mb-2">
|
||||
Your passwords stay yours
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-slate-600">
|
||||
Credentials are encrypted at rest and never shared. All connections
|
||||
use SSL/TLS and Gmail delivery uses OAuth2 — no app passwords needed.
|
||||
</p>
|
||||
@@ -205,36 +202,36 @@ export default function Home() {
|
||||
|
||||
{/* How It Works */}
|
||||
<div className="mt-20">
|
||||
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||
<h3 className="text-3xl font-extrabold text-center text-slate-950 mb-12">
|
||||
Three steps and you're done
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-[#e7f0ff] text-[#0b63f6] text-2xl font-bold mx-auto mb-4">
|
||||
1
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
|
||||
<p className="text-gray-600">
|
||||
<h4 className="text-xl font-bold text-slate-950 mb-2">Add your old inbox</h4>
|
||||
<p className="text-slate-600">
|
||||
Paste the email address — InboxConverge auto-detects the POP3/IMAP
|
||||
settings in seconds.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-[#e7f0ff] text-[#0b63f6] text-2xl font-bold mx-auto mb-4">
|
||||
2
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
|
||||
<p className="text-gray-600">
|
||||
<h4 className="text-xl font-bold text-slate-950 mb-2">Connect Gmail</h4>
|
||||
<p className="text-slate-600">
|
||||
Sign in with Google once. InboxConverge delivers mail directly into
|
||||
your inbox using the Gmail API — no SMTP relay needed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-[#e7f0ff] text-[#0b63f6] text-2xl font-bold mx-auto mb-4">
|
||||
3
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">Close the tab</h4>
|
||||
<p className="text-gray-600">
|
||||
<h4 className="text-xl font-bold text-slate-950 mb-2">Close the tab</h4>
|
||||
<p className="text-slate-600">
|
||||
Seriously, that's it. Your mail arrives automatically from now on.
|
||||
Check the dashboard whenever you like, but you won't need to.
|
||||
</p>
|
||||
@@ -245,10 +242,10 @@ export default function Home() {
|
||||
{/* Pricing — only rendered when paid plans exist (hidden in enterprise/all-free mode) */}
|
||||
{hasPaidPlans && (
|
||||
<div className="mt-24">
|
||||
<h3 className="text-3xl font-bold text-center text-gray-900 mb-4">
|
||||
<h3 className="text-3xl font-extrabold text-center text-slate-950 mb-4">
|
||||
Pricing
|
||||
</h3>
|
||||
<p className="text-center text-gray-500 mb-12 max-w-xl mx-auto">
|
||||
<p className="text-center text-slate-500 mb-12 max-w-xl mx-auto">
|
||||
Start free. Upgrade if you need more inboxes or faster checks.
|
||||
Cancel any time — no questions, no fuss.
|
||||
</p>
|
||||
@@ -257,7 +254,7 @@ export default function Home() {
|
||||
<PricingCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-8 text-center text-sm text-gray-500">
|
||||
<p className="mt-8 text-center text-sm text-slate-500">
|
||||
All paid plans include a{' '}
|
||||
<span className="font-medium">free tier</span> when you first sign up
|
||||
— no credit card required.
|
||||
@@ -267,11 +264,25 @@ export default function Home() {
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-20 border-t border-gray-200 bg-white">
|
||||
<footer className="mt-20 border-t border-[#d9e3f2] bg-[#fbfdff]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
<p className="text-center text-slate-600 text-sm mb-3">
|
||||
© {new Date().getFullYear()} InboxConverge — made for people, not enterprises.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 text-xs text-slate-400">
|
||||
<Link href="/privacy" className="hover:text-slate-600 transition-colors">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-slate-600 transition-colors">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link href="/impressum" className="hover:text-slate-600 transition-colors">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="hover:text-slate-600 transition-colors">
|
||||
Datenschutz
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Privacy Policy – ${APP_NAME}`,
|
||||
};
|
||||
|
||||
const LAST_UPDATED = 'April 18, 2026';
|
||||
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Privacy Policy</h1>
|
||||
<p className="text-sm text-gray-500 mb-8">Last updated: {LAST_UPDATED}</p>
|
||||
|
||||
{/* TOC */}
|
||||
<nav className="mb-8 p-4 bg-gray-50 rounded-md text-sm text-blue-700 space-y-1">
|
||||
<p className="font-semibold text-gray-700 mb-2">Contents</p>
|
||||
{[
|
||||
'Who We Are',
|
||||
'Scope of This Policy',
|
||||
'What Data We Collect and Why',
|
||||
'Google API Services — Limited Use Disclosure',
|
||||
'Data Minimisation and Purpose Limitation',
|
||||
'Cookies and Similar Technologies',
|
||||
'Third-Party Services',
|
||||
'International Data Transfers',
|
||||
'Data Retention',
|
||||
'Data Security',
|
||||
'Your Rights (EU / EEA / UK / Switzerland)',
|
||||
'Additional Rights — United States (CCPA / CPRA)',
|
||||
'Additional Rights — Canada (PIPEDA / Law 25)',
|
||||
'Additional Rights — Other Jurisdictions',
|
||||
'Changes to This Policy',
|
||||
'Contact Us',
|
||||
].map((title, i) => (
|
||||
<div key={i}>
|
||||
<a href={`#section-${i + 1}`} className="hover:underline">
|
||||
{i + 1}. {title}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 1 */}
|
||||
<section id="section-1" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">1. Who We Are</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
The data controller responsible for processing your personal data in
|
||||
accordance with the EU General Data Protection Regulation (GDPR) and
|
||||
equivalent data-protection laws worldwide is:
|
||||
</p>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Christian Louis IT Beratung<br />
|
||||
Alter Steinweg 3, 20459 Hamburg, Germany
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Contact:{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
For all data-protection enquiries (access, erasure, correction,
|
||||
objection or complaints) please use the email address above. We
|
||||
respond within 30 days (or the period required by applicable law).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 2 */}
|
||||
<section id="section-2" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
2. Scope of This Policy
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
This policy applies to the {APP_NAME} web application and all
|
||||
associated services. It covers all users worldwide, including those
|
||||
in the European Union (EU), European Economic Area (EEA), United
|
||||
Kingdom, Switzerland, and the United States.
|
||||
</p>
|
||||
<p className="text-gray-700 mt-3">
|
||||
A German-language version of this policy is available at{' '}
|
||||
<Link href="/datenschutz" className="text-blue-600 hover:text-blue-500">
|
||||
/datenschutz
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 3 */}
|
||||
<section id="section-3" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
3. What Data We Collect and Why
|
||||
</h2>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-700 mt-4 mb-2">
|
||||
3.1 Account and Authentication Data
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
When you create an account or sign in with Google we may receive
|
||||
your name, email address, and profile picture from Google OAuth 2.0.
|
||||
We use this data solely to authenticate you and identify your
|
||||
account within {APP_NAME}.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Legal basis (GDPR Art. 6): (b) contract performance.
|
||||
</p>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-700 mt-4 mb-2">
|
||||
3.2 Source Mailbox Credentials
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
To fetch email from your legacy POP3 / IMAP accounts you provide
|
||||
server details and credentials. These are stored encrypted at rest
|
||||
using AES-256 and are never transmitted to any third party.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Legal basis (GDPR Art. 6): (b) contract performance.
|
||||
</p>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-700 mt-4 mb-2">
|
||||
3.3 Gmail API OAuth Tokens
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
To inject email into your Gmail account, {APP_NAME} requests the
|
||||
following Google OAuth 2.0 scopes:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-2 mb-3">
|
||||
<li>
|
||||
<code className="text-xs bg-gray-100 px-1 rounded">
|
||||
https://www.googleapis.com/auth/gmail.insert
|
||||
</code>{' '}
|
||||
— inserts messages directly into your Gmail mailbox without
|
||||
sending them through SMTP.
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs bg-gray-100 px-1 rounded">
|
||||
https://www.googleapis.com/auth/gmail.labels
|
||||
</code>{' '}
|
||||
— creates and manages Gmail labels so imported messages can be
|
||||
tagged (e.g. “imported”).
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs bg-gray-100 px-1 rounded">
|
||||
https://www.googleapis.com/auth/gmail.readonly
|
||||
</code>{' '}
|
||||
— reads your Gmail profile (email address) to confirm the
|
||||
connection is working.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mb-2">
|
||||
The resulting access and refresh tokens are stored encrypted at
|
||||
rest. Tokens are refreshed automatically by the service when they
|
||||
expire and the refreshed token is persisted back to the database.
|
||||
You can revoke access at any time from your{' '}
|
||||
<a
|
||||
href="https://myaccount.google.com/permissions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Google Account permissions
|
||||
</a>{' '}
|
||||
page.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Legal basis (GDPR Art. 6): (b) contract performance.
|
||||
</p>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-700 mt-4 mb-2">
|
||||
3.4 Email Content
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
Email bodies and attachments are read from your source accounts and
|
||||
written to your Gmail account. They are processed in memory only;
|
||||
no email content is written to persistent storage other than within
|
||||
your own Gmail account.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Legal basis (GDPR Art. 6): (b) contract performance.
|
||||
</p>
|
||||
|
||||
<h3 className="text-base font-semibold text-gray-700 mt-4 mb-2">
|
||||
3.5 Processing Logs
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
We retain limited operational logs (message subject line, sender
|
||||
address, timestamp, success/failure flag) for up to 90 days. These
|
||||
are used to diagnose delivery problems and are accessible only to
|
||||
you and our operations team.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Legal basis (GDPR Art. 6): (f) legitimate interests.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 4 — the critical Google Limited Use Disclosure */}
|
||||
<section id="section-4" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
4. Google API Services — Limited Use Disclosure
|
||||
</h2>
|
||||
<div className="border-l-4 border-blue-400 bg-blue-50 p-4 rounded-r-md mb-4">
|
||||
<p className="text-gray-800 font-medium mb-1">
|
||||
Limited Use Policy Compliance
|
||||
</p>
|
||||
<p className="text-gray-700 text-sm">
|
||||
{APP_NAME}’s use of information received from Google APIs
|
||||
adheres to the{' '}
|
||||
<a
|
||||
href="https://developers.google.com/terms/api-services-user-data-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Google API Services User Data Policy
|
||||
</a>
|
||||
, including the Limited Use requirements.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Specifically, {APP_NAME} commits to the following with respect to
|
||||
data obtained via Google APIs:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-2">
|
||||
<li>
|
||||
<strong>Single purpose:</strong> Google user data is used only to
|
||||
deliver the core service — importing email from legacy mailboxes
|
||||
into your Gmail account.
|
||||
</li>
|
||||
<li>
|
||||
<strong>No transfer to third parties:</strong> We do not sell,
|
||||
rent, transfer, or disclose Google user data to third parties,
|
||||
except as necessary to provide the service (e.g. the Gmail API
|
||||
call itself) or as required by law.
|
||||
</li>
|
||||
<li>
|
||||
<strong>No advertising:</strong> We do not use Google user data
|
||||
to serve advertisements or for advertising-related purposes.
|
||||
</li>
|
||||
<li>
|
||||
<strong>No human reading:</strong> We do not allow humans to read
|
||||
your Gmail data unless you have given us explicit permission to do
|
||||
so (e.g. for diagnosing a reported technical problem), or it is
|
||||
required by law.
|
||||
</li>
|
||||
<li>
|
||||
<strong>No profiling:</strong> We do not use Google user data to
|
||||
build profiles, perform analytics, or engage in behavioural
|
||||
tracking beyond what is strictly necessary to operate the service.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Security:</strong> All data obtained from Google APIs is
|
||||
stored with AES-256 encryption at rest and transmitted only over
|
||||
TLS-encrypted connections.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 5 */}
|
||||
<section id="section-5" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
5. Data Minimisation and Purpose Limitation
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-1">
|
||||
<li>
|
||||
We collect only the minimum personal data needed to operate the
|
||||
service.
|
||||
</li>
|
||||
<li>
|
||||
Email content is processed solely for the forwarding purpose you
|
||||
initiate.
|
||||
</li>
|
||||
<li>
|
||||
No advertising, behavioural tracking, or profiling is performed.
|
||||
</li>
|
||||
<li>No tracking cookies or analytics scripts are loaded.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 6 */}
|
||||
<section id="section-6" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
6. Cookies and Similar Technologies
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
The service uses only strictly necessary session cookies to
|
||||
maintain your authenticated session. These cookies are essential
|
||||
for the service to function and are exempt from prior-consent
|
||||
requirements under the EU ePrivacy Directive (Art. 5(3)). We do
|
||||
not use analytics cookies, advertising cookies, or tracking pixels.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 7 */}
|
||||
<section id="section-7" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
7. Third-Party Services
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Google OAuth / Gmail API:</strong> Authentication and Gmail
|
||||
delivery are handled via Google’s APIs. Google processes your
|
||||
credentials according to its own{' '}
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
<strong>No sale or sharing for advertising:</strong> We do not
|
||||
sell, rent, or share your personal data with third parties for
|
||||
advertising or marketing purposes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 8 */}
|
||||
<section id="section-8" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
8. International Data Transfers
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Where personal data is transferred outside the EEA we rely on
|
||||
appropriate safeguards, including Standard Contractual Clauses
|
||||
(SCCs) pursuant to EU Decision 2021/914/EU and European Commission
|
||||
adequacy decisions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 9 */}
|
||||
<section id="section-9" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
9. Data Retention
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-1">
|
||||
<li>
|
||||
<strong>Session data:</strong> deleted on logout or session
|
||||
expiry.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Account configuration and metadata:</strong> retained for
|
||||
the duration of your use of the service.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Processing logs:</strong> retained for up to 90 days.
|
||||
</li>
|
||||
<li>
|
||||
<strong>OAuth tokens:</strong> stored encrypted and revocable at
|
||||
any time via your Google Account.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Account deletion:</strong> all data associated with your
|
||||
account is permanently deleted within 30 days of account closure.
|
||||
You can request deletion by emailing{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 10 */}
|
||||
<section id="section-10" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
10. Data Security
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-1">
|
||||
<li>AES-256 encryption of credentials at rest.</li>
|
||||
<li>TLS / HTTPS for all communications.</li>
|
||||
<li>Role-based access controls to protect personal data.</li>
|
||||
<li>CSRF protection for all state-changing requests.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 11 */}
|
||||
<section id="section-11" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
11. Your Rights (EU / EEA / UK / Switzerland)
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-1">
|
||||
<li>
|
||||
<strong>Right of access (Art. 15):</strong> a copy of the data
|
||||
held about you.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to rectification (Art. 16):</strong> correction of
|
||||
inaccurate data.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to erasure (Art. 17):</strong> deletion of your
|
||||
personal data.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to restriction (Art. 18):</strong> temporary
|
||||
suspension of processing.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to portability (Art. 20):</strong> data in a
|
||||
machine-readable format.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to object (Art. 21):</strong> objection to
|
||||
processing based on legitimate interests.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-3">
|
||||
To exercise any of these rights, contact us at{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
. Complaints may be directed to the relevant supervisory authority
|
||||
(in Germany: BfDI).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 12 */}
|
||||
<section id="section-12" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
12. Additional Rights — United States (CCPA / CPRA)
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
If you are a California resident or resident of another US state
|
||||
with applicable privacy legislation, you have the right to know,
|
||||
delete, correct, and opt out of the sale of personal information.
|
||||
We do not sell or share personal information. Contact:{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 13 */}
|
||||
<section id="section-13" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
13. Additional Rights — Canada (PIPEDA / Law 25)
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
If you are in Canada, you have the right to access, correct, and
|
||||
withdraw consent under PIPEDA and provincial privacy laws. Contact:{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 14 */}
|
||||
<section id="section-14" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
14. Additional Rights — Other Jurisdictions
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Users in Brazil (LGPD), Japan (APPI), Australia (Privacy Act 1988),
|
||||
South Korea (PIPA), Singapore (PDPA), and other markets may exercise
|
||||
equivalent data-protection rights under applicable national law.
|
||||
Contact:{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 15 */}
|
||||
<section id="section-15" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
15. Changes to This Policy
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
We may update this policy from time to time. The “Last
|
||||
updated” date at the top of this page indicates when it was
|
||||
last revised. For material changes we will notify users via an
|
||||
in-app notice or email.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 16 */}
|
||||
<section id="section-16" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
16. Contact Us
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
For questions or concerns about this policy or your data, please
|
||||
contact:{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
Christian Louis IT Beratung<br />
|
||||
Alter Steinweg 3, 20459 Hamburg, Germany
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 flex flex-wrap gap-4 text-sm text-gray-500">
|
||||
<Link href="/terms" className="hover:text-blue-600">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="hover:text-blue-600">
|
||||
Datenschutz (DE)
|
||||
</Link>
|
||||
<Link href="/impressum" className="hover:text-blue-600">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/login" className="hover:text-blue-600">
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -56,25 +57,31 @@ export default function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#f7faff] px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<Link href="/">
|
||||
<BrandMark />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ic-panel p-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
<h2 className="text-center text-3xl font-extrabold text-slate-950">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Start forwarding your emails
|
||||
<p className="mt-2 text-center text-sm text-slate-500">
|
||||
Start consolidating external mailboxes into Gmail.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="full_name" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="full_name" className="block text-sm font-bold text-slate-700">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
@@ -84,12 +91,12 @@ export default function RegisterPage() {
|
||||
required
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
className="ic-focus mt-1 relative block w-full rounded-md border border-[#b8c8df] px-3 py-2.5 text-slate-950 placeholder-slate-400 focus:border-[#0b63f6] sm:text-sm"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="email" className="block text-sm font-bold text-slate-700">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
@@ -100,12 +107,12 @@ export default function RegisterPage() {
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
className="ic-focus mt-1 relative block w-full rounded-md border border-[#b8c8df] px-3 py-2.5 text-slate-950 placeholder-slate-400 focus:border-[#0b63f6] sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="password" className="block text-sm font-bold text-slate-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -116,12 +123,12 @@ export default function RegisterPage() {
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
className="ic-focus mt-1 relative block w-full rounded-md border border-[#b8c8df] px-3 py-2.5 text-slate-950 placeholder-slate-400 focus:border-[#0b63f6] sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-bold text-slate-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
@@ -132,7 +139,7 @@ export default function RegisterPage() {
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
className="ic-focus mt-1 relative block w-full rounded-md border border-[#b8c8df] px-3 py-2.5 text-slate-950 placeholder-slate-400 focus:border-[#0b63f6] sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
@@ -142,21 +149,49 @@ export default function RegisterPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
className="ic-button-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Sign up'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-slate-600">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
<Link href="/login" className="font-bold text-[#0b63f6] hover:text-[#0649bf]">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-slate-400">
|
||||
By creating an account you agree to our{' '}
|
||||
<Link href="/terms" className="underline hover:text-slate-600">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/privacy" className="underline hover:text-slate-600">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center gap-4 text-xs text-slate-400">
|
||||
<Link href="/privacy" className="hover:text-slate-600 transition-colors">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/terms" className="hover:text-slate-600 transition-colors">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="/impressum" className="hover:text-slate-600 transition-colors">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="hover:text-slate-600 transition-colors">
|
||||
Datenschutz
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Bug,
|
||||
RotateCcw,
|
||||
Tags,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
|
||||
const DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES = ['{{source_email}}', 'imported'];
|
||||
@@ -147,6 +148,7 @@ function SettingsContent() {
|
||||
host: 'smtp.gmail.com',
|
||||
port: 587,
|
||||
username: '',
|
||||
sender_email: '',
|
||||
password: '',
|
||||
use_tls: true,
|
||||
});
|
||||
@@ -186,6 +188,7 @@ function SettingsContent() {
|
||||
host: smtpConfig.host,
|
||||
port: smtpConfig.port,
|
||||
username: smtpConfig.username,
|
||||
sender_email: smtpConfig.sender_email,
|
||||
use_tls: smtpConfig.use_tls,
|
||||
password: '', // never pre-fill password
|
||||
}));
|
||||
@@ -245,7 +248,20 @@ function SettingsContent() {
|
||||
mutationFn: smtpApi.remove,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['smtp-config'] });
|
||||
setSmtpForm({ host: 'smtp.gmail.com', port: 587, username: '', password: '', use_tls: true });
|
||||
setSmtpForm({ host: 'smtp.gmail.com', port: 587, username: '', sender_email: '', password: '', use_tls: true });
|
||||
},
|
||||
});
|
||||
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const testSmtpMutation = useMutation({
|
||||
mutationFn: smtpApi.test,
|
||||
onSuccess: (result) => {
|
||||
setSmtpTestResult(result);
|
||||
setTimeout(() => setSmtpTestResult(null), 6000);
|
||||
},
|
||||
onError: () => {
|
||||
setSmtpTestResult({ success: false, message: 'Request failed. Check your network connection.' });
|
||||
setTimeout(() => setSmtpTestResult(null), 6000);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -288,6 +304,7 @@ function SettingsContent() {
|
||||
host: smtpForm.host,
|
||||
port: smtpForm.port,
|
||||
username: smtpForm.username,
|
||||
sender_email: smtpForm.sender_email,
|
||||
password: smtpForm.password || undefined,
|
||||
use_tls: smtpForm.use_tls,
|
||||
});
|
||||
@@ -587,6 +604,21 @@ function SettingsContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sender Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="sender_email"
|
||||
value={smtpForm.sender_email}
|
||||
onChange={handleSmtpChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="sender@example.com"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
The From: address used when forwarding mail. Required when your SMTP username is an API token rather than an email address (e.g. Postmark, SendGrid).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
@@ -625,6 +657,21 @@ function SettingsContent() {
|
||||
'Save SMTP Settings'
|
||||
)}
|
||||
</button>
|
||||
{smtpConfig && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testSmtpMutation.mutate()}
|
||||
disabled={testSmtpMutation.isPending}
|
||||
title="Send a test email to your account address using these SMTP settings"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-amber-50 text-amber-700 rounded-md hover:bg-amber-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{testSmtpMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Sending…</>
|
||||
) : (
|
||||
<><Send className="h-4 w-4" />Send Test Email</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{smtpConfig && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -642,6 +689,22 @@ function SettingsContent() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{smtpTestResult !== null && (
|
||||
<div
|
||||
className={`flex items-center gap-2 text-sm rounded-md px-3 py-2 border ${
|
||||
smtpTestResult.success
|
||||
? 'text-green-700 bg-green-50 border-green-200'
|
||||
: 'text-red-700 bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
{smtpTestResult.success ? (
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{smtpTestResult.message}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Terms of Service – ${APP_NAME}`,
|
||||
};
|
||||
|
||||
const LAST_UPDATED = 'April 18, 2026';
|
||||
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow p-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Terms of Service</h1>
|
||||
<p className="text-sm text-gray-500 mb-8">Last updated: {LAST_UPDATED}</p>
|
||||
|
||||
{/* TOC */}
|
||||
<nav className="mb-8 p-4 bg-gray-50 rounded-md text-sm text-blue-700 space-y-1">
|
||||
<p className="font-semibold text-gray-700 mb-2">Contents</p>
|
||||
{[
|
||||
'Acceptance of Terms',
|
||||
'Service Description',
|
||||
'Account Registration',
|
||||
'Acceptable Use',
|
||||
'Google API Services',
|
||||
'Intellectual Property',
|
||||
'Data and Privacy',
|
||||
'Availability and Modifications',
|
||||
'Disclaimers',
|
||||
'Limitation of Liability',
|
||||
'Indemnification',
|
||||
'Termination',
|
||||
'Governing Law and Dispute Resolution',
|
||||
'Changes to These Terms',
|
||||
'Contact',
|
||||
].map((title, i) => (
|
||||
<div key={i}>
|
||||
<a href={`#section-${i + 1}`} className="hover:underline">
|
||||
{i + 1}. {title}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 1 */}
|
||||
<section id="section-1" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
1. Acceptance of Terms
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
By accessing or using {APP_NAME} (“the Service”), you agree
|
||||
to be bound by these Terms of Service (“Terms”). If you do
|
||||
not agree, do not use the Service.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
The Service is provided by Christian Louis IT Beratung, Alter
|
||||
Steinweg 3, 20459 Hamburg, Germany (“we”, “us”,
|
||||
“our”).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 2 */}
|
||||
<section id="section-2" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
2. Service Description
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
{APP_NAME} is an email consolidation service that polls legacy
|
||||
POP3 and IMAP mailboxes on your behalf and delivers the retrieved
|
||||
messages directly into your Gmail inbox via the Gmail API. The
|
||||
Service is designed for personal and small-business use.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
The Service is not affiliated with, endorsed by, or sponsored by
|
||||
Google LLC. Gmail and Google are trademarks of Google LLC.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 3 */}
|
||||
<section id="section-3" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
3. Account Registration
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-2">
|
||||
<li>
|
||||
You must be at least 16 years old (or the minimum age of digital
|
||||
consent in your jurisdiction) to create an account.
|
||||
</li>
|
||||
<li>
|
||||
You are responsible for maintaining the security of your account
|
||||
credentials and for all activity that occurs under your account.
|
||||
</li>
|
||||
<li>
|
||||
You must provide accurate, current, and complete information
|
||||
during registration and keep it up to date.
|
||||
</li>
|
||||
<li>
|
||||
You may not share your account with others or create accounts for
|
||||
the purpose of circumventing usage limits.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 4 */}
|
||||
<section id="section-4" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
4. Acceptable Use
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">You agree not to use the Service to:</p>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-2">
|
||||
<li>
|
||||
Violate any applicable law, regulation, or third-party rights.
|
||||
</li>
|
||||
<li>
|
||||
Transmit unsolicited commercial messages (spam) or malicious
|
||||
content.
|
||||
</li>
|
||||
<li>
|
||||
Attempt to gain unauthorised access to any systems or networks.
|
||||
</li>
|
||||
<li>
|
||||
Interfere with or disrupt the integrity or performance of the
|
||||
Service.
|
||||
</li>
|
||||
<li>
|
||||
Circumvent or reverse-engineer any technical protection measures.
|
||||
</li>
|
||||
<li>
|
||||
Use the Service in a way that could impose an unreasonable load on
|
||||
our infrastructure.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-3">
|
||||
We reserve the right to suspend or terminate accounts that violate
|
||||
this policy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 5 */}
|
||||
<section id="section-5" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
5. Google API Services
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
The Service uses the Google Gmail API to insert messages into your
|
||||
Gmail account. By connecting your Google account you also agree to
|
||||
Google’s{' '}
|
||||
<a
|
||||
href="https://policies.google.com/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-3">
|
||||
Our use of data obtained via Google APIs complies with the{' '}
|
||||
<a
|
||||
href="https://developers.google.com/terms/api-services-user-data-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Google API Services User Data Policy
|
||||
</a>
|
||||
, including the Limited Use requirements. In particular, we use
|
||||
Google user data only to provide the email-import service described
|
||||
in these Terms and never for advertising or unrelated purposes.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
You can revoke {APP_NAME}’s access to your Google account at
|
||||
any time from your{' '}
|
||||
<a
|
||||
href="https://myaccount.google.com/permissions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Google Account permissions
|
||||
</a>{' '}
|
||||
page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 6 */}
|
||||
<section id="section-6" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
6. Intellectual Property
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
The {APP_NAME} software is open-source and licensed under the
|
||||
terms of the project’s{' '}
|
||||
<a
|
||||
href="https://github.com/christianlouis/InboxConverge/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
LICENSE
|
||||
</a>{' '}
|
||||
file. Nothing in these Terms grants you a licence to use the
|
||||
{' '}{APP_NAME} name or logo for purposes outside of the permitted
|
||||
open-source licence.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
You retain all rights to your own email content. We claim no
|
||||
ownership over any messages processed by the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 7 */}
|
||||
<section id="section-7" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
7. Data and Privacy
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
Our collection and use of personal data is described in our{' '}
|
||||
<Link href="/privacy" className="text-blue-600 hover:text-blue-500">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
, which forms part of these Terms. By using the Service you
|
||||
acknowledge and agree to the Privacy Policy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 8 */}
|
||||
<section id="section-8" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
8. Availability and Modifications
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
We aim to provide a reliable service but do not guarantee
|
||||
uninterrupted availability. We may modify, suspend, or discontinue
|
||||
the Service (or any part of it) at any time with reasonable notice
|
||||
where practicable.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
We may update these Terms from time to time. Material changes will
|
||||
be communicated via in-app notice or email. Continued use of the
|
||||
Service after changes take effect constitutes acceptance of the
|
||||
revised Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 9 */}
|
||||
<section id="section-9" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
9. Disclaimers
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
The Service is provided “as is” and “as
|
||||
available” without warranties of any kind, express or implied,
|
||||
including but not limited to warranties of merchantability, fitness
|
||||
for a particular purpose, or non-infringement.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
We do not warrant that the Service will be error-free, that defects
|
||||
will be corrected, or that the Service or its infrastructure are
|
||||
free of viruses or other harmful components.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 10 */}
|
||||
<section id="section-10" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
10. Limitation of Liability
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
To the maximum extent permitted by applicable law, Christian Louis
|
||||
IT Beratung and its representatives shall not be liable for any
|
||||
indirect, incidental, special, consequential, or punitive damages,
|
||||
or any loss of profits, data, goodwill, or business opportunities
|
||||
arising out of or in connection with your use of the Service.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Our aggregate liability for any claim relating to the Service is
|
||||
limited to the greater of (a) the amount you paid us in the 12
|
||||
months preceding the claim, or (b) €50.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 11 */}
|
||||
<section id="section-11" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
11. Indemnification
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
You agree to indemnify and hold harmless Christian Louis IT Beratung
|
||||
and its representatives from and against any claims, damages,
|
||||
losses, and expenses (including reasonable legal fees) arising out
|
||||
of your use of the Service, your violation of these Terms, or your
|
||||
violation of any third-party rights.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 12 */}
|
||||
<section id="section-12" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
12. Termination
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
You may close your account at any time by contacting us at{' '}
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
. Upon closure, all your data will be permanently deleted within
|
||||
30 days.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
We may suspend or terminate your account immediately if you breach
|
||||
these Terms or if we are required to do so by law.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 13 */}
|
||||
<section id="section-13" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
13. Governing Law and Dispute Resolution
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">
|
||||
These Terms are governed by the laws of the Federal Republic of
|
||||
Germany, excluding its conflict-of-law provisions. The courts of
|
||||
Hamburg, Germany shall have exclusive jurisdiction over any dispute
|
||||
arising out of or in connection with these Terms, except where
|
||||
mandatory consumer-protection laws in your jurisdiction provide
|
||||
otherwise.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
EU consumers may also use the European Commission’s Online
|
||||
Dispute Resolution platform:{' '}
|
||||
<a
|
||||
href="https://ec.europa.eu/consumers/odr"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
https://ec.europa.eu/consumers/odr
|
||||
</a>
|
||||
. We are not obliged to participate in alternative dispute
|
||||
resolution procedures but will consider reasonable requests.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 14 */}
|
||||
<section id="section-14" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
14. Changes to These Terms
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
We reserve the right to modify these Terms at any time. We will
|
||||
provide at least 14 days’ notice of material changes via
|
||||
in-app notification or email. Your continued use of the Service
|
||||
after the effective date of revised Terms constitutes your
|
||||
acceptance of those Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 15 */}
|
||||
<section id="section-15" className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-3">
|
||||
15. Contact
|
||||
</h2>
|
||||
<p className="text-gray-700">
|
||||
For questions about these Terms, please contact:
|
||||
</p>
|
||||
<p className="text-gray-700 mt-2">
|
||||
Christian Louis IT Beratung<br />
|
||||
Alter Steinweg 3, 20459 Hamburg, Germany<br />
|
||||
<a
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 flex flex-wrap gap-4 text-sm text-gray-500">
|
||||
<Link href="/privacy" className="hover:text-blue-600">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="hover:text-blue-600">
|
||||
Datenschutz (DE)
|
||||
</Link>
|
||||
<Link href="/impressum" className="hover:text-blue-600">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/login" className="hover:text-blue-600">
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { AddMailAccountModal } from './AddMailAccountModal';
|
||||
import { MailAccount } from '@/lib/api';
|
||||
|
||||
// ── Mock state ──────────────────────────────────────────────────────────────
|
||||
const mockMutateAsyncCreate = jest.fn().mockResolvedValue({});
|
||||
const mockMutateAsyncUpdate = jest.fn().mockResolvedValue({});
|
||||
const mockInvalidateQueries = jest.fn();
|
||||
|
||||
let createIsPending = false;
|
||||
let updateIsPending = false;
|
||||
let mutationCallCount = 0;
|
||||
|
||||
// ── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useMutation: jest.fn().mockImplementation(() => {
|
||||
mutationCallCount++;
|
||||
if (mutationCallCount % 2 === 1) {
|
||||
return { mutateAsync: mockMutateAsyncCreate, isPending: createIsPending };
|
||||
}
|
||||
return { mutateAsync: mockMutateAsyncUpdate, isPending: updateIsPending };
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/api', () => ({
|
||||
mailAccountsApi: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
test: jest.fn(),
|
||||
testExisting: jest.fn(),
|
||||
autoDetect: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
user: { email: 'currentuser@example.com' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
X: ({ className }: { className?: string }) => (
|
||||
<span data-testid="icon-X" className={className} />
|
||||
),
|
||||
Loader2: ({ className }: { className?: string }) => (
|
||||
<span data-testid="icon-Loader2" className={className} />
|
||||
),
|
||||
CheckCircle: ({ className }: { className?: string }) => (
|
||||
<span data-testid="icon-CheckCircle" className={className} />
|
||||
),
|
||||
XCircle: ({ className }: { className?: string }) => (
|
||||
<span data-testid="icon-XCircle" className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('./ProviderWizard', () => ({
|
||||
ProviderWizard: ({
|
||||
onSelect,
|
||||
onManual,
|
||||
}: {
|
||||
onSelect: (config: unknown) => void;
|
||||
onManual: () => void;
|
||||
}) => (
|
||||
<div data-testid="provider-wizard">
|
||||
<button
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
name: 'Gmail',
|
||||
provider_name: 'Gmail',
|
||||
protocol: 'imap_ssl',
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
use_ssl: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Select Gmail
|
||||
</button>
|
||||
<button onClick={onManual}>Manual Setup</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockAccount: MailAccount = {
|
||||
id: 42,
|
||||
user_id: 1,
|
||||
name: 'My Work Email',
|
||||
email_address: 'work@example.com',
|
||||
protocol: 'imap_ssl',
|
||||
host: 'imap.example.com',
|
||||
port: 993,
|
||||
use_ssl: true,
|
||||
use_tls: false,
|
||||
username: 'work@example.com',
|
||||
forward_to: 'me@gmail.com',
|
||||
delivery_method: 'gmail_api',
|
||||
is_enabled: true,
|
||||
check_interval_minutes: 10,
|
||||
max_emails_per_check: 25,
|
||||
delete_after_forward: false,
|
||||
status: 'active',
|
||||
provider_name: 'Gmail',
|
||||
auto_detected: false,
|
||||
total_emails_processed: 100,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-02T00:00:00Z',
|
||||
last_checked_at: '2024-01-02T12:00:00Z',
|
||||
last_error: null,
|
||||
};
|
||||
|
||||
const { mailAccountsApi } = jest.requireMock('@/lib/api') as {
|
||||
mailAccountsApi: {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
test: jest.Mock;
|
||||
testExisting: jest.Mock;
|
||||
autoDetect: jest.Mock;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Setup / Teardown ────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mutationCallCount = 0;
|
||||
createIsPending = false;
|
||||
updateIsPending = false;
|
||||
});
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AddMailAccountModal', () => {
|
||||
// 1. Create mode renders ProviderWizard
|
||||
it('renders ProviderWizard in create mode', () => {
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
expect(screen.getByTestId('provider-wizard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 2. Edit mode shows form with pre-filled data
|
||||
it('renders form with pre-filled data in edit mode', () => {
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('My Work Email')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('work@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('imap.example.com')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('993')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 3. Shows correct title for create mode
|
||||
it('shows "Add Mail Account" title in create mode', () => {
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
expect(screen.getByText('Add Mail Account')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 4. Shows correct title for edit mode
|
||||
it('shows "Edit Mail Account" title in edit mode', () => {
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
expect(screen.getByText('Edit Mail Account')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 5. Provider selection fills form and advances to form step
|
||||
it('fills form and advances to form step on provider selection', () => {
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Select Gmail'));
|
||||
// Wizard should be gone, form should be visible
|
||||
expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Gmail')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('imap.gmail.com')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('993')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 6. Back to provider selection works in create mode
|
||||
it('goes back to provider selection in create mode', () => {
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
// Advance to form via manual
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument();
|
||||
// Click back
|
||||
fireEvent.click(screen.getByText('← Back to provider selection'));
|
||||
expect(screen.getByTestId('provider-wizard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 7. No back button in edit mode
|
||||
it('does not show back button in edit mode', () => {
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
expect(screen.queryByText('← Back to provider selection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 8. Form field changes update state
|
||||
it('updates form state when fields change', () => {
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
const nameInput = screen.getByDisplayValue('My Work Email');
|
||||
fireEvent.change(nameInput, { target: { name: 'name', value: 'New Name', type: 'text' } });
|
||||
expect(screen.getByDisplayValue('New Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 9. Username/email sync
|
||||
it('syncs email_address with username when username changes', () => {
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
const usernameInput = screen.getByPlaceholderText('user@example.com');
|
||||
fireEvent.change(usernameInput, {
|
||||
target: { name: 'username', value: 'new@example.com', type: 'text' },
|
||||
});
|
||||
// The forward_to field is a different input; email_address is internal state
|
||||
// We can verify by checking the username input value
|
||||
expect(usernameInput).toHaveValue('new@example.com');
|
||||
});
|
||||
|
||||
// 10. Checkbox changes work
|
||||
it('toggles checkbox values', () => {
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
const sslCheckbox = screen.getByLabelText('Use SSL/TLS');
|
||||
expect(sslCheckbox).toBeChecked();
|
||||
fireEvent.click(sslCheckbox);
|
||||
expect(sslCheckbox).not.toBeChecked();
|
||||
|
||||
const deleteCheckbox = screen.getByLabelText('Delete after forwarding');
|
||||
expect(deleteCheckbox).not.toBeChecked();
|
||||
fireEvent.click(deleteCheckbox);
|
||||
expect(deleteCheckbox).toBeChecked();
|
||||
|
||||
const enabledCheckbox = screen.getByLabelText('Enabled');
|
||||
expect(enabledCheckbox).toBeChecked();
|
||||
fireEvent.click(enabledCheckbox);
|
||||
expect(enabledCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
// 11. Auto-detect success updates form fields
|
||||
it('updates form on successful auto-detect', async () => {
|
||||
mailAccountsApi.autoDetect.mockResolvedValue({
|
||||
success: true,
|
||||
suggestions: [{ protocol: 'pop3_ssl', host: 'pop.detected.com', port: 995, use_ssl: true }],
|
||||
});
|
||||
jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
const usernameInput = screen.getByPlaceholderText('user@example.com');
|
||||
fireEvent.change(usernameInput, {
|
||||
target: { name: 'username', value: 'test@detect.com', type: 'text' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Auto-Detect'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('pop.detected.com')).toBeInTheDocument();
|
||||
});
|
||||
expect(window.alert).toHaveBeenCalledWith('Settings auto-detected successfully!');
|
||||
});
|
||||
|
||||
// 12. Auto-detect failure shows alert
|
||||
it('shows alert on auto-detect failure', async () => {
|
||||
mailAccountsApi.autoDetect.mockRejectedValue(new Error('fail'));
|
||||
jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
const usernameInput = screen.getByPlaceholderText('user@example.com');
|
||||
fireEvent.change(usernameInput, {
|
||||
target: { name: 'username', value: 'test@fail.com', type: 'text' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Auto-Detect'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledWith(
|
||||
'Failed to auto-detect settings. Please enter manually.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 13. Auto-detect requires username
|
||||
it('alerts when auto-detect is attempted without username', () => {
|
||||
jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
fireEvent.click(screen.getByText('Auto-Detect'));
|
||||
expect(window.alert).toHaveBeenCalledWith('Please enter an email address first');
|
||||
});
|
||||
|
||||
// 14. Test connection success
|
||||
it('shows success message on successful test connection', async () => {
|
||||
mailAccountsApi.test.mockResolvedValue({ success: true, message: 'Connected!' });
|
||||
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
|
||||
// Fill required fields
|
||||
const usernameInput = screen.getByPlaceholderText('user@example.com');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
const hostInput = screen.getByPlaceholderText('pop.gmail.com');
|
||||
|
||||
fireEvent.change(usernameInput, {
|
||||
target: { name: 'username', value: 'u@test.com', type: 'text' },
|
||||
});
|
||||
fireEvent.change(passwordInput, {
|
||||
target: { name: 'password', value: 'pass123', type: 'password' },
|
||||
});
|
||||
fireEvent.change(hostInput, {
|
||||
target: { name: 'host', value: 'mail.test.com', type: 'text' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Test Connection'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected!')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('icon-CheckCircle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 15. Test connection error
|
||||
it('shows error message on failed test connection', async () => {
|
||||
mailAccountsApi.test.mockResolvedValue({ success: false, message: 'Auth failed' });
|
||||
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
|
||||
const usernameInput = screen.getByPlaceholderText('user@example.com');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
const hostInput = screen.getByPlaceholderText('pop.gmail.com');
|
||||
|
||||
fireEvent.change(usernameInput, {
|
||||
target: { name: 'username', value: 'u@test.com', type: 'text' },
|
||||
});
|
||||
fireEvent.change(passwordInput, {
|
||||
target: { name: 'password', value: 'wrong', type: 'password' },
|
||||
});
|
||||
fireEvent.change(hostInput, {
|
||||
target: { name: 'host', value: 'mail.test.com', type: 'text' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Test Connection'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Auth failed')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('icon-XCircle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 16. Test connection missing fields
|
||||
it('shows validation error when test fields are missing', async () => {
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
fireEvent.click(screen.getByText('Test Connection'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Please fill in username, password, and host')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// 17. Test connection in edit mode uses testExisting when no password
|
||||
it('uses testExisting in edit mode when no password is entered', async () => {
|
||||
mailAccountsApi.testExisting.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Existing OK',
|
||||
});
|
||||
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Test Connection'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mailAccountsApi.testExisting).toHaveBeenCalledWith(42);
|
||||
});
|
||||
expect(screen.getByText('Existing OK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 18. Submit in create mode calls createMutation
|
||||
it('calls create mutation on submit in create mode', async () => {
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
|
||||
// Fill required form fields
|
||||
const nameInput = screen.getByPlaceholderText('My Email Account');
|
||||
const usernameInput = screen.getByPlaceholderText('user@example.com');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
const hostInput = screen.getByPlaceholderText('pop.gmail.com');
|
||||
const forwardInput = screen.getByPlaceholderText('you@gmail.com');
|
||||
|
||||
fireEvent.change(nameInput, {
|
||||
target: { name: 'name', value: 'Test Account', type: 'text' },
|
||||
});
|
||||
fireEvent.change(usernameInput, {
|
||||
target: { name: 'username', value: 'user@test.com', type: 'text' },
|
||||
});
|
||||
fireEvent.change(passwordInput, {
|
||||
target: { name: 'password', value: 'secret', type: 'password' },
|
||||
});
|
||||
fireEvent.change(hostInput, {
|
||||
target: { name: 'host', value: 'pop.test.com', type: 'text' },
|
||||
});
|
||||
fireEvent.change(forwardInput, {
|
||||
target: { name: 'forward_to', value: 'fwd@gmail.com', type: 'email' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsyncCreate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// 19. Submit in edit mode calls updateMutation (omits password when blank)
|
||||
it('calls update mutation on submit in edit mode, omitting blank password', async () => {
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsyncUpdate).toHaveBeenCalled();
|
||||
});
|
||||
const updateArg = mockMutateAsyncUpdate.mock.calls[0][0];
|
||||
expect(updateArg.password).toBeUndefined();
|
||||
expect(updateArg.name).toBe('My Work Email');
|
||||
});
|
||||
|
||||
// 20. Close button calls onClose
|
||||
it('calls onClose when close (X) button is clicked', () => {
|
||||
const onClose = jest.fn();
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={onClose} />);
|
||||
// The X icon button is the one containing the X icon
|
||||
const closeButton = screen.getByTestId('icon-X').closest('button')!;
|
||||
fireEvent.click(closeButton);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 21. Backdrop click calls onClose
|
||||
it('calls onClose when backdrop is clicked', () => {
|
||||
const onClose = jest.fn();
|
||||
const { container } = render(
|
||||
<AddMailAccountModal account={mockAccount} onClose={onClose} />
|
||||
);
|
||||
// Backdrop is the div with bg-gray-500/75
|
||||
const backdrop = container.querySelector('.bg-gray-500\\/75');
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.click(backdrop!);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 22. Cancel button calls onClose
|
||||
it('calls onClose when Cancel button is clicked', () => {
|
||||
const onClose = jest.fn();
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 23. Save button shows "Saving..." when mutation is pending
|
||||
it('shows "Saving..." when create mutation is pending', () => {
|
||||
createIsPending = true;
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Saving..." when update mutation is pending', () => {
|
||||
updateIsPending = true;
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 24. Error message extraction - string detail
|
||||
it('shows string error detail on submit failure', async () => {
|
||||
jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
mockMutateAsyncUpdate.mockRejectedValueOnce({
|
||||
response: { data: { detail: 'Account already exists' } },
|
||||
});
|
||||
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledWith('Account already exists');
|
||||
});
|
||||
});
|
||||
|
||||
// 25. Error message extraction - array detail (validation errors)
|
||||
it('shows joined validation errors on submit failure', async () => {
|
||||
jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
mockMutateAsyncUpdate.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: {
|
||||
detail: [{ msg: 'field required' }, { msg: 'invalid email' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledWith('field required; invalid email');
|
||||
});
|
||||
});
|
||||
|
||||
// 26. Error message extraction - Error instance
|
||||
it('shows Error.message on submit failure with Error instance', async () => {
|
||||
jest.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
mockMutateAsyncCreate.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledWith('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
// Test connection error extraction
|
||||
it('shows extracted error message on test connection exception', async () => {
|
||||
mailAccountsApi.test.mockRejectedValue({
|
||||
response: { data: { detail: 'Timeout' } },
|
||||
});
|
||||
|
||||
render(<AddMailAccountModal onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText('Manual Setup'));
|
||||
|
||||
const usernameInput = screen.getByPlaceholderText('user@example.com');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
const hostInput = screen.getByPlaceholderText('pop.gmail.com');
|
||||
|
||||
fireEvent.change(usernameInput, {
|
||||
target: { name: 'username', value: 'u@t.com', type: 'text' },
|
||||
});
|
||||
fireEvent.change(passwordInput, {
|
||||
target: { name: 'password', value: 'p', type: 'password' },
|
||||
});
|
||||
fireEvent.change(hostInput, {
|
||||
target: { name: 'host', value: 'h.com', type: 'text' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Test Connection'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Timeout')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
check_interval_minutes: account?.check_interval_minutes || 5,
|
||||
max_emails_per_check: account?.max_emails_per_check || 50,
|
||||
delete_after_forward: account?.delete_after_forward ?? true,
|
||||
debug_logging: account?.debug_logging ?? false,
|
||||
provider_name: account?.provider_name ?? null,
|
||||
});
|
||||
|
||||
@@ -188,6 +189,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
check_interval_minutes: formData.check_interval_minutes,
|
||||
max_emails_per_check: formData.max_emails_per_check,
|
||||
delete_after_forward: formData.delete_after_forward,
|
||||
debug_logging: formData.debug_logging,
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
@@ -383,6 +385,20 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="debug_logging"
|
||||
id="debug_logging"
|
||||
checked={formData.debug_logging ?? false}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="debug_logging" className="ml-2 block text-sm text-gray-700">
|
||||
Debug logging
|
||||
<span className="ml-1 text-xs text-gray-400">(auto-disables after 5 runs)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Mail } from 'lucide-react';
|
||||
|
||||
interface BrandMarkProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function BrandMark({ compact = false }: BrandMarkProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5 text-slate-950">
|
||||
<span className="inline-grid h-8 w-8 place-items-center rounded-md border-2 border-blue-600 bg-white text-blue-600">
|
||||
<Mail className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
{!compact && <span className="text-xl font-extrabold tracking-normal">InboxConverge</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { versionApi } from '@/lib/api';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
@@ -63,35 +64,35 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const allNavItems = [...navigation, ...adminNavigation];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-[#f7faff] text-slate-900">
|
||||
{/* Sidebar for desktop */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
|
||||
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-gray-900">InboxConverge</h1>
|
||||
<div className="flex flex-col flex-grow border-r border-[#d9e3f2] bg-white/95 shadow-[14px_0_42px_rgba(16,33,63,0.04)]">
|
||||
<div className="flex h-18 flex-shrink-0 items-center px-5 border-b border-[#d9e3f2]">
|
||||
<BrandMark />
|
||||
</div>
|
||||
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||
<nav className="flex-1 px-3 py-5 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
className={`flex items-center px-3.5 py-2.5 text-sm font-semibold rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
? 'bg-[#e7f0ff] text-[#0649bf]'
|
||||
: 'text-slate-600 hover:bg-[#f7faff] hover:text-slate-950'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-[#0b63f6]' : 'text-slate-400'}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{adminNavigation.length > 0 && (
|
||||
<>
|
||||
<div className="pt-4 pb-1 px-4">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Admin</p>
|
||||
<div className="pt-5 pb-1 px-3.5">
|
||||
<p className="text-xs font-bold uppercase text-slate-400">Admin</p>
|
||||
</div>
|
||||
{adminNavigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
@@ -99,13 +100,13 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
className={`flex items-center px-3.5 py-2.5 text-sm font-semibold rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-600'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
? 'bg-[#e8f7ef] text-[#11834d]'
|
||||
: 'text-slate-600 hover:bg-[#f7faff] hover:text-slate-950'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-purple-600' : 'text-gray-400'}`} />
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-[#11834d]' : 'text-slate-400'}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
@@ -113,12 +114,12 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex-shrink-0 border-t border-gray-200 p-4">
|
||||
<div className="flex-shrink-0 border-t border-[#d9e3f2] p-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
className="flex items-center w-full px-3.5 py-2.5 text-sm font-semibold text-slate-600 rounded-lg hover:bg-[#f7faff] hover:text-slate-950 transition-colors"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
|
||||
<LogOut className="mr-3 h-5 w-5 text-slate-400" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
@@ -128,15 +129,15 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{/* Mobile sidebar */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<div className="fixed inset-0 bg-gray-600/75" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white">
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-gray-900">InboxConverge</h1>
|
||||
<button onClick={() => setSidebarOpen(false)} className="text-gray-500 hover:text-gray-700">
|
||||
<div className="fixed inset-0 bg-slate-900/50" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="fixed inset-y-0 left-0 flex w-72 flex-col bg-white">
|
||||
<div className="flex items-center justify-between h-18 px-5 border-b border-[#d9e3f2]">
|
||||
<BrandMark />
|
||||
<button onClick={() => setSidebarOpen(false)} className="text-slate-500 hover:text-slate-700">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||
<nav className="flex-1 px-3 py-5 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
@@ -144,21 +145,21 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
className={`flex items-center px-3.5 py-2.5 text-sm font-semibold rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
? 'bg-[#e7f0ff] text-[#0649bf]'
|
||||
: 'text-slate-600 hover:bg-[#f7faff] hover:text-slate-950'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-[#0b63f6]' : 'text-slate-400'}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{adminNavigation.length > 0 && (
|
||||
<>
|
||||
<div className="pt-4 pb-1 px-4">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Admin</p>
|
||||
<div className="pt-5 pb-1 px-3.5">
|
||||
<p className="text-xs font-bold uppercase text-slate-400">Admin</p>
|
||||
</div>
|
||||
{adminNavigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
@@ -167,13 +168,13 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
className={`flex items-center px-3.5 py-2.5 text-sm font-semibold rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-600'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
? 'bg-[#e8f7ef] text-[#11834d]'
|
||||
: 'text-slate-600 hover:bg-[#f7faff] hover:text-slate-950'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-purple-600' : 'text-gray-400'}`} />
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-[#11834d]' : 'text-slate-400'}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
@@ -181,12 +182,12 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex-shrink-0 border-t border-gray-200 p-4">
|
||||
<div className="flex-shrink-0 border-t border-[#d9e3f2] p-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center w-full px-4 py-2 text-sm font-medium text-gray-700 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
className="flex items-center w-full px-3.5 py-2.5 text-sm font-semibold text-slate-600 rounded-lg hover:bg-[#f7faff] hover:text-slate-950 transition-colors"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5 text-gray-400" />
|
||||
<LogOut className="mr-3 h-5 w-5 text-slate-400" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
@@ -197,28 +198,28 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64 flex flex-col flex-1">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-10 flex h-16 flex-shrink-0 bg-white border-b border-gray-200">
|
||||
<div className="sticky top-0 z-10 flex h-16 flex-shrink-0 border-b border-[#d9e3f2] bg-white/90 backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 lg:hidden"
|
||||
className="border-r border-[#d9e3f2] px-4 text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex flex-1 justify-between px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-1 items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
<h2 className="text-lg font-bold text-slate-950">
|
||||
{allNavItems.find((item) => item.href === pathname)?.name || 'Dashboard'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-white ${user?.is_superuser ? 'bg-purple-600' : 'bg-blue-600'}`}>
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-md text-white ${user?.is_superuser ? 'bg-[#11834d]' : 'bg-[#0b63f6]'}`}>
|
||||
{user?.is_superuser ? <Shield className="h-4 w-4" /> : <User className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-medium text-gray-900">{user?.full_name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-sm font-semibold text-slate-950">{user?.full_name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{user?.email}
|
||||
{user?.is_superuser && (
|
||||
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
@@ -234,7 +235,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1">
|
||||
<div className="py-6">
|
||||
<div className="py-7">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
@@ -242,13 +243,13 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-gray-200 bg-white py-4 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl flex flex-wrap items-center justify-between gap-2 text-xs text-gray-400">
|
||||
<footer className="border-t border-[#d9e3f2] bg-white/80 py-4 px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl flex flex-wrap items-center justify-between gap-2 text-xs text-slate-400">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link href="/impressum" className="hover:text-gray-600 transition-colors">
|
||||
<Link href="/impressum" className="hover:text-slate-600 transition-colors">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="hover:text-gray-600 transition-colors">
|
||||
<Link href="/datenschutz" className="hover:text-slate-600 transition-colors">
|
||||
Datenschutz
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -79,10 +79,12 @@ describe('NotificationWizard', () => {
|
||||
|
||||
it('should show Email fields', () => {
|
||||
goToStep2('Email');
|
||||
expect(screen.getByText('Username / Email')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMTP Username')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMTP Password')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMTP Host')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMTP Port')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sender Email (From)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recipient Email (To)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Webhook fields', () => {
|
||||
@@ -294,6 +296,72 @@ describe('NotificationWizard', () => {
|
||||
});
|
||||
expect(url).toBe('tgram://mybot/mychat/');
|
||||
});
|
||||
|
||||
it('should build correct Email URL with recipient only', () => {
|
||||
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
|
||||
fireEvent.click(screen.getByText('Email'));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('user@example.com or API key'), {
|
||||
target: { value: 'apikey' },
|
||||
});
|
||||
fireEvent.change(document.querySelector('input[type="password"]')!, {
|
||||
target: { value: 'secret' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('smtp.example.com'), {
|
||||
target: { value: 'smtp.postmarkapp.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('587'), {
|
||||
target: { value: '587' },
|
||||
});
|
||||
// sender left blank (optional)
|
||||
fireEvent.change(screen.getByPlaceholderText('you@example.com'), {
|
||||
target: { value: 'me@example.com' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Next').closest('button')!);
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. My Telegram Alert'), {
|
||||
target: { value: 'Postmark' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save Channel').closest('button')!);
|
||||
|
||||
const url = mockOnComplete.mock.calls[0][0].apprise_url as string;
|
||||
expect(url).toContain('mailto://apikey:secret@smtp.postmarkapp.com:587/');
|
||||
expect(url).toContain('to=me%40example.com');
|
||||
expect(url).not.toContain('from=');
|
||||
});
|
||||
|
||||
it('should build correct Email URL with both sender and recipient', () => {
|
||||
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
|
||||
fireEvent.click(screen.getByText('Email'));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('user@example.com or API key'), {
|
||||
target: { value: 'apikey' },
|
||||
});
|
||||
fireEvent.change(document.querySelector('input[type="password"]')!, {
|
||||
target: { value: 'secret' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('smtp.example.com'), {
|
||||
target: { value: 'smtp.postmarkapp.com' },
|
||||
});
|
||||
// port left blank (optional)
|
||||
fireEvent.change(screen.getByPlaceholderText('noreply@example.com'), {
|
||||
target: { value: 'noreply@example.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('you@example.com'), {
|
||||
target: { value: 'me@example.com' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Next').closest('button')!);
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. My Telegram Alert'), {
|
||||
target: { value: 'Postmark' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save Channel').closest('button')!);
|
||||
|
||||
const url = mockOnComplete.mock.calls[0][0].apprise_url as string;
|
||||
expect(url).toContain('mailto://apikey:secret@smtp.postmarkapp.com/');
|
||||
expect(url).toContain('to=me%40example.com');
|
||||
expect(url).toContain('from=noreply%40example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit mode (initialData) ────────────────────────────────────────────
|
||||
|
||||
@@ -71,10 +71,28 @@ const CHANNEL_FIELDS: Record<string, ChannelField[]> = {
|
||||
},
|
||||
],
|
||||
email: [
|
||||
{ key: 'username', label: 'Username / Email', placeholder: 'user@example.com' },
|
||||
{
|
||||
key: 'username',
|
||||
label: 'SMTP Username',
|
||||
placeholder: 'user@example.com or API key',
|
||||
hint: 'SMTP authentication username — can be an API key (e.g. Postmark)',
|
||||
},
|
||||
{ key: 'password', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
|
||||
{ key: 'host', label: 'SMTP Host', placeholder: 'smtp.example.com' },
|
||||
{ key: 'port', label: 'SMTP Port', placeholder: '587', optional: true },
|
||||
{
|
||||
key: 'sender',
|
||||
label: 'Sender Email (From)',
|
||||
placeholder: 'noreply@example.com',
|
||||
hint: 'Email address shown as the sender. Defaults to SMTP Username if blank.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
key: 'recipient',
|
||||
label: 'Recipient Email (To)',
|
||||
placeholder: 'you@example.com',
|
||||
hint: 'Email address where notifications are delivered.',
|
||||
},
|
||||
],
|
||||
webhook: [
|
||||
{
|
||||
@@ -113,11 +131,15 @@ function buildAppriseUrl(channel: string, fields: Record<string, string>): strin
|
||||
if (match) return `slack://${match[1]}/${match[2]}/${match[3]}/`;
|
||||
return '';
|
||||
}
|
||||
case 'email':
|
||||
if (!fields.username || !fields.password || !fields.host) return '';
|
||||
case 'email': {
|
||||
if (!fields.username || !fields.password || !fields.host || !fields.recipient) return '';
|
||||
const params = new URLSearchParams();
|
||||
params.set('to', fields.recipient);
|
||||
if (fields.sender) params.set('from', fields.sender);
|
||||
return `mailto://${encodeURIComponent(fields.username)}:${encodeURIComponent(fields.password)}@${fields.host}${
|
||||
fields.port ? `:${fields.port}` : ''
|
||||
}`;
|
||||
}/?${params.toString()}`;
|
||||
}
|
||||
case 'webhook':
|
||||
return fields.url || '';
|
||||
case 'custom':
|
||||
|
||||
@@ -126,3 +126,736 @@ describe('Response interceptor', () => {
|
||||
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
// ── API Object Tests ────────────────────────────────────────────────────
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const {
|
||||
authApi,
|
||||
userApi,
|
||||
mailAccountsApi,
|
||||
processingRunsApi,
|
||||
gmailApi,
|
||||
smtpApi,
|
||||
adminApi,
|
||||
notificationsApi,
|
||||
adminNotificationsApi,
|
||||
versionApi,
|
||||
} = require('./api');
|
||||
|
||||
// ── authApi ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('authApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('login should post form data to /auth/login', async () => {
|
||||
const tokenData = { access_token: 'tok', refresh_token: 'ref', token_type: 'bearer' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: tokenData });
|
||||
|
||||
const result = await authApi.login({ username: 'u@test.com', password: 'pw' });
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/auth/login',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
);
|
||||
const formData: URLSearchParams = mockAxiosInstance.post.mock.calls[0][1];
|
||||
expect(formData.get('username')).toBe('u@test.com');
|
||||
expect(formData.get('password')).toBe('pw');
|
||||
expect(result).toEqual(tokenData);
|
||||
});
|
||||
|
||||
it('register should post user data to /auth/register', async () => {
|
||||
const user = { id: 1, email: 'u@test.com', full_name: 'Test' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: user });
|
||||
|
||||
const result = await authApi.register({ email: 'u@test.com', password: 'pw', full_name: 'Test' });
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/register', {
|
||||
email: 'u@test.com',
|
||||
password: 'pw',
|
||||
full_name: 'Test',
|
||||
});
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
|
||||
it('googleAuth should post code and redirect_uri to /auth/google', async () => {
|
||||
const tokenData = { access_token: 'tok', refresh_token: 'ref', token_type: 'bearer' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: tokenData });
|
||||
|
||||
const result = await authApi.googleAuth('code123', 'http://redirect');
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/google', {
|
||||
code: 'code123',
|
||||
redirect_uri: 'http://redirect',
|
||||
});
|
||||
expect(result).toEqual(tokenData);
|
||||
});
|
||||
|
||||
it('getGoogleAuthUrl should get authorization URL with redirect_uri param', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({
|
||||
data: { authorization_url: 'https://google.com/auth' },
|
||||
});
|
||||
|
||||
const result = await authApi.getGoogleAuthUrl('http://redirect');
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/auth/google/authorize-url', {
|
||||
params: { redirect_uri: 'http://redirect' },
|
||||
});
|
||||
expect(result).toBe('https://google.com/auth');
|
||||
});
|
||||
});
|
||||
|
||||
// ── userApi ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('userApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getCurrentUser should get /users/me', async () => {
|
||||
const user = { id: 1, email: 'u@test.com' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: user });
|
||||
|
||||
const result = await userApi.getCurrentUser();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/users/me');
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
|
||||
it('updateProfile should put data to /users/me', async () => {
|
||||
const user = { id: 1, email: 'new@test.com', full_name: 'New' };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: user });
|
||||
|
||||
const result = await userApi.updateProfile({ full_name: 'New', email: 'new@test.com' });
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/users/me', {
|
||||
full_name: 'New',
|
||||
email: 'new@test.com',
|
||||
});
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
});
|
||||
|
||||
// ── mailAccountsApi ─────────────────────────────────────────────────────
|
||||
|
||||
describe('mailAccountsApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('list should get /mail-accounts', async () => {
|
||||
const accounts = [{ id: 1, name: 'acc1' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: accounts });
|
||||
|
||||
const result = await mailAccountsApi.list();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts');
|
||||
expect(result).toEqual(accounts);
|
||||
});
|
||||
|
||||
it('create should post data to /mail-accounts', async () => {
|
||||
const account = { id: 1, name: 'new' };
|
||||
const createData = {
|
||||
name: 'new',
|
||||
email_address: 'a@b.com',
|
||||
protocol: 'imap',
|
||||
host: 'imap.b.com',
|
||||
port: 993,
|
||||
use_ssl: true,
|
||||
username: 'a@b.com',
|
||||
password: 'secret',
|
||||
forward_to: 'c@d.com',
|
||||
};
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: account });
|
||||
|
||||
const result = await mailAccountsApi.create(createData);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts', createData);
|
||||
expect(result).toEqual(account);
|
||||
});
|
||||
|
||||
it('update should put data to /mail-accounts/:id', async () => {
|
||||
const account = { id: 5, name: 'updated' };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: account });
|
||||
|
||||
const result = await mailAccountsApi.update(5, { name: 'updated' });
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/mail-accounts/5', { name: 'updated' });
|
||||
expect(result).toEqual(account);
|
||||
});
|
||||
|
||||
it('toggle should patch /mail-accounts/:id/toggle', async () => {
|
||||
const account = { id: 3, is_enabled: false };
|
||||
mockAxiosInstance.patch.mockResolvedValue({ data: account });
|
||||
|
||||
const result = await mailAccountsApi.toggle(3);
|
||||
|
||||
expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/mail-accounts/3/toggle');
|
||||
expect(result).toEqual(account);
|
||||
});
|
||||
|
||||
it('pullNow should post to /mail-accounts/:id/pull-now', async () => {
|
||||
const msg = { message: 'Pull initiated' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: msg });
|
||||
|
||||
const result = await mailAccountsApi.pullNow(7);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/7/pull-now');
|
||||
expect(result).toEqual(msg);
|
||||
});
|
||||
|
||||
it('delete should delete /mail-accounts/:id', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({});
|
||||
|
||||
await mailAccountsApi.delete(4);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/mail-accounts/4');
|
||||
});
|
||||
|
||||
it('test should post config to /mail-accounts/test', async () => {
|
||||
const response = { success: true, message: 'OK' };
|
||||
const config = {
|
||||
host: 'imap.test.com',
|
||||
port: 993,
|
||||
protocol: 'imap',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
use_ssl: true,
|
||||
};
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await mailAccountsApi.test(config);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/test', config);
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('testExisting should post to /mail-accounts/:id/test', async () => {
|
||||
const response = { success: true, message: 'Connected' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await mailAccountsApi.testExisting(10);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/10/test');
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('autoDetect should post email address to /mail-accounts/auto-detect', async () => {
|
||||
const response = { success: true, suggestions: [{ protocol: 'imap', host: 'imap.x.com' }] };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await mailAccountsApi.autoDetect('user@x.com');
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/auto-detect', {
|
||||
email_address: 'user@x.com',
|
||||
});
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
// ── processingRunsApi ───────────────────────────────────────────────────
|
||||
|
||||
describe('processingRunsApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('list should get /processing-runs with params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const params = { page: 1, page_size: 20, status: 'completed' };
|
||||
const result = await processingRunsApi.list(params);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs', { params });
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
|
||||
it('list should work without params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const result = await processingRunsApi.list();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs', { params: undefined });
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
|
||||
it('get should get /processing-runs/:id', async () => {
|
||||
const run = { id: 42, status: 'completed' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: run });
|
||||
|
||||
const result = await processingRunsApi.get(42);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/42');
|
||||
expect(result).toEqual(run);
|
||||
});
|
||||
|
||||
it('getLogs should get /processing-runs/:id/logs with params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 50, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const params = { page: 2, page_size: 50 };
|
||||
const result = await processingRunsApi.getLogs(42, params);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/42/logs', { params });
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
|
||||
it('getLogs should work without params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const result = await processingRunsApi.getLogs(10);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/10/logs', {
|
||||
params: undefined,
|
||||
});
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
|
||||
it('listForAccount should get /mail-accounts/:id/processing-runs with params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const params = { page: 1, page_size: 20, status: 'failed' };
|
||||
const result = await processingRunsApi.listForAccount(5, params);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts/5/processing-runs', {
|
||||
params,
|
||||
});
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
|
||||
it('listLogsForAccount should get /mail-accounts/:id/logs with params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const params = { page: 1, page_size: 20, level: 'error' };
|
||||
const result = await processingRunsApi.listLogsForAccount(3, params);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts/3/logs', { params });
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
});
|
||||
|
||||
// ── gmailApi ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gmailApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getAuthorizeUrl should get /providers/gmail/authorize-url with redirect_uri', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({
|
||||
data: { authorization_url: 'https://accounts.google.com/o/oauth2' },
|
||||
});
|
||||
|
||||
const result = await gmailApi.getAuthorizeUrl('http://localhost/callback');
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/providers/gmail/authorize-url', {
|
||||
params: { redirect_uri: 'http://localhost/callback' },
|
||||
});
|
||||
expect(result).toBe('https://accounts.google.com/o/oauth2');
|
||||
});
|
||||
|
||||
it('saveCallback should post code and redirect_uri to /providers/gmail/callback', async () => {
|
||||
const credential = { id: 1, gmail_email: 'test@gmail.com', is_valid: true };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: credential });
|
||||
|
||||
const result = await gmailApi.saveCallback('authcode', 'http://localhost/callback');
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/providers/gmail/callback', {
|
||||
code: 'authcode',
|
||||
redirect_uri: 'http://localhost/callback',
|
||||
});
|
||||
expect(result).toEqual(credential);
|
||||
});
|
||||
|
||||
it('getCredential should get /providers/gmail-credential', async () => {
|
||||
const credential = { id: 1, gmail_email: 'test@gmail.com', is_valid: true };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: credential });
|
||||
|
||||
const result = await gmailApi.getCredential();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/providers/gmail-credential');
|
||||
expect(result).toEqual(credential);
|
||||
});
|
||||
|
||||
it('disconnect should delete /providers/gmail-credential', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({});
|
||||
|
||||
await gmailApi.disconnect();
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/providers/gmail-credential');
|
||||
});
|
||||
|
||||
it('sendDebugEmail should post to /providers/gmail/debug-email', async () => {
|
||||
const response = {
|
||||
message: 'Sent',
|
||||
message_id: 'msg1',
|
||||
thread_id: 'thr1',
|
||||
label_ids: ['INBOX'],
|
||||
};
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await gmailApi.sendDebugEmail();
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/providers/gmail/debug-email');
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('updateImportLabels should put label templates to /providers/gmail-credential/labels', async () => {
|
||||
const credential = { id: 1, import_label_templates: ['Label1'] };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: credential });
|
||||
|
||||
const result = await gmailApi.updateImportLabels(['Label1']);
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/providers/gmail-credential/labels', {
|
||||
import_label_templates: ['Label1'],
|
||||
});
|
||||
expect(result).toEqual(credential);
|
||||
});
|
||||
});
|
||||
|
||||
// ── smtpApi ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('smtpApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('get should get /users/smtp-config', async () => {
|
||||
const config = { id: 1, host: 'smtp.test.com', port: 587 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: config });
|
||||
|
||||
const result = await smtpApi.get();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/users/smtp-config');
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('save should put config to /users/smtp-config', async () => {
|
||||
const config = { id: 1, host: 'smtp.test.com', port: 587 };
|
||||
const saveData = { host: 'smtp.test.com', port: 587, username: 'user', use_tls: true };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: config });
|
||||
|
||||
const result = await smtpApi.save(saveData);
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/users/smtp-config', saveData);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('remove should delete /users/smtp-config', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({});
|
||||
|
||||
await smtpApi.remove();
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/users/smtp-config');
|
||||
});
|
||||
});
|
||||
|
||||
// ── adminApi ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('adminApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getStats should get /admin/stats', async () => {
|
||||
const stats = { total_users: 10, total_mail_accounts: 20, total_processing_runs: 100 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: stats });
|
||||
|
||||
const result = await adminApi.getStats();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/stats');
|
||||
expect(result).toEqual(stats);
|
||||
});
|
||||
|
||||
it('listUsers should get /admin/users with skip and limit', async () => {
|
||||
const users = [{ id: 1, email: 'a@b.com' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: users });
|
||||
|
||||
const result = await adminApi.listUsers(10, 50);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users', {
|
||||
params: { skip: 10, limit: 50 },
|
||||
});
|
||||
expect(result).toEqual(users);
|
||||
});
|
||||
|
||||
it('listUsers should use defaults for skip and limit', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: [] });
|
||||
|
||||
await adminApi.listUsers();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users', {
|
||||
params: { skip: 0, limit: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
it('getUser should get /admin/users/:id', async () => {
|
||||
const user = { id: 5, email: 'u@t.com' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: user });
|
||||
|
||||
const result = await adminApi.getUser(5);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users/5');
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
|
||||
it('updateUser should put data to /admin/users/:id', async () => {
|
||||
const user = { id: 5, is_active: false };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: user });
|
||||
|
||||
const result = await adminApi.updateUser(5, { is_active: false });
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/users/5', { is_active: false });
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
|
||||
it('deleteUser should delete /admin/users/:id', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({});
|
||||
|
||||
await adminApi.deleteUser(5);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/users/5');
|
||||
});
|
||||
|
||||
it('listPlans should get /admin/plans', async () => {
|
||||
const plans = [{ id: 1, tier: 'free' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: plans });
|
||||
|
||||
const result = await adminApi.listPlans();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/plans');
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
|
||||
it('createPlan should post data to /admin/plans', async () => {
|
||||
const plan = { id: 1, tier: 'pro', name: 'Pro Plan' };
|
||||
const createData = {
|
||||
tier: 'pro',
|
||||
name: 'Pro Plan',
|
||||
price_monthly: 9.99,
|
||||
max_mail_accounts: 10,
|
||||
max_emails_per_day: 1000,
|
||||
check_interval_minutes: 5,
|
||||
support_level: 'email',
|
||||
is_active: true,
|
||||
};
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: plan });
|
||||
|
||||
const result = await adminApi.createPlan(createData);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/plans', createData);
|
||||
expect(result).toEqual(plan);
|
||||
});
|
||||
|
||||
it('updatePlan should put data to /admin/plans/:id', async () => {
|
||||
const plan = { id: 2, name: 'Updated Plan' };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: plan });
|
||||
|
||||
const result = await adminApi.updatePlan(2, { name: 'Updated Plan' });
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/plans/2', { name: 'Updated Plan' });
|
||||
expect(result).toEqual(plan);
|
||||
});
|
||||
|
||||
it('deletePlan should delete /admin/plans/:id', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({});
|
||||
|
||||
await adminApi.deletePlan(3);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/plans/3');
|
||||
});
|
||||
|
||||
it('listProcessingRuns should get /admin/processing-runs with params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const params = { page: 1, page_size: 20, user_id: 5 };
|
||||
const result = await adminApi.listProcessingRuns(params);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-runs', { params });
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
|
||||
it('listProcessingRuns should work without params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const result = await adminApi.listProcessingRuns();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-runs', {
|
||||
params: undefined,
|
||||
});
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
|
||||
it('listProcessingLogs should get /admin/processing-logs with params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const params = { page: 1, page_size: 20, level: 'error' };
|
||||
const result = await adminApi.listProcessingLogs(params);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-logs', { params });
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
|
||||
it('listProcessingLogs should work without params', async () => {
|
||||
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
|
||||
|
||||
const result = await adminApi.listProcessingLogs();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-logs', {
|
||||
params: undefined,
|
||||
});
|
||||
expect(result).toEqual(paginated);
|
||||
});
|
||||
});
|
||||
|
||||
// ── notificationsApi ────────────────────────────────────────────────────
|
||||
|
||||
describe('notificationsApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('list should get /notifications', async () => {
|
||||
const configs = [{ id: 1, name: 'Notif 1' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: configs });
|
||||
|
||||
const result = await notificationsApi.list();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/notifications');
|
||||
expect(result).toEqual(configs);
|
||||
});
|
||||
|
||||
it('create should post data to /notifications', async () => {
|
||||
const config = { id: 1, name: 'New Notif', channel: 'email' };
|
||||
const createData = { name: 'New Notif', channel: 'email' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: config });
|
||||
|
||||
const result = await notificationsApi.create(createData);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/notifications', createData);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('update should put data to /notifications/:id', async () => {
|
||||
const config = { id: 2, name: 'Updated' };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: config });
|
||||
|
||||
const result = await notificationsApi.update(2, { name: 'Updated' });
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/notifications/2', { name: 'Updated' });
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('delete should delete /notifications/:id', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({});
|
||||
|
||||
await notificationsApi.delete(3);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/notifications/3');
|
||||
});
|
||||
|
||||
it('test should post apprise_url to /notifications/test', async () => {
|
||||
const response = { success: true, message: 'Test sent' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await notificationsApi.test('apprise://slack/webhook');
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/notifications/test', {
|
||||
apprise_url: 'apprise://slack/webhook',
|
||||
});
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
// ── adminNotificationsApi ───────────────────────────────────────────────
|
||||
|
||||
describe('adminNotificationsApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('list should get /admin/notifications', async () => {
|
||||
const configs = [{ id: 1, name: 'Admin Notif' }];
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: configs });
|
||||
|
||||
const result = await adminNotificationsApi.list();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/notifications');
|
||||
expect(result).toEqual(configs);
|
||||
});
|
||||
|
||||
it('create should post data to /admin/notifications', async () => {
|
||||
const config = { id: 1, name: 'Admin Notif', apprise_url: 'apprise://test' };
|
||||
const createData = { name: 'Admin Notif', apprise_url: 'apprise://test' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: config });
|
||||
|
||||
const result = await adminNotificationsApi.create(createData);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/notifications', createData);
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('update should put data to /admin/notifications/:id', async () => {
|
||||
const config = { id: 2, name: 'Updated Admin' };
|
||||
mockAxiosInstance.put.mockResolvedValue({ data: config });
|
||||
|
||||
const result = await adminNotificationsApi.update(2, { name: 'Updated Admin' });
|
||||
|
||||
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/notifications/2', {
|
||||
name: 'Updated Admin',
|
||||
});
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
|
||||
it('delete should delete /admin/notifications/:id', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({});
|
||||
|
||||
await adminNotificationsApi.delete(4);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/notifications/4');
|
||||
});
|
||||
|
||||
it('test should post apprise_url to /admin/notifications/test', async () => {
|
||||
const response = { success: true, message: 'Admin test sent' };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await adminNotificationsApi.test('apprise://discord/webhook');
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/notifications/test', {
|
||||
apprise_url: 'apprise://discord/webhook',
|
||||
});
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
// ── versionApi ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('versionApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('get should get /version', async () => {
|
||||
const versionInfo = { version: '1.2.3', build_date: '2024-01-01' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: versionInfo });
|
||||
|
||||
const result = await versionApi.get();
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/version');
|
||||
expect(result).toEqual(versionInfo);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface MailAccount {
|
||||
check_interval_minutes: number;
|
||||
max_emails_per_check: number;
|
||||
delete_after_forward: boolean;
|
||||
debug_logging: boolean;
|
||||
status: string;
|
||||
provider_name?: string | null;
|
||||
auto_detected: boolean;
|
||||
@@ -95,6 +96,7 @@ export interface MailAccountCreate {
|
||||
check_interval_minutes?: number;
|
||||
max_emails_per_check?: number;
|
||||
delete_after_forward?: boolean;
|
||||
debug_logging?: boolean;
|
||||
provider_name?: string | null;
|
||||
}
|
||||
|
||||
@@ -114,6 +116,7 @@ export interface MailAccountUpdate {
|
||||
check_interval_minutes?: number;
|
||||
max_emails_per_check?: number;
|
||||
delete_after_forward?: boolean;
|
||||
debug_logging?: boolean;
|
||||
provider_name?: string | null;
|
||||
}
|
||||
|
||||
@@ -221,6 +224,7 @@ export interface UserSmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
sender_email: string;
|
||||
use_tls: boolean;
|
||||
has_password: boolean;
|
||||
created_at: string;
|
||||
@@ -361,6 +365,11 @@ export const mailAccountsApi = {
|
||||
}>("/mail-accounts/auto-detect", { email_address: emailAddress });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async clearError(id: number): Promise<MailAccount> {
|
||||
const response = await api.post<MailAccount>(`/mail-accounts/${id}/clear-error`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Processing Runs API ─────────────────────────────────────────────────
|
||||
@@ -477,6 +486,7 @@ export const smtpApi = {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
sender_email: string;
|
||||
password?: string;
|
||||
use_tls: boolean;
|
||||
}): Promise<UserSmtpConfig> {
|
||||
@@ -487,6 +497,13 @@ export const smtpApi = {
|
||||
async remove(): Promise<void> {
|
||||
await api.delete('/users/smtp-config');
|
||||
},
|
||||
|
||||
async test(): Promise<{ success: boolean; message: string }> {
|
||||
const response = await api.post<{ success: boolean; message: string }>(
|
||||
'/users/smtp-config/test'
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Admin Types ─────────────────────────────────────────────────────────
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "inboxconverge"
|
||||
version = "0.6.0"
|
||||
version = "0.10.7"
|
||||
description = "Multi-account email forwarding and processing service"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
schedule==1.2.0
|
||||
python-dotenv==1.0.0
|
||||
python-dotenv==1.2.2
|
||||
|
||||
Reference in New Issue
Block a user