229 Commits

Author SHA1 Message Date
dependabot[bot] 85ef54d980 chore(deps): bump uvicorn from 0.47.0 to 0.48.0 in /backend
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.47.0 to 0.48.0.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.47.0...0.48.0)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 16:55:41 +02:00
dependabot[bot] bab787a8cb chore(deps): bump faker from 40.18.0 to 40.19.1 in /backend (#266)
Bumps [faker](https://github.com/joke2k/faker) from 40.18.0 to 40.19.1.
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/joke2k/faker/compare/v40.18.0...v40.19.1)

---
updated-dependencies:
- dependency-name: faker
  dependency-version: 40.19.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 15:42:16 +02:00
dependabot[bot] c8926ce88b chore(deps): bump sqlalchemy from 2.0.49 to 2.0.50 in /backend (#267)
Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.49 to 2.0.50.
- [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases)
- [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst)
- [Commits](https://github.com/sqlalchemy/sqlalchemy/commits)

---
updated-dependencies:
- dependency-name: sqlalchemy
  dependency-version: 2.0.50
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 15:42:13 +02:00
dependabot[bot] 91133136d6 chore(deps): bump fastapi from 0.135.3 to 0.136.3 in /backend (#265)
Bumps [fastapi](https://github.com/fastapi/fastapi) from 0.135.3 to 0.136.3.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.135.3...0.136.3)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.136.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 15:42:06 +02:00
semantic-release 7e0b44d995 0.10.7
Automatically generated by python-semantic-release
2026-05-23 10:17:58 +00:00
Christian Krakau-Louis 2de22ab30f Merge pull request #264 from christianlouis/security/snyk-remediation-may-2026
fix: pin patched postcss dependency
2026-05-23 12:17:41 +02:00
Codex 97ee4d0e3c fix: pin patched postcss dependency 2026-05-23 12:12:24 +02:00
Christian Krakau-Louis ff70cd3606 Build GHCR images for amd64 2026-05-22 20:50:28 +02:00
Christian Krakau-Louis 37280321cd Use GHCR for CI image publishing 2026-05-22 19:26:04 +02:00
Christian Krakau-Louis eae610cc95 Align frontend with InboxConverge brand 2026-05-22 19:13:14 +02:00
Christian Krakau-Louis 8d15fa8602 Merge pull request #263 from christianlouis/fix/frontend-docker-webpack-build
Fix frontend Docker build on arm64
2026-05-18 19:14:33 +02:00
Christian Krakau-Louis 1c209ad7bc Fix frontend Docker build on arm64 2026-05-18 19:13:55 +02:00
dependabot[bot] 42b18e10a6 chore(deps): bump google-auth from 2.52.0 to 2.53.0 in /backend (#261)
Bumps [google-auth](https://github.com/googleapis/google-auth-library-python) from 2.52.0 to 2.53.0.
- [Release notes](https://github.com/googleapis/google-auth-library-python/releases)
- [Changelog](https://github.com/googleapis/google-auth-library-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-auth-library-python/commits)

---
updated-dependencies:
- dependency-name: google-auth
  dependency-version: 2.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-18 16:17:45 +02:00
dependabot[bot] 4b09a3beab chore(deps): bump authlib from 1.6.12 to 1.7.2 in /backend (#258)
Bumps [authlib](https://github.com/authlib/authlib) from 1.6.12 to 1.7.2.
- [Release notes](https://github.com/authlib/authlib/releases)
- [Commits](https://github.com/authlib/authlib/compare/1.6.12...v1.7.2)

---
updated-dependencies:
- dependency-name: authlib
  dependency-version: 1.7.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-18 16:17:39 +02:00
dependabot[bot] 9cc9af2735 chore(deps): bump faker from 40.12.0 to 40.18.0 in /backend (#262)
Bumps [faker](https://github.com/joke2k/faker) from 40.12.0 to 40.18.0.
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/v40.18.0/CHANGELOG.md)
- [Commits](https://github.com/joke2k/faker/compare/v40.12.0...v40.18.0)

---
updated-dependencies:
- dependency-name: faker
  dependency-version: 40.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 16:09:26 +02:00
dependabot[bot] 8130a6f81c chore(deps): bump pydantic-settings from 2.13.1 to 2.14.1 in /backend (#260)
Bumps [pydantic-settings](https://github.com/pydantic/pydantic-settings) from 2.13.1 to 2.14.1.
- [Release notes](https://github.com/pydantic/pydantic-settings/releases)
- [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.13.1...v2.14.1)

---
updated-dependencies:
- dependency-name: pydantic-settings
  dependency-version: 2.14.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 16:09:19 +02:00
dependabot[bot] 7d84f4395e chore(deps): bump google-auth-oauthlib from 1.3.1 to 1.4.0 in /backend (#259)
Bumps [google-auth-oauthlib](https://github.com/googleapis/google-cloud-python) from 1.3.1 to 1.4.0.
- [Release notes](https://github.com/googleapis/google-cloud-python/releases)
- [Changelog](https://github.com/googleapis/google-cloud-python/blob/main/packages/gcp-sphinx-docfx-yaml/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-python/compare/google-auth-oauthlib-v1.3.1...google-auth-oauthlib-v1.4.0)

---
updated-dependencies:
- dependency-name: google-auth-oauthlib
  dependency-version: 1.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 16:09:14 +02:00
dependabot[bot] fdc87218af chore(deps): bump python-multipart from 0.0.28 to 0.0.29 in /backend (#257)
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.28 to 0.0.29.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.28...0.0.29)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 16:09:08 +02:00
dependabot[bot] 37b48e1c37 chore(deps): bump uvicorn from 0.43.0 to 0.47.0 in /backend (#256)
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.43.0 to 0.47.0.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.43.0...0.47.0)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 16:09:03 +02:00
dependabot[bot] ec5b2e12f4 chore(deps): bump stripe to 15.1.0 (#232)
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 17:02:35 +02:00
dependabot[bot] a1ff9e70f7 chore(deps): bump psycopg2-binary to 2.9.12 (#238)
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 17:02:11 +02:00
dependabot[bot] 3b123d974f chore(deps): bump apprise from 1.9.9 to 1.10.0 in /backend (#236)
Bumps [apprise](https://github.com/caronc/apprise) from 1.9.9 to 1.10.0.
- [Release notes](https://github.com/caronc/apprise/releases)
- [Commits](https://github.com/caronc/apprise/compare/v1.9.9...v1.10.0)

---
updated-dependencies:
- dependency-name: apprise
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 16:45:57 +02:00
dependabot[bot] 4eaeb1ad8d chore(deps): bump cryptography from 46.0.7 to 48.0.0 in /backend (#245)
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.7 to 48.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.7...48.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 15:16:10 +02:00
dependabot[bot] a364748df7 chore(deps): bump eslint from 9.39.4 to 10.4.0 in /frontend (#230)
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.4 to 10.4.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.4.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 15:14:27 +02:00
dependabot[bot] 64b06bd1ed chore(deps): bump @types/node from 25.6.0 to 25.6.2 in /frontend (#253)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.6.0 to 25.6.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 15:14:16 +02:00
dependabot[bot] 34f6f7b8e9 chore(deps): bump google-api-python-client to 2.196.0 (#248)
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 15:10:19 +02:00
dependabot[bot] f7f4745f55 chore(deps): bump @tanstack/react-query in /frontend (#239)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.99.0 to 5.100.10.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/HEAD/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 15:07:34 +02:00
dependabot[bot] 311c1bc408 chore(deps): bump lucide-react to 1.16.0 (#237)
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 15:05:14 +02:00
dependabot[bot] cde645844c chore(deps): bump the pip group across 1 directory with 2 updates (#242)
Bumps the pip group with 2 updates in the / directory: [python-dotenv](https://github.com/theskumar/python-dotenv) and [authlib](https://github.com/authlib/authlib).


Updates `python-dotenv` from 1.0.0 to 1.2.2
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.2.2)

Updates `authlib` from 1.6.9 to 1.6.12
- [Release notes](https://github.com/authlib/authlib/releases)
- [Changelog](https://github.com/authlib/authlib/blob/1.6.12/docs/changelog.rst)
- [Commits](https://github.com/authlib/authlib/compare/v1.6.9...1.6.12)

---
updated-dependencies:
- dependency-name: authlib
  dependency-version: 1.6.11
  dependency-type: direct:production
- dependency-name: python-dotenv
  dependency-version: 1.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 15:03:24 +02:00
dependabot[bot] 58a2474255 chore(deps): bump google-auth-httplib2 from 0.3.1 to 0.4.0 in /backend (#247)
Bumps [google-auth-httplib2](https://github.com/googleapis/google-cloud-python) from 0.3.1 to 0.4.0.
- [Release notes](https://github.com/googleapis/google-cloud-python/releases)
- [Changelog](https://github.com/googleapis/google-cloud-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-python/compare/google-auth-httplib2-v0.3.1...google-auth-httplib2-v0.4.0)

---
updated-dependencies:
- dependency-name: google-auth-httplib2
  dependency-version: 0.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 15:01:47 +02:00
dependabot[bot] d4a2160e01 chore(deps): bump pydantic from 2.12.5 to 2.13.4 in /backend (#244)
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.12.5 to 2.13.4.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/v2.13.4/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.12.5...v2.13.4)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.13.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 15:01:31 +02:00
dependabot[bot] 60a1a9b2ae chore(deps): bump google-auth from 2.49.2 to 2.52.0 in /backend (#243)
Bumps [google-auth](https://github.com/googleapis/google-auth-library-python) from 2.49.2 to 2.52.0.
- [Release notes](https://github.com/googleapis/google-auth-library-python/releases)
- [Changelog](https://github.com/googleapis/google-auth-library-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-auth-library-python/commits)

---
updated-dependencies:
- dependency-name: google-auth
  dependency-version: 2.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Christian Krakau-Louis <christianlouis@gmail.com>
2026-05-17 15:01:15 +02:00
dependabot[bot] ebb88391db chore(deps): bump jest-environment-jsdom in /frontend (#252)
Bumps [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) from 30.3.0 to 30.4.1.
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.4.1/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: jest-environment-jsdom
  dependency-version: 30.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 14:53:33 +02:00
dependabot[bot] 9b0448cd3b chore(deps): bump zustand from 5.0.12 to 5.0.13 in /frontend (#250)
Bumps [zustand](https://github.com/pmndrs/zustand) from 5.0.12 to 5.0.13.
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](https://github.com/pmndrs/zustand/compare/v5.0.12...v5.0.13)

---
updated-dependencies:
- dependency-name: zustand
  dependency-version: 5.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 14:53:12 +02:00
dependabot[bot] 83b53aca9f chore(deps): bump @tailwindcss/postcss from 4.1.18 to 4.3.0 in /frontend (#249)
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.1.18 to 4.3.0.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 14:53:01 +02:00
dependabot[bot] bcca5a9192 chore(deps): bump axios from 1.15.0 to 1.16.1 in /frontend (#233)
Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.16.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.16.1)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 14:52:38 +02:00
dependabot[bot] e6027792c6 chore(deps): bump python-multipart from 0.0.26 to 0.0.28 in /backend (#246)
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.26 to 0.0.28.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.26...0.0.28)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 14:52:17 +02:00
dependabot[bot] fb8a11ffba chore(deps): bump next from 16.2.5 to 16.2.6 in /frontend (#251)
Bumps [next](https://github.com/vercel/next.js) from 16.2.5 to 16.2.6.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.5...v16.2.6)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 14:51:55 +02:00
Christian Krakau-Louis 2a7ca74db0 Fix Next.js DoS advisory 2026-05-17 13:13:32 +02:00
semantic-release 6c42c3759c 0.10.6
Automatically generated by python-semantic-release
2026-05-17 11:08:05 +00:00
Christian Krakau-Louis 8295b7aaf2 fix: linearize alembic migrations 2026-05-17 13:07:21 +02:00
semantic-release 8750ecb3cc 0.10.5
Automatically generated by python-semantic-release
2026-05-06 19:33:38 +00:00
Christian Krakau-Louis 31652c5eb2 Merge pull request #229 from christianlouis/copilot/fix-smtp-config-validation-error
fix(tests): add missing `sender_email` to SMTP config test fixture
2026-05-06 21:33:12 +02:00
copilot-swe-agent[bot] 1420149d8b fix: add sender_email to _make_smtp_config test helper
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5238ada2-2323-41a8-b58c-d3a49f9ffafd

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 23:28:21 +00:00
semantic-release 19a859ecce 0.10.4
Automatically generated by python-semantic-release
2026-05-03 23:17:17 +00:00
Christian Krakau-Louis 6437a4cf5e Merge pull request #228 from christianlouis/copilot/check-all-lints-in-main
fix: resolve all backend lint errors (black, ruff, mypy)
2026-05-04 01:16:53 +02:00
copilot-swe-agent[bot] fcdd51e6aa fix: resolve all lint errors (black, ruff, mypy)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/343db360-b55f-461c-8bc6-3754f58ba48e

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 23:02:01 +00:00
semantic-release c65f37a8dd 0.10.3
Automatically generated by python-semantic-release
2026-05-03 22:43:07 +00:00
Christian Krakau-Louis 5d8c20ff3a Merge pull request #227 from christianlouis/copilot/fix-mailbox-connectivity-issues
fix: POP3/IMAP connectivity — retries, DNS 8.8.8.8 fallback, debug counter, notification backoff
2026-05-04 00:42:44 +02:00
Christian Krakau-Louis f808d6262d Merge branch 'main' into copilot/fix-mailbox-connectivity-issues 2026-05-04 00:21:59 +02:00
copilot-swe-agent[bot] 428850b476 refactor: address code-review comments (DNS constant, last_exc, comment clarity)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5ad49738-4b0b-4ed7-8686-07adb0ba9e5d

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 22:09:08 +00:00
copilot-swe-agent[bot] 92f60c6052 fix: retry POP3/IMAP, DNS 8.8.8.8 fallback, debug counter, notification backoff
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5ad49738-4b0b-4ed7-8686-07adb0ba9e5d

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 22:06:12 +00:00
semantic-release 0838a6b718 0.10.2
Automatically generated by python-semantic-release
2026-05-03 21:51:10 +00:00
Christian Krakau-Louis b771484afa Merge pull request #226 from christianlouis/copilot/fix-smtp-fallback-issues
fix: add sender_email to SMTP config to decouple auth credential from From: header
2026-05-03 23:50:49 +02:00
semantic-release eea50f5d74 0.10.1
Automatically generated by python-semantic-release
2026-05-03 21:48:05 +00:00
Christian Krakau-Louis dd5f28ad25 Merge pull request #225 from christianlouis/copilot/fix-imap-connection-timeout
fix: normalise naive UTC expiry from google-auth before DB storage
2026-05-03 23:47:43 +02:00
copilot-swe-agent[bot] a21e2f7df6 fix: add sender_email to UserSmtpConfig to fix SMTP From header (501 bad sender address)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/dc853384-976a-46e5-945b-60c9a9cce638

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 21:46:47 +00:00
copilot-swe-agent[bot] 01a85ebede fix: normalise naive UTC expiry from google-auth to tz-aware in GmailService
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/a94e2685-c88f-4109-a6eb-566c7343ab1f

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 21:45:02 +00:00
semantic-release edb4e8b0cc 0.10.0
Automatically generated by python-semantic-release
2026-05-03 21:02:04 +00:00
Christian Krakau-Louis f2bdfbb9a1 Merge pull request #224 from christianlouis/copilot/add-test-mail-button
Add "Send Test Email" button for SMTP Fallback configuration
2026-05-03 23:01:44 +02:00
Christian Krakau-Louis 0396cc6f33 Merge branch 'main' into copilot/add-test-mail-button 2026-05-03 23:01:30 +02:00
semantic-release 2b0f05f971 0.9.2
Automatically generated by python-semantic-release
2026-05-03 21:01:03 +00:00
Christian Krakau-Louis 38dfed3360 Merge pull request #223 from christianlouis/copilot/debug-pop3-imap-connection-issues
fix: IPv4 preference + DNS cache fallback for stable POP3/IMAP connections
2026-05-03 23:00:42 +02:00
copilot-swe-agent[bot] 2a005d9103 feat: add Send Test Email button for SMTP Fallback config
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/9485ffb9-ce3d-4c6b-9d2c-9144dd4287ff

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 20:58:32 +00:00
copilot-swe-agent[bot] 28a7592731 fix: address code review comments (type hints, loop var name, em dashes)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/be0a3bbd-1af1-408e-b918-84753723154b

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 20:51:52 +00:00
copilot-swe-agent[bot] 45a2dd8be2 fix: IPv4 preference + DNS cache fallback for stable POP3/IMAP connections
- Add DNS_CACHE_FALLBACK_ENABLED setting (default: true)
- Add in-process DNS cache (_dns_cache, _dns_cache_lock, _get/_set_cached_ipv4)
- Add _resolve_ipv4 (async) and _resolve_ipv4_sync: AF_INET lookup, cache on
  success, return cached IP on EAI_AGAIN when fallback enabled
- Add _IMAP4SSLwithSNI: connects to pre-resolved IPv4 but uses original
  hostname for TLS SNI/cert verification
- Add _POP3SSLWithIPv4Pref / _POP3WithIPv4Pref: override _create_socket to
  use pre-resolved IPv4 while keeping original host for POP3_SSL SNI
- Add _make_pop3_conn factory and _make_imap_client async factory
- Wire IPv4/cache helpers into all 6 connection call-sites (IMAP x3, POP3 x3)
- Distinguish transient DNS (EAI_AGAIN) from permanent in _format_connection_error
- Replace asyncio.get_event_loop() with get_running_loop() in 4 async sites
- All 75 existing unit tests pass unchanged

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/be0a3bbd-1af1-408e-b918-84753723154b

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 20:49:51 +00:00
semantic-release 89ebdd2b79 0.9.1
Automatically generated by python-semantic-release
2026-05-03 20:26:18 +00:00
Christian Krakau-Louis 87a4b15b96 Merge pull request #222 from christianlouis/copilot/fix-apprise-smtp-channel
fix(notifications): add separate sender, recipient, and SMTP username fields to email channel
2026-05-03 22:25:58 +02:00
copilot-swe-agent[bot] c3e608d82f fix: add separate sender/recipient email fields for SMTP notification channel
The email (SMTP) form previously used the `username` field as both SMTP
auth credential and the from/to address. This breaks services like
Postmark that use API tokens as SMTP credentials.

Changes:
- Added "Sender Email (From)" (optional) and "Recipient Email (To)"
  (required) fields to the email channel configuration
- Renamed "Username / Email" to "SMTP Username" for clarity
- Updated `buildAppriseUrl` to build `mailto://user:pass@host/?to=...&from=...`
  so recipient and sender are explicit query parameters
- Updated tests to match new field labels and added two new email URL tests

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5be072bb-0824-436b-983b-1a6bb6df2699

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 20:20:06 +00:00
semantic-release 742f0eca6b 0.9.0
Automatically generated by python-semantic-release
2026-05-03 20:11:14 +00:00
Christian Krakau-Louis 651194178f Merge pull request #221 from christianlouis/copilot/add-debug-logging-for-mail-fetching
feat: IMAP/POP3 diagnostics — friendly errors, auto-clear stale status, per-account debug logging
2026-05-03 22:10:52 +02:00
copilot-swe-agent[bot] b1dd1714f7 fix: remove unused imports and bare f-string in test_format_connection_error.py (ruff F401/F541)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/9fad41f9-3b9d-4b9b-8eb5-9d0291f6746a

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 19:29:35 +00:00
copilot-swe-agent[bot] 79d664c0e4 fix: apply black formatting to test_format_connection_error.py (--target-version py312)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/a81adc93-0fee-44b1-8c26-ae9830e40e21

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 19:25:33 +00:00
Christian Krakau-Louis ed4c4f5a60 Merge branch 'main' into copilot/add-debug-logging-for-mail-fetching 2026-05-03 21:17:01 +02:00
Christian Krakau-Louis 6f8690850d Merge pull request #219 from christianlouis/copilot/fix-internal-server-error
Fix silent 500s in Google OAuth: log full tracebacks
2026-05-03 21:16:24 +02:00
copilot-swe-agent[bot] a8f37d6a1b Merge remote-tracking branch 'origin/main' into copilot/fix-internal-server-error
# Conflicts:
#	CHANGELOG.md

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 19:11:31 +00:00
semantic-release 7bf0819338 0.8.1
Automatically generated by python-semantic-release
2026-05-03 19:10:04 +00:00
Christian Krakau-Louis f0b6bbcf65 Merge pull request #220 from christianlouis/copilot/fix-docker-build-issues
fix: downgrade eslint to ^9 and typescript to ^5 to resolve eslint-config-next peer conflicts
2026-05-03 21:09:39 +02:00
copilot-swe-agent[bot] eeb3fa5c66 refactor: use __len__ for MailDebugRecorder, str() size estimate, clearer tests, fix unused import
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5d2918de-630e-4a66-8355-1b036c620b1c

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 18:59:41 +00:00
copilot-swe-agent[bot] aab60d6866 feat: frontend clear-error button, debug trace viewer, debug_logging toggle, docs updates
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5d2918de-630e-4a66-8355-1b036c620b1c

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 18:53:52 +00:00
copilot-swe-agent[bot] db893d05e9 feat: add friendly error messages, MailDebugRecorder, clear-error endpoint, debug_logging column
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5d2918de-630e-4a66-8355-1b036c620b1c

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 18:43:06 +00:00
copilot-swe-agent[bot] e98c81cce4 Add exc_info=True to OAuth error logging and endpoint-level catch in google_oauth
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/8d0fb51a-ede5-4bb7-aad8-3d29a0d3d7d3

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 17:35:49 +00:00
copilot-swe-agent[bot] f39241a547 fix: downgrade eslint to ^9 and typescript to ^5 for eslint-config-next compatibility
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/861f179a-eb73-4cd9-8c13-2a0e52b5d1bf

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 16:45:10 +00:00
Christian Krakau-Louis 0026afe131 Merge pull request #184 from christianlouis/dependabot/npm_and_yarn/frontend/multi-0193e73c84
chore(deps): bump react and @types/react in /frontend
2026-05-03 18:14:13 +02:00
semantic-release 4bc440d11c 0.8.0
Automatically generated by python-semantic-release
2026-05-03 16:13:40 +00:00
Christian Krakau-Louis 9e9a5858de Merge pull request #217 from christianlouis/copilot/improve-oauth-token-refresh
feat: proactive Gmail token refresh, typed GmailAuthError, in-depth OAuth logging
2026-05-03 18:13:20 +02:00
copilot-swe-agent[bot] be812dda79 fix: bump react to 19.2.5 to match react-dom, resolve npm ci ERESOLVE
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5060147f-ea01-424c-b960-5d9cd050d15f

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 16:07:53 +00:00
copilot-swe-agent[bot] 075ba92f09 feat: add in-depth OAuth logging across auth_service, auth, providers, gmail_service
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/91c7dd2b-0d88-46a9-a711-3a4c69d99520

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 15:05:27 +00:00
copilot-swe-agent[bot] 3470213fb3 fix: combine f-strings, align product name in notification titles
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/2bcd611e-3598-4b83-95bd-3276a7d5ad7c

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 14:53:17 +00:00
copilot-swe-agent[bot] 992e157f02 feat: proactive Gmail token refresh, GmailAuthError, improved logging
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/2bcd611e-3598-4b83-95bd-3276a7d5ad7c

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-05-03 14:50:58 +00:00
dependabot[bot] 00d06c63de chore(deps): bump react and @types/react in /frontend
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

Updates `react` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

Updates `@types/react` from 19.2.10 to 19.2.14
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 00:51:46 +00:00
Christian Krakau-Louis d00523f5ba Merge pull request #181 from christianlouis/dependabot/npm_and_yarn/frontend/react-dom-19.2.5
chore(deps): bump react-dom from 19.2.4 to 19.2.5 in /frontend
2026-04-19 02:50:36 +02:00
dependabot[bot] d6d4df2df1 chore(deps): bump react-dom from 19.2.4 to 19.2.5 in /frontend
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 00:50:22 +00:00
Christian Krakau-Louis bb0806e198 Merge pull request #173 from christianlouis/dependabot/pip/backend/pip-3344959f9f
chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /backend in the pip group across 1 directory
2026-04-19 02:49:03 +02:00
Christian Krakau-Louis b31e13195b Merge pull request #174 from christianlouis/dependabot/npm_and_yarn/frontend/npm_and_yarn-ad77747e51
chore(deps): bump next from 16.2.2 to 16.2.3 in /frontend in the npm_and_yarn group across 1 directory
2026-04-19 02:48:47 +02:00
dependabot[bot] 54ecd7f95c chore(deps): bump next
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.2.2 to 16.2.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.2...v16.2.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.3
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 00:44:46 +00:00
Christian Krakau-Louis fa890df5e4 Merge pull request #176 from christianlouis/dependabot/npm_and_yarn/frontend/axios-1.15.0
chore(deps): bump axios from 1.14.0 to 1.15.0 in /frontend
2026-04-19 02:42:47 +02:00
Christian Krakau-Louis a31030e3e3 Merge pull request #175 from christianlouis/dependabot/pip/backend/fastapi-0.135.3
chore(deps): bump fastapi from 0.135.2 to 0.135.3 in /backend
2026-04-19 02:26:54 +02:00
Christian Krakau-Louis 77878e3441 Merge pull request #177 from christianlouis/dependabot/pip/backend/aiohttp-3.13.5
chore(deps): bump aiohttp from 3.13.4 to 3.13.5 in /backend
2026-04-19 02:26:06 +02:00
dependabot[bot] 222fc96eff chore(deps): bump cryptography
Bumps the pip group with 1 update in the /backend directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.6 to 46.0.7
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.6...46.0.7)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.7
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 00:25:57 +00:00
Christian Krakau-Louis c735a77079 Merge pull request #178 from christianlouis/dependabot/npm_and_yarn/frontend/eslint-config-next-16.2.3
chore(deps): bump eslint-config-next from 16.2.2 to 16.2.3 in /frontend
2026-04-19 02:25:51 +02:00
Christian Krakau-Louis a9f3c22764 Merge pull request #179 from christianlouis/dependabot/pip/backend/google-auth-2.49.2
chore(deps): bump google-auth from 2.49.1 to 2.49.2 in /backend
2026-04-19 02:25:34 +02:00
Christian Krakau-Louis 3cf51e68ab Merge pull request #180 from christianlouis/dependabot/pip/backend/pytest-cov-7.1.0
chore(deps): bump pytest-cov from 4.1.0 to 7.1.0 in /backend
2026-04-19 02:25:16 +02:00
Christian Krakau-Louis 4f1635ee3b Merge pull request #182 from christianlouis/dependabot/pip/backend/prometheus-client-0.25.0
chore(deps): bump prometheus-client from 0.24.1 to 0.25.0 in /backend
2026-04-19 02:24:53 +02:00
Christian Krakau-Louis 40262c1d87 Merge pull request #183 from christianlouis/dependabot/pip/backend/pytest-9.0.3
chore(deps): bump pytest from 9.0.2 to 9.0.3 in /backend
2026-04-19 02:24:38 +02:00
dependabot[bot] 0ed6256fb7 chore(deps): bump axios from 1.14.0 to 1.15.0 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.14.0 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.14.0...v1.15.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 00:24:28 +00:00
Christian Krakau-Louis f66435cbb6 Merge pull request #185 from christianlouis/dependabot/pip/backend/python-multipart-0.0.26
chore(deps): bump python-multipart from 0.0.24 to 0.0.26 in /backend
2026-04-19 02:24:20 +02:00
Christian Krakau-Louis 5e959a6a6c Merge pull request #186 from christianlouis/dependabot/pip/backend/schedule-1.2.2
chore(deps): bump schedule from 1.2.0 to 1.2.2 in /backend
2026-04-19 02:23:59 +02:00
Christian Krakau-Louis 14ef146328 Merge pull request #187 from christianlouis/dependabot/npm_and_yarn/frontend/types/node-25.6.0
chore(deps): bump @types/node from 25.5.2 to 25.6.0 in /frontend
2026-04-19 02:23:34 +02:00
Christian Krakau-Louis 91dac9ccdb Merge pull request #188 from christianlouis/dependabot/npm_and_yarn/frontend/tanstack/react-query-5.99.0
chore(deps): bump @tanstack/react-query from 5.96.2 to 5.99.0 in /frontend
2026-04-19 02:23:11 +02:00
Christian Krakau-Louis 18726343a5 Merge pull request #189 from christianlouis/dependabot/npm_and_yarn/frontend/lucide-react-1.8.0
chore(deps): bump lucide-react from 1.7.0 to 1.8.0 in /frontend
2026-04-19 02:22:58 +02:00
semantic-release 06c59242c8 0.7.0
Automatically generated by python-semantic-release
2026-04-18 23:52:49 +00:00
Christian Krakau-Louis 34955881ee Merge pull request #190 from christianlouis/copilot/manage-token-timeouts
Add English privacy policy, Terms of Service, and legal footer links for Google OAuth verification
2026-04-19 01:52:30 +02:00
copilot-swe-agent[bot] 78610ef09d feat: add English privacy policy, ToS, and legal footer links for Google OAuth verification
- Add /privacy (English privacy policy with Google API Limited Use Disclosure)
- Add /terms (English Terms of Service)
- Fix home page footer: add Privacy Policy, Terms, Impressum, Datenschutz links
  (directly resolves Google verification rejection: "homepage has no privacy link")
- Update login page footer to include /privacy and /terms
- Add register page consent text linking to /privacy and /terms
- Add /privacy cross-link in German /datenschutz page (section 2 + footer)
- Update CHANGELOG.md and docs/TODO.md

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6800713f-ba49-421c-9e96-08d8ed6e48e4

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-18 17:22:50 +00:00
dependabot[bot] 095ee1059c chore(deps): bump lucide-react from 1.7.0 to 1.8.0 in /frontend
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.8.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:23:19 +00:00
dependabot[bot] b20da8940f chore(deps): bump @tanstack/react-query in /frontend
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.2 to 5.99.0.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.99.0/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.99.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:23:11 +00:00
dependabot[bot] 641007d51c chore(deps): bump @types/node from 25.5.2 to 25.6.0 in /frontend
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 25.6.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:23:03 +00:00
dependabot[bot] 06ea1958aa chore(deps): bump schedule from 1.2.0 to 1.2.2 in /backend
Bumps [schedule](https://github.com/dbader/schedule) from 1.2.0 to 1.2.2.
- [Changelog](https://github.com/dbader/schedule/blob/master/HISTORY.rst)
- [Commits](https://github.com/dbader/schedule/compare/1.2.0...1.2.2)

---
updated-dependencies:
- dependency-name: schedule
  dependency-version: 1.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:45 +00:00
dependabot[bot] 42c5900c06 chore(deps): bump python-multipart from 0.0.24 to 0.0.26 in /backend
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.24 to 0.0.26.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.24...0.0.26)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:41 +00:00
dependabot[bot] 8441f73478 chore(deps): bump pytest from 9.0.2 to 9.0.3 in /backend
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:33 +00:00
dependabot[bot] 8df1465a64 chore(deps): bump prometheus-client from 0.24.1 to 0.25.0 in /backend
Bumps [prometheus-client](https://github.com/prometheus/client_python) from 0.24.1 to 0.25.0.
- [Release notes](https://github.com/prometheus/client_python/releases)
- [Commits](https://github.com/prometheus/client_python/compare/v0.24.1...v0.25.0)

---
updated-dependencies:
- dependency-name: prometheus-client
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:27 +00:00
dependabot[bot] 24a85d6554 chore(deps): bump pytest-cov from 4.1.0 to 7.1.0 in /backend
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 4.1.0 to 7.1.0.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v4.1.0...v7.1.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:22 +00:00
dependabot[bot] 197d7fe060 chore(deps): bump google-auth from 2.49.1 to 2.49.2 in /backend
Bumps [google-auth](https://github.com/googleapis/google-auth-library-python) from 2.49.1 to 2.49.2.
- [Release notes](https://github.com/googleapis/google-auth-library-python/releases)
- [Changelog](https://github.com/googleapis/google-auth-library-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-auth-library-python/commits)

---
updated-dependencies:
- dependency-name: google-auth
  dependency-version: 2.49.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:18 +00:00
dependabot[bot] 45a237bdcb chore(deps): bump eslint-config-next from 16.2.2 to 16.2.3 in /frontend
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.2 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.3/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:16 +00:00
dependabot[bot] 4c03352650 chore(deps): bump aiohttp from 3.13.4 to 3.13.5 in /backend
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:13 +00:00
dependabot[bot] 56a85e9c9e chore(deps): bump fastapi from 0.135.2 to 0.135.3 in /backend
Bumps [fastapi](https://github.com/fastapi/fastapi) from 0.135.2 to 0.135.3.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.135.2...0.135.3)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.135.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 07:22:01 +00:00
semantic-release d4bd95efeb 0.6.5
Automatically generated by python-semantic-release
2026-04-07 11:09:52 +00:00
Christian Krakau-Louis 592495c1af Merge pull request #171 from christianlouis/copilot/ensure-emails-are-retried
fix: retry failed email forwards; only mark/delete successfully forwarded messages
2026-04-07 13:09:32 +02:00
copilot-swe-agent[bot] 74ef362833 fix: remove duplicate MailProcessor import in test_mail_processor_imap.py
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/15ec037d-0e37-4adb-8249-149dd5375121

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-07 10:46:50 +00:00
semantic-release 377f5a7270 0.6.4
Automatically generated by python-semantic-release
2026-04-07 10:44:54 +00:00
Christian Krakau-Louis b8aec18add Merge pull request #172 from christianlouis/copilot/fix-deprecated-pydantic-methods
fix: replace deprecated Pydantic .dict() with .model_dump() and fix db.add() AsyncMock warnings
2026-04-07 12:44:37 +02:00
copilot-swe-agent[bot] 31dc059274 fix: replace deprecated .dict() with .model_dump() and fix db.add() AsyncMock warnings
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/ac39d3f7-d4c3-4417-8c33-8e6340a27281

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-07 10:38:27 +00:00
copilot-swe-agent[bot] 3dd71cd61d fix: restore missing forward_email signature and reformat with black
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/3c4ae596-6052-4a61-9edd-b668a6cda32e

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-07 10:38:00 +00:00
Christian Krakau-Louis 4b203965c9 Merge pull request #131 from christianlouis/copilot/ensure-full-test-coverage
Add comprehensive test coverage for auth, mail_accounts, providers, gmail_service, AddMailAccountModal, and api.ts
2026-04-07 12:33:06 +02:00
copilot-swe-agent[bot] ff7b50d944 fix: retry failed forwarded emails; only post-process successfully forwarded messages
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/e1fb16dc-0187-4ab9-901a-e0a76de64599

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-07 10:32:38 +00:00
Christian Krakau-Louis 4c6fc339d2 Merge branch 'main' into copilot/ensure-full-test-coverage 2026-04-07 12:28:35 +02:00
semantic-release 025684386b 0.6.3
Automatically generated by python-semantic-release
2026-04-07 10:28:04 +00:00
Christian Krakau-Louis 517edfdfa3 Merge pull request #167 from christianlouis/dependabot/npm_and_yarn/frontend/eslint-10.2.0
chore(deps): bump eslint from 9.39.4 to 10.2.0 in /frontend
2026-04-07 12:27:47 +02:00
copilot-swe-agent[bot] efa553c8d5 fix(lint): specify explicit React version to fix ESLint 10 compatibility
eslint-plugin-react (bundled in eslint-config-next) calls context.getFilename()
during React version detection ('detect' mode). This API was removed in ESLint 10.
Specifying an explicit version string bypasses the detection code path.

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/b58e009c-5bb7-4a25-b0e2-89b2d08a5778

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-07 09:56:19 +00:00
Christian Krakau-Louis 05966f74ae Merge pull request #140 from christianlouis/dependabot/npm_and_yarn/frontend/lucide-react-1.7.0
chore(deps): bump lucide-react from 0.577.0 to 1.7.0 in /frontend
2026-04-07 11:45:36 +02:00
Christian Krakau-Louis e1fde09407 Merge pull request #158 from christianlouis/dependabot/npm_and_yarn/frontend/tanstack/react-query-5.96.2
chore(deps): bump @tanstack/react-query from 5.95.2 to 5.96.2 in /frontend
2026-04-07 11:45:28 +02:00
Christian Krakau-Louis 1f5b0fe38d Merge pull request #159 from christianlouis/dependabot/pip/backend/google-auth-httplib2-0.3.1
chore(deps): bump google-auth-httplib2 from 0.3.0 to 0.3.1 in /backend
2026-04-07 11:45:18 +02:00
Christian Krakau-Louis 900c279a28 Merge pull request #166 from christianlouis/dependabot/pip/backend/sqlalchemy-2.0.49
chore(deps): bump sqlalchemy from 2.0.48 to 2.0.49 in /backend
2026-04-07 11:44:00 +02:00
Christian Krakau-Louis 7503316435 Merge pull request #156 from christianlouis/dependabot/pip/backend/email-validator-2.3.0
chore(deps): bump email-validator from 2.1.0.post1 to 2.3.0 in /backend
2026-04-07 11:43:14 +02:00
Christian Krakau-Louis f91f1a7b8d Merge pull request #155 from christianlouis/dependabot/github_actions/docker/login-action-4
chore(ci): bump docker/login-action from 3 to 4
2026-04-07 11:42:29 +02:00
Christian Krakau-Louis a52bd5b2df Merge pull request #157 from christianlouis/dependabot/pip/backend/uvicorn-0.43.0
chore(deps): bump uvicorn from 0.42.0 to 0.43.0 in /backend
2026-04-07 11:42:19 +02:00
Christian Krakau-Louis 5dbc880cbc Merge pull request #160 from christianlouis/dependabot/npm_and_yarn/frontend/types/node-25.5.2
chore(deps): bump @types/node from 25.5.0 to 25.5.2 in /frontend
2026-04-07 11:42:05 +02:00
Christian Krakau-Louis 74f0c322c4 Merge pull request #161 from christianlouis/dependabot/pip/backend/faker-40.12.0
chore(deps): bump faker from 22.6.0 to 40.12.0 in /backend
2026-04-07 11:41:53 +02:00
Christian Krakau-Louis 1f8c4f46e6 Merge pull request #162 from christianlouis/dependabot/pip/backend/python-multipart-0.0.24
chore(deps): bump python-multipart from 0.0.22 to 0.0.24 in /backend
2026-04-07 11:41:15 +02:00
dependabot[bot] 233595e5dd chore(deps): bump google-auth-httplib2 from 0.3.0 to 0.3.1 in /backend
Bumps [google-auth-httplib2](https://github.com/googleapis/google-cloud-python) from 0.3.0 to 0.3.1.
- [Release notes](https://github.com/googleapis/google-cloud-python/releases)
- [Changelog](https://github.com/googleapis/google-cloud-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-python/compare/google-auth-httplib2-v0.3.0...google-auth-httplib2-v0.3.1)

---
updated-dependencies:
- dependency-name: google-auth-httplib2
  dependency-version: 0.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 09:40:09 +00:00
dependabot[bot] bfaa639eed chore(deps): bump lucide-react from 0.577.0 to 1.7.0 in /frontend
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.7.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.7.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 09:39:52 +00:00
dependabot[bot] 4bb92ac9d0 chore(deps): bump eslint from 9.39.4 to 10.2.0 in /frontend
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.4 to 10.2.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.2.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 09:39:51 +00:00
dependabot[bot] 466e852c8b chore(deps): bump @tanstack/react-query in /frontend
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.95.2 to 5.96.2.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.96.2/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.96.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 09:39:38 +00:00
dependabot[bot] 6283d1c4b4 chore(deps): bump sqlalchemy from 2.0.48 to 2.0.49 in /backend
Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.48 to 2.0.49.
- [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases)
- [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst)
- [Commits](https://github.com/sqlalchemy/sqlalchemy/commits)

---
updated-dependencies:
- dependency-name: sqlalchemy
  dependency-version: 2.0.49
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 09:38:54 +00:00
dependabot[bot] 6f414e175a chore(deps): bump email-validator from 2.1.0.post1 to 2.3.0 in /backend
Bumps [email-validator](https://github.com/JoshData/python-email-validator) from 2.1.0.post1 to 2.3.0.
- [Release notes](https://github.com/JoshData/python-email-validator/releases)
- [Changelog](https://github.com/JoshData/python-email-validator/blob/main/CHANGELOG.md)
- [Commits](https://github.com/JoshData/python-email-validator/commits/v2.3.0)

---
updated-dependencies:
- dependency-name: email-validator
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 09:38:47 +00:00
Christian Krakau-Louis af88176da4 Merge pull request #163 from christianlouis/dependabot/npm_and_yarn/frontend/next-16.2.2
chore(deps): bump next from 16.2.1 to 16.2.2 in /frontend
2026-04-07 11:38:38 +02:00
Christian Krakau-Louis d7479f5f8e Merge pull request #164 from christianlouis/dependabot/pip/backend/google-auth-oauthlib-1.3.1
chore(deps): bump google-auth-oauthlib from 1.2.0 to 1.3.1 in /backend
2026-04-07 11:38:24 +02:00
Christian Krakau-Louis 6bfedc748f Merge pull request #165 from christianlouis/dependabot/npm_and_yarn/frontend/eslint-config-next-16.2.2
chore(deps): bump eslint-config-next from 16.2.1 to 16.2.2 in /frontend
2026-04-07 11:38:15 +02:00
Christian Krakau-Louis a11831a725 Merge pull request #169 from christianlouis/dependabot/pip/backend/alembic-1.18.4
chore(deps): bump alembic from 1.13.1 to 1.18.4 in /backend
2026-04-07 11:37:47 +02:00
Christian Krakau-Louis cbf29d289e Merge pull request #168 from christianlouis/dependabot/pip/backend/aiosmtplib-5.1.0
chore(deps): bump aiosmtplib from 3.0.1 to 5.1.0 in /backend
2026-04-07 11:37:36 +02:00
Christian Krakau-Louis 0f5973680e Merge pull request #170 from christianlouis/dependabot/pip/backend/stripe-15.0.1
chore(deps): bump stripe from 14.4.1 to 15.0.1 in /backend
2026-04-07 11:37:19 +02:00
dependabot[bot] 582311a18e chore(deps): bump stripe from 14.4.1 to 15.0.1 in /backend
Bumps [stripe](https://github.com/stripe/stripe-python) from 14.4.1 to 15.0.1.
- [Release notes](https://github.com/stripe/stripe-python/releases)
- [Changelog](https://github.com/stripe/stripe-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-python/compare/v14.4.1...v15.0.1)

---
updated-dependencies:
- dependency-name: stripe
  dependency-version: 15.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:19:41 +00:00
dependabot[bot] bd51a5b541 chore(deps): bump alembic from 1.13.1 to 1.18.4 in /backend
Bumps [alembic](https://github.com/sqlalchemy/alembic) from 1.13.1 to 1.18.4.
- [Release notes](https://github.com/sqlalchemy/alembic/releases)
- [Changelog](https://github.com/sqlalchemy/alembic/blob/main/CHANGES)
- [Commits](https://github.com/sqlalchemy/alembic/commits)

---
updated-dependencies:
- dependency-name: alembic
  dependency-version: 1.18.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:19:35 +00:00
dependabot[bot] 284fbba47f chore(deps): bump aiosmtplib from 3.0.1 to 5.1.0 in /backend
Bumps [aiosmtplib](https://github.com/cole/aiosmtplib) from 3.0.1 to 5.1.0.
- [Release notes](https://github.com/cole/aiosmtplib/releases)
- [Changelog](https://github.com/cole/aiosmtplib/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/cole/aiosmtplib/compare/v3.0.1...v5.1.0)

---
updated-dependencies:
- dependency-name: aiosmtplib
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:19:32 +00:00
dependabot[bot] 9d53b117d4 chore(deps): bump eslint-config-next from 16.2.1 to 16.2.2 in /frontend
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.1 to 16.2.2.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.2/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:19:20 +00:00
dependabot[bot] 3bb7a7cd46 chore(deps): bump google-auth-oauthlib from 1.2.0 to 1.3.1 in /backend
Bumps [google-auth-oauthlib](https://github.com/googleapis/google-cloud-python) from 1.2.0 to 1.3.1.
- [Release notes](https://github.com/googleapis/google-cloud-python/releases)
- [Changelog](https://github.com/googleapis/google-cloud-python/blob/main/packages/gcp-sphinx-docfx-yaml/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-python/compare/kms-1.2.0...google-auth-oauthlib-v1.3.1)

---
updated-dependencies:
- dependency-name: google-auth-oauthlib
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:19:18 +00:00
dependabot[bot] a5d8700119 chore(deps): bump next from 16.2.1 to 16.2.2 in /frontend
Bumps [next](https://github.com/vercel/next.js) from 16.2.1 to 16.2.2.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.1...v16.2.2)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:19:06 +00:00
dependabot[bot] f479974a5a chore(deps): bump python-multipart from 0.0.22 to 0.0.24 in /backend
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.22 to 0.0.24.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.22...0.0.24)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:18:55 +00:00
dependabot[bot] 9553fae6b7 chore(deps): bump faker from 22.6.0 to 40.12.0 in /backend
Bumps [faker](https://github.com/joke2k/faker) from 22.6.0 to 40.12.0.
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/joke2k/faker/compare/v22.6.0...v40.12.0)

---
updated-dependencies:
- dependency-name: faker
  dependency-version: 40.12.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:18:52 +00:00
dependabot[bot] cc1edd8f5d chore(deps): bump @types/node from 25.5.0 to 25.5.2 in /frontend
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.0 to 25.5.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:18:47 +00:00
dependabot[bot] b2d044d8c2 chore(deps): bump uvicorn from 0.42.0 to 0.43.0 in /backend
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.42.0 to 0.43.0.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.42.0...0.43.0)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:18:22 +00:00
dependabot[bot] 6fe2f9ad95 chore(ci): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 07:18:18 +00:00
semantic-release 89b23e641f 0.6.2
Automatically generated by python-semantic-release
2026-04-05 19:29:15 +00:00
Christian Krakau-Louis 0ffe5fd875 Merge pull request #154 from christianlouis/copilot/analyze-test-coverage
test: add 150 unit tests covering 10 previously untested backend modules
2026-04-05 21:28:57 +02:00
copilot-swe-agent[bot] 7afa33f375 style: reformat test_gdpr.py and test_gmail_labels.py to fix black CI check
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/63618a76-39b3-43af-b7a8-be2ae3d3ec8a

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-05 19:23:22 +00:00
Christian Krakau-Louis 5f0e9ed598 Merge branch 'main' into copilot/analyze-test-coverage 2026-04-05 20:55:08 +02:00
semantic-release 3a2066d320 0.6.1
Automatically generated by python-semantic-release
2026-04-05 15:35:23 +00:00
Christian Krakau-Louis 0a42c5c6e9 Merge pull request #135 from christianlouis/dependabot/pip/backend/pytest-9.0.2
chore(deps): bump pytest from 7.4.4 to 9.0.2 in /backend
2026-04-05 17:35:02 +02:00
copilot-swe-agent[bot] 149ff3d997 fix: address CodeQL URL substring sanitization false positives in tests
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/8cb47b1c-b744-4212-8b6d-8132d6642d98

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-05 15:12:32 +00:00
copilot-swe-agent[bot] 0521c97394 docs: fix CHANGELOG entry to only reference pytest-asyncio bump
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/7634797f-a0bf-40e9-abd5-4504b7e4e357

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-05 15:11:33 +00:00
copilot-swe-agent[bot] 39052af4e2 fix: upgrade pytest-asyncio to 1.3.0 for pytest 9.x compatibility
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/7634797f-a0bf-40e9-abd5-4504b7e4e357

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-05 15:11:10 +00:00
copilot-swe-agent[bot] 58f9ad72ed test: add 150 new unit tests covering auth, users, notifications, logs, settings, GDPR, and Gmail label utilities
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/8cb47b1c-b744-4212-8b6d-8132d6642d98

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-04-05 15:10:38 +00:00
dependabot[bot] 4a6f93e5ac chore(deps): bump pytest from 7.4.4 to 9.0.2 in /backend
Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.4.4 to 9.0.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/7.4.4...9.0.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-05 15:05:10 +00:00
Christian Krakau-Louis 61c5f70158 Merge pull request #133 from christianlouis/dependabot/github_actions/actions/checkout-6
chore(ci): bump actions/checkout from 4 to 6
2026-04-05 17:04:07 +02:00
Christian Krakau-Louis 1c926cca73 Merge pull request #134 from christianlouis/dependabot/github_actions/docker/build-push-action-7
chore(ci): bump docker/build-push-action from 5 to 7
2026-04-05 17:03:59 +02:00
Christian Krakau-Louis 1c3d902c9b Merge pull request #136 from christianlouis/dependabot/pip/backend/python-dotenv-1.2.2
chore(deps): bump python-dotenv from 1.0.0 to 1.2.2 in /backend
2026-04-05 17:03:54 +02:00
Christian Krakau-Louis 587c2bf874 Merge pull request #137 from christianlouis/dependabot/github_actions/codecov/codecov-action-6
chore(ci): bump codecov/codecov-action from 5 to 6
2026-04-05 17:03:45 +02:00
Christian Krakau-Louis 190e41058e Merge pull request #138 from christianlouis/dependabot/pip/backend/celery-5.6.3
chore(deps): bump celery from 5.6.2 to 5.6.3 in /backend
2026-04-05 17:03:37 +02:00
Christian Krakau-Louis 0012972ed6 Merge pull request #139 from christianlouis/dependabot/npm_and_yarn/frontend/types/node-25.5.0
chore(deps): bump @types/node from 20.19.30 to 25.5.0 in /frontend
2026-04-05 17:03:22 +02:00
Christian Krakau-Louis 987a91b627 Merge pull request #141 from christianlouis/dependabot/github_actions/mikefarah/yq-4.52.5
chore(ci): bump mikefarah/yq from 4.44.6 to 4.52.5
2026-04-05 17:03:06 +02:00
Christian Krakau-Louis 669ddf70fb Merge pull request #143 from christianlouis/dependabot/pip/backend/bcrypt-5.0.0
chore(deps): bump bcrypt from 4.3.0 to 5.0.0 in /backend
2026-04-05 17:02:56 +02:00
Christian Krakau-Louis c764f5bb6f Merge pull request #144 from christianlouis/dependabot/github_actions/docker/metadata-action-6
chore(ci): bump docker/metadata-action from 5 to 6
2026-04-05 17:02:49 +02:00
dependabot[bot] 5992a9e802 chore(deps): bump celery from 5.6.2 to 5.6.3 in /backend
Bumps [celery](https://github.com/celery/celery) from 5.6.2 to 5.6.3.
- [Release notes](https://github.com/celery/celery/releases)
- [Changelog](https://github.com/celery/celery/blob/v5.6.3/Changelog.rst)
- [Commits](https://github.com/celery/celery/compare/v5.6.2...v5.6.3)

---
updated-dependencies:
- dependency-name: celery
  dependency-version: 5.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-05 15:02:44 +00:00
Christian Krakau-Louis 3432b93712 Merge pull request #145 from christianlouis/dependabot/pip/backend/httpx-0.28.1
chore(deps): bump httpx from 0.26.0 to 0.28.1 in /backend
2026-04-05 17:02:41 +02:00
Christian Krakau-Louis e2b3bac1cc Merge pull request #146 from christianlouis/dependabot/pip/backend/uvicorn-0.42.0
chore(deps): bump uvicorn from 0.27.0 to 0.42.0 in /backend
2026-04-05 17:02:31 +02:00
Christian Krakau-Louis bdbc49e5ba Merge pull request #151 from christianlouis/dependabot/npm_and_yarn/frontend/axios-1.14.0
chore(deps): bump axios from 1.13.6 to 1.14.0 in /frontend
2026-04-05 17:02:21 +02:00
Christian Krakau-Louis c06074e0d5 Merge pull request #147 from christianlouis/dependabot/pip/backend/apprise-1.9.9
chore(deps): bump apprise from 1.7.1 to 1.9.9 in /backend
2026-04-05 17:02:11 +02:00
Christian Krakau-Louis e5d3a7c166 Merge pull request #149 from christianlouis/dependabot/pip/backend/prometheus-client-0.24.1
chore(deps): bump prometheus-client from 0.19.0 to 0.24.1 in /backend
2026-04-05 17:01:57 +02:00
Christian Krakau-Louis 0ac7bd7fb4 Merge pull request #142 from christianlouis/dependabot/pip/backend/aiohttp-3.13.4
chore(deps): bump aiohttp from 3.13.3 to 3.13.4 in /backend
2026-04-05 17:01:45 +02:00
dependabot[bot] 3b580c36c7 chore(deps): bump axios from 1.13.6 to 1.14.0 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.13.6 to 1.14.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.6...v1.14.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-05 15:00:44 +00:00
Christian Krakau-Louis e669cc54d4 Merge pull request #148 from christianlouis/dependabot/pip/backend/redis-7.4.0
chore(deps): bump redis from 7.3.0 to 7.4.0 in /backend
2026-04-05 17:00:36 +02:00
Christian Krakau-Louis 937acf5fe8 Merge pull request #150 from christianlouis/dependabot/npm_and_yarn/frontend/next-16.2.1
chore(deps): bump next from 16.1.7 to 16.2.1 in /frontend
2026-04-05 17:00:28 +02:00
Christian Krakau-Louis e84fb44bcb Merge pull request #152 from christianlouis/dependabot/npm_and_yarn/frontend/typescript-6.0.2
chore(deps): bump typescript from 5.9.3 to 6.0.2 in /frontend
2026-04-05 16:59:36 +02:00
Christian Krakau-Louis 1fc95b4be8 Merge pull request #153 from christianlouis/dependabot/npm_and_yarn/frontend/tanstack/react-query-5.95.2
chore(deps): bump @tanstack/react-query from 5.95.0 to 5.95.2 in /frontend
2026-04-05 16:59:26 +02:00
dependabot[bot] 348c3477dc chore(deps): bump @tanstack/react-query in /frontend
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.95.0 to 5.95.2.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.95.2/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.95.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:22:36 +00:00
dependabot[bot] 726e089c46 chore(deps): bump typescript from 5.9.3 to 6.0.2 in /frontend
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:22:26 +00:00
dependabot[bot] ec90cd600b chore(deps): bump next from 16.1.7 to 16.2.1 in /frontend
Bumps [next](https://github.com/vercel/next.js) from 16.1.7 to 16.2.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.7...v16.2.1)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:22:05 +00:00
dependabot[bot] 77c84266fd chore(deps): bump prometheus-client from 0.19.0 to 0.24.1 in /backend
Bumps [prometheus-client](https://github.com/prometheus/client_python) from 0.19.0 to 0.24.1.
- [Release notes](https://github.com/prometheus/client_python/releases)
- [Commits](https://github.com/prometheus/client_python/compare/v0.19.0...v0.24.1)

---
updated-dependencies:
- dependency-name: prometheus-client
  dependency-version: 0.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:22:03 +00:00
dependabot[bot] b914ac0ecd chore(deps): bump redis from 7.3.0 to 7.4.0 in /backend
Bumps [redis](https://github.com/redis/redis-py) from 7.3.0 to 7.4.0.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v7.3.0...v7.4.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:22:00 +00:00
dependabot[bot] 094b1e177f chore(deps): bump apprise from 1.7.1 to 1.9.9 in /backend
Bumps [apprise](https://github.com/caronc/apprise) from 1.7.1 to 1.9.9.
- [Release notes](https://github.com/caronc/apprise/releases)
- [Commits](https://github.com/caronc/apprise/compare/v1.7.1...v1.9.9)

---
updated-dependencies:
- dependency-name: apprise
  dependency-version: 1.9.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:56 +00:00
dependabot[bot] ac6a837522 chore(deps): bump uvicorn from 0.27.0 to 0.42.0 in /backend
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.27.0 to 0.42.0.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.27.0...0.42.0)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:53 +00:00
dependabot[bot] c50cf6e0b3 chore(deps): bump httpx from 0.26.0 to 0.28.1 in /backend
Bumps [httpx](https://github.com/encode/httpx) from 0.26.0 to 0.28.1.
- [Release notes](https://github.com/encode/httpx/releases)
- [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/encode/httpx/compare/0.26.0...0.28.1)

---
updated-dependencies:
- dependency-name: httpx
  dependency-version: 0.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:50 +00:00
dependabot[bot] 0e87da85a6 chore(ci): bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:48 +00:00
dependabot[bot] f0213a0b03 chore(deps): bump bcrypt from 4.3.0 to 5.0.0 in /backend
Bumps [bcrypt](https://github.com/pyca/bcrypt) from 4.3.0 to 5.0.0.
- [Changelog](https://github.com/pyca/bcrypt/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/bcrypt/compare/4.3.0...5.0.0)

---
updated-dependencies:
- dependency-name: bcrypt
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:47 +00:00
dependabot[bot] f4c8e4a1dc chore(ci): bump mikefarah/yq from 4.44.6 to 4.52.5
Bumps [mikefarah/yq](https://github.com/mikefarah/yq) from 4.44.6 to 4.52.5.
- [Release notes](https://github.com/mikefarah/yq/releases)
- [Changelog](https://github.com/mikefarah/yq/blob/master/release_notes.txt)
- [Commits](https://github.com/mikefarah/yq/compare/v4.44.6...v4.52.5)

---
updated-dependencies:
- dependency-name: mikefarah/yq
  dependency-version: 4.52.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:44 +00:00
dependabot[bot] d919939647 chore(deps): bump aiohttp from 3.13.3 to 3.13.4 in /backend
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:44 +00:00
dependabot[bot] 917dacc7cb chore(deps): bump @types/node from 20.19.30 to 25.5.0 in /frontend
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.30 to 25.5.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:29 +00:00
dependabot[bot] 594e380301 chore(ci): bump codecov/codecov-action from 5 to 6
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:23 +00:00
dependabot[bot] 4dbe30b0ea chore(deps): bump python-dotenv from 1.0.0 to 1.2.2 in /backend
Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.0.0 to 1.2.2.
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.2.2)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-version: 1.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:22 +00:00
dependabot[bot] 2c26c156f6 chore(ci): bump docker/build-push-action from 5 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:18 +00:00
dependabot[bot] 68a6163fef chore(ci): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 07:21:13 +00:00
copilot-swe-agent[bot] a5920937cb docs: update TODO.md with gmail_service test coverage entry
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/250e0731-baa3-40c5-bc19-a4102475dac2

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 01:04:55 +00:00
copilot-swe-agent[bot] c3ee2fc821 test: expand gmail_service.py unit test coverage (10 new tests)
Add tests for uncovered branches:
- inject_email HttpError handling
- get_or_create_label: existing label, create new, HttpError, generic error
- inject_debug_email full flow + dedup of test label
- get_refreshed_token: token changed vs unchanged
- service property lazy initialization via build()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 01:01:33 +00:00
copilot-swe-agent[bot] 8eff0833fa test: expand api.ts test coverage with 56 new tests for all API objects
Add comprehensive tests for all 10 exported API objects: authApi,
userApi, mailAccountsApi, processingRunsApi, gmailApi, smtpApi,
adminApi, notificationsApi, adminNotificationsApi, and versionApi.

Each method is tested for correct HTTP method, URL, parameters/data,
and return value. Total api.test.ts tests: 65 (up from 9).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:58:20 +00:00
copilot-swe-agent[bot] d79f9caf9e Add comprehensive tests for AddMailAccountModal component
28 tests covering:
- Create/edit mode rendering and titles
- Provider wizard flow (select, manual, back)
- Form field changes, username/email sync, checkboxes
- Auto-detect success, failure, and validation
- Test connection success, error, validation, existing account
- Submit mutations (create/update, password omission)
- Modal close interactions (X, backdrop, cancel)
- Saving state indicators
- Error message extraction (string, array, Error)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:54:28 +00:00
copilot-swe-agent[bot] 4d1451ecde test: add 23 unit tests for provider endpoints
Cover all 9 endpoints in providers.py:
- Provider presets (list all, get by ID, not found)
- Gmail credential CRUD (create, update, get, delete)
- Import labels update
- Gmail authorize URL (success, not configured)
- Debug email (success, no credentials, injection failure)
- OAuth callback (new/update cred, not configured, token exchange
  failure, missing access token, verification failure)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:45:50 +00:00
copilot-swe-agent[bot] 65318b8c1b Add unit tests for mail account endpoints (30 tests)
Comprehensive test coverage for backend/app/api/v1/endpoints/mail_accounts.py:
- CRUD operations (create, list, get, update, delete)
- Subscription limit enforcement and superuser bypass
- Account toggle with ERROR→ACTIVE status reset
- Pull-now with disabled account guard
- Test connection (new and existing) with decrypt failure handling
- Auto-detect mail settings
- Paginated processing runs and logs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:37:44 +00:00
copilot-swe-agent[bot] 4d07d04c63 Add comprehensive unit tests for auth endpoints
22 tests covering:
- Helper functions (_domain_of, _check_domain_allowed, _default_tier, _is_admin_email)
- POST /register (success, duplicate email, domain restriction)
- POST /login (success, user not found, wrong password, inactive, admin auto-promotion)
- POST /google (existing user, new user, unverified email, domain restriction)
- GET /google/authorize-url (correct URL construction)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:30:38 +00:00
copilot-swe-agent[bot] 55536c964f Initial plan 2026-03-29 00:21:25 +00:00
69 changed files with 12488 additions and 1479 deletions
+17 -26
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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")
+2 -2
View File
@@ -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)
+92 -55
View File
@@ -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)
+168 -2
View File
@@ -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
+95 -1
View File
@@ -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}",
)
+9
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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),
+8
View File
@@ -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
+41 -3
View File
@@ -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)})
+152 -3
View File
@@ -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
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+487
View File
@@ -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
+371
View File
@@ -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
+192
View File
@@ -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"]
+1 -2
View File
@@ -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"
)
+212
View File
@@ -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"]
+101
View File
@@ -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
+183
View File
@@ -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
+287 -1
View File
@@ -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
+335
View File
@@ -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
+775
View File
@@ -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
+189 -14
View File
@@ -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()
+175 -17
View File
@@ -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
+709
View File
@@ -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()
+256
View File
@@ -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
+248
View File
@@ -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
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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;
+1025 -903
View File
File diff suppressed because it is too large Load Diff
+16 -11
View File
@@ -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"
}
+53 -32
View File
@@ -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
+46 -28
View File
@@ -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>
+16 -2
View File
@@ -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>
+96 -10
View File
@@ -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);
}
+35 -21
View File
@@ -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&apos;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>
+67 -7
View File
@@ -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
View File
@@ -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&apos;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&apos;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&apos;s it. Your mail arrives automatically from now on.
Check the dashboard whenever you like, but you won&apos;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>
+542
View File
@@ -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. &ldquo;imported&rdquo;).
</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}&rsquo;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&rsquo;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 &ldquo;Last
updated&rdquo; 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>
);
}
+53 -18
View File
@@ -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>
);
+64 -1
View File
@@ -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>
+412
View File
@@ -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} (&ldquo;the Service&rdquo;), you agree
to be bound by these Terms of Service (&ldquo;Terms&rdquo;). 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 (&ldquo;we&rdquo;, &ldquo;us&rdquo;,
&ldquo;our&rdquo;).
</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&rsquo;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}&rsquo;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&rsquo;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 &ldquo;as is&rdquo; and &ldquo;as
available&rdquo; 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&rsquo;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&rsquo; 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>
+16
View File
@@ -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>
);
}
+49 -48
View File
@@ -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) ────────────────────────────────────────────
+26 -4
View File
@@ -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':
+733
View File
@@ -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);
});
});
+17
View File
@@ -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
View File
@@ -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
View File
@@ -1,2 +1,2 @@
schedule==1.2.0
python-dotenv==1.0.0
python-dotenv==1.2.2