444 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
Christian Krakau-Louis 6e7139645b Merge pull request #132 from christianlouis/copilot/update-readme-docs-and-badges
docs: rewrite README as a targeted replacement for Google's 2026 POP/Gmailify shutdown
2026-03-29 01:57:50 +01:00
copilot-swe-agent[bot] 9c12555a81 docs: fix secret key generation syntax in README quickstart
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/917d1096-2c18-4324-96db-036f51d98bc3

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:56:37 +00:00
copilot-swe-agent[bot] 06efeed7be docs: rewrite README focused on Google 2026 POP/Gmailify shutdown replacement
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/917d1096-2c18-4324-96db-036f51d98bc3

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:56:01 +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] 88e8abae4e Initial plan 2026-03-29 00:52:22 +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
Christian Krakau-Louis 5ea44d1822 Merge pull request #127 from christianlouis/copilot/improve-test-coverage-tasks
Add 40 unit tests for backend/app/workers/tasks.py (9% → 96% coverage)
2026-03-29 01:15:53 +01:00
Christian Krakau-Louis 815629910b Merge branch 'main' into copilot/improve-test-coverage-tasks 2026-03-29 01:15:40 +01:00
Christian Krakau-Louis b6999baf2c Merge pull request #129 from christianlouis/copilot/improve-test-coverage-admin-file
Add 87 unit tests for admin endpoints (24% → 100% coverage)
2026-03-29 01:15:13 +01:00
Christian Krakau-Louis ddad290e82 Merge branch 'main' into copilot/improve-test-coverage-tasks 2026-03-29 01:11:22 +01:00
Christian Krakau-Louis 3cd7a781f6 Merge branch 'main' into copilot/improve-test-coverage-admin-file 2026-03-29 01:10:45 +01:00
semantic-release 2c1f7125e4 0.6.0
Automatically generated by python-semantic-release
2026-03-29 00:10:29 +00:00
Christian Krakau-Louis e2c5c8580b Merge pull request #130 from christianlouis/copilot/improve-frontend-test-coverage
feat: frontend test coverage 6 → 119 tests across all components
2026-03-29 01:10:08 +01:00
Christian Krakau-Louis aa754bf217 Merge branch 'main' into copilot/improve-frontend-test-coverage 2026-03-29 01:10:01 +01:00
Christian Krakau-Louis f847625442 Merge pull request #128 from christianlouis/copilot/improve-test-coverage-mail-processor
Improve mail_processor.py test coverage from 42% to 98%
2026-03-29 01:08:15 +01:00
copilot-swe-agent[bot] 5423f2bb94 Fix duplicate ### Fixed section in CHANGELOG.md
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/d17e4b46-03fd-450a-af61-1870011377b4

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:07:49 +00:00
copilot-swe-agent[bot] e52f00bc16 Move model imports to module level per code review feedback
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/519609d2-8cd4-44e8-96b0-4e9aaa76b45a

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:06:19 +00:00
copilot-swe-agent[bot] da9d822afa Add 87 unit tests for admin endpoints (24% → 100% coverage), fix conftest.py fixtures
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/d17e4b46-03fd-450a-af61-1870011377b4

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:06:02 +00:00
copilot-swe-agent[bot] 769907a65b feat: add NotificationWizard + ProviderWizard tests, fix lint, update docs
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6b527e29-8e26-4f86-88e2-847687f759ad

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:05:38 +00:00
copilot-swe-agent[bot] 983ce75264 Add 40 unit tests for backend/app/workers/tasks.py (9% → 96% coverage)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/519609d2-8cd4-44e8-96b0-4e9aaa76b45a

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:04:40 +00:00
copilot-swe-agent[bot] eb47594e53 Add 46 unit tests for mail_processor.py: POP3, forwarding, connection testing, and IMAP edge cases (coverage 42%→98%)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/4dd19cb3-dd43-4b00-be39-493fa3a35458

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:03:58 +00:00
copilot-swe-agent[bot] 63839c3886 feat: add frontend test coverage for date-utils, api interceptors, AuthGuard, QueryProvider, DashboardLayout
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6b527e29-8e26-4f86-88e2-847687f759ad

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-29 00:00:08 +00:00
copilot-swe-agent[bot] 7dd0b5f600 chore: initial plan for frontend test coverage
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6b527e29-8e26-4f86-88e2-847687f759ad

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 23:56:11 +00:00
copilot-swe-agent[bot] 187bf16866 Initial plan 2026-03-28 23:53:30 +00:00
copilot-swe-agent[bot] c0e2dd22cb Initial plan 2026-03-28 23:52:43 +00:00
copilot-swe-agent[bot] 8b6436d1c6 Initial plan 2026-03-28 23:52:20 +00:00
copilot-swe-agent[bot] 0bf943d24f Initial plan 2026-03-28 23:51:54 +00:00
Christian Krakau-Louis 0568caeb8f Merge pull request #125 from christianlouis/copilot/fix-mail-account-processing-issues
Silence recurring log noise from aioimaplib and Gmail discovery client
2026-03-29 00:51:20 +01:00
Christian Krakau-Louis cd85fba347 Merge pull request #126 from christianlouis/copilot/add-logos-to-mail-accounts
Add domain-based logo fallback for mail accounts without provider_name
2026-03-29 00:49:17 +01:00
copilot-swe-agent[bot] dd9a80a8c5 Add domain-based logo fallback for mail accounts without provider_name
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/2a2d3ad8-ff03-4ff2-9dc0-0d1e58ac050d

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 23:42:28 +00:00
copilot-swe-agent[bot] 029c272a4e Fix log noise: cache_discovery=False for Gmail API, suppress aioimaplib INFO logs
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/8855bf16-124d-4d85-b6bd-ab82e8361114

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 23:40:49 +00:00
copilot-swe-agent[bot] 337423bf68 Initial plan 2026-03-28 23:36:56 +00:00
copilot-swe-agent[bot] 63bb458815 Initial plan 2026-03-28 23:36:10 +00:00
semantic-release 703bbb42d3 0.5.1
Automatically generated by python-semantic-release
2026-03-28 23:35:28 +00:00
Christian Krakau-Louis c813df8a03 Merge pull request #124 from christianlouis/copilot/fix-eslint-require-imports
fix(frontend): convert jest.config.js to ES module syntax
2026-03-29 00:35:06 +01:00
copilot-swe-agent[bot] 0a0836e993 fix: convert jest.config.js to jest.config.mjs (ES module syntax)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6e25705d-50db-4b0c-969c-1be4cb3f1745

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 23:27:56 +00:00
copilot-swe-agent[bot] b866627937 Initial plan 2026-03-28 23:26:24 +00:00
semantic-release 3db6c9fb11 0.5.0
Automatically generated by python-semantic-release
2026-03-28 23:23:27 +00:00
Christian Krakau-Louis d098126542 Merge pull request #123 from christianlouis/copilot/setup-codecov-integration
feat(ci): Add Codecov integration with frontend Jest coverage
2026-03-29 00:23:02 +01:00
copilot-swe-agent[bot] 0645bc0462 feat(ci): add Codecov integration with frontend Jest coverage and CODECOV_TOKEN
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/53f8deb9-5c3a-4940-a058-c8ee7e4104e0

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 23:14:15 +00:00
copilot-swe-agent[bot] 2638f415aa Initial plan 2026-03-28 23:07:47 +00:00
semantic-release 3a11e07f09 0.4.5
Automatically generated by python-semantic-release
2026-03-28 23:07:38 +00:00
Christian Krakau-Louis 85fdf0a600 Merge pull request #122 from christianlouis/copilot/run-safety-scan-json
ci: replace `safety scan` with `pip-audit` to fix interactive EOF failure
2026-03-29 00:07:19 +01:00
copilot-swe-agent[bot] 8152635238 fix(ci): replace safety scan with pip-audit to fix CI EOF error
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/986fc7f4-e884-4169-bd81-eca034e926e2

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 23:04:23 +00:00
copilot-swe-agent[bot] 61d525cb82 Initial plan 2026-03-28 23:03:11 +00:00
semantic-release 90665ea100 0.4.4
Automatically generated by python-semantic-release
2026-03-28 22:51:23 +00:00
Christian Krakau-Louis 9712e8a3b6 Merge pull request #121 from christianlouis/copilot/fix-new-mail-account-wizard
fix: mail account wizard shows [object Object] on save error
2026-03-28 23:51:01 +01:00
copilot-swe-agent[bot] 191fee9be4 fix: show proper error message instead of [object Object] in mail account wizard
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/404b7221-8b33-4178-8601-6c0815552c7f

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 22:14:58 +00:00
copilot-swe-agent[bot] 5ff5cb54be Initial plan 2026-03-28 22:09:14 +00:00
Christian Krakau-Louis d6235852c2 Merge pull request #120 from christianlouis/copilot/debug-t-online-mail-issue
Fix all IMAP emails appearing empty (T-Online, GMX, and any IMAP account)
2026-03-28 23:08:56 +01:00
copilot-swe-agent[bot] da05016143 Fix IMAP emails appearing empty: accept bytearray from aioimaplib literals
aioimaplib stores RFC822 literal data as bytearray in response.lines,
not bytes. The extraction loop checked isinstance(line, bytes) which
returns False for bytearray, silently dropping every email body and
causing all IMAP emails (T-Online, GMX, etc.) to appear empty.

Fix: accept (bytes, bytearray) and convert to bytes() immediately so
the rest of the pipeline is unaffected. Two new regression tests mirror
the real aioimaplib behaviour by passing bytearray as the literal.

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/341f5c82-70f8-438d-91ca-8e906a06c400

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 22:08:04 +00:00
copilot-swe-agent[bot] fd2adb08cd Initial plan 2026-03-28 21:58:09 +00:00
semantic-release 1377ca266c 0.4.3
Automatically generated by python-semantic-release
2026-03-28 21:46:50 +00:00
Christian Krakau-Louis 99bfff541f Merge pull request #119 from christianlouis/copilot/check-empty-emails-and-imap-errors
fix: drop empty emails, clear stale IMAP errors on success, harden IMAP RFC822 extraction
2026-03-28 22:46:27 +01:00
copilot-swe-agent[bot] 0720901265 fix: drop empty emails, clear stale errors on success, harden IMAP extraction
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/d50e7f06-4c6f-4caa-b5dd-329068e8096c

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 21:42:19 +00:00
copilot-swe-agent[bot] db3ce61467 fix: apply parseUTC to dashboard, unify date helpers in date-utils.ts
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/3f06f1fc-4d21-47eb-b883-070c6cbefa93

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 21:27:51 +00:00
copilot-swe-agent[bot] 129d8a1bdd Initial plan 2026-03-28 21:23:19 +00:00
Christian Krakau-Louis 466e380cb2 Merge pull request #118 from christianlouis/copilot/upgrade-fastapi-starlette
security: fix safety CI failures — upgrade starlette, fix deprecated scan command, suppress unfixable ecdsa CVEs
2026-03-28 22:07:20 +01:00
Christian Krakau-Louis eae2f59c53 Merge branch 'main' into copilot/upgrade-fastapi-starlette 2026-03-28 22:07:04 +01:00
copilot-swe-agent[bot] 5bddbb42f5 security: upgrade fastapi to 0.135.2, fix safety scan command, add safety policy for ecdsa CVEs
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/902f3b74-792c-4afa-8aae-96611262ee5f

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 21:04:13 +00:00
Christian Krakau-Louis 8763881b6c Merge pull request #117 from christianlouis/copilot/fix-imap-protocol-bugs
Fix IMAP UID SEARCH crash and missing RFC 3501 flag parentheses
2026-03-28 22:02:12 +01:00
copilot-swe-agent[bot] 062739a9a1 Fix IMAP UID SEARCH crash and RFC 3501 flag parentheses
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/a44d5455-25ea-49b9-8310-4efd6d467e84

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 21:00:44 +00:00
copilot-swe-agent[bot] 6a4b991f29 Initial plan 2026-03-28 21:00:21 +00:00
copilot-swe-agent[bot] 6b9e12017e Initial plan 2026-03-28 20:53:27 +00:00
semantic-release 9017505b0f 0.4.2
Automatically generated by python-semantic-release
2026-03-28 20:48:29 +00:00
Christian Krakau-Louis 56df261eae Merge pull request #115 from christianlouis/copilot/fix-ci-pipeline-node-update
fix: CI pipeline — Node.js 20 deprecation, Codecov invalid input, `<img>` lint errors
2026-03-28 21:48:08 +01:00
Christian Krakau-Louis aabccbe96d Merge pull request #116 from christianlouis/copilot/fix-hardcoded-bind-all-interfaces
fix: suppress bandit B104 false positive for intentional 0.0.0.0 binding
2026-03-28 21:47:55 +01:00
copilot-swe-agent[bot] 40c23c43fe fix: CI pipeline Node.js 20 deprecation, Codecov input, img lint errors
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6bbc5b0e-ece1-4bed-8bf7-20e1e7027948

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 20:39:15 +00:00
copilot-swe-agent[bot] c092dd9ac6 fix: suppress bandit B104 false positive for 0.0.0.0 binding in config.py
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/3b265b6d-865c-4453-b22c-60feff2aa049

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 20:37:25 +00:00
copilot-swe-agent[bot] c406cb09d3 Initial plan 2026-03-28 20:34:53 +00:00
copilot-swe-agent[bot] 15f1108732 Initial plan 2026-03-28 20:33:54 +00:00
Christian Krakau-Louis 19a1b3d066 Merge pull request #114 from christianlouis/copilot/fix-black-check-errors
style: fix black formatting in test_mail_processor_imap.py
2026-03-28 21:29:38 +01:00
copilot-swe-agent[bot] 3c8bb4bfb6 style: apply black formatting to test_mail_processor_imap.py
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/2f63c66a-f2dd-41f4-aba4-b89b66f9e11b

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 20:28:33 +00:00
copilot-swe-agent[bot] feeb6fc99c Initial plan 2026-03-28 20:27:29 +00:00
semantic-release 9e0d143f8d 0.4.1
Automatically generated by python-semantic-release
2026-03-28 20:22:22 +00:00
Christian Krakau-Louis 472f0bf57e Merge pull request #113 from christianlouis/copilot/debug-imap-errors-t-online
Fix IMAP "Too many invalid IMAP commands" / mid-session BYE errors
2026-03-28 21:22:03 +01:00
Christian Krakau-Louis 9073f43aed Merge branch 'main' into copilot/debug-imap-errors-t-online 2026-03-28 21:21:54 +01:00
copilot-swe-agent[bot] 454452a0b6 fix: use UID-based IMAP commands and batch STORE operations to fix T-Online BYE errors
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/d01a05d3-31e1-4aca-b75d-c4e63b7e5af0

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 20:20:43 +00:00
Christian Krakau-Louis 50fe46a46a Merge pull request #112 from christianlouis/copilot/fix-status-tracking-issue
Fix worker status persistence failure and UTC timestamp display drift
2026-03-28 21:19:51 +01:00
Christian Krakau-Louis 96dda16a66 Merge branch 'main' into copilot/fix-status-tracking-issue 2026-03-28 21:19:31 +01:00
copilot-swe-agent[bot] 9eab576bf1 Fix timezone display: parse UTC timestamps correctly in logs pages
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/70a69601-e0b9-4a00-bcf4-0a85d2bdf6cb

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 20:12:13 +00:00
copilot-swe-agent[bot] 4779c17434 Initial plan 2026-03-28 20:09:36 +00:00
semantic-release a0d863d5ae 0.4.0
Automatically generated by python-semantic-release
2026-03-28 20:09:24 +00:00
Christian Krakau-Louis 0be9493a63 Merge pull request #111 from christianlouis/copilot/update-dashboard-status-view
Dashboard: replace per-run table with per-account Mailbox Status view
2026-03-28 21:09:03 +01:00
Christian Krakau-Louis bf1b465971 Merge branch 'main' into copilot/update-dashboard-status-view 2026-03-28 21:08:53 +01:00
Christian Krakau-Louis c1fd12f5ba Merge pull request #110 from christianlouis/copilot/fix-imap-fetch-error
Fix provider logos not saving; improve Fetch button UX on accounts page
2026-03-28 21:08:24 +01:00
copilot-swe-agent[bot] d92affabec Fix worker status tracking: use fresh DB session for notifications, commit before notifying
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/70a69601-e0b9-4a00-bcf4-0a85d2bdf6cb

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 20:04:52 +00:00
copilot-swe-agent[bot] f7caa05c8f Fix provider logos, improve Fetch button UX on mail accounts page
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/058b6481-f8eb-43ed-8f5f-715c19c4a377

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 20:03:19 +00:00
copilot-swe-agent[bot] 6afd7c871e feat(dashboard): replace per-run table with per-account Mailbox Status view
- Remove noisy "Recent Processing Runs" table from dashboard
- Add per-account AccountStatusRow showing: name, email, OK/Error/Pending
  badge, relative last-check time, inline error message, lifetime counters
- Stats cards: "Emails Forwarded Today" → "Emails Processed" (all-time),
  "Errors" → "Accounts with Errors" (turns gray when 0)
- Remove unused processingRunsApi import (saves one API call on page load)
- "View activity & history →" link to /logs for full drill-down
- Update CHANGELOG.md and docs/TODO.md

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/0d97741e-e999-49dd-a8f2-dceac2c1da1f

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 20:02:12 +00:00
copilot-swe-agent[bot] 57db094d44 Initial plan 2026-03-28 19:57:55 +00:00
copilot-swe-agent[bot] 105d6dcd6e Initial plan 2026-03-28 19:56:23 +00:00
copilot-swe-agent[bot] fb3311cb7f Initial plan 2026-03-28 19:55:11 +00:00
semantic-release 2792c0f6fe 0.3.2
Automatically generated by python-semantic-release
2026-03-28 19:53:34 +00:00
Christian Krakau-Louis f080375f94 Merge pull request #109 from christianlouis/copilot/fix-mypy-type-errors
fix: resolve 21 mypy type errors across backend modules
2026-03-28 20:53:11 +01:00
copilot-swe-agent[bot] 0698eca628 fix: resolve 21 mypy type errors across backend modules
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/bc16203d-7907-4e3f-952f-18c760c85076

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 19:32:53 +00:00
copilot-swe-agent[bot] 312fd9976f Initial plan 2026-03-28 19:25:21 +00:00
Christian Krakau-Louis 15af7fde61 Merge pull request #108 from christianlouis/copilot/add-pull-now-option-mailbox
Add "Pull Now" button, provider logo banners, and Proton Mail support
2026-03-28 20:24:34 +01:00
copilot-swe-agent[bot] aa2ea611b1 Logo banner layout rework, Proton Mail provider, ProviderWizard sizing improvements
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/8dc8f341-02b2-4449-8a88-a2124033c31a

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 19:18:17 +00:00
semantic-release 31ca3a285f 0.3.1
Automatically generated by python-semantic-release
2026-03-28 19:15:07 +00:00
Christian Krakau-Louis 9638e031ea Merge pull request #107 from christianlouis/copilot/check-mailbox-credentials
Fix: Test Connection always reports success regardless of auth result
2026-03-28 20:14:42 +01:00
copilot-swe-agent[bot] 35ddcdf9df Replace provider logos with high-quality SVGs from Wikimedia/official sources
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/0201089a-598d-4ab0-a5a3-8d2ae1120c9e

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 19:05:12 +00:00
copilot-swe-agent[bot] fc7e09d4d6 Add Pull Now button and provider logos to accounts page
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/0201089a-598d-4ab0-a5a3-8d2ae1120c9e

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 19:02:52 +00:00
copilot-swe-agent[bot] 441e8b54e9 fix: test connection always showed success; add per-account test endpoint
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6268035d-325f-422a-886f-8b2919127261

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 18:57:25 +00:00
copilot-swe-agent[bot] 66f94e1ecb Initial plan 2026-03-28 18:55:01 +00:00
semantic-release 350ef6288d 0.3.0
Automatically generated by python-semantic-release
2026-03-28 18:53:42 +00:00
Christian Krakau-Louis 5419627613 Merge pull request #106 from christianlouis/copilot/limit-log-noise-successful-attempts
feat: mailbox-centric activity view — suppress empty polling cycle noise
2026-03-28 19:53:21 +01:00
copilot-swe-agent[bot] c09019d333 Initial plan 2026-03-28 18:46:30 +00:00
copilot-swe-agent[bot] dd4532f660 feat: mailbox-centric activity view, reduce log noise from empty polling cycles
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/1a78a249-127f-4a73-a454-3c6bafbf6e58

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 18:45:38 +00:00
copilot-swe-agent[bot] ccd74c34b6 Initial plan 2026-03-28 18:33:57 +00:00
Christian Krakau-Louis ae98a54859 Merge pull request #105 from christianlouis/copilot/debug-celery-logging-issues
Fix Celery datetime crash that silently blocked all mail processing + respect sub-5-min poll intervals
2026-03-28 19:07:15 +01:00
copilot-swe-agent[bot] d0719eff76 Fix Celery datetime timezone crash and per-account polling interval
- Wrap account.last_check_at with _as_utc() helper before datetime
  subtraction to fix "can't subtract offset-naive and offset-aware
  datetimes" crash that silently prevented all mail account processing
- Change beat schedule from every 5 min to every 1 min so accounts
  configured with check_interval_minutes=1 are polled as expected

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/53169783-5139-43a1-bda8-709fadd0f774

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 17:59:22 +00:00
copilot-swe-agent[bot] f1970e0e0f Initial plan 2026-03-28 17:55:48 +00:00
Christian Krakau-Louis 526824de05 Merge pull request #104 from christianlouis/copilot/update-processing-log-entries
Fix processing log entries: stale runs, error handler crash, and duration display bug
2026-03-28 12:30:39 +01:00
Christian Krakau-Louis f821cadbbf Merge pull request #103 from christianlouis/copilot/add-status-bar-to-inboxconverge
Add version/build-date status bar to dashboard footer
2026-03-28 12:30:07 +01:00
copilot-swe-agent[bot] 88fac1d8dd Fix processing log bugs: error handler crash, early-exit path, stale-run detection, and duration formatting
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/3f1ca3bd-627e-4c78-a652-0262d63638fc

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 11:19:37 +00:00
copilot-swe-agent[bot] 1bb3f71230 Add version/build-date status bar to dashboard footer
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/9efbf677-2d92-487b-abae-9adf0c728f88

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-28 11:09:38 +00:00
copilot-swe-agent[bot] 1135ee81e2 Initial plan 2026-03-28 11:03:41 +00:00
copilot-swe-agent[bot] b1dd322ec1 Initial plan 2026-03-28 11:02:32 +00:00
semantic-release 1cf8dfbb60 0.2.2
Automatically generated by python-semantic-release
2026-03-28 10:51:07 +00:00
Christian Krakau-Louis 4a3d368a40 Merge pull request #101 from christianlouis/copilot/add-mail-account-logos
Replace emoji placeholders with SVG brand logos in provider selection wizard
2026-03-28 11:50:42 +01:00
Christian Krakau-Louis eed61c0597 Merge pull request #102 from christianlouis/copilot/fix-changelog-update-issues
fix: add PSR insertion flag and restructure CHANGELOG to unblock automated updates
2026-03-28 11:50:24 +01:00
test d3e0ca4e33 fix: resolve semantic-release CHANGELOG.md not updating properly
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/36c964fc-1fc5-4b0a-b223-43a033f7b26b

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-27 21:27:16 +00:00
copilot-swe-agent[bot] b76f241b98 Replace emoji icons with SVG brand logos in provider selection wizard
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/d14501d8-c118-48ef-a860-d329fda432b9

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-27 21:14:20 +00:00
copilot-swe-agent[bot] 189e2f729d Initial plan 2026-03-27 21:06:50 +00:00
Christian Krakau-Louis 4b15b63b56 Merge pull request #100 from christianlouis/copilot/decouple-gmail-login-token-gathering
Decouple Gmail permissions from Google Sign-In
2026-03-27 22:04:24 +01:00
copilot-swe-agent[bot] 9a7faf49b6 Initial plan 2026-03-27 21:02:21 +00:00
copilot-swe-agent[bot] 4844377342 Decouple Gmail permissions from Google Sign-In OAuth flow
The Google Sign-In flow now only requests basic profile scopes (openid,
email, profile) instead of also requesting Gmail API scopes. Users can
grant Gmail access separately via the "Connect Gmail" button in Settings.

- Remove GMAIL_SCOPES from GOOGLE_LOGIN_SCOPES in auth.py
- Remove Gmail credential auto-provisioning from google_oauth endpoint
- Simplify /auth/google/authorize-url (no offline access or forced consent)
- Clean up auth_service.py OAuth registration to only use login scopes
- Remove unused imports (GmailCredential, GmailService, encrypt_credential, etc.)

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/91d2db11-811f-46f5-ac89-29952ab358b5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-27 21:01:46 +00:00
copilot-swe-agent[bot] ad3cb81709 Initial plan 2026-03-27 20:55:30 +00:00
Christian Krakau-Louis bb34920c94 Merge pull request #99 from christianlouis/dependabot/pip/backend/pip-6ba9ca5f64
chore(deps): bump cryptography from 46.0.5 to 46.0.6 in /backend in the pip group across 1 directory
2026-03-27 21:45:39 +01:00
semantic-release 880dfebf60 0.2.1
Automatically generated by python-semantic-release
2026-03-27 20:25:12 +00:00
Christian Krakau-Louis d256f62afc Merge pull request #98 from christianlouis/copilot/fix-invalid-escape-sequence
Fix SyntaxWarning in mail_processor and processing runs stuck in "running" state
2026-03-27 21:24:49 +01:00
dependabot[bot] 2fd018afa1 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.5 to 46.0.6
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 20:24:12 +00:00
semantic-release 2168a1413c 0.2.0
Automatically generated by python-semantic-release
2026-03-27 20:23:26 +00:00
Christian Krakau-Louis 207b119f4c Merge pull request #97 from christianlouis/copilot/update-readme-to-include-release-numbers
Configure semantic-release to write version numbers to pyproject.toml and document release process in README
2026-03-27 21:23:04 +01:00
copilot-swe-agent[bot] f8a9f3ce53 fix: SyntaxWarning in mail_processor and stale running runs in tasks
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/b5def92f-03a4-493c-a5a7-d15a7ca82492

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-27 17:28:28 +00:00
copilot-swe-agent[bot] 8673b898a1 Initial plan 2026-03-27 17:20:20 +00:00
copilot-swe-agent[bot] aa0a7526c0 feat: configure semantic-release version file updates and update README
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/7f2a54df-87b2-46b3-af80-0aa5aa616ee7

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-27 16:26:14 +00:00
copilot-swe-agent[bot] 5f3f6e47e7 Initial plan 2026-03-27 16:23:29 +00:00
Christian Krakau-Louis ab2598c48b Merge pull request #94 from christianlouis/dependabot/npm_and_yarn/frontend/npm_and_yarn-6c4c142770
chore(deps): bump brace-expansion from 1.1.12 to 1.1.13 in /frontend in the npm_and_yarn group across 1 directory
2026-03-27 17:21:04 +01:00
Christian Krakau-Louis 351a197173 Merge pull request #96 from christianlouis/copilot/fix-autodeploy-docker-tags
[WIP] Fix autodeploy and update of docker tags
2026-03-27 17:20:46 +01:00
copilot-swe-agent[bot] f3ae8cc662 Fix autodeploy: use private registry in update-k8s-manifest CI job
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/56fb7508-4411-4d29-8edc-9bcd6c864c45

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-27 16:19:57 +00:00
copilot-swe-agent[bot] 8a9176c7e5 Initial plan 2026-03-27 16:17:17 +00:00
Christian Krakau-Louis a2ddaed54c Merge pull request #95 from christianlouis/copilot/update-preprod-k8s-manifest
ci: validate GH_PAT repo access before k8s manifest checkout
2026-03-27 15:35:53 +01:00
copilot-swe-agent[bot] 67f5f0b18e Fix Update Preprod K8s Manifest CI failure by validating PAT repo access
- Enhance pat-check step to verify PAT has read access to k8s-cluster-state
  via GitHub API probe before attempting checkout
- Use --oauth2-bearer to avoid PAT appearing in process listings
- Extract k8s repo path to workflow-level K8S_STATE_REPO env var

Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/e5e9c2ee-5f82-4cf5-84a3-b968518699c8

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-27 14:27:06 +00:00
copilot-swe-agent[bot] 762a971081 Initial plan 2026-03-27 14:23:09 +00:00
dependabot[bot] 78cb104c73 chore(deps): bump brace-expansion
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 14:12:42 +00:00
Christian Krakau-Louis fd7f797d12 Merge pull request #93 from christianlouis/copilot/fix-git-fetch-error
fix(ci): guard update-k8s-manifest job against missing GH_PAT secret
2026-03-27 15:04:21 +01:00
copilot-swe-agent[bot] ccd94eb9cd fix: add GH_PAT availability check in update-k8s-manifest job to avoid 403
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/3ae3a579-b355-457a-8eb7-9c81348fa0ed

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-27 07:54:58 +00:00
copilot-swe-agent[bot] 999a5112f3 Initial plan 2026-03-27 07:52:01 +00:00
Christian Krakau-Louis 6b285f4fb9 Merge pull request #92 from christianlouis/copilot/fix-git-safe-directory-issue 2026-03-27 00:25:40 +01:00
copilot-swe-agent[bot] e5f359bbd9 fix: apply black formatting to alembic migration file
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/7d808813-2577-41cc-b90b-26cf974a22cb

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 22:11:17 +00:00
copilot-swe-agent[bot] 82829661da fix: track alembic migrations and logs pages (fix .gitignore exclusions)
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/fccf042a-a3d9-4eff-a897-9bb3650629cc

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 21:19:52 +00:00
copilot-swe-agent[bot] 69de187aab fix: add Alembic migration for notification_configs columns and create /logs and /admin/logs pages
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/fccf042a-a3d9-4eff-a897-9bb3650629cc

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 21:17:55 +00:00
copilot-swe-agent[bot] 6d10022833 fix(ci): fix k8s-cluster-state checkout and image tag format in update-k8s-manifest job
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5bb770b4-72d8-464f-91d5-5ce6974fa6e4

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 20:51:50 +00:00
copilot-swe-agent[bot] 2fa57e7fbe Initial plan 2026-03-26 20:47:35 +00:00
Christian Krakau-Louis b96b34954b Merge pull request #91 from christianlouis/copilot/fix-lints-and-check-endpoints
Fix ESLint parse error and NotificationConfigCreate schema test failure
2026-03-26 20:54:12 +01:00
copilot-swe-agent[bot] 412e7bc6f5 fix: make NotificationConfigBase.name optional with default 'My Notification' to match DB default
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/7640aaa6-61f4-4c65-8eb7-f33c2d3eb755

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 19:34:38 +00:00
copilot-swe-agent[bot] fd229d1b51 fix: add missing comma in DashboardLayout.tsx lucide-react import to fix ESLint parse error
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/53b5844c-0622-4ef1-9944-2da36a498e06

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 19:27:29 +00:00
copilot-swe-agent[bot] 896329ccb7 Fix black lint, fix /processing-runs 404s, add semantic release and GitOps deploy
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/3fdf6219-3e1f-4867-b74d-efb6e4fe7e72

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 19:17:11 +00:00
copilot-swe-agent[bot] 69716517aa Initial plan 2026-03-26 19:10:43 +00:00
Christian Krakau-Louis a0f2d9bf22 Merge pull request #89 from christianlouis/copilot/add-apprise-alerting-capabilities
Add Apprise-powered alerting for users and admins
2026-03-26 19:41:27 +01:00
Christian Krakau-Louis b824095bf5 Merge branch 'main' into copilot/add-apprise-alerting-capabilities 2026-03-26 19:39:27 +01:00
Christian Krakau-Louis 64b8e788ce Merge pull request #90 from christianlouis/copilot/add-google-import-labels
Add configurable Gmail import labels with opinionated defaults
2026-03-26 19:36:57 +01:00
copilot-swe-agent[bot] 0bc08b9825 feat: add Apprise alerting capabilities with user and admin notification channels
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/858e72a6-5282-4ce8-b38a-db4b9e2e6f65

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 18:25:21 +00:00
copilot-swe-agent[bot] 9314c38775 chore: address review follow-ups
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/4d504045-2740-4bab-b51e-960fe4dafe4b

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 18:24:00 +00:00
Christian Krakau-Louis 56c62f8e6c Merge pull request #87 from christianlouis/copilot/add-logging-and-reporting-capabilities
feat: Deep processing logs & reporting with GDPR-compliant masking
2026-03-26 19:20:59 +01:00
copilot-swe-agent[bot] 5711a56569 feat: add configurable gmail import labels
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/4d504045-2740-4bab-b51e-960fe4dafe4b

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 18:20:09 +00:00
copilot-swe-agent[bot] 2708a010bf fix: show accurate dot count for masked Apprise URL in wizard
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 18:19:46 +00:00
copilot-swe-agent[bot] 5f24e7f1b6 feat: add Apprise notification frontend
- Add NotificationConfig, AdminNotificationConfig types and
  notificationsApi / adminNotificationsApi namespaces to api.ts
- Create NotificationWizard component with 3-step flow:
  channel selection, field entry (Telegram/Discord/Slack/Email/
  Webhook/Custom), URL preview + preferences
- Create /notifications page with full CRUD: list, add, edit,
  delete, enable/disable toggle, test per channel
- Add Notifications nav item (Bell icon) to DashboardLayout
- Add System Alert Channels section to admin/page.tsx with
  inline AdminNotificationModal for add/edit/delete/test

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

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 18:18:30 +00:00
copilot-swe-agent[bot] fd45c838fc feat: Add logging and reporting capabilities with GDPR compliance
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/d6e65ca9-f07f-43a2-bac2-09eb44588ded

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 18:12:22 +00:00
Christian Krakau-Louis 5d76df7270 Merge pull request #88 from christianlouis/copilot/rename-project-to-inboxconverge
Rename project to InboxConverge; add configurable contact/domain settings
2026-03-26 19:09:58 +01:00
copilot-swe-agent[bot] 92d67369e9 feat: rename project to InboxConverge with configurable contact details
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/82f2f361-3513-44e6-991b-db1a19902772

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 18:06:46 +00:00
copilot-swe-agent[bot] bbd2febfc1 Add Apprise alerting capabilities (backend)
- Add name + apprise_url columns to NotificationConfig model
- Add AdminNotificationConfig model for system-wide alert channels
- Create NotificationService with send_user_notification,
  send_admin_notification, and test_notification helpers
- Replace stub notifications.py with full CRUD + /test endpoint
- Add admin notification config CRUD + /test to admin.py
- Update NotificationConfig schemas: name required, apprise_url
  optional, config optional (default {})
- Add AdminNotificationConfig* schemas
- Integrate send_user_notification into process_mail_account task
  for Gmail revocation, forwarding failures, and exceptions
- Update CHANGELOG.md

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

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 18:06:05 +00:00
copilot-swe-agent[bot] 5bf4cbc2e9 Initial plan 2026-03-26 18:03:35 +00:00
copilot-swe-agent[bot] a6c95bef4d Initial plan 2026-03-26 17:55:47 +00:00
copilot-swe-agent[bot] eba086fff0 Initial plan 2026-03-26 17:50:14 +00:00
copilot-swe-agent[bot] 276ef1f8b2 Initial plan 2026-03-26 17:48:40 +00:00
Christian Krakau-Louis 3f9aa69ff9 Merge pull request #86 from christianlouis/copilot/check-admin-dashboard-access
Fix blank admin pages and missing superuser flag on existing accounts
2026-03-26 18:45:13 +01:00
copilot-swe-agent[bot] 74fa460660 Fix blank page on direct navigation to /admin pages
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/8bc46694-c28c-4041-af60-8c8b72fe512a

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 17:28:24 +00:00
copilot-swe-agent[bot] 3dcb700e68 Fix: auto-promote ADMIN_EMAIL user to superuser on application startup
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/1cafe07b-4978-4609-992d-59bffb30b208

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 17:14:03 +00:00
copilot-swe-agent[bot] 3332344ca0 Initial plan 2026-03-26 17:02:12 +00:00
Christian Krakau-Louis c9a8f48f7a Merge pull request #85 from christianlouis/copilot/add-system-health-monitoring
feat: Prometheus metrics + Grafana dashboard for system health monitoring
2026-03-26 17:58:56 +01:00
copilot-swe-agent[bot] ca87abce0a feat: add Prometheus metrics and Grafana dashboard for system health monitoring
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/11ac4536-eaad-44fe-920c-e8306918f06f

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 16:31:23 +00:00
copilot-swe-agent[bot] d85bd6cfd1 Initial plan 2026-03-26 16:18:17 +00:00
Christian Krakau-Louis dd91110ff1 Merge pull request #83 from christianlouis/copilot/add-admin-interface-for-users
Fix test_app_title assertion after InboxRescue rebrand
2026-03-26 17:18:00 +01:00
Christian Krakau-Louis b1abae6e08 Merge pull request #84 from christianlouis/copilot/add-privacy-imprint-information
Add Impressum and Datenschutz (Privacy Policy) pages
2026-03-26 17:17:40 +01:00
copilot-swe-agent[bot] 744618fefe Fix test_app_title to match renamed app InboxRescue
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/1411bfd7-f8c6-4e93-8182-a6885ef511d8

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 16:16:28 +00:00
copilot-swe-agent[bot] f1a55b4f74 Add Impressum and Datenschutz pages with footer links
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/6d3074a3-7a69-4817-b739-a7eaed9a68d6

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 16:15:07 +00:00
copilot-swe-agent[bot] d4aa353383 B2C/B2B split: DEFAULT_USER_TIER, ALLOWED_DOMAINS, zero-price filter, InboxRescue branding & pricing page
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 16:10:39 +00:00
copilot-swe-agent[bot] 24b1be84c3 Initial plan 2026-03-26 16:09:19 +00:00
copilot-swe-agent[bot] 53334402aa wip: plan for B2C/B2B split requirements
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 16:04:29 +00:00
copilot-swe-agent[bot] 7971d3f76a Fix subscription limits; seed Free/Good/Better/Best default plans
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 16:00:56 +00:00
copilot-swe-agent[bot] 979b7b6cec Add admin interface, auto-promote admin email, fix sender name
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-26 15:53:24 +00:00
copilot-swe-agent[bot] 37b0af2eb7 Initial plan 2026-03-26 15:39:16 +00:00
Christian Krakau-Louis c7a1a2b884 Merge pull request #82 from christianlouis/copilot/fix-datetime-subtraction-error
Fix offset-naive/offset-aware datetime subtraction in process_mail_account
2026-03-26 14:05:38 +01:00
copilot-swe-agent[bot] 30d24c96f2 Fix offset-naive/offset-aware datetime subtraction in process_mail_account
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/ff7ce94c-aeb6-4577-a87c-77fcdcb414f7
2026-03-26 13:00:57 +00:00
copilot-swe-agent[bot] 1353253ad0 Initial plan 2026-03-26 12:51:18 +00:00
Christian Krakau-Louis 739852720a Merge pull request #80 from christianlouis/copilot/fix-mailbox-parameter-editing
Fix mailbox edit form: all fields editable, pre-populated, credentials preserved
2026-03-26 11:49:12 +01:00
Christian Krakau-Louis c404afa539 Merge branch 'main' into copilot/fix-mailbox-parameter-editing 2026-03-26 11:47:37 +01:00
Christian Krakau-Louis aa3f188b6f Merge pull request #81 from christianlouis/copilot/add-debug-functionality-gmail-integration
security: upgrade python-jose 3.3.0 → 3.4.0 (algorithm confusion with OpenSSH ECDSA keys)
2026-03-26 11:45:20 +01:00
copilot-swe-agent[bot] 8faa6deb78 Security: upgrade python-jose 3.3.0 → 3.5.0 (ECDSA algorithm confusion CVE)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/20fa7a89-23e5-462c-8b4d-9c6cdb4d0501
2026-03-25 23:09:24 +00:00
copilot-swe-agent[bot] 8cf0d101bf Make all mailbox fields editable; pre-populate username from API response
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/20fa7a89-23e5-462c-8b4d-9c6cdb4d0501
2026-03-25 23:06:04 +00:00
copilot-swe-agent[bot] dda768ff73 security: upgrade python-jose 3.3.0 → 3.4.0 (algorithm confusion CVE)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/c609ede7-b9d7-4ab1-acdb-8d29164066cc
2026-03-25 23:04:22 +00:00
copilot-swe-agent[bot] 5c40415590 Add Gmail debug email functionality
- GmailService: add get_or_create_label() and inject_debug_email() methods
- providers.py: add POST /providers/gmail/debug-email endpoint
- api.ts: add GmailDebugEmailResponse interface and gmailApi.sendDebugEmail()
- settings/page.tsx: add Send Debug Email button with success/error feedback
- Update CHANGELOG.md and docs/TODO.md

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/c609ede7-b9d7-4ab1-acdb-8d29164066cc
2026-03-25 23:00:47 +00:00
copilot-swe-agent[bot] 20170f2f2d Fix mailbox edit form: pre-populate fields, prevent credential overwrite, lock immutable settings
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/20fa7a89-23e5-462c-8b4d-9c6cdb4d0501
2026-03-25 23:00:00 +00:00
Christian Krakau-Louis 53c1b48d33 Merge pull request #77 from christianlouis/dependabot/pip/backend/pip-49f76fc107
chore(deps): bump python-jose from 3.3.0 to 3.4.0 in /backend in the pip group across 1 directory
2026-03-25 23:54:28 +01:00
Christian Krakau-Louis 08f7543fdf Merge pull request #78 from christianlouis/dependabot/npm_and_yarn/frontend/npm_and_yarn-bf05dc1ecf
chore(deps): bump the npm_and_yarn group across 1 directory with 1 update
2026-03-25 23:54:15 +01:00
Christian Krakau-Louis 75f6e5f083 Merge pull request #79 from christianlouis/copilot/fix-process-enabled-mail-accounts
Fix "Exception terminating connection" in Celery async tasks
2026-03-25 23:53:49 +01:00
copilot-swe-agent[bot] fb9b3893c4 Fix Exception terminating connection in Celery workers by disposing engine after each task
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/3960b056-8c90-407f-8024-c8abd2e479fb
2026-03-25 22:52:14 +00:00
copilot-swe-agent[bot] a0e8f07328 Initial plan 2026-03-25 22:50:53 +00:00
copilot-swe-agent[bot] 6bea660d16 Initial plan 2026-03-25 22:47:41 +00:00
copilot-swe-agent[bot] 43db7f4f66 Initial plan 2026-03-25 22:46:32 +00:00
dependabot[bot] d494460701 chore(deps): bump the npm_and_yarn group across 1 directory with 1 update
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 22:42:04 +00:00
Christian Krakau-Louis 68063b20b8 Merge pull request #76 from christianlouis/copilot/update-gmail-api-scopes
Expand Gmail OAuth scopes and unify Google callback URL
2026-03-25 23:11:19 +01:00
copilot-swe-agent[bot] b46fabd8cd feat: expand Gmail OAuth scopes, unify Google callback URL, store Gmail creds on login
- Add gmail.readonly to GMAIL_SCOPES (gmail_service) and GMAIL_API_SCOPES (providers)
  so users().getProfile() no longer returns 403 insufficientPermissions
- Consolidate Gmail scope list: GMAIL_SCOPES in gmail_service.py is the single
  source of truth; auth.py and providers.py now import and spread it
- Add include_granted_scopes=true to both Gmail and login authorize URLs so
  scope additions take effect for previously-connected users
- Add state=gmail_connect to the Gmail authorize URL; the shared /auth/callback
  page routes to gmailApi.saveCallback() when this state is present, otherwise
  falls through to the normal login flow
- Google Sign-In authorize URL now requests all six scopes (openid, email,
  profile + 3 Gmail scopes) with access_type=offline, prompt=consent, and
  include_granted_scopes=true
- POST /auth/google now stores Gmail credentials automatically after sign-in
  (non-fatal: login succeeds even if credential storage fails)
- Settings Connect Gmail button now uses /auth/callback instead of
  /auth/gmail-callback - only ONE redirect URI needed in Google Cloud Console
- URL-encode scope parameter in both authorize URL builders
- Update auth_service._register_google scope to include all Gmail scopes
- Update CHANGELOG.md and docs/TODO.md

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/d6e2de8b-088d-46f3-8127-137245c3ecdd
2026-03-25 19:53:46 +00:00
dependabot[bot] 960ab2a96f chore(deps): bump python-jose
Bumps the pip group with 1 update in the /backend directory: [python-jose](https://github.com/mpdavis/python-jose).


Updates `python-jose` from 3.3.0 to 3.4.0
- [Release notes](https://github.com/mpdavis/python-jose/releases)
- [Changelog](https://github.com/mpdavis/python-jose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mpdavis/python-jose/compare/3.3.0...3.4.0)

---
updated-dependencies:
- dependency-name: python-jose
  dependency-version: 3.4.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 19:43:17 +00:00
copilot-swe-agent[bot] 372632ce6f Initial plan 2026-03-25 19:42:22 +00:00
148 changed files with 32735 additions and 2143 deletions
+6 -1
View File
@@ -14,7 +14,7 @@ POP3_ACCOUNT_1_USE_SSL=true
# POP3_ACCOUNT_2_USE_SSL=true
# ── Bootstrap Settings (always from env, never from database) ──────
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/inbox_converge
# SECRET_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
# ENCRYPTION_KEY=<generate with: python -c 'import secrets; print(secrets.token_urlsafe(32))'>
@@ -41,5 +41,10 @@ MAX_EMAILS_PER_RUN=50
# Throttling (emails per minute)
THROTTLE_EMAILS_PER_MINUTE=10
# Application identity (used by frontend server components at runtime)
APP_NAME=InboxConverge
APP_URL=https://inboxconverge.com
CONTACT_EMAIL=christian@inboxconverge.com
# Logging
LOG_LEVEL=INFO
+1 -1
View File
@@ -21,7 +21,7 @@ assignees: ''
<!-- What actually happened -->
## Environment
- **Component**: [e.g., Backend API, Worker, Docker, pop3_forwarder.py]
- **Component**: [e.g., Backend API, Worker, Docker, inbox_converge.py]
- **Version**: [e.g., v1.0.0, main branch]
- **Deployment**: [e.g., Docker, Kubernetes, local]
- **OS**: [e.g., Ubuntu 22.04, macOS, Windows]
+129 -28
View File
@@ -14,6 +14,8 @@ on:
env:
GHCR_REGISTRY: ghcr.io
PRIVATE_REGISTRY: registry.cklnet.com
K8S_STATE_REPO: christianlouis/k8s-cluster-state
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Phase 1: Lint ──────────────────────────────────────────────────────
@@ -24,7 +26,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -77,7 +79,7 @@ jobs:
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pop3_forwarder_test
POSTGRES_DB: inbox_converge_test
options: >-
--health-cmd pg_isready
--health-interval 10s
@@ -97,7 +99,7 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -113,7 +115,7 @@ jobs:
- name: Run tests with coverage
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/pop3_forwarder_test
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/inbox_converge_test
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key-for-ci-cd-at-least-32-chars
ENCRYPTION_KEY: test-encryption-key-for-ci-cd-at-least-32-chars
@@ -121,12 +123,37 @@ jobs:
cd backend
pytest tests/ -v --cov=app --cov-report=xml --cov-report=term
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
- name: Set up Node.js
uses: actions/setup-node@v6
with:
file: ./backend/coverage.xml
flags: unittests
name: codecov-umbrella
node-version: '20.19.0'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Run frontend tests with coverage
run: npm run test:ci
working-directory: frontend
- name: Upload backend coverage to Codecov
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./backend/coverage.xml
flags: backend
name: backend-coverage
fail_ci_if_error: false
- name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./frontend/coverage/lcov.info
flags: frontend
name: frontend-coverage
fail_ci_if_error: false
# ── Phase 3: Security ──────────────────────────────────────────────────
@@ -138,7 +165,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -148,7 +175,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install bandit safety
pip install bandit pip-audit
pip install -r backend/requirements.txt
- name: Run Bandit security scan
@@ -156,7 +183,7 @@ jobs:
continue-on-error: true
- name: Check dependencies for known vulnerabilities
run: safety check --json
run: pip-audit -r backend/requirements.txt
continue-on-error: true
# ── Phase 4: Build ─────────────────────────────────────────────────────
@@ -173,39 +200,30 @@ jobs:
matrix:
include:
- context: ./backend
image_name: gmail-puller/backend
image_name: inboxconverge/backend
- context: ./frontend
image_name: gmail-puller/frontend
image_name: inboxconverge/frontend
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Requires PRIVATE_REGISTRY_USERNAME and PRIVATE_REGISTRY_PASSWORD secrets
- name: Log in to private registry
uses: docker/login-action@v3
with:
registry: ${{ env.PRIVATE_REGISTRY }}
username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }}
password: ${{ secrets.PRIVATE_REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image_name }}
${{ env.PRIVATE_REGISTRY }}/${{ matrix.image_name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
@@ -215,7 +233,7 @@ jobs:
- name: Build and push Docker image
id: build-push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
push: true
@@ -223,4 +241,87 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
build-args: |
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
# ── Phase 5: GitOps update preprod k8s manifest ─────────────────────────
update-k8s-manifest:
name: Update Preprod K8s Manifest
runs-on: ubuntu-latest
needs: [build]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
steps:
- name: Compute image tags
id: tag
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
echo "backend_image=${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/inboxconverge/backend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "frontend_image=${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/inboxconverge/frontend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Check if GH_PAT is configured and has repo access
id: pat-check
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
if [ -z "$GH_PAT" ]; then
echo "::warning::GH_PAT secret is not configured. Skipping k8s manifest update."
echo "available=false" >> "$GITHUB_OUTPUT"
else
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--oauth2-bearer "$GH_PAT" \
"https://api.github.com/repos/${{ env.K8S_STATE_REPO }}")
if [ "$HTTP_CODE" = "200" ]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::GH_PAT does not have access to ${{ env.K8S_STATE_REPO }} (HTTP $HTTP_CODE). Skipping k8s manifest update."
echo "available=false" >> "$GITHUB_OUTPUT"
fi
fi
- name: Checkout k8s-cluster-state
if: steps.pat-check.outputs.available == 'true'
uses: actions/checkout@v6
with:
repository: ${{ env.K8S_STATE_REPO }}
token: ${{ secrets.GH_PAT }}
path: k8s-cluster-state
ref: main
- name: Update backend image tag in preprod manifest
if: steps.pat-check.outputs.available == 'true'
uses: mikefarah/yq@v4.52.5
env:
IMAGE: ${{ steps.tag.outputs.backend_image }}
with:
cmd: |
yq -i '(.. | select(tag == "!!str") | select(test("^(registry\\.cklnet\\.com|ghcr\\.io/christianlouis)/inboxconverge/backend:"))) = strenv(IMAGE)' \
k8s-cluster-state/apps/gmail-puller/preprod/gmail-puller-stack.yaml
- name: Update frontend image tag in preprod manifest
if: steps.pat-check.outputs.available == 'true'
uses: mikefarah/yq@v4.52.5
env:
IMAGE: ${{ steps.tag.outputs.frontend_image }}
with:
cmd: |
yq -i '(.. | select(tag == "!!str") | select(test("^(registry\\.cklnet\\.com|ghcr\\.io/christianlouis)/inboxconverge/frontend:"))) = strenv(IMAGE)' \
k8s-cluster-state/apps/gmail-puller/preprod/gmail-puller-stack.yaml
- name: Commit and push manifest update
if: steps.pat-check.outputs.available == 'true'
run: |
cd k8s-cluster-state
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps/gmail-puller/preprod/gmail-puller-stack.yaml
if git diff --staged --quiet; then
echo "No changes to commit"
else
git commit -m "chore(preprod): update inboxconverge images to ${{ steps.tag.outputs.short_sha }}"
git push
fi
+67
View File
@@ -0,0 +1,67 @@
name: Semantic Release
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write
issues: write
pull-requests: write
packages: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
release:
name: Semantic Release
runs-on: ubuntu-latest
if: github.repository == 'christianlouis/InboxConverge'
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install python-semantic-release
- name: Configure Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Run Semantic Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
semantic-release version --print
semantic-release version
semantic-release publish
- name: Update changelog if no new version was released
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Detect whether `semantic-release version` just created a version commit.
# PSR's version commits have a bare version number as the subject (e.g. "0.2.2").
# If the latest commit matches that pattern the changelog was already updated.
LAST_COMMIT_MSG=$(git log -1 --format="%s")
if echo "$LAST_COMMIT_MSG" | grep -qP "^\d+\.\d+\.\d+$"; then
echo "semantic-release created version commit '$LAST_COMMIT_MSG' - CHANGELOG.md already updated"
else
echo "No new version was released; CHANGELOG.md unchanged"
fi
-2
View File
@@ -38,7 +38,6 @@ env/
# Logs
*.log
logs/
# OS
.DS_Store
@@ -55,7 +54,6 @@ htmlcov/
# Backend specific
backend/.env
backend/alembic/versions/*_*.py
backend/*.db
backend/*.sqlite
+2 -2
View File
@@ -33,7 +33,7 @@ repos:
- id: black
language_version: python3.11
args: [--line-length=100]
files: ^(backend/|pop3_forwarder\.py)
files: ^(backend/|inboxconverge\.py)
# Python linting with Ruff (replaces flake8, isort, etc.)
- repo: https://github.com/astral-sh/ruff-pre-commit
@@ -41,7 +41,7 @@ repos:
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
files: ^(backend/|pop3_forwarder\.py)
files: ^(backend/|inboxconverge\.py)
# Type checking with mypy
- repo: https://github.com/pre-commit/mirrors-mypy
+22
View File
@@ -0,0 +1,22 @@
# Safety policy configuration
# See: https://docs.safetycli.com/safety-docs/safety-policy-file
version: "3.0"
security:
ignore-cvss-severity-below: 0
ignore-cvss-unknown-severity: false
ignore-vulnerabilities:
# ecdsa CVE: side-channel / Minerva attack (CVE-64396, CVE-64459)
# The ecdsa maintainers have explicitly stated that these vulnerabilities
# cannot be fixed in pure Python. ecdsa is a transitive dependency of
# python-jose[cryptography], which is used only for JWT token
# verification/signing a context where precise timing side-channels
# are not exploitable by remote attackers. The 'cryptography' package
# (already installed) handles all sensitive key operations.
64396:
reason: "ecdsa side-channel (Minerva) — unfixable in pure Python per maintainers; not exploitable in our JWT-signing context"
expires: "2027-01-01"
64459:
reason: "ecdsa side-channel attack — unfixable in pure Python per maintainers; not exploitable in our JWT-signing context"
expires: "2027-01-01"
+1093 -147
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,4 +1,4 @@
# Contributing to POP3 to Gmail Forwarder
# Contributing to InboxConverge
Thank you for your interest in contributing! This document provides guidelines for contributing to the project.
@@ -10,7 +10,7 @@ Be respectful and inclusive. We welcome contributions from everyone.
### Reporting Bugs
1. Check if the bug has already been reported in [Issues](https://github.com/christianlouis/pop_puller_to_gmail/issues)
1. Check if the bug has already been reported in [Issues](https://github.com/christianlouis/inboxconverge/issues)
2. If not, create a new issue with:
- Clear title and description
- Steps to reproduce
@@ -77,8 +77,8 @@ Be respectful and inclusive. We welcome contributions from everyone.
```bash
# Clone your fork
git clone https://github.com/YOUR-USERNAME/pop_puller_to_gmail.git
cd pop_puller_to_gmail
git clone https://github.com/YOUR-USERNAME/inboxconverge.git
cd inboxconverge
# Install all development dependencies
make install-dev
@@ -88,7 +88,7 @@ cp .env.example .env
# Edit .env with test credentials
# Run the legacy forwarder script directly
python pop3_forwarder.py
python inbox_converge.py
# Or start the SaaS backend in dev mode
make run-dev
+5 -5
View File
@@ -8,13 +8,13 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY pop3_forwarder.py .
COPY inboxconverge.py .
# Create non-root user for security
RUN useradd -m -u 1000 forwarder && \
chown -R forwarder:forwarder /app
RUN useradd -m -u 1000 inboxconverge && \
chown -R inboxconverge:inboxconverge /app
USER forwarder
USER inboxconverge
# Run the application
CMD ["python", "-u", "pop3_forwarder.py"]
CMD ["python", "-u", "inboxconverge.py"]
+3 -3
View File
@@ -120,12 +120,12 @@ run-beat: ## Run Celery beat scheduler
run-flower: ## Run Flower (Celery monitoring)
cd backend && celery -A app.core.celery_app flower --port=5555
run-legacy: ## Run legacy pop3_forwarder script
python pop3_forwarder.py
run-legacy: ## Run legacy inboxconverge script
python inboxconverge.py
# Database Shell
shell: ## Open database shell
docker-compose exec db psql -U postgres -d pop3_forwarder
docker-compose exec db psql -U postgres -d inbox_converge
shell-python: ## Open Python shell with app context
cd backend && python -c "from app.core.database import SessionLocal; db = SessionLocal(); print('Database session available as db')"
+67 -222
View File
@@ -1,261 +1,106 @@
# POP3 to Gmail Forwarder
# InboxConverge
[![CI Tests](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/test.yml/badge.svg)](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/test.yml)
[![Lint](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/lint.yml/badge.svg)](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/lint.yml)
[![Security Scan](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/security.yml/badge.svg)](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/security.yml)
[![Docker Build](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/docker-build.yml/badge.svg)](https://github.com/christianlouis/pop_puller_to_gmail/actions/workflows/docker-build.yml)
[![CI](https://github.com/christianlouis/InboxConverge/actions/workflows/ci.yml/badge.svg)](https://github.com/christianlouis/InboxConverge/actions/workflows/ci.yml)
[![GitHub Release](https://img.shields.io/github/v/release/christianlouis/InboxConverge?sort=semver&label=release)](https://github.com/christianlouis/InboxConverge/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://www.docker.com/)
A Docker-based solution that automatically fetches emails from POP3 mailboxes and forwards them to Gmail, replacing Google's discontinued POP3 import feature.
**Google is removing "Check mail from other accounts" (POP) and Gmailify in 2026. InboxConverge is the self-hosted replacement.**
## Features
Gmail's built-in POP fetcher and Gmailify are being shut down imminently. Google's suggested alternatives — asking your old provider to configure outbound forwarding, or reading mail in the Gmail mobile app over IMAP — don't replicate the seamless, automatic consolidation you had. InboxConverge does: it polls your POP3/IMAP mailboxes on a schedule and injects new messages directly into your Gmail inbox, exactly like the old feature, running on your own infrastructure.
- **Multiple POP3 Accounts** — support for unlimited POP3 mailboxes
- **Dual Delivery** — inject emails via **Gmail API** (preferred) or forward via **SMTP**
- **Hybrid Configuration** — configure via environment variables, `.env` files, **or** the database
- **Smart Throttling** — configurable rate limiting to stay within Gmail quotas
- **Error Reporting** — multi-channel notifications (Apprise: email, Telegram, Slack, Discord, webhooks)
- **Scheduled Polling** — configurable check intervals (default: every 5 minutes)
- **Docker Ready** — fully containerized with Docker Compose support
- **Secure** — runs as non-root user, SSL/TLS connections, encrypted credential storage
> **Google's own announcement:** *"Gmail no longer supports fetching email from third-party accounts via POP. The 'Check mail from other accounts' option is no longer available in Gmail."*
>
> InboxConverge puts that option back — permanently, on your own terms.
### SaaS Platform (in development)
## Who this is for
The repository also includes a multi-tenant SaaS backend built with FastAPI, PostgreSQL, Redis, and Celery. It adds multi-user support, OAuth2 authentication, POP3/IMAP protocol support, encrypted credential storage, and background job processing. See the [SaaS README](docs/README_SAAS.md) for details.
- You used Gmail's "Check mail from other accounts" and it's going away
- You have one or more external mailboxes (work, old ISP, custom domain) that you want consolidated into Gmail automatically
- You don't want to rely on your old provider supporting outbound forwarding
- You want email delivered cleanly into Gmail without headers being mangled or spam filters misfiring
## Quick Start
## What you get
### Using a Pre-built Docker Image (Recommended)
| | Google POP Import (shutting down 2026) | InboxConverge |
|---|---|---|
| Still works? | ❌ Shutting down 2026 | ✅ |
| Multiple source accounts | Limited | ✅ Unlimited |
| Automatic, scheduled polling | ✅ | ✅ Every 5 min (configurable) |
| Original headers preserved | ❌ | ✅ Via Gmail API injection |
| Counts against sending quota | ❌ N/A | ✅ No (Gmail API) / ⚠️ Yes (SMTP) |
| Alerts when something breaks | ❌ | ✅ Email, Slack, Telegram, Discord… |
| Self-hosted, no third-party dependency | ❌ | ✅ Docker, runs anywhere |
| Open source | ❌ | ✅ MIT |
## Get started in 5 minutes
```bash
# Pull and configure
curl -O https://raw.githubusercontent.com/christianlouis/pop_puller_to_gmail/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/christianlouis/pop_puller_to_gmail/main/.env.example
# 1. Grab the config files
curl -O https://raw.githubusercontent.com/christianlouis/InboxConverge/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/christianlouis/InboxConverge/main/.env.example
# Edit .env with your credentials
# 2. Fill in your credentials
nano .env
# Start
# 3. Launch
docker-compose up -d
```
### Building from Source
Minimum `.env` to get going:
```bash
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
cd pop_puller_to_gmail
cp .env.example .env # then edit .env
docker-compose up -d
```dotenv
# Source mailbox — add _2_, _3_, … for additional accounts
POP3_ACCOUNT_1_HOST=pop.yourprovider.com
POP3_ACCOUNT_1_USER=you@yourprovider.com
POP3_ACCOUNT_1_PASSWORD=your-pop3-password
# Destination — Gmail App Password (quickest way to start)
SMTP_USER=you@gmail.com
SMTP_PASSWORD=xxxx-xxxx-xxxx-xxxx
# Required internal secrets — run each command below and paste the output
SECRET_KEY= # python -c 'import secrets; print(secrets.token_urlsafe(32))'
ENCRYPTION_KEY= # python -c 'import secrets; print(secrets.token_urlsafe(32))'
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/inboxconverge
```
See the [Quick Start Guide](docs/QUICKSTART.md) for detailed instructions.
See the [Quick Start Guide](docs/QUICKSTART.md) for a full walkthrough, including the recommended Gmail API setup which preserves headers and avoids sending-quota limits.
## Configuration
## Two ways to deliver mail into Gmail
### Hybrid Configuration (Environment + Database)
**Gmail API injection (recommended)** — messages land in your inbox with original `From`, `Reply-To`, and `Message-ID` intact, don't count against your sending quota, and bypass the spam-filter penalties that forwarded mail often triggers. Requires a one-time Google OAuth2 authorisation.
The application supports a **hybrid configuration model**:
**SMTP with App Password (zero-setup fallback)** — works immediately with a [Gmail App Password](https://myaccount.google.com/apppasswords). Forwarded messages may be re-wrapped and count toward your 500-message/day free-tier limit. Good for getting started quickly; upgrade to the API later.
| Source | Priority | Use For |
|--------|----------|---------|
| **Database** (`app_settings` table) | Highest | SMTP, processing, Gmail API, notifications |
| **Environment variables / `.env`** | Fallback | All settings; required for bootstrap settings |
| **Built-in defaults** | Lowest | Sensible defaults for all non-bootstrap settings |
## Key settings
**Bootstrap settings** (`DATABASE_URL`, `SECRET_KEY`, `ENCRYPTION_KEY`) always come from environment variables because the database connection depends on them.
| Variable | Default | What it does |
|---|---|---|
| `CHECK_INTERVAL_MINUTES` | `5` | How often mailboxes are polled |
| `MAX_EMAILS_PER_RUN` | `50` | Maximum messages fetched per account per run |
| `THROTTLE_EMAILS_PER_MINUTE` | `10` | Rate cap toward Gmail |
All other settings (SMTP, processing intervals, Gmail API, etc.) can be managed via the admin API at `/api/v1/settings` and are stored in the PostgreSQL database. When a database setting exists, it takes priority over the corresponding environment variable.
### POP3 Accounts
Add multiple POP3 accounts by incrementing the account number in your `.env`:
```bash
POP3_ACCOUNT_1_HOST=pop.provider1.com
POP3_ACCOUNT_1_USER=user1@provider1.com
POP3_ACCOUNT_1_PASSWORD=password1
POP3_ACCOUNT_2_HOST=pop.provider2.com
POP3_ACCOUNT_2_USER=user2@provider2.com
POP3_ACCOUNT_2_PASSWORD=password2
```
### Email Delivery Methods
The forwarder supports two delivery methods for getting emails into Gmail:
#### Gmail API Injection (Preferred)
Emails are injected directly into your Gmail account using Google's `users.messages.insert()` API. This is the **recommended method** because it:
- Preserves original email headers and metadata exactly as-is
- Does not modify `From`, `Reply-To`, or `Message-ID` headers
- Applies Gmail labels (e.g., `INBOX`) on injection
- Does not count against Gmail's SMTP sending quotas
- Does not require an SMTP App Password
**Setup:**
1. Configure Google OAuth2 credentials (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`)
2. Authenticate via the SaaS web UI or API (`POST /api/v1/providers/gmail-credential`)
3. Set `delivery_method` to `gmail_api` when creating mail accounts
**Required OAuth2 Scopes:**
- `https://www.googleapis.com/auth/gmail.insert`
- `https://www.googleapis.com/auth/gmail.labels`
#### SMTP Forwarding (Fallback)
Emails are forwarded to Gmail via SMTP. This is the legacy method and is used as a fallback when Gmail API credentials are not available.
**Limitations vs Gmail API:**
- Modifies email headers (adds `Received`, may rewrite `From`)
- Counts against Gmail's SMTP sending quota (500/day for free accounts)
- Requires a Gmail App Password (see below)
- May trigger spam filters for forwarded mail
**Setup:**
1. Go to your [Google Account Security](https://myaccount.google.com/security)
2. Under "Signing in to Google," select **App Passwords**
3. Generate a new app password for "Mail"
4. Set `SMTP_PASSWORD` in your environment or database settings
### Environment Variables
> **Note:** All settings marked ★ can also be managed via the database
> through the admin API (`/api/v1/settings`). Database values take precedence.
#### Bootstrap Settings (env only)
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DATABASE_URL` | Yes | `postgresql+asyncpg://...` | PostgreSQL connection string |
| `SECRET_KEY` | Yes | — | JWT signing key (min 32 chars) |
| `ENCRYPTION_KEY` | Yes | — | Credential encryption key (min 32 chars) |
#### POP3/IMAP Accounts (env only — or via API)
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `POP3_ACCOUNT_N_HOST` | Yes | — | POP3 server hostname |
| `POP3_ACCOUNT_N_PORT` | No | `995` | POP3 server port |
| `POP3_ACCOUNT_N_USER` | Yes | — | POP3 username |
| `POP3_ACCOUNT_N_PASSWORD` | Yes | — | POP3 password |
| `POP3_ACCOUNT_N_USE_SSL` | No | `true` | Use SSL/TLS |
#### SMTP Settings (★ database-configurable)
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `SMTP_HOST` | No | `smtp.gmail.com` | SMTP server |
| `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_USER` | For SMTP | — | SMTP username |
| `SMTP_PASSWORD` | For SMTP | — | SMTP password (App Password) |
| `SMTP_USE_TLS` | No | `true` | Use STARTTLS |
#### Gmail API Settings (★ database-configurable)
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `GOOGLE_CLIENT_ID` | For Gmail API | — | Google OAuth2 client ID |
| `GOOGLE_CLIENT_SECRET` | For Gmail API | — | Google OAuth2 client secret |
| `GMAIL_API_ENABLED` | No | `true` | Enable Gmail API delivery |
#### Processing Settings (★ database-configurable)
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CHECK_INTERVAL_MINUTES` | No | `5` | Polling interval |
| `MAX_EMAILS_PER_RUN` | No | `50` | Max emails per account per run |
| `THROTTLE_EMAILS_PER_MINUTE` | No | `10` | Rate limit |
| `LOG_LEVEL` | No | `INFO` | Logging level |
## How It Works
```
┌─────────────────┐
│ POP3 Server 1 │
└────────┬────────┘
│ (Fetch emails)
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ POP3 Server 2 │─────▶│ Forwarder │─────▶│ Gmail API │
└─────────────────┘ │ Container │ │ (Preferred) │
│ │ │ └──────────────────┘
┌────────▼────────┐ │ Config from: │ ┌──────────────────┐
│ POP3 Server N │ │ • Database │─────▶│ Gmail SMTP │
└─────────────────┘ │ • Environment │ │ (Fallback) │
└──────┬───────────┘ └──────────────────┘
│ (Notifications)
┌─────────────────┐
│ Apprise │
│ (Email, Slack, │
│ Telegram ...) │
└─────────────────┘
```
1. **Polling** — checks POP3/IMAP mailboxes at the configured interval
2. **Fetching** — retrieves new emails from each account
3. **Delivery** — injects into Gmail via API (preferred) or forwards via SMTP (fallback)
4. **Cleanup** — deletes from source after successful delivery
5. **Throttling** — respects rate limits to avoid quota issues
6. **Notifications** — sends alerts via Apprise (email, Telegram, Slack, Discord, webhooks)
## Development
```bash
# Install dependencies
make install-dev
# Run linting & formatting
make lint
make format
# Run tests
make test
# Start backend in dev mode
make run-dev
```
See the [Testing Guide](docs/TESTING_GUIDE.md) for the full test workflow.
All settings except the three bootstrap secrets can be changed at runtime in the admin web UI — no restart needed.
## Documentation
Detailed documentation lives in the [`docs/`](docs/) directory:
| Document | Description |
|----------|-------------|
| [Architecture](docs/ARCHITECTURE.md) | System design and component overview |
| [Quick Start](docs/QUICKSTART.md) | Step-by-step setup guide |
| [Migration Guide](docs/MIGRATION_GUIDE.md) | Upgrading from v1 to v2 |
| [Deployment Checklist](docs/DEPLOYMENT_CHECKLIST.md) | Production deployment guide |
| [Roadmap](docs/ROADMAP.md) | Planned features and milestones |
| [Testing Guide](docs/TESTING_GUIDE.md) | How to run and write tests |
| [Coding Patterns](docs/CODING_PATTERNS.md) | Code style and conventions |
| [SaaS README](docs/README_SAAS.md) | Multi-tenant SaaS platform details |
| | |
|---|---|
| [Quick Start](docs/QUICKSTART.md) | Step-by-step setup, Gmail API & SMTP |
| [Deployment Checklist](docs/DEPLOYMENT_CHECKLIST.md) | Production hardening guide |
| [Architecture](docs/ARCHITECTURE.md) | How the pieces fit together |
| [Migration Guide](docs/MIGRATION_GUIDE.md) | Upgrading from an older version |
| [Roadmap](docs/ROADMAP.md) | What's coming next |
## Contributing
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on:
- Reporting bugs and suggesting features
- Development setup and code style
- Pull request process
Bug reports, feature requests, and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
## Security
To report a vulnerability, please see [SECURITY.md](SECURITY.md). **Do not open public issues for security concerns.**
Please report vulnerabilities privately via [SECURITY.md](SECURITY.md) rather than opening a public issue.
## License
This project is licensed under the MIT License — see [LICENSE](LICENSE) for details.
## Support
- [Issue Tracker](https://github.com/christianlouis/pop_puller_to_gmail/issues)
- [Discussions](https://github.com/christianlouis/pop_puller_to_gmail/discussions)
MIT — see [LICENSE](LICENSE).
+1 -1
View File
@@ -15,7 +15,7 @@
If you discover a security vulnerability, please report it responsibly:
1. **Email**: Send details to the repository maintainer via the email listed on the [GitHub profile](https://github.com/christianlouis).
2. **GitHub Private Vulnerability Reporting**: Use [GitHub's security advisory feature](https://github.com/christianlouis/pop_puller_to_gmail/security/advisories/new) to report privately.
2. **GitHub Private Vulnerability Reporting**: Use [GitHub's security advisory feature](https://github.com/christianlouis/inboxconverge/security/advisories/new) to report privately.
### What to Include
+5 -3
View File
@@ -1,5 +1,5 @@
# Database Configuration
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/pop3_forwarder
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/inbox_converge
# Security
SECRET_KEY=change-this-to-a-secure-random-secret-key-minimum-32-characters
@@ -41,12 +41,14 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/0
LOG_LEVEL=INFO
# Admin Account (created on first startup)
ADMIN_EMAIL=admin@example.com
ADMIN_EMAIL=christian@inboxconverge.com
ADMIN_PASSWORD=change-this-secure-password
# Application
APP_NAME=POP3 Forwarder SaaS
APP_NAME=InboxConverge
APP_VERSION=2.0.0
APP_URL=https://inboxconverge.com
CONTACT_EMAIL=christian@inboxconverge.com
DEBUG=false
HOST=0.0.0.0
PORT=8000
+4
View File
@@ -18,6 +18,10 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Inject build metadata
ARG BUILD_DATE=""
ENV BUILD_DATE=${BUILD_DATE}
# Create non-root user
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
@@ -0,0 +1,38 @@
"""Add name and apprise_url columns to notification_configs
Revision ID: 0001
Revises:
Create Date: 2026-03-26
These two columns were added to the NotificationConfig ORM model in the
Apprise alerting feature PR. SQLAlchemy's create_all() does not ALTER
existing tables, so deployments that had notification_configs created before
this change are missing the columns and raise a ProgrammingError at runtime.
Using ADD COLUMN IF NOT EXISTS makes this migration idempotent it is safe
to run against both fresh installs (where create_all already created the
columns) and existing deployments (where the columns are absent).
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE notification_configs "
"ADD COLUMN IF NOT EXISTS name VARCHAR(255) NOT NULL DEFAULT 'My Notification'"
)
op.execute(
"ALTER TABLE notification_configs " "ADD COLUMN IF NOT EXISTS apprise_url TEXT"
)
def downgrade() -> None:
op.execute("ALTER TABLE notification_configs DROP COLUMN IF EXISTS apprise_url")
op.execute("ALTER TABLE notification_configs DROP COLUMN IF EXISTS name")
@@ -0,0 +1,37 @@
"""Add debug_logging column to mail_accounts
Revision ID: 0002
Revises: 0001
Create Date: 2026-05-03
Adds a boolean ``debug_logging`` column to ``mail_accounts``.
When True, the next processing run will record a detailed connection trace
(timings, phase-by-phase events, message UIDs/sizes) and persist it as a
``ProcessingLog`` row with ``level='DEBUG'``. The column auto-resets to
False after 5 completed runs in a 24-hour window to prevent it being left
on indefinitely.
The column defaults to False so all existing rows are unaffected.
Using IF NOT EXISTS makes the migration idempotent against fresh installs
where create_all() already created the column.
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE mail_accounts "
"ADD COLUMN IF NOT EXISTS debug_logging BOOLEAN NOT NULL DEFAULT FALSE"
)
def downgrade() -> None:
op.execute("ALTER TABLE mail_accounts DROP COLUMN IF EXISTS debug_logging")
@@ -0,0 +1,45 @@
"""Add debug_logging_run_count and error_notification_sent columns
Revision ID: 0003
Revises: 0002
Create Date: 2026-05-03
Changes:
- debug_logging_run_count (INTEGER, default 0): counts completed/partial_failure
runs since debug_logging was last enabled. Auto-disables debug_logging once
this counter reaches 5. The counter is reset to 0 whenever debug_logging is
toggled back on via the API.
- error_notification_sent (BOOLEAN, default FALSE): tracks whether a failure
notification has already been dispatched for the current consecutive error
streak. Prevents notification spam: only the first failure in a streak fires
a notification. Cleared (and a recovery notice sent) when a run succeeds.
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE mail_accounts "
"ADD COLUMN IF NOT EXISTS debug_logging_run_count INTEGER NOT NULL DEFAULT 0"
)
op.execute(
"ALTER TABLE mail_accounts "
"ADD COLUMN IF NOT EXISTS error_notification_sent BOOLEAN NOT NULL DEFAULT FALSE"
)
def downgrade() -> None:
op.execute(
"ALTER TABLE mail_accounts DROP COLUMN IF EXISTS debug_logging_run_count"
)
op.execute(
"ALTER TABLE mail_accounts DROP COLUMN IF EXISTS error_notification_sent"
)
@@ -0,0 +1,36 @@
"""Add sender_email column to user_smtp_configs
Revision ID: 0004
Revises: 0003
Create Date: 2026-05-03
Adds a ``sender_email`` column to ``user_smtp_configs``.
SMTP providers such as Postmark use an API token (UUID) as the SMTP username
for authentication, but require a real email address as the ``From:`` header.
This column stores the address that should appear as the sender; when blank,
the existing ``username`` value is used as a fallback so existing rows remain
fully functional.
Using IF NOT EXISTS makes the migration idempotent against fresh installs
where create_all() already created the column.
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE user_smtp_configs "
"ADD COLUMN IF NOT EXISTS sender_email VARCHAR(255) NOT NULL DEFAULT ''"
)
def downgrade() -> None:
op.execute("ALTER TABLE user_smtp_configs DROP COLUMN IF EXISTS sender_email")
+6
View File
@@ -13,6 +13,8 @@ from app.api.v1.endpoints import (
admin,
providers,
app_settings,
logs,
version,
)
api_router = APIRouter()
@@ -34,3 +36,7 @@ api_router.include_router(
)
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
api_router.include_router(app_settings.router, prefix="/settings", tags=["Settings"])
api_router.include_router(
logs.router, prefix="/processing-runs", tags=["Processing Logs"]
)
api_router.include_router(version.router, prefix="/version", tags=["Version"])
+536 -2
View File
@@ -1,12 +1,41 @@
"""Admin endpoints"""
from fastapi import APIRouter, Depends
import math
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.deps import get_current_superuser
from app.models.database_models import User, MailAccount, ProcessingRun
from app.core.gdpr import mask_email, mask_from_header
from app.models.database_models import (
User,
MailAccount,
ProcessingLog,
ProcessingRun,
SubscriptionPlan,
SubscriptionTier,
AdminNotificationConfig,
)
from app.models.schemas import (
AdminUserListResponse,
AdminUserUpdate,
UserDetailResponse,
SubscriptionPlanResponse,
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
AdminNotificationConfigCreate,
AdminNotificationConfigUpdate,
AdminNotificationConfigResponse,
NotificationTestRequest,
NotificationTestResponse,
AdminProcessingRunResponse,
AdminProcessingLogResponse,
PaginatedAdminRunsResponse,
PaginatedAdminLogsResponse,
)
from app.services.notification_service import test_notification
router = APIRouter()
@@ -35,3 +64,508 @@ async def get_admin_stats(
"total_mail_accounts": total_accounts,
"total_processing_runs": total_runs,
}
# ── User management ────────────────────────────────────────────────────────────
@router.get("/users", response_model=List[AdminUserListResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""List all users with their mail account counts (admin only)"""
result = await db.execute(
select(User).order_by(User.created_at.desc()).offset(skip).limit(limit)
)
users = result.scalars().all()
# Fetch mail account counts per user in one query
counts_result = await db.execute(
select(MailAccount.user_id, func.count(MailAccount.id).label("cnt")).group_by(
MailAccount.user_id
)
)
counts = {row.user_id: row.cnt for row in counts_result}
response = []
for u in users:
response.append(
AdminUserListResponse(
id=u.id, # type: ignore[arg-type]
email=u.email, # type: ignore[arg-type]
full_name=u.full_name, # type: ignore[arg-type]
is_active=u.is_active, # type: ignore[arg-type]
is_superuser=u.is_superuser, # type: ignore[arg-type]
subscription_tier=u.subscription_tier, # type: ignore[arg-type]
subscription_status=u.subscription_status, # type: ignore[arg-type]
google_id=u.google_id, # type: ignore[arg-type]
oauth_provider=u.oauth_provider, # type: ignore[arg-type]
last_login_at=u.last_login_at, # type: ignore[arg-type]
created_at=u.created_at, # type: ignore[arg-type]
mail_account_count=counts.get(u.id, 0), # type: ignore[arg-type]
)
)
return response
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Get a specific user's details (admin only)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return user
@router.put("/users/{user_id}", response_model=UserDetailResponse)
async def update_user(
user_id: int,
user_update: AdminUserUpdate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Update a user's details, plan, or admin status (admin only)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if user_update.full_name is not None:
user.full_name = user_update.full_name # type: ignore[assignment]
if user_update.email is not None:
user.email = user_update.email # type: ignore[assignment]
if user_update.is_active is not None:
user.is_active = user_update.is_active # type: ignore[assignment]
if user_update.is_superuser is not None:
user.is_superuser = user_update.is_superuser # type: ignore[assignment]
if user_update.subscription_tier is not None:
user.subscription_tier = SubscriptionTier(user_update.subscription_tier.value) # type: ignore[assignment]
if user_update.subscription_status is not None:
user.subscription_status = user_update.subscription_status # type: ignore[assignment]
await db.commit()
await db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Delete a user and all their data (admin only)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account via admin endpoint",
)
await db.delete(user)
await db.commit()
# ── Plan management ────────────────────────────────────────────────────────────
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
async def list_all_plans(
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""List all subscription plans including inactive ones (admin only)"""
result = await db.execute(select(SubscriptionPlan).order_by(SubscriptionPlan.tier))
return result.scalars().all()
@router.post(
"/plans",
response_model=SubscriptionPlanResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_plan(
plan_in: SubscriptionPlanCreate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Create a new subscription plan (admin only)"""
existing = await db.execute(
select(SubscriptionPlan).where(
SubscriptionPlan.tier == SubscriptionTier(plan_in.tier.value)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"A plan for tier '{plan_in.tier.value}' already exists",
)
plan = SubscriptionPlan(
tier=SubscriptionTier(plan_in.tier.value),
name=plan_in.name,
description=plan_in.description,
price_monthly=plan_in.price_monthly,
price_yearly=plan_in.price_yearly,
max_mail_accounts=plan_in.max_mail_accounts,
max_emails_per_day=plan_in.max_emails_per_day,
check_interval_minutes=plan_in.check_interval_minutes,
support_level=plan_in.support_level,
features=plan_in.features,
is_active=plan_in.is_active,
)
db.add(plan)
await db.commit()
await db.refresh(plan)
return plan
@router.put("/plans/{plan_id}", response_model=SubscriptionPlanResponse)
async def update_plan(
plan_id: int,
plan_update: SubscriptionPlanUpdate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Update a subscription plan's limits or pricing (admin only)"""
result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id)
)
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
)
if plan_update.name is not None:
plan.name = plan_update.name # type: ignore[assignment]
if plan_update.description is not None:
plan.description = plan_update.description # type: ignore[assignment]
if plan_update.price_monthly is not None:
plan.price_monthly = plan_update.price_monthly # type: ignore[assignment]
if plan_update.price_yearly is not None:
plan.price_yearly = plan_update.price_yearly # type: ignore[assignment]
if plan_update.max_mail_accounts is not None:
plan.max_mail_accounts = plan_update.max_mail_accounts # type: ignore[assignment]
if plan_update.max_emails_per_day is not None:
plan.max_emails_per_day = plan_update.max_emails_per_day # type: ignore[assignment]
if plan_update.check_interval_minutes is not None:
plan.check_interval_minutes = plan_update.check_interval_minutes # type: ignore[assignment]
if plan_update.support_level is not None:
plan.support_level = plan_update.support_level # type: ignore[assignment]
if plan_update.features is not None:
plan.features = plan_update.features # type: ignore[assignment]
if plan_update.is_active is not None:
plan.is_active = plan_update.is_active # type: ignore[assignment]
await db.commit()
await db.refresh(plan)
return plan
@router.delete("/plans/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_plan(
plan_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Delete a subscription plan (admin only)"""
result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id)
)
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
)
await db.delete(plan)
await db.commit()
# ── Admin notification config management ──────────────────────────────────────
@router.get("/notifications", response_model=List[AdminNotificationConfigResponse])
async def list_admin_notification_configs(
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""List all admin notification configurations (admin only)"""
result = await db.execute(select(AdminNotificationConfig))
return result.scalars().all()
@router.post(
"/notifications",
response_model=AdminNotificationConfigResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_admin_notification_config(
config_in: AdminNotificationConfigCreate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Create a new admin notification configuration (admin only)"""
config = AdminNotificationConfig(**config_in.model_dump())
db.add(config)
await db.commit()
await db.refresh(config)
return config
@router.get(
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
)
async def get_admin_notification_config(
config_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Get a specific admin notification configuration (admin only)"""
result = await db.execute(
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Admin notification config not found",
)
return config
@router.put(
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
)
async def update_admin_notification_config(
config_id: int,
config_in: AdminNotificationConfigUpdate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Update an admin notification configuration (admin only)"""
result = await db.execute(
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Admin notification config not found",
)
update_data = config_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
await db.commit()
await db.refresh(config)
return config
@router.delete("/notifications/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_admin_notification_config(
config_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Delete an admin notification configuration (admin only)"""
result = await db.execute(
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Admin notification config not found",
)
await db.delete(config)
await db.commit()
@router.post("/notifications/test", response_model=NotificationTestResponse)
async def test_admin_notification_config(
request: NotificationTestRequest,
current_user: User = Depends(get_current_superuser),
):
"""Test an admin notification channel by sending a test message (admin only)"""
success, message = await test_notification(request.apprise_url)
return NotificationTestResponse(success=success, message=message)
# ── Admin Logs ─────────────────────────────────────────────────────────────────
def _admin_paginate(total: int, page: int, page_size: int) -> dict:
pages = max(1, math.ceil(total / page_size)) if total else 1
return {"total": total, "page": page, "page_size": page_size, "pages": pages}
@router.get(
"/processing-runs",
response_model=PaginatedAdminRunsResponse,
summary="List all processing runs across all users (admin only)",
)
async def admin_list_processing_runs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: Optional[int] = Query(None, description="Filter by user ID"),
account_id: Optional[int] = Query(None, description="Filter by mail account ID"),
status_filter: Optional[str] = Query(
None,
alias="status",
description="Filter by run status",
),
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""
Return a paginated list of all processing runs in the system with
GDPR-masked user / account email addresses.
"""
base = (
select(
ProcessingRun,
MailAccount.name.label("account_name"),
MailAccount.email_address.label("account_email"),
MailAccount.user_id.label("uid"),
User.email.label("user_email"),
)
.join(MailAccount, ProcessingRun.mail_account_id == MailAccount.id)
.join(User, MailAccount.user_id == User.id)
)
if user_id is not None:
base = base.where(MailAccount.user_id == user_id)
if account_id is not None:
base = base.where(ProcessingRun.mail_account_id == account_id)
if status_filter:
base = base.where(ProcessingRun.status == status_filter)
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
offset = (page - 1) * page_size
rows = (
await db.execute(
base.order_by(ProcessingRun.started_at.desc())
.offset(offset)
.limit(page_size)
)
).all()
items = [
AdminProcessingRunResponse(
id=row.ProcessingRun.id, # type: ignore[arg-type]
mail_account_id=row.ProcessingRun.mail_account_id, # type: ignore[arg-type]
started_at=row.ProcessingRun.started_at, # type: ignore[arg-type]
completed_at=row.ProcessingRun.completed_at, # type: ignore[arg-type]
duration_seconds=row.ProcessingRun.duration_seconds, # type: ignore[arg-type]
emails_fetched=row.ProcessingRun.emails_fetched, # type: ignore[arg-type]
emails_forwarded=row.ProcessingRun.emails_forwarded, # type: ignore[arg-type]
emails_failed=row.ProcessingRun.emails_failed, # type: ignore[arg-type]
status=row.ProcessingRun.status, # type: ignore[arg-type]
error_message=row.ProcessingRun.error_message, # type: ignore[arg-type]
account_name=row.account_name,
account_email=mask_email(row.account_email) if row.account_email else None,
user_id=row.uid,
user_email=mask_email(row.user_email) if row.user_email else None,
)
for row in rows
]
return PaginatedAdminRunsResponse(
items=items, **_admin_paginate(total, page, page_size) # type: ignore[arg-type]
)
@router.get(
"/processing-logs",
response_model=PaginatedAdminLogsResponse,
summary="List all per-email processing logs across all users (admin only)",
)
async def admin_list_processing_logs(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
user_id: Optional[int] = Query(None, description="Filter by user ID"),
account_id: Optional[int] = Query(None, description="Filter by mail account ID"),
run_id: Optional[int] = Query(None, description="Filter by processing run ID"),
level: Optional[str] = Query(
None, description="Filter by level (INFO, WARNING, ERROR)"
),
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""
Return paginated per-email log entries with GDPR-masked sender addresses.
Subject lines are shown as-is (the user owns their own mail content);
sender addresses are pseudonymised for operator privacy.
"""
base = select(
ProcessingLog,
User.email.label("user_email"),
).join(User, ProcessingLog.user_id == User.id)
if user_id is not None:
base = base.where(ProcessingLog.user_id == user_id)
if account_id is not None:
base = base.where(ProcessingLog.mail_account_id == account_id)
if run_id is not None:
base = base.where(ProcessingLog.processing_run_id == run_id)
if level:
base = base.where(ProcessingLog.level == level.upper())
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
offset = (page - 1) * page_size
rows = (
await db.execute(
base.order_by(ProcessingLog.timestamp.desc())
.offset(offset)
.limit(page_size)
)
).all()
items = [
AdminProcessingLogResponse(
id=row.ProcessingLog.id, # type: ignore[arg-type]
timestamp=row.ProcessingLog.timestamp, # type: ignore[arg-type]
level=row.ProcessingLog.level, # type: ignore[arg-type]
message=row.ProcessingLog.message, # type: ignore[arg-type]
email_subject=row.ProcessingLog.email_subject, # type: ignore[arg-type]
email_from=(
mask_from_header(row.ProcessingLog.email_from)
if row.ProcessingLog.email_from
else None
),
success=row.ProcessingLog.success, # type: ignore[arg-type]
mail_account_id=row.ProcessingLog.mail_account_id, # type: ignore[arg-type]
processing_run_id=row.ProcessingLog.processing_run_id, # type: ignore[arg-type]
email_size_bytes=row.ProcessingLog.email_size_bytes, # type: ignore[arg-type]
error_details=row.ProcessingLog.error_details, # type: ignore[arg-type]
user_id=row.ProcessingLog.user_id, # type: ignore[arg-type]
user_email=mask_email(row.user_email) if row.user_email else None,
)
for row in rows
]
return PaginatedAdminLogsResponse(
items=items, **_admin_paginate(total, page, page_size) # type: ignore[arg-type]
)
+189 -59
View File
@@ -7,10 +7,17 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timezone
from urllib.parse import quote as urlquote
import logging
from app.core.config import settings
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash
from app.core.metrics import (
AUTH_LOGINS_TOTAL,
AUTH_REGISTRATIONS_TOTAL,
OAUTH_CALLBACKS_TOTAL,
)
from app.models.database_models import User, SubscriptionTier
from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
from app.services.auth_service import oauth_service
@@ -18,6 +25,57 @@ from app.services.auth_service import oauth_service
router = APIRouter()
logger = logging.getLogger(__name__)
# Scopes requested during Google Sign-In only basic profile information.
# Gmail API access is granted separately via the "Connect Gmail" flow in
# Settings (/providers/gmail/authorize-url).
GOOGLE_LOGIN_SCOPES = [
"openid",
"email",
"profile",
]
def _domain_of(email: str) -> str:
"""Return the lowercased domain part of an email address."""
return email.split("@")[-1].lower()
def _check_domain_allowed(email: str) -> None:
"""
Raise 403 if ALLOWED_DOMAINS is configured and the email's domain is not
in the list. Always passes when ALLOWED_DOMAINS is empty (no restriction).
"""
if not settings.ALLOWED_DOMAINS:
return
domain = _domain_of(email)
if domain not in settings.ALLOWED_DOMAINS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"Registrations are restricted to approved domains. "
f"'{domain}' is not authorised."
),
)
def _default_tier() -> SubscriptionTier:
"""Return the SubscriptionTier that should be assigned to every new user."""
try:
return SubscriptionTier(settings.DEFAULT_USER_TIER)
except ValueError:
logger.warning(
"DEFAULT_USER_TIER '%s' is not a valid tier; falling back to FREE.",
settings.DEFAULT_USER_TIER,
)
return SubscriptionTier.FREE
def _is_admin_email(email: str) -> bool:
return (
settings.ADMIN_EMAIL is not None
and email.lower() == settings.ADMIN_EMAIL.lower()
)
@router.post(
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
@@ -34,6 +92,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
)
# Domain restriction check (before creating the account)
_check_domain_allowed(user_in.email)
# Create new user
user = User(
email=user_in.email,
@@ -41,14 +102,16 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
hashed_password=(
get_password_hash(user_in.password) if user_in.password else None
),
subscription_tier=SubscriptionTier.FREE,
subscription_tier=_default_tier(),
is_active=True,
is_superuser=_is_admin_email(user_in.email),
)
db.add(user)
await db.commit()
await db.refresh(user)
AUTH_REGISTRATIONS_TOTAL.labels(method="password", status="success").inc()
logger.info(f"New user registered: {user.email}")
return user
@@ -65,6 +128,7 @@ async def login(
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
AUTH_LOGINS_TOTAL.labels(method="password", status="failure").inc()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
@@ -73,6 +137,7 @@ async def login(
# Verify password
if not verify_password(form_data.password, user.hashed_password): # type: ignore[arg-type]
AUTH_LOGINS_TOTAL.labels(method="password", status="failure").inc()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
@@ -81,17 +146,29 @@ async def login(
# Check if user is active
if not user.is_active:
AUTH_LOGINS_TOTAL.labels(method="password", status="failure").inc()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
)
# Domain restriction — superusers always bypass
if not user.is_superuser:
_check_domain_allowed(str(user.email))
# Update last login
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
# Auto-promote to superuser if this is the configured admin email
if not user.is_superuser and _is_admin_email(str(user.email)):
user.is_superuser = True # type: ignore[assignment]
logger.info(f"Auto-promoted admin user: {user.email}")
await db.commit()
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
AUTH_LOGINS_TOTAL.labels(method="password", status="success").inc()
logger.info(f"User logged in: {user.email}")
return tokens
@@ -105,73 +182,126 @@ async def google_oauth(
Authenticate with Google OAuth2.
Exchange authorization code for access token and user info.
"""
# Get user info from Google
user_info = await oauth_service.get_google_user_info(
code=auth_request.code, redirect_uri=auth_request.redirect_uri
logger.debug(
"OAuth [Google sign-in]: callback received (redirect_uri=%s)",
auth_request.redirect_uri,
)
if not user_info.get("verified_email"):
try:
# Get user info from Google
user_info = await oauth_service.get_google_user_info(
code=auth_request.code, redirect_uri=auth_request.redirect_uri
)
if not user_info.get("verified_email"):
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="error").inc()
logger.warning(
"OAuth [Google sign-in]: rejecting unverified email=%s",
user_info.get("email"),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email not verified with Google",
)
email = user_info["email"]
google_id = user_info["google_id"]
# Check if user exists
result = await db.execute(
select(User).where((User.email == email) | (User.google_id == google_id))
)
user = result.scalar_one_or_none()
if user:
# Update Google ID if not set
if not user.google_id:
user.google_id = google_id # type: ignore[assignment]
user.oauth_provider = "google" # type: ignore[assignment]
logger.info(
"OAuth [Google sign-in]: linked Google ID to existing account "
"(email=%s)",
email,
)
# Update last login
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
# Domain restriction — superusers always bypass
if not user.is_superuser:
_check_domain_allowed(email)
# Auto-promote to superuser if this is the configured admin email
if not user.is_superuser and _is_admin_email(email):
user.is_superuser = True # type: ignore[assignment]
logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}")
logger.info(f"Existing user logged in with Google: {user.email}")
AUTH_LOGINS_TOTAL.labels(method="google", status="success").inc()
else:
# Domain restriction check before creating the account
_check_domain_allowed(email)
# Create new user
user = User(
email=email,
full_name=user_info.get("full_name"),
google_id=google_id,
oauth_provider="google",
subscription_tier=_default_tier(),
is_active=True,
last_login_at=datetime.now(timezone.utc),
is_superuser=_is_admin_email(email),
)
db.add(user)
logger.info(f"New user registered with Google: {user.email}")
AUTH_REGISTRATIONS_TOTAL.labels(method="google", status="success").inc()
await db.commit()
await db.refresh(user)
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
logger.debug(
"OAuth [Google sign-in]: sign-in complete, JWT tokens issued for user_id=%s",
user.id,
)
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="success").inc()
return tokens
except HTTPException:
raise
except Exception:
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="error").inc()
logger.error(
"OAuth [Google sign-in]: unhandled error during sign-in flow",
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email not verified with Google",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth authentication failed",
)
email = user_info["email"]
google_id = user_info["google_id"]
# Check if user exists
result = await db.execute(
select(User).where((User.email == email) | (User.google_id == google_id))
)
user = result.scalar_one_or_none()
if user:
# Update Google ID if not set
if not user.google_id:
user.google_id = google_id # type: ignore[assignment]
user.oauth_provider = "google" # type: ignore[assignment]
# Update last login
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
logger.info(f"Existing user logged in with Google: {user.email}")
else:
# Create new user
user = User(
email=email,
full_name=user_info.get("full_name"),
google_id=google_id,
oauth_provider="google",
subscription_tier=SubscriptionTier.FREE,
is_active=True,
last_login_at=datetime.now(timezone.utc),
)
db.add(user)
logger.info(f"New user registered with Google: {user.email}")
await db.commit()
await db.refresh(user)
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
return tokens
@router.get("/google/authorize-url")
async def get_google_authorize_url(redirect_uri: str):
"""Get Google OAuth2 authorization URL"""
from app.core.config import settings
"""Get Google OAuth2 authorization URL for sign-in (profile scopes only)."""
logger.debug(
"OAuth [Google sign-in]: authorization URL requested "
"(redirect_uri=%s, scopes=%s)",
redirect_uri,
GOOGLE_LOGIN_SCOPES,
)
scope = urlquote(" ".join(GOOGLE_LOGIN_SCOPES))
auth_url = (
f"https://accounts.google.com/o/oauth2/v2/auth?"
f"client_id={settings.GOOGLE_CLIENT_ID}&"
f"response_type=code&"
f"scope=openid%20email%20profile&"
f"redirect_uri={redirect_uri}&"
f"access_type=offline"
"https://accounts.google.com/o/oauth2/v2/auth"
f"?client_id={settings.GOOGLE_CLIENT_ID}"
"&response_type=code"
f"&scope={scope}"
f"&redirect_uri={redirect_uri}"
"&prompt=select_account"
)
return {"authorization_url": auth_url}
+242
View File
@@ -0,0 +1,242 @@
"""
Processing logs and run history endpoints for users.
Users can view the full history of processing runs and per-email logs
for their own mailboxes. Admin equivalents live in admin.py.
"""
from __future__ import annotations
import math
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import (
MailAccount,
ProcessingLog,
ProcessingRun,
User,
)
from app.models.schemas import (
PaginatedProcessingLogsResponse,
PaginatedProcessingRunsResponse,
ProcessingLogDetailResponse,
ProcessingRunDetailResponse,
)
router = APIRouter()
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _paginate(total: int, page: int, page_size: int) -> dict:
pages = max(1, math.ceil(total / page_size)) if total else 1
return {"total": total, "page": page, "page_size": page_size, "pages": pages}
# ---------------------------------------------------------------------------
# Processing Runs (all accounts belonging to the current user)
# ---------------------------------------------------------------------------
@router.get(
"",
response_model=PaginatedProcessingRunsResponse,
summary="List processing runs for the current user",
)
async def list_processing_runs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
account_id: Optional[int] = Query(None, description="Filter by mail account ID"),
status_filter: Optional[str] = Query(
None,
alias="status",
description="Filter by run status (completed, failed, partial_failure, running)",
),
has_emails: Optional[bool] = Query(
None,
description="When true, only return runs that fetched at least one email",
),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""
Return a paginated list of processing runs for all mail accounts owned by
the authenticated user, optionally filtered by account, status, or whether
any emails were fetched (has_emails=true reduces log noise by hiding empty
polling cycles).
"""
# Base query: join with MailAccount to enforce ownership
base = (
select(ProcessingRun, MailAccount.name, MailAccount.email_address)
.join(MailAccount, ProcessingRun.mail_account_id == MailAccount.id)
.where(MailAccount.user_id == current_user.id) # type: ignore[arg-type]
)
if account_id is not None:
base = base.where(ProcessingRun.mail_account_id == account_id)
if status_filter:
base = base.where(ProcessingRun.status == status_filter)
if has_emails is True:
base = base.where(ProcessingRun.emails_fetched > 0)
# Total count
count_q = select(func.count()).select_from(base.subquery())
total = (await db.execute(count_q)).scalar_one()
offset = (page - 1) * page_size
rows = (
await db.execute(
base.order_by(ProcessingRun.started_at.desc())
.offset(offset)
.limit(page_size)
)
).all()
items = [
ProcessingRunDetailResponse(
id=row.ProcessingRun.id, # type: ignore[arg-type]
mail_account_id=row.ProcessingRun.mail_account_id, # type: ignore[arg-type]
started_at=row.ProcessingRun.started_at, # type: ignore[arg-type]
completed_at=row.ProcessingRun.completed_at, # type: ignore[arg-type]
duration_seconds=row.ProcessingRun.duration_seconds, # type: ignore[arg-type]
emails_fetched=row.ProcessingRun.emails_fetched, # type: ignore[arg-type]
emails_forwarded=row.ProcessingRun.emails_forwarded, # type: ignore[arg-type]
emails_failed=row.ProcessingRun.emails_failed, # type: ignore[arg-type]
status=row.ProcessingRun.status, # type: ignore[arg-type]
error_message=row.ProcessingRun.error_message, # type: ignore[arg-type]
account_name=row.name,
account_email=row.email_address,
)
for row in rows
]
return PaginatedProcessingRunsResponse(
items=items, **_paginate(total, page, page_size) # type: ignore[arg-type]
)
@router.get(
"/{run_id}",
response_model=ProcessingRunDetailResponse,
summary="Get a single processing run",
)
async def get_processing_run(
run_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Return details for a single processing run owned by the current user."""
row = (
await db.execute(
select(ProcessingRun, MailAccount.name, MailAccount.email_address)
.join(MailAccount, ProcessingRun.mail_account_id == MailAccount.id)
.where(
ProcessingRun.id == run_id,
MailAccount.user_id == current_user.id, # type: ignore[arg-type]
)
)
).one_or_none()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Processing run not found"
)
return ProcessingRunDetailResponse(
id=row.ProcessingRun.id, # type: ignore[arg-type]
mail_account_id=row.ProcessingRun.mail_account_id, # type: ignore[arg-type]
started_at=row.ProcessingRun.started_at, # type: ignore[arg-type]
completed_at=row.ProcessingRun.completed_at, # type: ignore[arg-type]
duration_seconds=row.ProcessingRun.duration_seconds, # type: ignore[arg-type]
emails_fetched=row.ProcessingRun.emails_fetched, # type: ignore[arg-type]
emails_forwarded=row.ProcessingRun.emails_forwarded, # type: ignore[arg-type]
emails_failed=row.ProcessingRun.emails_failed, # type: ignore[arg-type]
status=row.ProcessingRun.status, # type: ignore[arg-type]
error_message=row.ProcessingRun.error_message, # type: ignore[arg-type]
account_name=row.name,
account_email=row.email_address,
)
@router.get(
"/{run_id}/logs",
response_model=PaginatedProcessingLogsResponse,
summary="Get per-email logs for a processing run",
)
async def get_run_logs(
run_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""
Return the detailed per-email log entries recorded during a specific
processing run. Ownership is verified by joining with MailAccount.
"""
# Verify the run belongs to this user
run_row = (
await db.execute(
select(ProcessingRun)
.join(MailAccount, ProcessingRun.mail_account_id == MailAccount.id)
.where(
ProcessingRun.id == run_id,
MailAccount.user_id == current_user.id, # type: ignore[arg-type]
)
)
).scalar_one_or_none()
if not run_row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Processing run not found"
)
count_q = select(func.count(ProcessingLog.id)).where(
ProcessingLog.processing_run_id == run_id
)
total = (await db.execute(count_q)).scalar_one()
offset = (page - 1) * page_size
logs = (
(
await db.execute(
select(ProcessingLog)
.where(ProcessingLog.processing_run_id == run_id)
.order_by(ProcessingLog.timestamp.asc())
.offset(offset)
.limit(page_size)
)
)
.scalars()
.all()
)
items = [
ProcessingLogDetailResponse(
id=log.id, # type: ignore[arg-type]
timestamp=log.timestamp, # type: ignore[arg-type]
level=log.level, # type: ignore[arg-type]
message=log.message, # type: ignore[arg-type]
email_subject=log.email_subject, # type: ignore[arg-type]
email_from=log.email_from, # type: ignore[arg-type]
success=log.success, # type: ignore[arg-type]
mail_account_id=log.mail_account_id, # type: ignore[arg-type]
processing_run_id=log.processing_run_id, # type: ignore[arg-type]
email_size_bytes=log.email_size_bytes, # type: ignore[arg-type]
error_details=log.error_details, # type: ignore[arg-type]
)
for log in logs
]
return PaginatedProcessingLogsResponse(
items=items, **_paginate(total, page, page_size) # type: ignore[arg-type]
)
+312 -27
View File
@@ -1,14 +1,23 @@
"""Mail account management endpoints"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
import math
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select, desc, func
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.core.security import encrypt_credential
from app.models.database_models import User, MailAccount, AccountStatus
from app.core.security import encrypt_credential, decrypt_credential
from app.models.database_models import (
User,
MailAccount,
ProcessingLog,
ProcessingRun,
AccountStatus,
SubscriptionPlan,
)
from app.workers.tasks import process_mail_account as process_mail_account_task
from app.models.schemas import (
MailAccountCreate,
MailAccountResponse,
@@ -17,6 +26,10 @@ from app.models.schemas import (
MailAccountTestResponse,
MailAccountAutoDetectRequest,
MailAccountAutoDetectResponse,
PaginatedProcessingRunsResponse,
PaginatedProcessingLogsResponse,
ProcessingRunDetailResponse,
ProcessingLogDetailResponse,
)
from app.services.mail_processor import MailProcessor, MailServerAutoDetect
from app.core.config import settings
@@ -34,26 +47,41 @@ async def create_mail_account(
):
"""Create a new mail account"""
# Check subscription limits
result = await db.execute(
select(MailAccount).where(MailAccount.user_id == current_user.id)
)
existing_accounts = result.scalars().all()
tier_limits = {
"free": settings.TIER_FREE_MAX_ACCOUNTS,
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
}
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
if len(existing_accounts) >= max_accounts:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Account limit reached. Upgrade your subscription to add more accounts.",
# Superusers are not subject to subscription limits
if not current_user.is_superuser:
# Count existing accounts for this user
result = await db.execute(
select(MailAccount).where(MailAccount.user_id == current_user.id)
)
existing_accounts = result.scalars().all()
# Try to look up the limit from the active SubscriptionPlan in the DB first
# so that admin-managed plan limits take effect immediately.
plan_result = await db.execute(
select(SubscriptionPlan).where(
SubscriptionPlan.tier == current_user.subscription_tier,
SubscriptionPlan.is_active.is_(True),
)
)
plan = plan_result.scalar_one_or_none()
if plan is not None:
max_accounts = plan.max_mail_accounts
else:
# Fall back to env-var / config values when no plan row exists
tier_limits = {
"free": settings.TIER_FREE_MAX_ACCOUNTS,
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
}
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1) # type: ignore[assignment]
if len(existing_accounts) >= max_accounts:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Account limit reached. Upgrade your subscription to add more accounts.",
)
# Encrypt password
encrypted_password = encrypt_credential(account_in.password)
@@ -76,6 +104,8 @@ async def create_mail_account(
check_interval_minutes=account_in.check_interval_minutes,
max_emails_per_check=account_in.max_emails_per_check,
delete_after_forward=account_in.delete_after_forward,
debug_logging=account_in.debug_logging,
provider_name=account_in.provider_name,
)
db.add(account)
@@ -146,9 +176,14 @@ async def update_mail_account(
update_data = account_update.model_dump(exclude_unset=True)
if "password" in update_data:
update_data["encrypted_password"] = encrypt_credential(
update_data.pop("password")
)
password = update_data.pop("password")
if password: # Only update when a non-empty password is provided
update_data["encrypted_password"] = encrypt_credential(password)
# When debug logging is (re-)enabled, reset the run counter so the user
# always gets exactly 5 debugged runs from the moment they check the box.
if update_data.get("debug_logging") is True:
update_data["debug_logging_run_count"] = 0
for field, value in update_data.items():
setattr(account, field, value)
@@ -214,6 +249,72 @@ async def toggle_mail_account(
return account
@router.post("/{account_id}/clear-error", response_model=MailAccountResponse)
async def clear_account_error(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Clear the error status of a mail account.
Resets last_error_message, last_error_at and sets status to ACTIVE
if the account is currently in ERROR state. Use this after fixing the
underlying problem (e.g. wrong password, DNS issue) to immediately remove
the error indicator without waiting for the next successful fetch.
"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
account.last_error_message = None # type: ignore[assignment]
account.last_error_at = None # type: ignore[assignment]
if account.status == AccountStatus.ERROR:
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
await db.commit()
await db.refresh(account)
return account
@router.post("/{account_id}/pull-now", status_code=status.HTTP_202_ACCEPTED)
async def pull_now(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Immediately queue a pull for the given mail account"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
if not account.is_enabled:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Account is disabled. Enable it before pulling.",
)
process_mail_account_task.delay(account_id)
return {"message": "Pull queued successfully"}
@router.post("/test", response_model=MailAccountTestResponse)
async def test_mail_connection(
test_request: MailAccountTestRequest,
@@ -242,6 +343,39 @@ async def test_mail_connection(
return MailAccountTestResponse(success=success, message=message)
@router.post("/{account_id}/test", response_model=MailAccountTestResponse)
async def test_existing_mail_connection(
account_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Test connection for an existing mail account using its stored credentials"""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id, MailAccount.user_id == current_user.id
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
try:
password = decrypt_credential(str(account.encrypted_password))
except Exception:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to decrypt stored credentials",
)
processor = MailProcessor(account, password)
success, message = await processor.test_connection()
return MailAccountTestResponse(success=success, message=message)
@router.post("/auto-detect", response_model=MailAccountAutoDetectResponse)
async def auto_detect_mail_settings(
detect_request: MailAccountAutoDetectRequest,
@@ -254,3 +388,154 @@ async def auto_detect_mail_settings(
return MailAccountAutoDetectResponse(
success=len(suggestions) > 0, suggestions=suggestions
)
# ---------------------------------------------------------------------------
# Per-account processing runs & logs
# ---------------------------------------------------------------------------
@router.get(
"/{account_id}/processing-runs",
response_model=PaginatedProcessingRunsResponse,
summary="List processing runs for a specific mail account",
)
async def list_account_runs(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
has_emails: Optional[bool] = Query(
None,
description="When true, only return runs that fetched at least one email",
),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated processing runs for a mail account owned by the user."""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id,
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
base = select(ProcessingRun).where(ProcessingRun.mail_account_id == account_id)
if has_emails is True:
base = base.where(ProcessingRun.emails_fetched > 0)
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
offset = (page - 1) * page_size
runs = (
(
await db.execute(
base.order_by(desc(ProcessingRun.started_at))
.offset(offset)
.limit(page_size)
)
)
.scalars()
.all()
)
pages = max(1, math.ceil(total / page_size)) if total else 1
items = [
ProcessingRunDetailResponse(
id=r.id, # type: ignore[arg-type]
mail_account_id=r.mail_account_id, # type: ignore[arg-type]
started_at=r.started_at, # type: ignore[arg-type]
completed_at=r.completed_at, # type: ignore[arg-type]
duration_seconds=r.duration_seconds, # type: ignore[arg-type]
emails_fetched=r.emails_fetched, # type: ignore[arg-type]
emails_forwarded=r.emails_forwarded, # type: ignore[arg-type]
emails_failed=r.emails_failed, # type: ignore[arg-type]
status=r.status, # type: ignore[arg-type]
error_message=r.error_message, # type: ignore[arg-type]
account_name=account.name, # type: ignore[arg-type]
account_email=account.email_address, # type: ignore[arg-type]
)
for r in runs
]
return PaginatedProcessingRunsResponse(
items=items, total=total, page=page, page_size=page_size, pages=pages
)
@router.get(
"/{account_id}/logs",
response_model=PaginatedProcessingLogsResponse,
summary="List processing logs for a specific mail account",
)
async def list_account_logs(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
level: Optional[str] = Query(
None, description="Filter by log level (INFO, WARNING, ERROR)"
),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated per-email log entries for a mail account owned by the user."""
result = await db.execute(
select(MailAccount).where(
MailAccount.id == account_id,
MailAccount.user_id == current_user.id,
)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found"
)
base = select(ProcessingLog).where(
ProcessingLog.mail_account_id == account_id,
ProcessingLog.user_id == current_user.id, # type: ignore[arg-type]
)
if level:
base = base.where(ProcessingLog.level == level.upper())
total = (
await db.execute(select(func.count()).select_from(base.subquery()))
).scalar_one()
offset = (page - 1) * page_size
logs = (
(
await db.execute(
base.order_by(ProcessingLog.timestamp.desc())
.offset(offset)
.limit(page_size)
)
)
.scalars()
.all()
)
pages = max(1, math.ceil(total / page_size)) if total else 1
items = [
ProcessingLogDetailResponse(
id=log.id, # type: ignore[arg-type]
timestamp=log.timestamp, # type: ignore[arg-type]
level=log.level, # type: ignore[arg-type]
message=log.message, # type: ignore[arg-type]
email_subject=log.email_subject, # type: ignore[arg-type]
email_from=log.email_from, # type: ignore[arg-type]
success=log.success, # type: ignore[arg-type]
mail_account_id=log.mail_account_id, # type: ignore[arg-type]
processing_run_id=log.processing_run_id, # type: ignore[arg-type]
email_size_bytes=log.email_size_bytes, # type: ignore[arg-type]
error_details=log.error_details, # type: ignore[arg-type]
)
for log in logs
]
return PaginatedProcessingLogsResponse(
items=items, total=total, page=page, page_size=page_size, pages=pages
)
+94 -4
View File
@@ -1,7 +1,7 @@
"""Notification configuration endpoints"""
from typing import List
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -10,8 +10,12 @@ from app.core.deps import get_current_active_user
from app.models.database_models import User, NotificationConfig
from app.models.schemas import (
NotificationConfigCreate,
NotificationConfigUpdate,
NotificationConfigResponse,
NotificationTestRequest,
NotificationTestResponse,
)
from app.services.notification_service import test_notification
router = APIRouter()
@@ -24,8 +28,8 @@ async def create_notification_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Create notification configuration"""
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
"""Create a new notification configuration"""
config = NotificationConfig(user_id=current_user.id, **config_in.model_dump())
db.add(config)
await db.commit()
await db.refresh(config)
@@ -37,8 +41,94 @@ async def list_notification_configs(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""List all notification configurations"""
"""List all notification configurations for the current user"""
result = await db.execute(
select(NotificationConfig).where(NotificationConfig.user_id == current_user.id)
)
return result.scalars().all()
@router.get("/{config_id}", response_model=NotificationConfigResponse)
async def get_notification_config(
config_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get a specific notification configuration"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.id == config_id,
NotificationConfig.user_id == current_user.id,
)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification config not found",
)
return config
@router.put("/{config_id}", response_model=NotificationConfigResponse)
async def update_notification_config(
config_id: int,
config_in: NotificationConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Update a notification configuration"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.id == config_id,
NotificationConfig.user_id == current_user.id,
)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification config not found",
)
update_data = config_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
await db.commit()
await db.refresh(config)
return config
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_notification_config(
config_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a notification configuration"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.id == config_id,
NotificationConfig.user_id == current_user.id,
)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification config not found",
)
await db.delete(config)
await db.commit()
@router.post("/test", response_model=NotificationTestResponse)
async def test_notification_config(
request: NotificationTestRequest,
current_user: User = Depends(get_current_active_user),
):
"""Test a notification channel by sending a test message"""
success, message = await test_notification(request.apprise_url)
return NotificationTestResponse(success=success, message=message)
+313 -9
View File
@@ -2,6 +2,7 @@
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from urllib.parse import quote as urlquote
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -10,7 +11,7 @@ import logging
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.core.security import encrypt_credential
from app.core.security import encrypt_credential, decrypt_credential
from app.core.config import settings
from app.models.database_models import User, GmailCredential
from app.models.schemas import (
@@ -20,18 +21,37 @@ from app.models.schemas import (
GmailCredentialResponse,
GmailAuthorizeResponse,
GmailCallbackRequest,
GmailImportLabelsUpdate,
)
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
from app.utils.gmail_labels import (
MAX_IMPORT_LABELS,
build_gmail_credential_scopes,
extract_granted_scopes,
normalize_import_label_templates,
)
from app.services.gmail_service import GmailService
router = APIRouter()
logger = logging.getLogger(__name__)
# Gmail API scopes needed for email injection
def _validated_import_label_templates(label_templates: List[str]) -> List[str]:
normalized = normalize_import_label_templates(label_templates)
if len(normalized) > MAX_IMPORT_LABELS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"You can configure up to {MAX_IMPORT_LABELS} Gmail import labels.",
)
return normalized
# Gmail API scopes requested during the "Connect Gmail" OAuth flow.
# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from
# gmail_service so the scope list stays in sync with what GmailService uses.
GMAIL_API_SCOPES = [
"openid",
"email",
"https://www.googleapis.com/auth/gmail.insert",
"https://www.googleapis.com/auth/gmail.labels",
*GMAIL_SCOPES,
]
# Provider presets with server configurations
@@ -135,6 +155,15 @@ PROVIDER_PRESETS: List[ProviderPreset] = [
pop3_ssl={"host": "pop.mail.de", "port": 995},
notes="Use your mail.de email credentials.",
),
ProviderPreset(
id="protonmail",
name="Proton Mail",
icon="protonmail",
domains=["proton.me", "protonmail.com", "protonmail.ch", "pm.me"],
imap_ssl={"host": "127.0.0.1", "port": 1143},
pop3_ssl={"host": "127.0.0.1", "port": 1144},
notes="Requires Proton Mail Bridge running locally. Use your Bridge password (not your Proton account password). Default Bridge ports: IMAP 127.0.0.1:1143, POP3 127.0.0.1:1144.",
),
ProviderPreset(
id="icloud",
name="iCloud Mail",
@@ -188,6 +217,13 @@ async def save_gmail_credential(
Save Gmail API OAuth2 credentials for the current user.
These are used to inject emails directly into Gmail via the API.
"""
logger.debug(
"OAuth [Gmail credential]: verifying credentials for user_id=%s "
"(gmail=%s, has_refresh_token=%s)",
current_user.id,
credential_in.gmail_email,
bool(credential_in.refresh_token),
)
# Verify the credentials work
gmail_service = GmailService(
access_token=credential_in.access_token,
@@ -198,6 +234,12 @@ async def save_gmail_credential(
is_valid = await gmail_service.verify_access()
if not is_valid:
logger.warning(
"OAuth [Gmail credential]: credential verification failed for user_id=%s "
"(gmail=%s)",
current_user.id,
credential_in.gmail_email,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Gmail API credentials are invalid or expired",
@@ -221,10 +263,19 @@ async def save_gmail_credential(
existing.gmail_email = credential_in.gmail_email # type: ignore[assignment]
existing.encrypted_access_token = encrypted_access # type: ignore[assignment]
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
existing.scopes = build_gmail_credential_scopes(
existing.granted_scopes,
existing.import_label_templates,
) # type: ignore[assignment]
existing.is_valid = True # type: ignore[assignment]
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
await db.commit()
await db.refresh(existing)
logger.info(
"OAuth [Gmail credential]: updated credential for user_id=%s (gmail=%s)",
current_user.id,
credential_in.gmail_email,
)
return existing
else:
# Create new
@@ -233,12 +284,18 @@ async def save_gmail_credential(
gmail_email=credential_in.gmail_email,
encrypted_access_token=encrypted_access,
encrypted_refresh_token=encrypted_refresh,
scopes=build_gmail_credential_scopes(granted_scopes=[]),
is_valid=True,
last_verified_at=datetime.now(timezone.utc),
)
db.add(credential)
await db.commit()
await db.refresh(credential)
logger.info(
"OAuth [Gmail credential]: created new credential for user_id=%s (gmail=%s)",
current_user.id,
credential_in.gmail_email,
)
return credential
@@ -279,10 +336,50 @@ async def delete_gmail_credential(
detail="No Gmail credentials found",
)
logger.info(
"OAuth [Gmail credential]: deleting credential for user_id=%s (gmail=%s)",
current_user.id,
credential.gmail_email,
)
await db.delete(credential)
await db.commit()
@router.put("/gmail-credential/labels", response_model=GmailCredentialResponse)
async def update_gmail_import_labels(
labels_in: GmailImportLabelsUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Update the Gmail labels applied to imported messages."""
result = await db.execute(
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
)
credential = result.scalar_one_or_none()
if not credential:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Gmail credentials found. Connect Gmail first.",
)
validated = _validated_import_label_templates(labels_in.import_label_templates)
logger.info(
"OAuth [Gmail credential]: updating import labels for user_id=%s "
"(gmail=%s, labels=%s)",
current_user.id,
credential.gmail_email,
validated,
)
credential.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
extract_granted_scopes(credential.scopes),
validated,
)
await db.commit()
await db.refresh(credential)
return credential
@router.get("/gmail/authorize-url", response_model=GmailAuthorizeResponse)
async def get_gmail_authorize_url(
redirect_uri: str,
@@ -302,7 +399,14 @@ async def get_gmail_authorize_url(
detail="Google OAuth2 is not configured on this server.",
)
scope = " ".join(GMAIL_API_SCOPES)
logger.info(
"OAuth [Gmail connect]: authorization URL requested for user_id=%s "
"(redirect_uri=%s, scopes=%s)",
current_user.id,
redirect_uri,
GMAIL_API_SCOPES,
)
scope = urlquote(" ".join(GMAIL_API_SCOPES))
url = (
"https://accounts.google.com/o/oauth2/v2/auth"
f"?client_id={settings.GOOGLE_CLIENT_ID}"
@@ -311,10 +415,119 @@ async def get_gmail_authorize_url(
f"&redirect_uri={redirect_uri}"
"&access_type=offline"
"&prompt=consent"
"&include_granted_scopes=true"
"&state=gmail_connect"
)
return GmailAuthorizeResponse(authorization_url=url)
@router.post("/gmail/debug-email", status_code=status.HTTP_200_OK)
async def send_gmail_debug_email(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""
Inject a debug/test email into the current user's Gmail inbox.
The message appears to have been sent by christian@docuelevate.org,
carries today's date in the subject, and is tagged with the custom
labels "test" and "imported" as well as placed in the inbox.
Useful for verifying that Gmail API delivery is working end-to-end
without requiring an active mail-account polling cycle.
"""
logger.info(
"OAuth [Gmail debug-email]: debug injection requested by user_id=%s",
current_user.id,
)
result = await db.execute(
select(GmailCredential).where(
GmailCredential.user_id == current_user.id,
GmailCredential.is_valid == True, # noqa: E712
)
)
credential = result.scalar_one_or_none()
if not credential:
logger.warning(
"OAuth [Gmail debug-email]: no valid credential found for user_id=%s",
current_user.id,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No valid Gmail credentials found. Connect Gmail first.",
)
logger.debug(
"OAuth [Gmail debug-email]: using credential for user_id=%s (gmail=%s, "
"token_expiry=%s, has_refresh_token=%s)",
current_user.id,
credential.gmail_email,
credential.token_expiry,
bool(credential.encrypted_refresh_token),
)
access_token = decrypt_credential(credential.encrypted_access_token) # type: ignore[arg-type]
refresh_token = (
decrypt_credential(credential.encrypted_refresh_token) # type: ignore[arg-type]
if credential.encrypted_refresh_token
else None
)
gmail_service = GmailService(
access_token=access_token,
refresh_token=refresh_token,
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
)
try:
inject_result = await gmail_service.inject_debug_email(
recipient_email=credential.gmail_email, # type: ignore[arg-type]
import_label_templates=credential.import_label_templates,
)
except GmailInjectionError as exc:
logger.error(
"OAuth [Gmail debug-email]: injection failed for user_id=%s (gmail=%s): %s",
current_user.id,
credential.gmail_email,
exc,
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gmail injection failed: {exc}",
)
# Persist refreshed token if the google-auth library renewed it
refreshed = gmail_service.get_refreshed_token()
if refreshed:
logger.debug(
"OAuth [Gmail debug-email]: access token was auto-refreshed for "
"user_id=%s; persisting new expiry=%s",
current_user.id,
refreshed.get("expiry"),
)
credential.encrypted_access_token = encrypt_credential( # type: ignore[assignment]
refreshed["access_token"]
)
if refreshed.get("expiry"):
credential.token_expiry = refreshed["expiry"] # type: ignore[assignment]
await db.commit()
logger.info(
"OAuth [Gmail debug-email]: injection succeeded for user_id=%s (gmail=%s, "
"message_id=%s)",
current_user.id,
credential.gmail_email,
inject_result.get("message_id"),
)
return {
"message": "Debug email injected successfully",
"message_id": inject_result.get("message_id"),
"thread_id": inject_result.get("thread_id"),
"label_ids": inject_result.get("label_ids", []),
}
@router.post(
"/gmail/callback",
response_model=GmailCredentialResponse,
@@ -347,7 +560,18 @@ async def gmail_oauth_callback(
detail="Google OAuth2 is not configured on this server.",
)
logger.info(
"OAuth [Gmail connect]: callback received for user_id=%s (redirect_uri=%s)",
current_user.id,
callback_in.redirect_uri,
)
# Exchange code for tokens
logger.debug(
"OAuth [Gmail connect]: exchanging authorization code for tokens "
"(user_id=%s)",
current_user.id,
)
async with httpx.AsyncClient() as client:
token_resp = await client.post(
"https://oauth2.googleapis.com/token",
@@ -361,7 +585,13 @@ async def gmail_oauth_callback(
)
if token_resp.status_code != 200:
logger.error(f"Gmail token exchange failed: {token_resp.text}")
logger.error(
"OAuth [Gmail connect]: token exchange failed for user_id=%s "
"(status=%s, body=%s)",
current_user.id,
token_resp.status_code,
token_resp.text,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code with Google.",
@@ -371,13 +601,41 @@ async def gmail_oauth_callback(
access_token: Optional[str] = token_data.get("access_token")
refresh_token: Optional[str] = token_data.get("refresh_token")
logger.debug(
"OAuth [Gmail connect]: token exchange succeeded for user_id=%s"
"scopes=%s, has_refresh_token=%s, expires_in=%s",
current_user.id,
token_data.get("scope", ""),
bool(refresh_token),
token_data.get("expires_in"),
)
if not access_token:
logger.error(
"OAuth [Gmail connect]: Google response contained no access_token "
"for user_id=%s (keys_present=%s)",
current_user.id,
list(token_data.keys()),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google did not return an access token.",
)
if not refresh_token:
logger.warning(
"OAuth [Gmail connect]: Google did not return a refresh_token for "
"user_id=%s — token refresh may fail after 1 hour. "
"This can happen if the user has previously authorised the app and "
"access_type=offline was not honoured.",
current_user.id,
)
# Fetch the Gmail email address to associate with this credential
logger.debug(
"OAuth [Gmail connect]: fetching Google profile for user_id=%s",
current_user.id,
)
async with httpx.AsyncClient() as client:
profile_resp = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
@@ -387,6 +645,20 @@ async def gmail_oauth_callback(
gmail_email = current_user.email # fallback
if profile_resp.status_code == 200:
gmail_email = profile_resp.json().get("email", current_user.email)
logger.debug(
"OAuth [Gmail connect]: Google profile retrieved for user_id=%s "
"(gmail=%s)",
current_user.id,
gmail_email,
)
else:
logger.warning(
"OAuth [Gmail connect]: could not fetch Google profile for user_id=%s "
"(status=%s); falling back to account email=%s",
current_user.id,
profile_resp.status_code,
current_user.email,
)
# Calculate token expiry (Google access tokens last 1 hour)
token_expiry = datetime.now(timezone.utc) + timedelta(
@@ -394,6 +666,11 @@ async def gmail_oauth_callback(
)
# Verify the credentials actually work with the Gmail API
logger.debug(
"OAuth [Gmail connect]: verifying Gmail API access for user_id=%s (gmail=%s)",
current_user.id,
gmail_email,
)
gmail_service = GmailService(
access_token=access_token,
refresh_token=refresh_token,
@@ -402,6 +679,12 @@ async def gmail_oauth_callback(
)
is_valid = await gmail_service.verify_access()
if not is_valid:
logger.error(
"OAuth [Gmail connect]: Gmail API access verification failed for "
"user_id=%s (gmail=%s) — ensure gmail.insert scope was granted",
current_user.id,
gmail_email,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Obtained tokens but could not verify Gmail API access. "
@@ -423,11 +706,23 @@ async def gmail_oauth_callback(
if encrypted_refresh:
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
existing.token_expiry = token_expiry # type: ignore[assignment]
existing.scopes = token_data.get("scope", "").split() # type: ignore[assignment]
existing.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
token_data.get("scope", "").split(),
existing.import_label_templates,
)
existing.is_valid = True # type: ignore[assignment]
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
await db.commit()
await db.refresh(existing)
logger.info(
"OAuth [Gmail connect]: updated credential for user_id=%s "
"(gmail=%s, token_expiry=%s, has_refresh_token=%s, scopes=%s)",
current_user.id,
gmail_email,
token_expiry,
bool(encrypted_refresh),
token_data.get("scope", ""),
)
return existing
else:
credential = GmailCredential(
@@ -436,11 +731,20 @@ async def gmail_oauth_callback(
encrypted_access_token=encrypted_access,
encrypted_refresh_token=encrypted_refresh,
token_expiry=token_expiry,
scopes=token_data.get("scope", "").split(),
scopes=build_gmail_credential_scopes(token_data.get("scope", "").split()),
is_valid=True,
last_verified_at=datetime.now(timezone.utc),
)
db.add(credential)
await db.commit()
await db.refresh(credential)
logger.info(
"OAuth [Gmail connect]: created new credential for user_id=%s "
"(gmail=%s, token_expiry=%s, has_refresh_token=%s, scopes=%s)",
current_user.id,
gmail_email,
token_expiry,
bool(encrypted_refresh),
token_data.get("scope", ""),
)
return credential
+24 -3
View File
@@ -15,9 +15,18 @@ router = APIRouter()
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
"""List all available subscription plans"""
"""
List subscription plans shown in marketing / pricing pages.
Zero-price plans (price_monthly == 0) are intentionally excluded so that
enterprise / white-label deployments that assign a free plan to all users
don't surface that plan in the public pricing UI.
"""
result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True) # noqa: E712
select(SubscriptionPlan).where(
SubscriptionPlan.is_active.is_(True),
SubscriptionPlan.price_monthly > 0,
)
)
return result.scalars().all()
@@ -25,10 +34,22 @@ async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
@router.get("/current")
async def get_current_subscription(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get current user's subscription details"""
"""Get current user's subscription details including plan limits"""
plan_result = await db.execute(
select(SubscriptionPlan).where(
SubscriptionPlan.tier == current_user.subscription_tier,
SubscriptionPlan.is_active.is_(True),
)
)
plan = plan_result.scalar_one_or_none()
return {
"tier": current_user.subscription_tier,
"status": current_user.subscription_status,
"expires_at": current_user.subscription_expires_at,
"max_mail_accounts": plan.max_mail_accounts if plan else None,
"max_emails_per_day": plan.max_emails_per_day if plan else None,
"check_interval_minutes": plan.check_interval_minutes if plan else None,
}
+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}",
)
+18
View File
@@ -0,0 +1,18 @@
"""
Version endpoint returns the application version and build date.
"""
from fastapi import APIRouter
from app.core.config import settings
router = APIRouter()
@router.get("")
async def get_version():
"""Return application version and build metadata."""
return {
"version": settings.APP_VERSION,
"build_date": settings.BUILD_DATE or None,
}
+44 -4
View File
@@ -27,18 +27,23 @@ class Settings(BaseSettings):
)
# Application
APP_NAME: str = "POP3 Forwarder SaaS"
APP_NAME: str = "InboxConverge"
APP_VERSION: str = "2.0.0"
BUILD_DATE: str = ""
APP_URL: str = "https://inboxconverge.com"
CONTACT_EMAIL: str = "christian@inboxconverge.com"
DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1"
# Server
HOST: str = "0.0.0.0"
HOST: str = (
"0.0.0.0" # nosec B104 intentional: containerised service binds all interfaces
)
PORT: int = 8000
# Database
DATABASE_URL: str = (
"postgresql+asyncpg://user:password@localhost:5432/pop3_forwarder"
"postgresql+asyncpg://user:password@localhost:5432/inbox_converge"
)
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
@@ -90,13 +95,31 @@ class Settings(BaseSettings):
# Apprise (notifications)
APPRISE_ENABLED: bool = True
# Mail connectivity resilience
# When True the app caches the last successfully resolved IPv4 address for
# every mail host and uses that address as a fallback if DNS subsequently
# fails (e.g. EAI_AGAIN / Temporary failure in name resolution). This also
# makes every outgoing POP3/IMAP connection prefer IPv4, avoiding
# ENETUNREACH errors on Docker hosts where IPv6 is not routed to the
# internet.
DNS_CACHE_FALLBACK_ENABLED: bool = True
# Logging
LOG_LEVEL: str = "INFO"
# Admin
ADMIN_EMAIL: Optional[str] = None
ADMIN_EMAIL: Optional[str] = "christian@inboxconverge.com"
ADMIN_PASSWORD: Optional[str] = None
# User defaults & access control
# Tier assigned to every new user on registration: free | basic | pro | enterprise
DEFAULT_USER_TIER: str = "free"
# Comma-separated list of allowed email domains (empty = no restriction).
# When set, only addresses from these domains may register or log in.
# Useful for B2B / Google Workspace installations.
# Example: "company.com,subsidiary.com"
ALLOWED_DOMAINS: List[str] = []
# Mail Server Presets
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json"
@@ -108,6 +131,23 @@ class Settings(BaseSettings):
return [i.strip() for i in v.split(",")]
return v
@field_validator("ALLOWED_DOMAINS", mode="before")
@classmethod
def assemble_allowed_domains(cls, v: str | List[str]) -> List[str]:
"""Parse allowed domains from a comma-separated environment variable"""
if isinstance(v, str):
return [d.strip().lower() for d in v.split(",") if d.strip()]
return [d.lower() for d in v if d]
@field_validator("DEFAULT_USER_TIER")
@classmethod
def validate_default_user_tier(cls, v: str) -> str:
"""Ensure DEFAULT_USER_TIER is one of the known tier values"""
valid = {"free", "basic", "pro", "enterprise"}
if v.lower() not in valid:
raise ValueError(f"DEFAULT_USER_TIER must be one of {valid}, got '{v}'")
return v.lower()
@field_validator("SECRET_KEY")
@classmethod
def validate_secret_key(cls, v: str) -> str:
+106
View File
@@ -0,0 +1,106 @@
"""
GDPR-compliant data masking utilities.
These helpers are used in admin-facing API responses to pseudonymise
personal data (email addresses, names) so that operators can audit
system behaviour without seeing full end-user PII.
"""
from __future__ import annotations
import re
def mask_email(email: str) -> str:
"""
Partially mask an email address for GDPR-compliant display.
Examples
--------
>>> mask_email("john.doe@example.com")
'jo***@e***.com'
>>> mask_email("ab@x.io")
'ab***@x***.io'
>>> mask_email("a@b.de")
'a***@b***.de'
"""
if not email or "@" not in email:
return "***"
local, _, domain = email.partition("@")
# Local part: keep first 2 chars (or all if shorter), then "***"
visible_local = local[:2] if len(local) >= 2 else local
masked_local = f"{visible_local}***"
# Domain part: keep first char of SLD and the TLD unchanged
domain_parts = domain.rsplit(".", 1)
if len(domain_parts) == 2:
sld, tld = domain_parts
visible_sld = sld[:1] if sld else ""
masked_domain = f"{visible_sld}***.{tld}"
else:
masked_domain = "***"
return f"{masked_local}@{masked_domain}"
def mask_name(name: str) -> str:
"""
Partially mask a display name.
Examples
--------
>>> mask_name("John Doe")
'Jo*** D***'
>>> mask_name("Alice")
'Al***'
"""
if not name:
return "***"
words = name.split()
masked_words = []
for word in words:
visible = word[:2] if len(word) >= 2 else word
masked_words.append(f"{visible}***")
return " ".join(masked_words)
# RFC 5322 address pattern extracts the bare email from strings like
# "John Doe <john@example.com>" or just "john@example.com".
_ADDR_RE = re.compile(r"<([^>]+)>|(\S+@\S+\.\S+)")
def mask_from_header(from_header: str) -> str:
"""
Mask a raw RFC 5322 From header value for GDPR-compliant display.
Examples
--------
>>> mask_from_header("John Doe <john.doe@example.com>")
'Jo*** D*** <jo***@e***.com>'
>>> mask_from_header("john.doe@example.com")
'jo***@e***.com'
"""
if not from_header:
return "***"
# Try to parse "Display Name <email>" form
angle_match = re.search(r"^(.*?)<([^>]+)>", from_header.strip())
if angle_match:
display_name = angle_match.group(1).strip().strip('"')
email_part = angle_match.group(2).strip()
masked_email = mask_email(email_part)
if display_name:
masked_display = mask_name(display_name)
return f"{masked_display} <{masked_email}>"
return masked_email
# Plain email address
bare_match = _ADDR_RE.search(from_header)
if bare_match:
email_part = bare_match.group(1) or bare_match.group(2)
return mask_email(email_part)
# Fallback: mask the whole string
return mask_name(from_header)
+122
View File
@@ -0,0 +1,122 @@
"""
Prometheus metrics definitions for InboxConverge.
All application metrics are defined here as module-level singletons so that
every subsystem (HTTP layer, Celery workers, Gmail service, auth) imports
the same registry objects.
"""
from prometheus_client import Counter, Histogram, Gauge
# ---------------------------------------------------------------------------
# HTTP layer
# ---------------------------------------------------------------------------
HTTP_REQUESTS_TOTAL = Counter(
"http_requests_total",
"Total HTTP requests received",
["method", "endpoint", "status_code"],
)
HTTP_REQUEST_DURATION_SECONDS = Histogram(
"http_request_duration_seconds",
"HTTP request duration in seconds",
["method", "endpoint"],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)
# ---------------------------------------------------------------------------
# Mail processing
# ---------------------------------------------------------------------------
MAIL_PROCESSING_RUNS_TOTAL = Counter(
"mail_processing_runs_total",
"Total mail-account processing runs by final status",
["status"], # completed | partial_failure | failed
)
MAIL_PROCESSING_EMAILS_TOTAL = Counter(
"mail_processing_emails_total",
"Total emails encountered during processing runs",
["operation"], # fetched | forwarded | failed
)
MAIL_PROCESSING_DURATION_SECONDS = Histogram(
"mail_processing_duration_seconds",
"Duration of a single mail-account processing run in seconds",
buckets=(1, 5, 10, 30, 60, 120, 300, 600),
)
ACTIVE_MAIL_ACCOUNTS = Gauge(
"active_mail_accounts_total",
"Number of enabled mail accounts queued for this scheduler cycle",
)
# ---------------------------------------------------------------------------
# Gmail API
# ---------------------------------------------------------------------------
GMAIL_API_REQUESTS_TOTAL = Counter(
"gmail_api_requests_total",
"Total Gmail API requests by operation and outcome",
[
"operation",
"status",
], # operation: inject|verify|get_label|get_profile status: success|error
)
GMAIL_API_DURATION_SECONDS = Histogram(
"gmail_api_duration_seconds",
"Gmail API call duration in seconds",
["operation"],
buckets=(0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0),
)
GMAIL_TOKEN_REFRESHES_TOTAL = Counter(
"gmail_token_refreshes_total",
"Total number of OAuth access-token refreshes performed by GmailService",
)
GMAIL_CREDENTIALS_INVALIDATED_TOTAL = Counter(
"gmail_credentials_invalidated_total",
"Total times a user's Gmail credentials were marked invalid (revoked token)",
)
# ---------------------------------------------------------------------------
# Authentication / OAuth
# ---------------------------------------------------------------------------
AUTH_LOGINS_TOTAL = Counter(
"auth_logins_total",
"Total login attempts by method and outcome",
["method", "status"], # method: password|google status: success|failure
)
AUTH_REGISTRATIONS_TOTAL = Counter(
"auth_registrations_total",
"Total registration attempts by method and outcome",
["method", "status"], # method: password|google status: success|failure
)
OAUTH_CALLBACKS_TOTAL = Counter(
"oauth_callbacks_total",
"Total OAuth2 callback events by provider and outcome",
["provider", "status"], # provider: google status: success|error
)
# ---------------------------------------------------------------------------
# Celery tasks
# ---------------------------------------------------------------------------
CELERY_TASKS_TOTAL = Counter(
"celery_tasks_total",
"Total Celery task executions by task name and status",
["task_name", "status"], # status: success|failure
)
CELERY_TASK_DURATION_SECONDS = Histogram(
"celery_task_duration_seconds",
"Celery task execution duration in seconds",
["task_name"],
buckets=(1, 5, 10, 30, 60, 120, 300, 600, 1800),
)
+95 -4
View File
@@ -2,16 +2,30 @@
Main FastAPI application.
"""
import asyncio
import os
import re
import time
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
import logging
from alembic.config import Config as AlembicConfig
from alembic import command as alembic_command # type: ignore[attr-defined]
from app.core.config import settings
from app.core.middleware import SecurityHeadersMiddleware, CSRFProtectionMiddleware
from app.core.metrics import HTTP_REQUESTS_TOTAL, HTTP_REQUEST_DURATION_SECONDS
from app.api.v1.api import api_router
# Absolute path to alembic.ini one level above this file's directory
# (backend/app/main.py → backend/alembic.ini)
_ALEMBIC_INI = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "alembic.ini")
)
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper()),
@@ -22,7 +36,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Application lifespan handler for startup and shutdown events."""
# Startup
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
@@ -44,6 +58,25 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
exc_info=True,
)
# Apply schema migrations for columns added to existing tables.
# Alembic's ADD COLUMN IF NOT EXISTS migrations are idempotent safe for
# both fresh installs (create_all already added the columns) and existing
# deployments (where the columns may be absent).
try:
def _run_alembic_upgrade() -> None:
alembic_cfg = AlembicConfig(_ALEMBIC_INI)
alembic_command.upgrade(alembic_cfg, "head")
await asyncio.to_thread(_run_alembic_upgrade)
logger.info("Database schema migrations applied")
except Exception as exc:
logger.error(
"Could not apply schema migrations: %s — some columns may be missing",
exc,
exc_info=True,
)
# Seed default database-backed settings (no-op if they already exist)
try:
from app.core.database import async_session_maker
@@ -54,6 +87,34 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
except Exception as exc:
logger.warning("Could not seed default settings: %s", exc, exc_info=True)
# Ensure the configured ADMIN_EMAIL user has is_superuser=True.
# This runs on every startup so that existing accounts created before the
# auto-promotion login logic existed are also promoted correctly.
if settings.ADMIN_EMAIL:
try:
from sqlalchemy import select, func
from app.core.database import async_session_maker
from app.models.database_models import User
async with async_session_maker() as db:
result = await db.execute(
select(User).where(
func.lower(User.email) == settings.ADMIN_EMAIL.lower()
)
)
admin_user = result.scalar_one_or_none()
if admin_user and not admin_user.is_superuser:
admin_user.is_superuser = True # type: ignore[assignment]
await db.commit()
logger.info(
"Auto-promoted admin user to superuser on startup: %s",
admin_user.email,
)
except Exception as exc:
logger.warning(
"Could not auto-promote admin user on startup: %s", exc, exc_info=True
)
yield
# Shutdown
logger.info("Shutting down application")
@@ -65,7 +126,7 @@ def create_application() -> FastAPI:
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management",
description="Poll your legacy POP3/IMAP inboxes and deliver everything to Gmail. For real people, not enterprises.",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
@@ -88,11 +149,35 @@ def create_application() -> FastAPI:
# Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@app.middleware("http")
async def prometheus_middleware(request: Request, call_next):
"""Record per-request Prometheus metrics."""
# Normalize the path so high-cardinality IDs don't explode label sets.
path = request.url.path
# Strip numeric path segments (e.g. /api/v1/accounts/42 → /api/v1/accounts/{id})
normalized = re.sub(r"/\d+", "/{id}", path)
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
HTTP_REQUESTS_TOTAL.labels(
method=request.method,
endpoint=normalized,
status_code=str(response.status_code),
).inc()
HTTP_REQUEST_DURATION_SECONDS.labels(
method=request.method,
endpoint=normalized,
).observe(duration)
return response
@app.get("/")
async def root():
"""Root endpoint"""
return {
"message": "POP3 Forwarder SaaS API",
"message": "InboxConverge API",
"version": settings.APP_VERSION,
"docs": "/api/docs",
}
@@ -102,6 +187,12 @@ def create_application() -> FastAPI:
"""Health check endpoint for container orchestration"""
return {"status": "healthy"}
@app.get("/metrics", include_in_schema=False)
async def metrics():
"""Prometheus metrics endpoint."""
data = generate_latest()
return Response(content=data, media_type=CONTENT_TYPE_LATEST)
return app
+62
View File
@@ -17,6 +17,12 @@ from sqlalchemy import (
Index,
)
from sqlalchemy.orm import relationship
from app.utils.gmail_labels import (
DEFAULT_IMPORT_LABEL_TEMPLATES,
extract_granted_scopes,
extract_import_label_templates,
)
import enum
from app.core.database import Base
@@ -165,6 +171,18 @@ class MailAccount(Base):
provider_name = Column(String(100), nullable=True) # e.g., "Gmail", "GMX"
auto_detected = Column(Boolean, default=False)
# Debug logging
debug_logging = Column(Boolean, default=False)
# Number of completed/partial_failure runs since debug_logging was last
# enabled. Resets to 0 when debug_logging is toggled True via the API.
# debug_logging is auto-disabled once this reaches 5.
debug_logging_run_count = Column(Integer, default=0)
# Notification backoff: True once an error notification has been sent for
# the current consecutive failure streak. Cleared (with a recovery notice)
# when a run succeeds so the next new failure streak triggers a fresh alert.
error_notification_sent = Column(Boolean, default=False)
# Statistics
total_emails_processed = Column(Integer, default=0)
total_emails_failed = Column(Integer, default=0)
@@ -289,6 +307,11 @@ class NotificationConfig(Base):
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name = Column(String(255), nullable=False, default="My Notification")
apprise_url = Column(
Text, nullable=True
) # The Apprise URL e.g. tgram://token/chatid
# Channel details
channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False)
is_enabled = Column(Boolean, default=True)
@@ -461,6 +484,7 @@ class UserSmtpConfig(Base):
username = Column(String(255), nullable=False, default="")
encrypted_password = Column(Text, nullable=False, default="")
use_tls = Column(Boolean, default=True)
sender_email = Column(String(255), nullable=False, default="")
created_at = Column(
DateTime(timezone=True),
@@ -557,6 +581,18 @@ class GmailCredential(Base):
# Relationships
user = relationship("User", backref="gmail_credential")
@property
def granted_scopes(self) -> list[str]:
return extract_granted_scopes(self.scopes)
@property
def import_label_templates(self) -> list[str]:
return extract_import_label_templates(self.scopes)
@property
def default_import_label_templates(self) -> list[str]:
return DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
class AppSetting(Base):
"""
@@ -590,3 +626,29 @@ class AppSetting(Base):
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
class AdminNotificationConfig(Base):
"""System-wide admin notification channels"""
__tablename__ = "admin_notification_configs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
apprise_url = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True)
notify_on_errors = Column(Boolean, default=True)
notify_on_system_events = Column(Boolean, default=True)
description = Column(Text, nullable=True)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
+204 -3
View File
@@ -74,6 +74,7 @@ class UserDetailResponse(UserResponse):
stripe_customer_id: Optional[str] = None
subscription_expires_at: Optional[datetime] = None
last_login_at: Optional[datetime] = None
is_superuser: bool = False
model_config = ConfigDict(from_attributes=True)
@@ -112,6 +113,8 @@ class MailAccountBase(BaseModel):
check_interval_minutes: int = Field(default=5, gt=0, le=1440)
max_emails_per_check: int = Field(default=50, gt=0, le=1000)
delete_after_forward: bool = True
provider_name: Optional[str] = Field(None, max_length=100)
debug_logging: bool = False
class MailAccountCreate(MailAccountBase):
@@ -120,6 +123,13 @@ class MailAccountCreate(MailAccountBase):
class MailAccountUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=255)
email_address: Optional[EmailStr] = None
protocol: Optional[MailProtocol] = None
host: Optional[str] = Field(None, max_length=255)
port: Optional[int] = Field(None, gt=0, lt=65536)
use_ssl: Optional[bool] = None
use_tls: Optional[bool] = None
username: Optional[str] = Field(None, max_length=255)
password: Optional[str] = None
forward_to: Optional[EmailStr] = None
delivery_method: Optional[DeliveryMethod] = None
@@ -127,6 +137,8 @@ class MailAccountUpdate(BaseModel):
check_interval_minutes: Optional[int] = Field(None, gt=0, le=1440)
max_emails_per_check: Optional[int] = Field(None, gt=0, le=1000)
delete_after_forward: Optional[bool] = None
provider_name: Optional[str] = Field(None, max_length=100)
debug_logging: Optional[bool] = None
class MailAccountResponse(MailAccountBase):
@@ -145,9 +157,8 @@ class MailAccountResponse(MailAccountBase):
created_at: datetime
updated_at: datetime
# Don't expose password or username in responses
# Don't expose password in responses; username is safe to return
password: str = Field(exclude=True, default="")
username: str = Field(exclude=True, default="")
model_config = ConfigDict(from_attributes=True)
@@ -197,6 +208,15 @@ class ProcessingRunResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ProcessingRunDetailResponse(ProcessingRunResponse):
"""ProcessingRunResponse with optional account metadata."""
account_name: Optional[str] = None
account_email: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# Processing Log Schemas
class ProcessingLogResponse(BaseModel):
id: int
@@ -210,11 +230,81 @@ class ProcessingLogResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ProcessingLogDetailResponse(ProcessingLogResponse):
"""ProcessingLogResponse with additional fields."""
mail_account_id: int
processing_run_id: Optional[int] = None
email_size_bytes: Optional[int] = None
error_details: Optional[Dict[str, Any]] = None
model_config = ConfigDict(from_attributes=True)
class PaginatedProcessingRunsResponse(BaseModel):
items: List[ProcessingRunDetailResponse]
total: int
page: int
page_size: int
pages: int
class PaginatedProcessingLogsResponse(BaseModel):
items: List[ProcessingLogDetailResponse]
total: int
page: int
page_size: int
pages: int
class AdminProcessingRunResponse(ProcessingRunDetailResponse):
"""ProcessingRunDetailResponse with user info for admin views."""
user_id: Optional[int] = None
user_email: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class PaginatedAdminRunsResponse(BaseModel):
items: List[AdminProcessingRunResponse]
total: int
page: int
page_size: int
pages: int
class AdminProcessingLogResponse(ProcessingLogDetailResponse):
"""ProcessingLogDetailResponse with user info for admin views."""
user_id: int
user_email: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class PaginatedAdminLogsResponse(BaseModel):
items: List[AdminProcessingLogResponse]
total: int
page: int
page_size: int
pages: int
# Notification Config Schemas
class NotificationConfigBase(BaseModel):
name: str = Field(
default="My Notification",
max_length=255,
description="Friendly name for this notification channel",
)
channel: NotificationChannel
apprise_url: Optional[str] = Field(None, description="Apprise notification URL")
is_enabled: bool = True
config: Dict[str, Any]
config: Dict[str, Any] = Field(
default_factory=dict,
description="Legacy channel-specific configuration (deprecated in favour of apprise_url)",
)
notify_on_errors: bool = True
notify_on_success: bool = False
notify_threshold: int = Field(default=3, gt=0, le=100)
@@ -225,6 +315,9 @@ class NotificationConfigCreate(NotificationConfigBase):
class NotificationConfigUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=255)
channel: Optional[NotificationChannel] = None
apprise_url: Optional[str] = None
is_enabled: Optional[bool] = None
config: Optional[Dict[str, Any]] = None
notify_on_errors: Optional[bool] = None
@@ -241,6 +334,46 @@ class NotificationConfigResponse(NotificationConfigBase):
model_config = ConfigDict(from_attributes=True)
class NotificationTestRequest(BaseModel):
apprise_url: str = Field(..., description="Apprise URL to test")
class NotificationTestResponse(BaseModel):
success: bool
message: str
# Admin Notification Config Schemas
class AdminNotificationConfigBase(BaseModel):
name: str = Field(..., max_length=255)
apprise_url: str = Field(..., description="Apprise notification URL")
is_enabled: bool = True
notify_on_errors: bool = True
notify_on_system_events: bool = True
description: Optional[str] = None
class AdminNotificationConfigCreate(AdminNotificationConfigBase):
pass
class AdminNotificationConfigUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=255)
apprise_url: Optional[str] = None
is_enabled: Optional[bool] = None
notify_on_errors: Optional[bool] = None
notify_on_system_events: Optional[bool] = None
description: Optional[str] = None
class AdminNotificationConfigResponse(AdminNotificationConfigBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Subscription Schemas
class SubscriptionPlanResponse(BaseModel):
id: int
@@ -319,6 +452,8 @@ class GmailCredentialResponse(BaseModel):
user_id: int
gmail_email: str
is_valid: bool
import_label_templates: List[str] = Field(default_factory=list)
default_import_label_templates: List[str] = Field(default_factory=list)
last_verified_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
@@ -346,6 +481,7 @@ class UserSmtpConfigBase(BaseModel):
host: str = "smtp.gmail.com"
port: int = Field(587, gt=0, lt=65536)
username: str = ""
sender_email: str = ""
use_tls: bool = True
@@ -363,6 +499,11 @@ class UserSmtpConfigResponse(UserSmtpConfigBase):
model_config = ConfigDict(from_attributes=True)
class SmtpTestResponse(BaseModel):
success: bool
message: str
# Gmail OAuth Schemas
class GmailAuthorizeResponse(BaseModel):
authorization_url: str
@@ -371,3 +512,63 @@ class GmailAuthorizeResponse(BaseModel):
class GmailCallbackRequest(BaseModel):
code: str
redirect_uri: str
class GmailImportLabelsUpdate(BaseModel):
import_label_templates: List[str] = Field(default_factory=list)
# Admin Schemas
class AdminUserListResponse(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: bool
is_superuser: bool
subscription_tier: SubscriptionTier
subscription_status: str
google_id: Optional[str] = None
oauth_provider: Optional[str] = None
last_login_at: Optional[datetime] = None
created_at: datetime
mail_account_count: int = 0
model_config = ConfigDict(from_attributes=True)
class AdminUserUpdate(BaseModel):
full_name: Optional[str] = None
email: Optional[EmailStr] = None
is_active: Optional[bool] = None
is_superuser: Optional[bool] = None
subscription_tier: Optional[SubscriptionTier] = None
subscription_status: Optional[str] = None
class SubscriptionPlanCreate(BaseModel):
tier: SubscriptionTier
name: str
description: Optional[str] = None
price_monthly: float = 0.0
price_yearly: Optional[float] = None
max_mail_accounts: int = 1
max_emails_per_day: int = 1000
check_interval_minutes: int = 5
support_level: str = "community"
features: Optional[Dict[str, Any]] = None
is_active: bool = True
class SubscriptionPlanUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price_monthly: Optional[float] = None
price_yearly: Optional[float] = None
max_mail_accounts: Optional[int] = None
max_emails_per_day: Optional[int] = None
check_interval_minutes: Optional[int] = None
support_level: Optional[str] = None
features: Optional[Dict[str, Any]] = None
is_active: Optional[bool] = None
+47 -4
View File
@@ -25,12 +25,13 @@ class OAuthService:
def _register_google(self):
"""Register Google OAuth2 provider"""
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
scope = "openid email profile"
self.oauth.register(
name="google",
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
client_kwargs={"scope": scope},
)
async def get_google_user_info(
@@ -48,6 +49,11 @@ class OAuthService:
"""
try:
# Exchange code for token
logger.debug(
"OAuth [Google sign-in]: exchanging authorization code for tokens "
"(redirect_uri=%s)",
redirect_uri,
)
async with httpx.AsyncClient() as client:
token_response = await client.post(
"https://oauth2.googleapis.com/token",
@@ -61,7 +67,12 @@ class OAuthService:
)
if token_response.status_code != 200:
logger.error(f"Google token exchange failed: {token_response.text}")
logger.error(
"OAuth [Google sign-in]: token exchange failed "
"(status=%s, body=%s)",
token_response.status_code,
token_response.text,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code",
@@ -71,12 +82,26 @@ class OAuthService:
access_token = token_data.get("access_token")
if not access_token:
logger.error(
"OAuth [Google sign-in]: token exchange response contained "
"no access_token (keys_present=%s)",
list(token_data.keys()),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No access token received",
)
logger.debug(
"OAuth [Google sign-in]: token exchange succeeded — "
"scopes=%s, has_refresh_token=%s, expires_in=%s",
token_data.get("scope", ""),
bool(token_data.get("refresh_token")),
token_data.get("expires_in"),
)
# Get user info
logger.debug("OAuth [Google sign-in]: fetching Google user profile")
user_info_response = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
@@ -84,7 +109,10 @@ class OAuthService:
if user_info_response.status_code != 200:
logger.error(
f"Google user info fetch failed: {user_info_response.text}"
"OAuth [Google sign-in]: user-info fetch failed "
"(status=%s, body=%s)",
user_info_response.status_code,
user_info_response.text,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -92,6 +120,12 @@ class OAuthService:
)
user_info = user_info_response.json()
logger.debug(
"OAuth [Google sign-in]: user profile retrieved — "
"email=%s, verified=%s",
user_info.get("email"),
user_info.get("verified_email"),
)
return {
"email": user_info.get("email"),
@@ -99,12 +133,20 @@ class OAuthService:
"google_id": user_info.get("id"),
"picture": user_info.get("picture"),
"verified_email": user_info.get("verified_email", False),
"access_token": access_token,
"refresh_token": token_data.get("refresh_token"),
"expires_in": token_data.get("expires_in"),
"scope": token_data.get("scope", ""),
}
except HTTPException:
raise
except Exception as e:
logger.error(f"OAuth error: {e}")
logger.error(
"OAuth [Google sign-in]: unexpected error: %s",
e,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth authentication failed",
@@ -121,6 +163,7 @@ class OAuthService:
Returns:
Dict with access_token, refresh_token, and token_type
"""
logger.debug("OAuth: issuing application JWT tokens for user_id=%s", user.id)
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
+84 -1
View File
@@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.database_models import AppSetting
from app.models.database_models import AppSetting, SubscriptionPlan, SubscriptionTier
logger = logging.getLogger(__name__)
@@ -338,4 +338,87 @@ class ConfigService:
if created:
await db.commit()
logger.info("Seeded %d default settings into the database", created)
# Seed default subscription plans
await ConfigService.seed_default_plans(db)
return created
@staticmethod
async def seed_default_plans(db: AsyncSession) -> int:
"""
Populate the database with default subscription plans (skip existing tiers).
Limits mirror the env-var defaults so behaviour is unchanged on first boot
but can be overridden by admins via the plan management UI.
Returns the number of plans created.
"""
from app.core.config import settings # local import to avoid circular deps
default_plans = [
{
"tier": SubscriptionTier.FREE,
"name": "Free",
"description": "Dip your toes in. One old inbox pulled into Gmail, checked every 30 minutes. Free forever, no card needed.",
"price_monthly": 0.0,
"price_yearly": 0.0,
"max_mail_accounts": settings.TIER_FREE_MAX_ACCOUNTS,
"max_emails_per_day": 100,
"check_interval_minutes": 30,
"support_level": "community",
"is_active": True,
},
{
"tier": SubscriptionTier.BASIC,
"name": "Good",
"description": "Got a handful of dusty inboxes you just can't let go of? This one's for you. Less than a coffee per month.",
"price_monthly": 0.99,
"price_yearly": 9.90,
"max_mail_accounts": settings.TIER_BASIC_MAX_ACCOUNTS,
"max_emails_per_day": 1000,
"check_interval_minutes": 15,
"support_level": "email",
"is_active": True,
},
{
"tier": SubscriptionTier.PRO,
"name": "Better",
"description": "You're clearly the type who keeps every email address you've ever had. Respect. Checked every 5 minutes.",
"price_monthly": 1.99,
"price_yearly": 19.90,
"max_mail_accounts": settings.TIER_PRO_MAX_ACCOUNTS,
"max_emails_per_day": 10000,
"check_interval_minutes": 5,
"support_level": "email",
"is_active": True,
},
{
"tier": SubscriptionTier.ENTERPRISE,
"name": "Best",
"description": "Every old inbox you've ever had, all landing neatly in Gmail, checked every minute. The full works.",
"price_monthly": 2.99,
"price_yearly": 29.90,
"max_mail_accounts": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
"max_emails_per_day": 100000,
"check_interval_minutes": 1,
"support_level": "email",
"is_active": True,
},
]
created = 0
for plan_data in default_plans:
result = await db.execute(
select(SubscriptionPlan).where(
SubscriptionPlan.tier == plan_data["tier"]
)
)
if result.scalar_one_or_none() is None:
db.add(SubscriptionPlan(**plan_data))
created += 1
if created:
await db.commit()
logger.info("Seeded %d default subscription plans into the database", created)
return created
+346 -3
View File
@@ -9,18 +9,33 @@ This is preferred over SMTP forwarding as it doesn't modify the email.
import asyncio
import base64
import logging
import textwrap
import time
from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText
from email.utils import format_datetime
from typing import Optional, Dict, Any
import google.auth.exceptions
from google.auth.transport.requests import Request as GoogleAuthRequest
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from app.core.metrics import (
GMAIL_API_REQUESTS_TOTAL,
GMAIL_API_DURATION_SECONDS,
GMAIL_TOKEN_REFRESHES_TOTAL,
)
from app.utils.gmail_labels import render_import_labels
logger = logging.getLogger(__name__)
# Gmail API scopes needed for email injection
GMAIL_SCOPES = [
"https://www.googleapis.com/auth/gmail.insert",
"https://www.googleapis.com/auth/gmail.labels",
"https://www.googleapis.com/auth/gmail.readonly",
]
@@ -30,6 +45,12 @@ class GmailInjectionError(Exception):
pass
class GmailAuthError(GmailInjectionError):
"""Raised when Gmail OAuth2 authentication fails (token expired or revoked)."""
pass
class GmailService:
"""
Service for injecting emails into Gmail via the Gmail API.
@@ -66,12 +87,21 @@ class GmailService:
scopes=GMAIL_SCOPES,
)
self._service = None
logger.debug(
"OAuth [GmailService]: initialized — has_refresh_token=%s, "
"has_client_id=%s, has_client_secret=%s",
bool(refresh_token),
bool(client_id),
bool(client_secret),
)
@property
def service(self):
"""Lazy-initialize the Gmail API service."""
if self._service is None:
self._service = build("gmail", "v1", credentials=self.credentials)
self._service = build(
"gmail", "v1", credentials=self.credentials, cache_discovery=False
)
return self._service
async def inject_email(
@@ -111,6 +141,7 @@ class GmailService:
loop = asyncio.get_event_loop()
_start = time.perf_counter()
try:
result = await loop.run_in_executor(
None,
@@ -120,6 +151,10 @@ class GmailService:
.execute(),
)
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="success").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="inject").observe(_dur)
logger.info(
f"Injected email into Gmail: id={result.get('id')}"
f"{f' from {source_account_name}' if source_account_name else ''}"
@@ -132,13 +167,29 @@ class GmailService:
}
except HttpError as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="error").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="inject").observe(_dur)
error_msg = (
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
)
logger.error(error_msg)
# Surface 401 so callers can mark credentials as invalid
raise GmailInjectionError(error_msg)
except google.auth.exceptions.RefreshError as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="error").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="inject").observe(_dur)
error_msg = (
f"Gmail token refresh failed — the refresh token may have been revoked. "
f"The user must re-authorise. Detail: {e}"
)
logger.error(error_msg)
raise GmailAuthError(error_msg)
except Exception as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="inject", status="error").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="inject").observe(_dur)
error_msg = f"Failed to inject email into Gmail: {str(e)}"
logger.error(error_msg)
raise GmailInjectionError(error_msg)
@@ -152,15 +203,31 @@ class GmailService:
"""
loop = asyncio.get_event_loop()
_start = time.perf_counter()
try:
result = await loop.run_in_executor(
None,
lambda: self.service.users().getProfile(userId="me").execute(),
)
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="verify", status="success").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="verify").observe(_dur)
email = result.get("emailAddress", "unknown")
logger.info(f"Gmail API access verified for: {email}")
return True
except google.auth.exceptions.RefreshError as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="verify", status="error").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="verify").observe(_dur)
logger.error(
f"Gmail token refresh failed during access verification — "
f"refresh token may be revoked. Detail: {e}"
)
return False
except Exception as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="verify", status="error").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="verify").observe(_dur)
logger.error(f"Gmail API access verification failed: {e}")
return False
@@ -173,16 +240,286 @@ class GmailService:
"""
loop = asyncio.get_event_loop()
_start = time.perf_counter()
try:
result = await loop.run_in_executor(
None,
lambda: self.service.users().getProfile(userId="me").execute(),
)
return result.get("emailAddress")
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(
operation="get_profile", status="success"
).inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_profile").observe(_dur)
email = result.get("emailAddress")
logger.debug("OAuth [GmailService]: fetched email address=%s", email)
return email
except google.auth.exceptions.RefreshError as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(
operation="get_profile", status="error"
).inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_profile").observe(_dur)
logger.error(
"OAuth [GmailService]: token refresh failed while fetching email "
"address — refresh token may be revoked. Detail: %s",
e,
)
return None
except Exception as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(
operation="get_profile", status="error"
).inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_profile").observe(_dur)
logger.error(f"Failed to get Gmail email address: {e}")
return None
async def get_or_create_label(self, name: str) -> str:
"""
Return the Gmail label ID for a label with the given name.
Lists the user's existing labels and returns the ID of the first
match (case-insensitive). If no matching label is found, a new
label is created and its ID is returned.
Args:
name: Human-readable label name (e.g. "test", "imported").
Returns:
Gmail label ID string (e.g. "Label_1234567890").
Raises:
GmailInjectionError: If the Gmail API call fails.
"""
loop = asyncio.get_event_loop()
_start = time.perf_counter()
try:
labels_resp = await loop.run_in_executor(
None,
lambda: self.service.users().labels().list(userId="me").execute(),
)
for label in labels_resp.get("labels", []):
if label.get("name", "").lower() == name.lower():
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(
operation="get_label", status="success"
).inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(
_dur
)
return label["id"]
# Label not found create it
created = await loop.run_in_executor(
None,
lambda: self.service.users()
.labels()
.create(userId="me", body={"name": name})
.execute(),
)
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(
operation="get_label", status="success"
).inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(_dur)
logger.info(f"Created Gmail label '{name}' with id={created['id']}")
return created["id"]
except HttpError as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="get_label", status="error").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(_dur)
error_msg = f"Gmail API error while managing label '{name}': {e.reason if hasattr(e, 'reason') else str(e)}"
logger.error(error_msg)
raise GmailInjectionError(error_msg)
except google.auth.exceptions.RefreshError as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="get_label", status="error").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(_dur)
error_msg = (
f"Gmail token refresh failed while managing label '{name}'"
f"refresh token may be revoked. Detail: {e}"
)
logger.error(error_msg)
raise GmailAuthError(error_msg)
except Exception as e:
_dur = time.perf_counter() - _start
GMAIL_API_REQUESTS_TOTAL.labels(operation="get_label", status="error").inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_label").observe(_dur)
error_msg = f"Failed to get/create Gmail label '{name}': {str(e)}"
logger.error(error_msg)
raise GmailInjectionError(error_msg)
async def inject_debug_email(
self,
recipient_email: str,
import_label_templates: Optional[list[str]] = None,
) -> Dict[str, Any]:
"""
Inject a debug/test email into the user's Gmail inbox.
The message is made to appear as if it was sent by
christian@docuelevate.org on the current date. It is placed in
the inbox and tagged with the custom labels "test" and "imported"
so it is easy to identify and clean up.
Args:
recipient_email: The Gmail address to deliver the message to
(the authenticated user's address).
Returns:
Dict with message_id, thread_id, and label_ids.
Raises:
GmailInjectionError: If injection or label management fails.
"""
now = datetime.now(timezone.utc)
date_str = now.strftime("%d %B %Y") # e.g. "25 March 2026"
subject = f"Test Import {date_str}"
body = textwrap.dedent(f"""\
Hi there,
This is an automated test message injected via the Gmail API to
confirm that the import pipeline is working correctly.
Date: {date_str}
Source: DocuElevate Integration Test
If you can see this message in your inbox it means that Gmail API
delivery is functioning as expected. Feel free to delete it.
Best regards,
Christian Krakau-Louis
DocuElevate
""")
msg = MIMEText(body, "plain", "utf-8")
msg["From"] = "Christian Krakau-Louis <christian@docuelevate.org>"
msg["To"] = recipient_email
msg["Subject"] = subject
msg["Date"] = format_datetime(now)
msg["Message-ID"] = f"<debug-{now.strftime('%Y%m%d%H%M%S')}@docuelevate.org>"
raw_bytes = msg.as_bytes()
label_ids = await self.build_import_label_ids(import_label_templates)
test_label_id = await self.get_or_create_label("test")
if test_label_id not in label_ids:
label_ids.append(test_label_id)
return await self.inject_email(
raw_email=raw_bytes,
label_ids=label_ids,
source_account_name="debug",
)
async def build_import_label_ids(
self,
import_label_templates: Optional[list[str]] = None,
source_email: Optional[str] = None,
) -> list[str]:
"""Resolve configured import labels into Gmail label IDs."""
label_ids = ["INBOX"]
for label_name in render_import_labels(import_label_templates, source_email):
if label_name.upper() == "INBOX":
continue
label_id = await self.get_or_create_label(label_name)
if label_id not in label_ids:
label_ids.append(label_id)
return label_ids
@staticmethod
def _tz_aware_expiry(expiry: Optional[datetime]) -> Optional[datetime]:
"""Return *expiry* with UTC tzinfo attached if it is naive.
google-auth sets ``credentials.expiry`` as a naive UTC datetime
(``datetime.utcnow() + timedelta(...)``). Storing a naive datetime
into a ``DateTime(timezone=True)`` column causes silent tz-mismatch
bugs in comparisons and storage, so we always normalise before
returning expiry values to callers.
"""
if expiry is not None and expiry.tzinfo is None:
return expiry.replace(tzinfo=timezone.utc)
return expiry
def is_token_expiring_soon(self, within_minutes: int = 30) -> bool:
"""
Return True if the access token has already expired or will expire
within *within_minutes* minutes.
When ``credentials.expiry`` is None the expiry is unknown, which is
treated as expiring soon so a proactive refresh is performed.
Args:
within_minutes: Threshold in minutes before which a token is
considered "expiring soon".
Returns:
True if the token needs refreshing, False otherwise.
"""
if self.credentials.expiry is None:
return True
threshold = datetime.now(timezone.utc) + timedelta(minutes=within_minutes)
expiry = self.credentials.expiry
# google-auth stores expiry as a naive UTC datetime; make it tz-aware.
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
return expiry <= threshold
async def proactive_refresh(self) -> Dict[str, Any]:
"""
Explicitly refresh the access token using the stored refresh token.
Unlike the lazy refresh that happens automatically during API calls,
this method triggers a refresh regardless of whether the current
access token has expired. Use this from a scheduled task to keep
tokens fresh and to detect revocation early.
Returns:
Dict with ``access_token`` and ``expiry`` (datetime | None)
representing the newly obtained access token.
Raises:
GmailAuthError: If the refresh token is missing, has been revoked,
or the refresh request fails for an auth-related reason.
GmailInjectionError: For unexpected non-auth errors.
"""
if not self.credentials.refresh_token:
raise GmailAuthError(
"Cannot refresh: no refresh token stored. "
"Re-authorise Gmail to obtain a new refresh token."
)
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
lambda: self.credentials.refresh(GoogleAuthRequest()),
)
GMAIL_TOKEN_REFRESHES_TOTAL.inc()
logger.info(
"Gmail access token refreshed proactively; new expiry: %s",
self.credentials.expiry,
)
return {
"access_token": self.credentials.token,
"expiry": self._tz_aware_expiry(self.credentials.expiry),
}
except google.auth.exceptions.RefreshError as e:
error_msg = f"Gmail refresh token has been revoked or is invalid — the user must re-authorise. Detail: {e}"
logger.error(error_msg)
raise GmailAuthError(error_msg)
except Exception as e:
raise GmailInjectionError(
f"Unexpected error during Gmail token refresh: {e}"
)
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
"""
Return the current access token and expiry if the token was refreshed
@@ -198,8 +535,14 @@ class GmailService:
"""
current_token = self.credentials.token
if current_token and current_token != self._initial_access_token:
GMAIL_TOKEN_REFRESHES_TOTAL.inc()
logger.debug(
"OAuth [GmailService]: access token was auto-refreshed during API "
"call; new expiry=%s",
self.credentials.expiry,
)
return {
"access_token": current_token,
"expiry": self.credentials.expiry,
"expiry": self._tz_aware_expiry(self.credentials.expiry),
}
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
"""
Notification service using Apprise for multi-channel alerting.
Supports:
- User-specific notifications (per-user Apprise URLs)
- Admin-wide system notifications (system-level alerts)
- Test notifications to verify configuration
"""
import logging
import apprise
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.database_models import NotificationConfig, AdminNotificationConfig
logger = logging.getLogger(__name__)
async def send_user_notification(
db: AsyncSession,
user_id: int,
title: str,
body: str,
notify_on_error: bool = True,
) -> int:
"""
Send a notification to all enabled notification channels for a given user.
Args:
db: Database session
user_id: The user to notify
title: Notification title/subject
body: Notification body text
notify_on_error: If True, only sends to channels with notify_on_errors=True
If False, only sends to channels with notify_on_success=True
Returns:
Number of channels notified successfully
"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.user_id == user_id,
NotificationConfig.is_enabled == True, # noqa: E712
NotificationConfig.apprise_url.isnot(None),
)
)
configs = result.scalars().all()
if not configs:
return 0
sent = 0
for config in configs:
if notify_on_error and not config.notify_on_errors:
continue
if not notify_on_error and not config.notify_on_success:
continue
try:
success = await _send_apprise(str(config.apprise_url or ""), title, body)
if success:
sent += 1
except Exception as exc:
logger.warning(
"Failed to send notification via channel %s (user %s): %s",
config.id,
user_id,
exc,
)
return sent
async def send_admin_notification(
db: AsyncSession,
title: str,
body: str,
) -> int:
"""
Send a notification to all enabled admin notification channels.
Returns:
Number of channels notified successfully
"""
result = await db.execute(
select(AdminNotificationConfig).where(
AdminNotificationConfig.is_enabled == True, # noqa: E712
AdminNotificationConfig.notify_on_errors == True, # noqa: E712
)
)
configs = result.scalars().all()
if not configs:
return 0
sent = 0
for config in configs:
try:
success = await _send_apprise(str(config.apprise_url or ""), title, body)
if success:
sent += 1
except Exception as exc:
logger.warning(
"Failed to send admin notification via channel %s: %s",
config.id,
exc,
)
return sent
async def test_notification(apprise_url: str) -> tuple[bool, str]:
"""
Send a test notification to the given Apprise URL.
Returns:
(success, message) tuple
"""
try:
success = await _send_apprise(
apprise_url,
title="InboxRescue: Test Notification",
body="This is a test notification from InboxRescue. Your notification channel is configured correctly!",
)
if success:
return True, "Test notification sent successfully"
return False, "Notification delivery failed (check your Apprise URL)"
except Exception as exc:
return False, f"Error sending test notification: {exc}"
async def _send_apprise(url: str, title: str, body: str) -> bool:
"""Internal helper create an Apprise instance, load the URL, and notify."""
ap = apprise.Apprise()
if not ap.add(url):
logger.warning("Apprise could not parse URL: %s", url[:60])
return False
result = await ap.async_notify(title=title, body=body)
return bool(result)
+97
View File
@@ -0,0 +1,97 @@
"""Helpers for Gmail import label configuration and rendering."""
from typing import Any, Iterable, Optional
SOURCE_EMAIL_LABEL_TEMPLATE = "{{source_email}}"
DEFAULT_IMPORT_LABEL_TEMPLATES = [SOURCE_EMAIL_LABEL_TEMPLATE, "imported"]
MAX_IMPORT_LABELS = 10
def _normalize_string_list(values: Optional[Iterable[str]]) -> list[str]:
normalized: list[str] = []
seen: set[str] = set()
for value in values or []:
cleaned = value.strip()
if not cleaned:
continue
lowered = cleaned.casefold()
if lowered in seen:
continue
seen.add(lowered)
normalized.append(cleaned)
return normalized
def normalize_import_label_templates(
label_templates: Optional[Iterable[str]],
) -> list[str]:
"""Return a cleaned, de-duplicated label template list."""
normalized = _normalize_string_list(label_templates)
return normalized or DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
def extract_granted_scopes(scopes_data: Any) -> list[str]:
"""Read granted scopes from legacy list or new JSON object storage."""
if isinstance(scopes_data, list):
return _normalize_string_list(
value for value in scopes_data if isinstance(value, str)
)
if isinstance(scopes_data, dict):
granted_scopes = scopes_data.get("granted_scopes", [])
if isinstance(granted_scopes, list):
return _normalize_string_list(
value for value in granted_scopes if isinstance(value, str)
)
return []
def extract_import_label_templates(scopes_data: Any) -> list[str]:
"""Read import label templates from stored Gmail credential metadata."""
if isinstance(scopes_data, dict):
stored_templates = scopes_data.get("import_label_templates", [])
if isinstance(stored_templates, list):
return normalize_import_label_templates(
value for value in stored_templates if isinstance(value, str)
)
return DEFAULT_IMPORT_LABEL_TEMPLATES.copy()
def build_gmail_credential_scopes(
granted_scopes: Optional[Iterable[str]],
import_label_templates: Optional[Iterable[str]] = None,
) -> dict[str, list[str]]:
"""Persist Gmail metadata in the existing JSON column."""
return {
"granted_scopes": _normalize_string_list(granted_scopes),
"import_label_templates": normalize_import_label_templates(
import_label_templates
),
}
def render_import_labels(
import_label_templates: Optional[Iterable[str]],
source_email: Optional[str],
) -> list[str]:
"""Render label templates into actual Gmail label names."""
rendered_labels: list[str] = []
seen: set[str] = set()
resolved_source_email = source_email.strip() if source_email else ""
for template in normalize_import_label_templates(import_label_templates):
rendered = template.replace(SOURCE_EMAIL_LABEL_TEMPLATE, resolved_source_email)
rendered = rendered.strip()
if not rendered:
continue
lowered = rendered.casefold()
if lowered in seen:
continue
seen.add(lowered)
rendered_labels.append(rendered)
return rendered_labels
+11 -2
View File
@@ -10,9 +10,12 @@ from app.core.config import settings
logger = logging.getLogger(__name__)
# Suppress noisy INFO-level "ignored untagged response" messages from aioimaplib
logging.getLogger("aioimaplib").setLevel(logging.WARNING)
# Create Celery app
celery_app = Celery(
"pop3_forwarder",
"inboxconverge",
broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND,
include=["app.workers.tasks"],
@@ -36,7 +39,13 @@ celery_app.conf.update(
celery_app.conf.beat_schedule = {
"process-all-mail-accounts": {
"task": "app.workers.tasks.process_all_enabled_accounts",
"schedule": crontab(minute="*/5"), # Every 5 minutes
"schedule": crontab(
minute="*"
), # Every minute (per-account interval gates actual work)
},
"refresh-gmail-tokens": {
"task": "app.workers.tasks.refresh_gmail_tokens",
"schedule": crontab(minute="*/45"), # Every 45 minutes
},
"cleanup-old-logs": {
"task": "app.workers.tasks.cleanup_old_logs",
+708 -29
View File
@@ -3,13 +3,25 @@ Celery tasks for background email processing.
"""
import asyncio
import email as email_lib
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
from celery import Task
import logging
from app.workers.celery_app import celery_app
from app.core.database import async_session_maker
from app.core.database import async_session_maker, engine
from app.core.security import decrypt_credential, encrypt_credential
from app.core.metrics import (
MAIL_PROCESSING_RUNS_TOTAL,
MAIL_PROCESSING_EMAILS_TOTAL,
MAIL_PROCESSING_DURATION_SECONDS,
ACTIVE_MAIL_ACCOUNTS,
GMAIL_CREDENTIALS_INVALIDATED_TOTAL,
CELERY_TASKS_TOTAL,
CELERY_TASK_DURATION_SECONDS,
)
from app.models.database_models import (
MailAccount,
ProcessingRun,
@@ -20,22 +32,42 @@ from app.models.database_models import (
DownloadedMessageId,
UserSmtpConfig,
)
from app.services.mail_processor import MailProcessor
from app.services.gmail_service import GmailService
from app.services.mail_processor import MailProcessor, MailDebugRecorder
from app.services.gmail_service import GmailService, GmailAuthError
from app.services.config_service import ConfigService
from app.services.notification_service import send_user_notification
from app.core.config import settings
from sqlalchemy import select, delete
from sqlalchemy import select, delete, or_, update as sa_update
logger = logging.getLogger(__name__)
def _as_utc(dt: datetime) -> datetime:
"""Return *dt* with UTC tzinfo, attaching it if the datetime is naive."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
class AsyncTask(Task):
"""Base task class that handles async operations"""
def __call__(self, *args, **kwargs):
"""Run async task in event loop"""
# Use asyncio.run() for better event loop management
return asyncio.run(self.run(*args, **kwargs))
async def _run():
try:
return await self.run(*args, **kwargs)
finally:
# Dispose the connection pool before the event loop closes.
# Each asyncio.run() creates a fresh event loop; if pooled
# asyncpg connections are still open when the loop is torn
# down, asyncpg raises "Exception terminating connection".
# Disposing the engine here closes those connections cleanly
# inside the same loop, before asyncio.run() shuts it down.
await engine.dispose()
return asyncio.run(_run())
@celery_app.task(base=AsyncTask, name="app.workers.tasks.process_mail_account")
@@ -46,6 +78,7 @@ async def process_mail_account(account_id: int):
Args:
account_id: ID of mail account to process
"""
_task_start = time.monotonic()
async with async_session_maker() as db:
try:
# Get account
@@ -58,10 +91,15 @@ async def process_mail_account(account_id: int):
logger.warning(f"Account {account_id} not found or disabled")
return
# Capture start time in a local variable so the error handler can
# compute duration_seconds without touching the (expired) ORM
# attribute after a session rollback.
_run_started_at = datetime.now(timezone.utc)
# Create processing run
run = ProcessingRun(
mail_account_id=account.id,
started_at=datetime.now(timezone.utc),
started_at=_run_started_at,
status="running",
)
db.add(run)
@@ -79,8 +117,13 @@ async def process_mail_account(account_id: int):
)
already_seen_uids = set(seen_result.scalars().all())
# Create debug recorder if debug logging is enabled for this account
_debug_recorder: Optional[MailDebugRecorder] = (
MailDebugRecorder() if account.debug_logging else None # type: ignore[attr-defined]
)
# Create processor
processor = MailProcessor(account, password)
processor = MailProcessor(account, password, debug_recorder=_debug_recorder)
# Fetch emails (returns raw bytes + new UIDs)
emails, new_uids = await processor.fetch_emails(
@@ -89,6 +132,7 @@ async def process_mail_account(account_id: int):
)
run.emails_fetched = len(emails) # type: ignore[assignment]
MAIL_PROCESSING_EMAILS_TOTAL.labels(operation="fetched").inc(len(emails))
# Forward emails
emails_forwarded = 0
@@ -99,6 +143,7 @@ async def process_mail_account(account_id: int):
gmail_service = None
smtp_config = None
gmail_cred = None
if use_gmail_api:
# Get user's Gmail credentials
@@ -117,6 +162,27 @@ async def process_mail_account(account_id: int):
if gmail_cred.encrypted_refresh_token
else None
)
# Log token expiry to help diagnose timeout issues.
if gmail_cred.token_expiry:
_now = datetime.now(timezone.utc)
_expiry = gmail_cred.token_expiry
if _expiry.tzinfo is None:
_expiry = _expiry.replace(tzinfo=timezone.utc)
_secs = (_expiry - _now).total_seconds()
if _secs < 0:
logger.info(
"Gmail access token for user %s expired %.0f s ago; "
"google-auth will refresh automatically using the refresh token.",
account.user_id,
-_secs,
)
elif _secs < 300:
logger.info(
"Gmail access token for user %s expires in %.0f s; "
"will be refreshed on the first API call.",
account.user_id,
_secs,
)
gmail_service = GmailService(
access_token=access_token,
refresh_token=refresh_token,
@@ -144,6 +210,7 @@ async def process_mail_account(account_id: int):
"host": user_smtp.host,
"port": user_smtp.port,
"username": user_smtp.username,
"sender_email": user_smtp.sender_email or "",
"password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type]
"use_tls": user_smtp.use_tls,
}
@@ -156,10 +223,16 @@ async def process_mail_account(account_id: int):
)
run.status = "failed" # type: ignore[assignment]
run.error_message = "No delivery method configured (SMTP credentials missing and Gmail API not set up)" # type: ignore[assignment]
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
run.duration_seconds = ( # type: ignore[assignment]
run.completed_at - _run_started_at
).total_seconds()
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
await db.commit()
return
successfully_forwarded_uids: list[str] = []
skipped_empty_uids: list[str] = []
if len(emails) != len(new_uids):
logger.error(
@@ -167,15 +240,95 @@ async def process_mail_account(account_id: int):
f"for account {account.id}; truncating to shorter list"
)
# Field length limits matching the DB column definitions
_MAX_SUBJECT_LEN = 500
_MAX_FROM_LEN = 255
for email_data, uid in zip(emails, new_uids):
# ── Parse email metadata for logging ───────────────────────
email_subject: str | None = None
email_from: str | None = None
try:
if use_gmail_api and gmail_service:
msg = email_lib.message_from_bytes(email_data)
raw_subject = msg.get("Subject", "") or ""
email_subject = (
raw_subject[:_MAX_SUBJECT_LEN] if raw_subject else None
)
raw_from = msg.get("From", "") or ""
email_from = raw_from[:_MAX_FROM_LEN] if raw_from else None
except (ValueError, TypeError, UnicodeDecodeError) as exc:
logger.debug(
"Could not parse email headers for account %s: %s",
account.id,
exc,
)
email_size_bytes = len(email_data)
forwarded_ok = False
error_msg: str | None = None
# ── Detect completely empty emails ───────────────────────────
# T-Online (and potentially other servers) may return empty
# RFC822 responses. An email with no subject, no sender and no
# body provides no value and should not be forwarded. We log a
# warning (it may indicate a server-side bug) and skip the
# message while still recording its UID so it is not retried.
if not email_subject and not email_from:
body_has_content = False
try:
if msg.is_multipart(): # type: ignore[possibly-undefined]
for part in msg.walk():
payload = part.get_payload(decode=True)
if isinstance(payload, bytes) and payload.strip():
body_has_content = True
break
else:
payload = msg.get_payload(decode=True) # type: ignore[possibly-undefined]
body_has_content = isinstance(payload, bytes) and bool(
payload.strip()
)
except Exception:
pass
if not body_has_content:
logger.warning(
"Dropping completely empty email (uid=%s, account=%s, "
"size=%d bytes) — no subject, sender, or body; "
"this may indicate a server-side error.",
uid,
account.id,
email_size_bytes,
)
skipped_empty_uids.append(uid)
db.add(
ProcessingLog(
user_id=account.user_id,
mail_account_id=account.id,
processing_run_id=run.id,
level="WARNING",
message="Dropped empty email (no subject, sender, or body)",
email_subject=None,
email_from=None,
email_size_bytes=email_size_bytes,
success=False,
error_details={"reason": "empty_email"},
)
)
continue
try:
if use_gmail_api and gmail_service and gmail_cred:
# Inject via Gmail API (preferred)
label_ids = await gmail_service.build_import_label_ids(
import_label_templates=gmail_cred.import_label_templates,
source_email=account.email_address, # type: ignore[arg-type]
)
await gmail_service.inject_email(
raw_email=email_data,
label_ids=["INBOX"],
label_ids=label_ids,
source_account_name=account.name, # type: ignore[arg-type]
)
forwarded_ok = True
emails_forwarded += 1
successfully_forwarded_uids.append(uid)
else:
@@ -184,15 +337,61 @@ async def process_mail_account(account_id: int):
email_data, account.name, account.forward_to, smtp_config # type: ignore[arg-type]
)
if success:
forwarded_ok = True
emails_forwarded += 1
successfully_forwarded_uids.append(uid)
else:
logger.warning(
"Email delivery returned False for uid=%s "
"on account %s (subject=%r, from=%r); "
"message will be retried on next run",
uid,
account.id,
email_subject,
email_from,
)
emails_failed += 1
except GmailAuthError as e:
# Token refresh failed: refresh token is likely revoked.
# Mark the credential invalid so the user is prompted to
# re-authorise, and stop retrying for this run.
if use_gmail_api and gmail_cred:
gmail_cred.is_valid = False # type: ignore[assignment]
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
logger.warning(
"Gmail credentials revoked for user %s "
"(account %s, token_expiry=%s). "
"User must re-authorise. Error: %s",
account.user_id,
account.id,
getattr(gmail_cred, "token_expiry", "unknown"),
e,
)
try:
async with async_session_maker() as notif_db:
await send_user_notification(
db=notif_db,
user_id=int(account.user_id),
title="InboxRescue: Gmail Authorization Expired",
body=(
f"Your Gmail credentials for account '{account.name}' "
f"have been revoked or have expired. "
f"Please re-authorize Gmail access in Settings."
),
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(
"Failed to send revocation notification: %s", notify_exc
)
error_msg = str(e)
emails_failed += 1
except Exception as e:
error_str = str(e).lower()
# If Gmail returns 401/403 the refresh token was revoked
# mark credentials invalid so the user gets notified.
# Catch any remaining auth-style errors that slipped through
# (e.g. HttpError 401/403 returned after a successful refresh).
if (
use_gmail_api
and gmail_cred
@@ -203,13 +402,90 @@ async def process_mail_account(account_id: int):
)
):
gmail_cred.is_valid = False # type: ignore[assignment]
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
logger.warning(
f"Gmail credentials revoked for user {account.user_id}. "
"User must re-authorise."
"Gmail credentials invalidated for user %s "
"(account %s, token_expiry=%s) due to HTTP auth error. "
"User must re-authorise. Error: %s",
account.user_id,
account.id,
getattr(gmail_cred, "token_expiry", "unknown"),
e,
)
logger.error(f"Error delivering email: {e}")
try:
async with async_session_maker() as notif_db:
await send_user_notification(
db=notif_db,
user_id=int(account.user_id),
title="InboxRescue: Gmail Authorization Expired",
body=(
f"Your Gmail credentials for account '{account.name}' "
f"have been revoked. Please re-authorize Gmail access "
f"in Settings."
),
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(
f"Failed to send revocation notification: {notify_exc}"
)
logger.error(
"Error delivering email (account %s, uid=%s): %s",
account.id,
uid,
e,
)
error_msg = str(e)
emails_failed += 1
# ── Write per-email ProcessingLog entry ─────────────────────
db.add(
ProcessingLog(
user_id=account.user_id,
mail_account_id=account.id,
processing_run_id=run.id,
level="INFO" if forwarded_ok else "ERROR",
message=(
f"Forwarded: {email_subject or '(no subject)'}"
if forwarded_ok
else f"Failed: {error_msg or 'delivery error'}"
),
email_subject=email_subject,
email_from=email_from,
email_size_bytes=email_size_bytes,
success=forwarded_ok,
error_details={"error": error_msg} if error_msg else None,
)
)
# Post-process: mark successfully forwarded messages as \Seen (IMAP)
# and/or delete them from the source mailbox. This is done AFTER
# the forwarding loop so that any message that failed to forward is
# left untouched in the source and will be retried on the next run.
if successfully_forwarded_uids:
try:
await processor.post_process_messages(successfully_forwarded_uids)
except Exception as post_exc:
logger.warning(
"Failed to post-process messages for account %s: %s",
account.id,
post_exc,
)
# Persist debug connection trace (if debug logging was enabled)
if _debug_recorder and _debug_recorder.has_entries():
db.add(
ProcessingLog(
user_id=account.user_id,
mail_account_id=account.id,
processing_run_id=run.id,
level="DEBUG",
message=f"Connection trace ({len(_debug_recorder)} entries)",
success=True,
error_details=_debug_recorder.as_details(),
)
)
# Persist new message UIDs so they are not processed again
for uid in successfully_forwarded_uids:
if uid not in already_seen_uids:
@@ -220,6 +496,17 @@ async def process_mail_account(account_id: int):
)
)
# Also persist UIDs of dropped empty emails so they are not
# re-fetched and re-evaluated on the next run.
for uid in skipped_empty_uids:
if uid not in already_seen_uids:
db.add(
DownloadedMessageId(
mail_account_id=account.id,
message_uid=uid,
)
)
# If Gmail API was used, persist any refreshed access token back to
# the DB so the next run doesn't need an extra token-refresh call.
if use_gmail_api and gmail_service and gmail_cred:
@@ -237,7 +524,9 @@ async def process_mail_account(account_id: int):
run.emails_forwarded = emails_forwarded # type: ignore[assignment]
run.emails_failed = emails_failed # type: ignore[assignment]
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
run.duration_seconds = (run.completed_at - run.started_at).total_seconds()
run.duration_seconds = (
run.completed_at - _as_utc(run.started_at) # type: ignore[arg-type]
).total_seconds()
run.status = "completed" if emails_failed == 0 else "partial_failure" # type: ignore[assignment]
# Update account
@@ -245,16 +534,99 @@ async def process_mail_account(account_id: int):
account.total_emails_failed += emails_failed # type: ignore[assignment]
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
if emails_failed == 0:
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
else:
account.status = AccountStatus.ERROR # type: ignore[assignment]
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment]
# The fetch/connection succeeded: always clear any connection-level error
# and mark the account ACTIVE regardless of per-email forwarding failures.
# Per-email failures are already tracked in ProcessingLog and the
# run's emails_failed counter so the user can drill into them without
# having the account badge stuck in ERROR indefinitely.
# Capture pre-mutation state for the post-commit notification logic.
# These must be read before the account status and flag are cleared below.
_was_in_error = account.status == AccountStatus.ERROR # type: ignore[comparison-overlap]
_had_notified = bool(account.error_notification_sent) # type: ignore[attr-defined]
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
account.last_error_message = None # type: ignore[assignment]
account.last_error_at = None # type: ignore[assignment]
# Clear the notification-sent flag so a future error streak fires a fresh alert.
account.error_notification_sent = False # type: ignore[assignment]
# Auto-disable debug logging after 5 runs since it was last enabled.
# The counter resets to 0 each time the user turns the flag on via
# the API, so "5 runs" is always counted from the moment of enabling.
if account.debug_logging: # type: ignore[attr-defined]
account.debug_logging_run_count = (account.debug_logging_run_count or 0) + 1 # type: ignore[attr-defined,assignment]
if account.debug_logging_run_count >= 5: # type: ignore[attr-defined]
account.debug_logging = False # type: ignore[assignment]
account.debug_logging_run_count = 0 # type: ignore[assignment]
logger.info(
"Auto-disabled debug logging for account %s after 5 runs",
account.id,
)
await db.commit()
# Send failure notification after the commit so the status is
# persisted even if the notification fails. Use a fresh session
# to avoid interfering with the (now-committed) main transaction.
#
# Backoff policy:
# - Recovery: if the account was previously in ERROR and we had
# already sent a failure notification, send one recovery notice now.
# - New error streak: only notify on the FIRST run that has failures
# (error_notification_sent was False before this run). Subsequent
# failing runs stay silent until recovery resets the flag.
if _was_in_error and _had_notified:
try:
async with async_session_maker() as notif_db:
await send_user_notification(
db=notif_db,
user_id=int(account.user_id),
title="InboxRescue: Mail Account Recovered",
body=f"Mail account '{account.name}' is processing normally again.",
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(
f"Failed to send recovery notification: {notify_exc}"
)
if emails_failed > 0 and not _had_notified:
try:
async with async_session_maker() as notif_db:
# Persist the flag so the next failing run stays silent.
async with notif_db.begin():
await notif_db.execute(
sa_update(MailAccount)
.where(MailAccount.id == account.id)
.values(error_notification_sent=True)
)
await send_user_notification(
db=notif_db,
user_id=int(account.user_id),
title="InboxRescue: Mail Forwarding Failures",
body=f"Mail account '{account.name}': {emails_failed} email(s) failed to forward.",
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(f"Failed to send notification: {notify_exc}")
# Record Prometheus metrics for this completed run
_run_status = "completed" if emails_failed == 0 else "partial_failure"
MAIL_PROCESSING_RUNS_TOTAL.labels(status=_run_status).inc()
MAIL_PROCESSING_EMAILS_TOTAL.labels(operation="forwarded").inc(
emails_forwarded
)
MAIL_PROCESSING_EMAILS_TOTAL.labels(operation="failed").inc(emails_failed)
_task_duration = time.monotonic() - _task_start
MAIL_PROCESSING_DURATION_SECONDS.observe(_task_duration)
CELERY_TASKS_TOTAL.labels(
task_name="process_mail_account", status="success"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(
task_name="process_mail_account"
).observe(_task_duration)
logger.info(
f"Processed account {account.id}: "
f"{emails_forwarded} forwarded, {emails_failed} failed"
@@ -263,13 +635,31 @@ async def process_mail_account(account_id: int):
except Exception as e:
logger.error(f"Error processing account {account_id}: {e}")
# Mark run as failed
# Record failure metric
_task_duration = time.monotonic() - _task_start
MAIL_PROCESSING_RUNS_TOTAL.labels(status="failed").inc()
CELERY_TASKS_TOTAL.labels(
task_name="process_mail_account", status="failure"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(
task_name="process_mail_account"
).observe(_task_duration)
# Mark run as failed roll back any pending/broken transaction first
# so the session is in a clean state before we write the failure status.
try:
await db.rollback()
except Exception as rb_exc:
logger.warning(f"Rollback failed during error handler: {rb_exc}")
if "run" in locals():
run.status = "failed" # type: ignore[assignment]
run.error_message = str(e) # type: ignore[assignment]
run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
run.duration_seconds = (
run.completed_at - run.started_at
# Use the locally-captured start time to avoid accessing an
# expired ORM attribute after the session rollback above.
run.duration_seconds = ( # type: ignore[assignment]
run.completed_at - _run_started_at
).total_seconds()
# Update account error status
@@ -277,8 +667,55 @@ async def process_mail_account(account_id: int):
account.status = AccountStatus.ERROR # type: ignore[assignment]
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
account.last_error_message = str(e) # type: ignore[assignment]
# Always update last_check_at so process_all_enabled_accounts
# throttles re-dispatch instead of queuing a new task every cycle.
account.last_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
await db.commit()
try:
await db.commit()
except Exception as commit_exc:
logger.error(
f"Failed to persist failed status for run of account "
f"{account_id}: {commit_exc}"
)
# Send error notification after the commit (and outside the run/account
# guards) so the status is always persisted first. Use a fresh session
# to avoid the post-rollback session's broken greenlet context causing
# the notification query itself to fail with "greenlet_spawn has not
# been called".
#
# Backoff: only notify on the first failure in a consecutive error
# streak. error_notification_sent is set True here and cleared on
# the next successful run, so repeat failures stay silent until the
# account recovers.
if "account" in locals() and account is not None:
_already_notified = bool(
getattr(account, "error_notification_sent", False)
)
if not _already_notified:
try:
async with async_session_maker() as notif_db:
# Persist the flag first so even if the notification
# delivery fails the flag is set and the next run won't
# try again.
async with notif_db.begin():
await notif_db.execute(
sa_update(MailAccount)
.where(MailAccount.id == account.id)
.values(error_notification_sent=True)
)
await send_user_notification(
db=notif_db,
user_id=int(account.user_id),
title="InboxRescue: Mail Processing Error",
body=f"Error processing mail account '{account.name}': {e}",
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(
f"Failed to send error notification: {notify_exc}"
)
@celery_app.task(base=AsyncTask, name="app.workers.tasks.process_all_enabled_accounts")
@@ -287,8 +724,35 @@ async def process_all_enabled_accounts():
Process all enabled mail accounts.
This task is scheduled to run periodically.
"""
_task_start = time.monotonic()
async with async_session_maker() as db:
try:
# Mark stale "running" runs as failed. A run is considered stale
# when it has been in the "running" state longer than the Celery
# hard time limit (30 min) plus a small buffer this recovers from
# worker crashes or SIGKILL events faster than waiting for the
# daily cleanup_old_logs task.
stale_threshold = datetime.now(timezone.utc) - timedelta(minutes=35)
stale_result = await db.execute(
select(ProcessingRun).where(
ProcessingRun.status == "running",
ProcessingRun.started_at < stale_threshold,
)
)
stale_runs = stale_result.scalars().all()
for stale_run in stale_runs:
stale_run.status = "failed" # type: ignore[assignment]
stale_run.error_message = ( # type: ignore[assignment]
"Run timed out or worker was killed before completion"
)
stale_run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
stale_run.duration_seconds = ( # type: ignore[assignment]
stale_run.completed_at - _as_utc(stale_run.started_at) # type: ignore[arg-type]
).total_seconds()
if stale_runs:
await db.commit()
logger.info(f"Marked {len(stale_runs)} stale processing runs as failed")
# Fetch all enabled accounts regardless of operational status so
# that accounts in ERROR state are retried automatically.
result = await db.execute(
@@ -299,13 +763,14 @@ async def process_all_enabled_accounts():
accounts = result.scalars().all()
logger.info(f"Processing {len(accounts)} enabled mail accounts")
ACTIVE_MAIL_ACCOUNTS.set(len(accounts))
# Process each account
for account in accounts:
# Check if it's time to check this account
if account.last_check_at:
time_since_last_check = (
datetime.now(timezone.utc) - account.last_check_at
time_since_last_check = datetime.now(timezone.utc) - _as_utc(
account.last_check_at
)
if time_since_last_check.total_seconds() < (
account.check_interval_minutes * 60
@@ -316,22 +781,64 @@ async def process_all_enabled_accounts():
# Queue processing task
process_mail_account.delay(account.id)
_task_duration = time.monotonic() - _task_start
CELERY_TASKS_TOTAL.labels(
task_name="process_all_enabled_accounts", status="success"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(
task_name="process_all_enabled_accounts"
).observe(_task_duration)
except Exception as e:
logger.error(f"Error processing accounts: {e}")
_task_duration = time.monotonic() - _task_start
CELERY_TASKS_TOTAL.labels(
task_name="process_all_enabled_accounts", status="failure"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(
task_name="process_all_enabled_accounts"
).observe(_task_duration)
@celery_app.task(base=AsyncTask, name="app.workers.tasks.cleanup_old_logs")
async def cleanup_old_logs(days_to_keep: int = 30):
"""
Clean up old processing logs, runs, and downloaded message ID records.
Also marks stale "running" runs (older than the Celery task time-limit)
as "failed" to recover from worker crashes or SIGKILL events.
Args:
days_to_keep: Number of days of data to retain
"""
_task_start = time.monotonic()
async with async_session_maker() as db:
try:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
# Mark stale "running" runs as "failed".
# A run is considered stale when it has been in the "running" state
# for longer than the Celery hard time limit (30 min) plus a small
# buffer meaning the worker was likely killed before it could write
# the final status (OOM kill, container restart, SIGKILL, etc.).
stale_threshold = datetime.now(timezone.utc) - timedelta(minutes=35)
stale_result = await db.execute(
select(ProcessingRun).where(
ProcessingRun.status == "running",
ProcessingRun.started_at < stale_threshold,
)
)
stale_runs = stale_result.scalars().all()
for stale_run in stale_runs:
stale_run.status = "failed" # type: ignore[assignment]
stale_run.error_message = "Run timed out or worker was killed before completion" # type: ignore[assignment]
stale_run.completed_at = datetime.now(timezone.utc) # type: ignore[assignment]
stale_run.duration_seconds = ( # type: ignore[assignment]
stale_run.completed_at - _as_utc(stale_run.started_at) # type: ignore[arg-type]
).total_seconds()
if stale_runs:
logger.info(f"Marked {len(stale_runs)} stale processing runs as failed")
# Delete old processing runs
result = await db.execute(
select(ProcessingRun).where(ProcessingRun.started_at < cutoff_date)
@@ -365,5 +872,177 @@ async def cleanup_old_logs(days_to_keep: int = 30):
f"{len(old_logs)} old logs"
)
_task_duration = time.monotonic() - _task_start
CELERY_TASKS_TOTAL.labels(
task_name="cleanup_old_logs", status="success"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(task_name="cleanup_old_logs").observe(
_task_duration
)
except Exception as e:
logger.error(f"Error cleaning up logs: {e}")
_task_duration = time.monotonic() - _task_start
CELERY_TASKS_TOTAL.labels(
task_name="cleanup_old_logs", status="failure"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(task_name="cleanup_old_logs").observe(
_task_duration
)
@celery_app.task(base=AsyncTask, name="app.workers.tasks.refresh_gmail_tokens")
async def refresh_gmail_tokens():
"""
Proactively refresh Gmail OAuth2 access tokens that are close to expiry.
Runs every 45 minutes via Celery Beat. Any credential whose access token
expires within the next 30 minutes (or whose expiry is unknown) is
refreshed using its stored refresh token.
Benefits:
- Keeps the DB's ``encrypted_access_token`` and ``token_expiry`` columns
up to date so mail-processing tasks never start with an already-expired
token (which would cause a delayed in-band refresh).
- Detects revoked refresh tokens early, before they block email delivery,
and sends the user a re-authorisation notification.
Credentials without a refresh token are skipped — they cannot be refreshed
automatically and will simply fail on the next delivery attempt.
"""
_task_start = time.monotonic()
async with async_session_maker() as db:
try:
# Find all valid credentials with a refresh token whose access
# token expires within the next 30 minutes (or expiry is unknown).
refresh_threshold = datetime.now(timezone.utc) + timedelta(minutes=30)
cred_result = await db.execute(
select(GmailCredential).where(
GmailCredential.is_valid == True, # noqa: E712
GmailCredential.encrypted_refresh_token.is_not(None),
or_(
GmailCredential.token_expiry.is_(None),
GmailCredential.token_expiry <= refresh_threshold,
),
)
)
credentials_to_refresh = cred_result.scalars().all()
if not credentials_to_refresh:
logger.debug("refresh_gmail_tokens: no credentials need refreshing")
_task_duration = time.monotonic() - _task_start
CELERY_TASKS_TOTAL.labels(
task_name="refresh_gmail_tokens", status="success"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(
task_name="refresh_gmail_tokens"
).observe(_task_duration)
return
logger.info(
"refresh_gmail_tokens: refreshing %d credential(s)",
len(credentials_to_refresh),
)
refreshed_count = 0
failed_count = 0
for cred in credentials_to_refresh:
access_token = decrypt_credential(cred.encrypted_access_token) # type: ignore[arg-type]
refresh_token = decrypt_credential(cred.encrypted_refresh_token) # type: ignore[arg-type]
gmail_service = GmailService(
access_token=access_token,
refresh_token=refresh_token,
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
)
try:
new_token_info = await gmail_service.proactive_refresh()
cred.encrypted_access_token = encrypt_credential( # type: ignore[assignment]
new_token_info["access_token"]
)
if new_token_info.get("expiry"):
cred.token_expiry = new_token_info["expiry"] # type: ignore[assignment]
cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
logger.info(
"refresh_gmail_tokens: refreshed token for user %s "
"(gmail=%s, new_expiry=%s)",
cred.user_id,
cred.gmail_email,
new_token_info.get("expiry"),
)
refreshed_count += 1
except GmailAuthError as e:
# Refresh token is revoked — mark the credential invalid
# and notify the user.
cred.is_valid = False # type: ignore[assignment]
GMAIL_CREDENTIALS_INVALIDATED_TOTAL.inc()
logger.warning(
"refresh_gmail_tokens: refresh token revoked for user %s "
"(gmail=%s). Marking invalid. Error: %s",
cred.user_id,
cred.gmail_email,
e,
)
failed_count += 1
try:
async with async_session_maker() as notif_db:
await send_user_notification(
db=notif_db,
user_id=int(cred.user_id),
title="InboxRescue: Gmail Re-authorisation Required",
body=(
"Your Gmail access has been revoked. "
"Please open Settings → Gmail API and click "
"'Connect Gmail' to restore email delivery."
),
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(
"refresh_gmail_tokens: failed to send revocation "
"notification for user %s: %s",
cred.user_id,
notify_exc,
)
except Exception as e:
# Non-auth error (e.g. network timeout) — log but do not
# mark as invalid; it may succeed on the next run.
logger.warning(
"refresh_gmail_tokens: unexpected error refreshing token "
"for user %s (gmail=%s): %s",
cred.user_id,
cred.gmail_email,
e,
)
failed_count += 1
await db.commit()
logger.info(
"refresh_gmail_tokens: finished — %d refreshed, %d failed",
refreshed_count,
failed_count,
)
_task_duration = time.monotonic() - _task_start
CELERY_TASKS_TOTAL.labels(
task_name="refresh_gmail_tokens", status="success"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(
task_name="refresh_gmail_tokens"
).observe(_task_duration)
except Exception as e:
logger.error("refresh_gmail_tokens: unexpected error: %s", e)
_task_duration = time.monotonic() - _task_start
CELERY_TASKS_TOTAL.labels(
task_name="refresh_gmail_tokens", status="failure"
).inc()
CELERY_TASK_DURATION_SECONDS.labels(
task_name="refresh_gmail_tokens"
).observe(_task_duration)
+32 -31
View File
@@ -1,58 +1,59 @@
# Core Framework
fastapi==0.109.1 # Updated: Fixed ReDoS vulnerability (was 0.109.0)
uvicorn[standard]==0.27.0
pydantic==2.12.5
pydantic-settings==2.13.1
fastapi==0.136.3 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0
uvicorn[standard]==0.48.0
pydantic==2.13.4
pydantic-settings==2.14.1
# Database
sqlalchemy==2.0.48 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
alembic==1.13.1
psycopg2-binary==2.9.11
sqlalchemy==2.0.50 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
alembic==1.18.4
psycopg2-binary==2.9.12
asyncpg==0.31.0
# Authentication
python-jose[cryptography]==3.3.0
bcrypt==4.3.0
python-multipart==0.0.22 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
authlib==1.6.9 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
httpx==0.26.0
python-jose[cryptography]==3.5.0 # Updated: Fixed algorithm confusion with OpenSSH ECDSA keys (was 3.3.0)
bcrypt==5.0.0
python-multipart==0.0.29 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
authlib==1.7.2 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
httpx==0.28.1
# Payment Processing
stripe==14.4.1
stripe==15.1.0
# Email & Mail Processing
aiosmtplib==3.0.1
aiohttp==3.13.3 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
aiosmtplib==5.1.0
aiohttp==3.13.5 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
aioimaplib==2.0.1
email-validator==2.1.0.post1
email-validator==2.3.0
# Gmail API (for direct email injection)
google-api-python-client==2.193.0
google-auth==2.49.1
google-auth-oauthlib==1.2.0
google-auth-httplib2==0.3.0
google-api-python-client==2.196.0
google-auth==2.53.0
google-auth-oauthlib==1.4.0
google-auth-httplib2==0.4.0
# Job Queue & Cache
celery==5.6.2
redis==7.3.0
celery==5.6.3
redis==7.4.0
# Security & Encryption
cryptography==46.0.5 # Updated: Fixed NULL pointer dereference (was 42.0.0)
cryptography==48.0.0 # Updated: Fixed NULL pointer dereference (was 42.0.0)
# Notifications
apprise==1.7.1
apprise==1.10.0
# Monitoring & Logging
prometheus-client==0.19.0
prometheus-client==0.25.0
python-json-logger==2.0.7
# Development & Testing
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-cov==4.1.0
faker==22.6.0
pytest==9.0.3
pytest-asyncio==1.3.0
pytest-cov==7.1.0
faker==40.19.1
# Utilities
python-dotenv==1.0.0
schedule==1.2.0
python-dotenv==1.2.2
schedule==1.2.2
tenacity==9.1.4
+6 -10
View File
@@ -16,7 +16,7 @@ from app.core.security import get_password_hash, create_access_token
# Test database URL (use different database for tests)
TEST_DATABASE_URL = settings.DATABASE_URL.replace(
"/pop3_forwarder", "/pop3_forwarder_test"
"/inbox_converge", "/inbox_converge_test"
)
@@ -82,7 +82,6 @@ async def test_user(db_session: AsyncSession) -> User:
email="test@example.com",
hashed_password=get_password_hash("testpassword123"),
is_active=True,
is_verified=True,
)
db_session.add(user)
await db_session.commit()
@@ -97,8 +96,7 @@ async def test_admin_user(db_session: AsyncSession) -> User:
email="admin@example.com",
hashed_password=get_password_hash("adminpassword123"),
is_active=True,
is_verified=True,
is_admin=True,
is_superuser=True,
)
db_session.add(user)
await db_session.commit()
@@ -109,14 +107,14 @@ async def test_admin_user(db_session: AsyncSession) -> User:
@pytest.fixture
def auth_headers(test_user: User) -> dict:
"""Generate authentication headers for test user"""
access_token = create_access_token(data={"sub": test_user.email})
access_token = create_access_token(data={"sub": str(test_user.id)})
return {"Authorization": f"Bearer {access_token}"}
@pytest.fixture
def admin_auth_headers(test_admin_user: User) -> dict:
"""Generate authentication headers for admin user"""
access_token = create_access_token(data={"sub": test_admin_user.email})
access_token = create_access_token(data={"sub": str(test_admin_user.id)})
return {"Authorization": f"Bearer {access_token}"}
@@ -131,8 +129,7 @@ def user_factory(db_session: AsyncSession):
email: str = None,
password: str = "testpassword123",
is_active: bool = True,
is_verified: bool = True,
is_admin: bool = False,
is_superuser: bool = False,
) -> User:
if email is None:
import uuid
@@ -143,8 +140,7 @@ def user_factory(db_session: AsyncSession):
email=email,
hashed_password=get_password_hash(password),
is_active=is_active,
is_verified=is_verified,
is_admin=is_admin,
is_superuser=is_superuser,
)
db_session.add(user)
await db_session.commit()
File diff suppressed because it is too large Load Diff
@@ -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"]
+1 -1
View File
@@ -89,7 +89,7 @@ class TestApplicationFactory:
async def test_app_title(self, app):
"""Test that app has correct title"""
assert app.title == "POP3 Forwarder SaaS"
assert app.title == "InboxConverge"
async def test_app_version(self, app):
"""Test that app has a version"""
@@ -0,0 +1,241 @@
"""
Unit tests for app settings endpoints (api/v1/endpoints/app_settings.py).
All database interactions and auth dependencies are mocked.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.core.deps import get_current_superuser, get_current_user
from app.models.database_models import User, SubscriptionTier
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_admin_user(**overrides) -> MagicMock:
defaults = dict(
id=1,
email="admin@example.com",
full_name="Admin",
is_active=True,
is_superuser=True,
subscription_tier=SubscriptionTier.FREE,
subscription_status="active",
google_id=None,
oauth_provider=None,
last_login_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
stripe_customer_id=None,
stripe_subscription_id=None,
subscription_expires_at=None,
)
defaults.update(overrides)
u = MagicMock(spec=User)
for k, v in defaults.items():
setattr(u, k, v)
return u
def _make_setting(**overrides) -> MagicMock:
defaults = dict(
id=1,
key="SOME_SETTING",
value="some_value",
value_type="string",
description="A setting",
is_secret=False,
category="general",
)
defaults.update(overrides)
s = MagicMock()
for k, v in defaults.items():
setattr(s, k, v)
return s
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def mock_db():
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.add = MagicMock()
db.delete = AsyncMock()
return db
@pytest.fixture
def admin_user():
return _make_admin_user()
@pytest.fixture
async def admin_client(app, admin_user, mock_db):
async def _override_superuser():
return admin_user
async def _override_db():
yield mock_db
app.dependency_overrides[get_current_superuser] = _override_superuser
app.dependency_overrides[get_current_user] = _override_superuser
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ── GET /app-settings ─────────────────────────────────────────────────────────
class TestListSettings:
async def test_returns_list(self, admin_client):
setting = _make_setting()
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
new=AsyncMock(return_value=[setting]),
):
response = await admin_client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["key"] == "SOME_SETTING"
assert data[0]["value"] == "some_value"
async def test_secret_value_masked(self, admin_client):
setting = _make_setting(key="SECRET_KEY", value="supersecret", is_secret=True)
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
new=AsyncMock(return_value=[setting]),
):
response = await admin_client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert data[0]["value"] == "********"
async def test_filters_by_category(self, admin_client):
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
new=AsyncMock(return_value=[]),
):
response = await admin_client.get("/api/v1/settings?category=general")
assert response.status_code == 200
async def test_unauthenticated_401(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/settings")
assert response.status_code == 401
# ── PUT /app-settings/{key} ───────────────────────────────────────────────────
class TestUpsertSetting:
async def test_upsert_creates_or_updates(self, admin_client):
setting = _make_setting(key="MY_KEY", value="myval")
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.set",
new=AsyncMock(return_value=setting),
):
response = await admin_client.put(
"/api/v1/settings/MY_KEY",
json={
"key": "MY_KEY",
"value": "myval",
"value_type": "string",
"is_secret": False,
},
)
assert response.status_code == 200
data = response.json()
assert data["key"] == "MY_KEY"
assert data["value"] == "myval"
async def test_bootstrap_key_rejected_400(self, admin_client):
response = await admin_client.put(
"/api/v1/settings/SECRET_KEY",
json={"key": "SECRET_KEY", "value": "new_secret"},
)
assert response.status_code == 400
assert "bootstrap" in response.json()["detail"].lower()
async def test_database_url_rejected(self, admin_client):
response = await admin_client.put(
"/api/v1/settings/DATABASE_URL",
json={"key": "DATABASE_URL", "value": "postgresql://..."},
)
assert response.status_code == 400
async def test_secret_value_masked_in_response(self, admin_client):
setting = _make_setting(key="API_KEY", value="secret!", is_secret=True)
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.set",
new=AsyncMock(return_value=setting),
):
response = await admin_client.put(
"/api/v1/settings/API_KEY",
json={
"key": "API_KEY",
"value": "secret!",
"is_secret": True,
},
)
assert response.status_code == 200
assert response.json()["value"] == "********"
# ── DELETE /app-settings/{key} ────────────────────────────────────────────────
class TestDeleteSetting:
async def test_deletes_existing_setting(self, admin_client):
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.delete",
new=AsyncMock(return_value=True),
):
response = await admin_client.delete("/api/v1/settings/MY_KEY")
assert response.status_code == 204
async def test_404_when_not_found(self, admin_client):
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.delete",
new=AsyncMock(return_value=False),
):
response = await admin_client.delete("/api/v1/settings/MISSING_KEY")
assert response.status_code == 404
async def test_bootstrap_key_rejected_400(self, admin_client):
response = await admin_client.delete("/api/v1/settings/SECRET_KEY")
assert response.status_code == 400
# ── POST /app-settings/seed-defaults ─────────────────────────────────────────
class TestSeedDefaultSettings:
async def test_seeds_defaults(self, admin_client):
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.seed_defaults",
new=AsyncMock(return_value=5),
):
response = await admin_client.post("/api/v1/settings/seed-defaults")
assert response.status_code == 200
data = response.json()
assert data["created"] == 5
assert "Seeded 5" in data["message"]
+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,225 @@
"""
Unit tests for empty-email detection in the task processing pipeline.
Covers two layers:
1. tasks._empty_email_check_logic the inline body-inspection that
decides whether a parsed email should be dropped.
2. The "clear last_error_message on success" contract verifying that a
successful run nulls out any previously stored error so the status page
no longer shows stale IMAP errors.
"""
import email as email_lib
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse(raw: bytes):
"""Parse raw bytes into an email.Message object (same call used in tasks.py)."""
return email_lib.message_from_bytes(raw)
def _body_has_content(msg) -> bool:
"""
Mirror of the body-detection logic in tasks.py's email processing loop.
Returns True if the message has at least one non-empty / non-whitespace
text or binary payload.
"""
if msg.is_multipart():
for part in msg.walk():
payload = part.get_payload(decode=True)
if isinstance(payload, bytes) and payload.strip():
return True
return False
else:
payload = msg.get_payload(decode=True)
return isinstance(payload, bytes) and bool(payload.strip())
def _is_empty_email(raw: bytes) -> bool:
"""
Replicates the complete empty-email gate from tasks.py:
- no subject AND no from AND no body → True (should be dropped)
"""
msg = _parse(raw)
email_subject = (msg.get("Subject", "") or "").strip() or None
email_from = (msg.get("From", "") or "").strip() or None
if email_subject or email_from:
return False # Has at least a header → not empty
return not _body_has_content(msg)
# ---------------------------------------------------------------------------
# Body-detection tests
# ---------------------------------------------------------------------------
class TestBodyHasContent:
def test_plain_text_body(self):
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\nHello world"
msg = _parse(raw)
assert _body_has_content(msg) is True
def test_empty_body(self):
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n"
msg = _parse(raw)
assert _body_has_content(msg) is False
def test_crlf_only_body(self):
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n\r\n"
msg = _parse(raw)
assert _body_has_content(msg) is False
def test_whitespace_only_body(self):
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n \t "
msg = _parse(raw)
assert _body_has_content(msg) is False
def test_multipart_with_content(self):
raw = (
b"MIME-Version: 1.0\r\n"
b"Content-Type: multipart/mixed; boundary=X\r\n\r\n"
b"--X\r\n"
b"Content-Type: text/plain\r\n\r\n"
b"Hello from multipart\r\n"
b"--X--\r\n"
)
msg = _parse(raw)
assert _body_has_content(msg) is True
def test_multipart_all_empty_parts(self):
raw = (
b"MIME-Version: 1.0\r\n"
b"Content-Type: multipart/mixed; boundary=X\r\n\r\n"
b"--X\r\n"
b"Content-Type: text/plain\r\n\r\n"
b"\r\n"
b"--X--\r\n"
)
msg = _parse(raw)
assert _body_has_content(msg) is False
# ---------------------------------------------------------------------------
# Complete empty-email gate tests
# ---------------------------------------------------------------------------
class TestIsEmptyEmail:
def test_completely_empty_raw_bytes(self):
assert _is_empty_email(b"") is True
def test_only_headers_no_body(self):
raw = b"\r\n"
assert _is_empty_email(raw) is True
def test_no_subject_no_from_no_body(self):
raw = b"Date: Mon, 1 Jan 2024 00:00:00 +0000\r\n\r\n"
assert _is_empty_email(raw) is True
def test_has_subject_no_from_no_body(self):
"""Subject alone is enough to keep the email."""
raw = b"Subject: Alert\r\n\r\n"
assert _is_empty_email(raw) is False
def test_has_from_no_subject_no_body(self):
"""From alone is enough to keep the email."""
raw = b"From: sender@example.com\r\n\r\n"
assert _is_empty_email(raw) is False
def test_no_headers_but_body_has_content(self):
"""Body content alone is enough to keep the email."""
raw = b"\r\nThis is the body."
assert _is_empty_email(raw) is False
def test_normal_email_is_not_empty(self):
raw = (
b"From: sender@example.com\r\n"
b"Subject: Hello\r\n\r\n"
b"Some body text.\r\n"
)
assert _is_empty_email(raw) is False
def test_crlf_only_is_empty(self):
assert _is_empty_email(b"\r\n\r\n") is True
# ---------------------------------------------------------------------------
# Status-clearing contract
# ---------------------------------------------------------------------------
class TestLastErrorMessageClearOnSuccess:
"""
The `last_error_message` field must be cleared when a run completes with
zero forwarding failures, so the status page does not show stale errors.
This test exercises the same branch logic used in tasks.py without
needing a full Celery / DB setup.
"""
def _simulate_account_status_update(
self, emails_failed: int, current_error_message: str | None
) -> dict:
"""
Simulate the account-status block from tasks.py:
if emails_failed == 0:
account.status = ACTIVE
account.last_error_message = None
account.last_error_at = None
else:
account.status = ERROR
account.last_error_at = <now>
account.last_error_message = f"{emails_failed} emails failed to forward"
Returns a dict with the resulting field values.
"""
from app.models.database_models import AccountStatus
state = {
"last_error_message": current_error_message,
"last_error_at": "2024-01-01",
}
if emails_failed == 0:
state["status"] = AccountStatus.ACTIVE
state["last_error_message"] = None
state["last_error_at"] = None
else:
state["status"] = AccountStatus.ERROR
state["last_error_message"] = f"{emails_failed} emails failed to forward"
state["last_error_at"] = "2024-01-02"
return state
def test_error_cleared_on_zero_failures(self):
"""A previously stored error is wiped when all emails forward OK."""
result = self._simulate_account_status_update(
emails_failed=0,
current_error_message="IMAP fetch error: UID only possible with …",
)
assert result["last_error_message"] is None
assert result["last_error_at"] is None
def test_error_set_when_failures_exist(self):
"""When emails fail, the error message is updated, not cleared."""
result = self._simulate_account_status_update(
emails_failed=3,
current_error_message=None,
)
assert result["last_error_message"] == "3 emails failed to forward"
assert result["last_error_at"] is not None
def test_no_error_remains_none_on_success(self):
"""A clean account stays clean after a successful run."""
result = self._simulate_account_status_update(
emails_failed=0,
current_error_message=None,
)
assert result["last_error_message"] is None
@@ -0,0 +1,264 @@
"""
Unit tests for _format_connection_error() and MailDebugRecorder.
These tests verify:
- _format_connection_error returns human-readable, non-empty strings
for every relevant exception type.
- MailDebugRecorder caps its trace at the configured limits and never
includes password or credential data in its output.
"""
import asyncio
import poplib
import socket
import ssl
from app.services.mail_processor import (
MailDebugRecorder,
_format_connection_error,
_MAX_TRACE_ENTRIES,
)
# ---------------------------------------------------------------------------
# _format_connection_error helpers
# ---------------------------------------------------------------------------
def _fmt(exc: BaseException, host: str = "mail.example.com", port: int = 993) -> str:
return _format_connection_error(exc, host, port, "IMAP")
# ---------------------------------------------------------------------------
# DNS failure (socket.gaierror)
# ---------------------------------------------------------------------------
class TestFormatConnectionErrorDns:
def test_gaierror_includes_hostname(self):
exc = socket.gaierror(-5, "No address associated with hostname")
msg = _fmt(exc, host="pop.web.de", port=995)
assert "pop.web.de" in msg
assert "DNS" in msg or "resolve" in msg.lower()
def test_gaierror_never_empty(self):
exc = socket.gaierror(-2, "Name or service not known")
assert _fmt(exc)
def test_gaierror_without_host(self):
exc = socket.gaierror(-5, "No address associated with hostname")
msg = _format_connection_error(exc)
assert msg
assert "DNS" in msg or "lookup" in msg.lower()
# ---------------------------------------------------------------------------
# Timeout
# ---------------------------------------------------------------------------
class TestFormatConnectionErrorTimeout:
def test_socket_timeout_includes_host_port(self):
exc = socket.timeout("timed out")
msg = _fmt(exc, host="imap.gmx.net", port=993)
# Verify both the host and timeout indicator appear in the message
assert "imap.gmx.net" in msg
assert "993" in msg
assert "timed out" in msg.lower() or "timeout" in msg.lower()
def test_asyncio_timeout_error(self):
exc = asyncio.TimeoutError()
msg = _fmt(exc)
assert msg # never empty even though str() is ""
assert "timed out" in msg.lower() or "timeout" in msg.lower()
def test_bare_timeout_error(self):
"""Python 3.11+ TimeoutError (subclass of OSError) should be handled."""
exc = TimeoutError("timed out")
msg = _fmt(exc)
assert msg
# ---------------------------------------------------------------------------
# Connection refused / reset
# ---------------------------------------------------------------------------
class TestFormatConnectionErrorRefused:
def test_connection_refused_includes_host_port(self):
exc = ConnectionRefusedError(111, "Connection refused")
msg = _fmt(exc, host="smtp.example.com", port=587)
# Verify both host and port appear in the message
assert "smtp.example.com" in msg
assert "587" in msg
assert "refused" in msg.lower()
def test_connection_reset(self):
exc = ConnectionResetError(104, "Connection reset by peer")
msg = _fmt(exc)
assert msg
assert "reset" in msg.lower()
# ---------------------------------------------------------------------------
# SSL errors
# ---------------------------------------------------------------------------
class TestFormatConnectionErrorSsl:
def test_ssl_cert_verification(self):
try:
# Create a real SSLCertVerificationError if possible
exc = ssl.SSLCertVerificationError(
1, "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed"
)
except Exception:
exc = ssl.SSLError(1, "CERTIFICATE_VERIFY_FAILED") # type: ignore[assignment]
msg = _fmt(exc)
assert msg
assert "TLS" in msg or "SSL" in msg or "certificate" in msg.lower()
def test_ssl_generic_error(self):
exc = ssl.SSLError(1, "wrong version number")
msg = _fmt(exc)
assert msg
assert "TLS" in msg or "SSL" in msg
# ---------------------------------------------------------------------------
# POP3 protocol errors
# ---------------------------------------------------------------------------
class TestFormatConnectionErrorPop3:
def test_auth_error_identifies_auth_failure(self):
exc = poplib.error_proto("-ERR authentication failed")
msg = _format_connection_error(exc, "pop.example.com", 995, "POP3")
# Verify both host and port appear in the message
assert "pop.example.com" in msg
assert "995" in msg
assert "Authentication" in msg or "authentication" in msg
def test_generic_pop3_error(self):
exc = poplib.error_proto("-ERR mailbox is locked")
msg = _format_connection_error(exc, "pop.example.com", 995, "POP3")
assert msg
assert "POP3" in msg
def test_empty_pop3_error(self):
"""Even a bare error_proto with no message must produce a non-empty string."""
exc = poplib.error_proto(b"")
msg = _format_connection_error(exc, "pop.example.com", 995, "POP3")
assert msg
# ---------------------------------------------------------------------------
# Catch-all: empty-string exception
# ---------------------------------------------------------------------------
class TestFormatConnectionErrorFallback:
def test_empty_string_exception(self):
"""An exception whose str() is empty must still produce a non-empty message."""
exc = asyncio.TimeoutError()
assert str(exc) == "" # confirm the premise
msg = _fmt(exc)
assert msg
assert len(msg) > 0
def test_generic_exception(self):
exc = RuntimeError("something went wrong")
msg = _fmt(exc)
assert "something went wrong" in msg or "RuntimeError" in msg
def test_no_host(self):
exc = RuntimeError("test")
msg = _format_connection_error(exc)
assert msg
# ---------------------------------------------------------------------------
# MailDebugRecorder
# ---------------------------------------------------------------------------
class TestMailDebugRecorder:
def test_record_entry_appears_in_trace(self):
rec = MailDebugRecorder()
rec.record("connect", "Connected to mail.example.com:993", {"elapsed_ms": 42})
details = rec.as_details()
assert len(details["trace"]) == 1
entry = details["trace"][0]
assert entry["phase"] == "connect"
assert entry["msg"] == "Connected to mail.example.com:993"
assert entry["data"]["elapsed_ms"] == 42
assert not details["truncated"]
def test_has_entries_false_when_empty(self):
rec = MailDebugRecorder()
assert not rec.has_entries()
def test_has_entries_true_after_record(self):
rec = MailDebugRecorder()
rec.record("auth", "Logged in")
assert rec.has_entries()
def test_size_cap_triggers_truncation(self):
"""Recording beyond _MAX_TRACE_BYTES silently truncates."""
rec = MailDebugRecorder()
# Each entry is ~100 chars; flood until truncated
long_msg = "x" * 1000
for i in range(200):
rec.record("flood", long_msg, {"i": i})
details = rec.as_details()
assert details["truncated"]
# After truncation, recording new entries is a no-op
prev_count = len(rec)
rec.record("after_truncate", "should be ignored")
assert len(rec) == prev_count
def test_entry_cap_triggers_truncation(self):
"""Recording more than _MAX_TRACE_ENTRIES entries truncates."""
rec = MailDebugRecorder()
for i in range(_MAX_TRACE_ENTRIES + 10):
rec.record("phase", f"entry {i}")
assert rec.as_details()["truncated"]
# Total entries should be MAX + 1 (the truncation sentinel)
assert len(rec.as_details()["trace"]) <= _MAX_TRACE_ENTRIES + 1
def test_no_password_in_trace_from_production_phases(self):
"""Production instrumentation records only usernames and counts, never passwords.
The recorder itself has no auto-redaction; the contract is that callers
(the instrumented IMAP/POP3 code) must never pass credential data.
This test verifies the expected production-phase entries contain no
password strings.
"""
password = "s3cr3tP@ssw0rd!"
rec = MailDebugRecorder()
# Simulate the entries that production code actually records
rec.record("auth", "Authenticated as user@example.com", {"elapsed_ms": 5})
rec.record("stat", "Mailbox has 3 messages", {"count": 3})
import json
serialised = json.dumps(rec.as_details())
# The password must not appear in any of these entries
assert password not in serialised
def test_timestamps_are_iso_format(self):
rec = MailDebugRecorder()
rec.record("connect", "ok")
ts = rec.as_details()["trace"][0]["ts"]
# Should be parseable as ISO 8601
from datetime import datetime
datetime.fromisoformat(ts) # raises if invalid
def test_multiple_phases_ordered(self):
rec = MailDebugRecorder()
for phase in ["connect", "auth", "select", "search", "fetch_msg", "logout"]:
rec.record(phase, f"{phase} done")
details = rec.as_details()
phases = [e["phase"] for e in details["trace"]]
assert phases == ["connect", "auth", "select", "search", "fetch_msg", "logout"]
+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
+336 -1
View File
@@ -2,9 +2,22 @@
Unit tests for Gmail service module.
"""
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import MagicMock
from googleapiclient.errors import HttpError
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
from app.utils.gmail_labels import (
DEFAULT_IMPORT_LABEL_TEMPLATES,
SOURCE_EMAIL_LABEL_TEMPLATE,
build_gmail_credential_scopes,
extract_granted_scopes,
extract_import_label_templates,
render_import_labels,
)
class TestGmailService:
@@ -153,3 +166,325 @@ class TestGmailService:
email = await service.get_email_address()
assert email is None
def test_gmail_label_metadata_helpers(self):
"""Test Gmail metadata extraction remains backward compatible."""
scopes = build_gmail_credential_scopes(
["scope-a", "scope-b"],
[SOURCE_EMAIL_LABEL_TEMPLATE, "Imported", " imported "],
)
assert extract_granted_scopes(scopes) == ["scope-a", "scope-b"]
assert extract_import_label_templates(scopes) == [
SOURCE_EMAIL_LABEL_TEMPLATE,
"Imported",
]
assert (
extract_import_label_templates(["legacy-scope"])
== DEFAULT_IMPORT_LABEL_TEMPLATES
)
def test_render_import_labels_uses_source_email_template(self):
"""Test that source email templates render to the source mailbox address."""
rendered = render_import_labels(
[SOURCE_EMAIL_LABEL_TEMPLATE, "Imported", ""],
"source@example.com",
)
assert rendered == ["source@example.com", "Imported"]
@pytest.mark.asyncio
async def test_build_import_label_ids_creates_configured_labels(self):
"""Test that configured import labels are created and added alongside INBOX."""
service = GmailService(access_token="test-access-token")
service.get_or_create_label = AsyncMock(
side_effect=["Label-source", "Label-imported"]
) # type: ignore[method-assign]
label_ids = await service.build_import_label_ids(
import_label_templates=[SOURCE_EMAIL_LABEL_TEMPLATE, "imported"],
source_email="source@example.com",
)
assert label_ids == ["INBOX", "Label-source", "Label-imported"]
# ------------------------------------------------------------------
# Helper for HttpError construction
# ------------------------------------------------------------------
@staticmethod
def _make_http_error(
status_code: int = 401, reason: str = "Unauthorized"
) -> HttpError:
resp = MagicMock()
resp.status = status_code
resp.reason = reason
content = json.dumps({"error": {"message": reason}}).encode()
return HttpError(resp, content, uri="https://gmail.googleapis.com/test")
# ------------------------------------------------------------------
# inject_email HttpError branch
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_inject_email_http_error(self):
"""HttpError in inject_email is caught and re-raised as GmailInjectionError."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().messages().insert().execute.side_effect = (
self._make_http_error(403, "Forbidden")
)
service._service = mock_api
with pytest.raises(GmailInjectionError, match="Gmail API error"):
await service.inject_email(
raw_email=b"From: a@b.com\r\nSubject: X\r\n\r\nBody",
)
# ------------------------------------------------------------------
# get_or_create_label existing label found
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_label_existing(self):
"""Returns ID of an existing label matched case-insensitively."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().labels().list().execute.return_value = {
"labels": [
{"id": "Label_1", "name": "imported"},
{"id": "INBOX", "name": "INBOX"},
]
}
service._service = mock_api
label_id = await service.get_or_create_label("Imported")
assert label_id == "Label_1"
# ------------------------------------------------------------------
# get_or_create_label label not found, creates new one
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_label_creates_new(self):
"""Creates a new label when no existing label matches."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().labels().list().execute.return_value = {
"labels": [{"id": "INBOX", "name": "INBOX"}]
}
mock_api.users().labels().create().execute.return_value = {
"id": "Label_new",
"name": "new-label",
}
service._service = mock_api
label_id = await service.get_or_create_label("new-label")
assert label_id == "Label_new"
# ------------------------------------------------------------------
# get_or_create_label HttpError handling
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_label_http_error(self):
"""HttpError during label list raises GmailInjectionError."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().labels().list().execute.side_effect = self._make_http_error(
500, "Internal Server Error"
)
service._service = mock_api
with pytest.raises(GmailInjectionError, match="Gmail API error"):
await service.get_or_create_label("test")
# ------------------------------------------------------------------
# get_or_create_label generic Exception handling
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_label_generic_exception(self):
"""Generic exception during label management raises GmailInjectionError."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().labels().list().execute.side_effect = RuntimeError("boom")
service._service = mock_api
with pytest.raises(GmailInjectionError, match="Failed to get/create"):
await service.get_or_create_label("oops")
# ------------------------------------------------------------------
# inject_debug_email full flow
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_inject_debug_email(self):
"""inject_debug_email creates labels and injects a test email."""
service = GmailService(access_token="test-access-token")
service.build_import_label_ids = AsyncMock( # type: ignore[method-assign]
return_value=["INBOX", "Label_imported"]
)
service.get_or_create_label = AsyncMock( # type: ignore[method-assign]
return_value="Label_test"
)
service.inject_email = AsyncMock( # type: ignore[method-assign]
return_value={
"message_id": "msg1",
"thread_id": "t1",
"label_ids": ["INBOX", "Label_imported", "Label_test"],
}
)
result = await service.inject_debug_email("user@gmail.com")
assert result["message_id"] == "msg1"
assert "Label_test" in result["label_ids"]
service.build_import_label_ids.assert_awaited_once()
service.get_or_create_label.assert_awaited_once_with("test")
service.inject_email.assert_awaited_once()
# The test label should have been appended to label_ids
call_kwargs = service.inject_email.call_args
assert "Label_test" in call_kwargs.kwargs["label_ids"]
# ------------------------------------------------------------------
# inject_debug_email test label already present in import labels
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_inject_debug_email_test_label_already_present(self):
"""inject_debug_email does not duplicate the test label."""
service = GmailService(access_token="test-access-token")
service.build_import_label_ids = AsyncMock( # type: ignore[method-assign]
return_value=["INBOX", "Label_test"]
)
service.get_or_create_label = AsyncMock( # type: ignore[method-assign]
return_value="Label_test"
)
service.inject_email = AsyncMock( # type: ignore[method-assign]
return_value={
"message_id": "msg2",
"thread_id": "t2",
"label_ids": ["INBOX", "Label_test"],
}
)
result = await service.inject_debug_email("user@gmail.com")
assert result["message_id"] == "msg2"
call_kwargs = service.inject_email.call_args
# Label_test should appear only once
assert call_kwargs.kwargs["label_ids"].count("Label_test") == 1
# ------------------------------------------------------------------
# get_refreshed_token token was NOT refreshed
# ------------------------------------------------------------------
def test_get_refreshed_token_no_change(self):
"""Returns None when the token has not changed."""
service = GmailService(access_token="original-token")
assert service.get_refreshed_token() is None
# ------------------------------------------------------------------
# get_refreshed_token token was refreshed
# ------------------------------------------------------------------
def test_get_refreshed_token_changed(self):
"""Returns new token info when the token was refreshed."""
service = GmailService(access_token="original-token")
# Simulate an automatic token refresh by mutating credentials
new_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
service.credentials.token = "new-refreshed-token"
service.credentials.expiry = new_expiry
result = service.get_refreshed_token()
assert result is not None
assert result["access_token"] == "new-refreshed-token"
assert result["expiry"] == new_expiry
# ------------------------------------------------------------------
# service property lazy init builds the service
# ------------------------------------------------------------------
def test_service_property_builds_service(self):
"""Accessing .service triggers googleapiclient.discovery.build."""
with patch("app.services.gmail_service.build") as mock_build:
mock_build.return_value = MagicMock()
service = GmailService(access_token="test-access-token")
assert service._service is None
api = service.service # trigger lazy init
mock_build.assert_called_once_with(
"gmail", "v1", credentials=service.credentials, cache_discovery=False
)
assert api is mock_build.return_value
# Second access should NOT call build again
_ = service.service
mock_build.assert_called_once()
# ------------------------------------------------------------------
# _tz_aware_expiry timezone normalisation helper
# ------------------------------------------------------------------
def test_tz_aware_expiry_naive_becomes_utc(self):
"""A naive datetime is made tz-aware (UTC)."""
naive = datetime(2026, 6, 1, 12, 0, 0) # no tzinfo
result = GmailService._tz_aware_expiry(naive)
assert result is not None
assert result.tzinfo is not None
assert result.utcoffset().total_seconds() == 0
assert result.replace(tzinfo=None) == naive
def test_tz_aware_expiry_aware_unchanged(self):
"""An already tz-aware datetime is returned as-is."""
aware = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
result = GmailService._tz_aware_expiry(aware)
assert result is aware
def test_tz_aware_expiry_none_unchanged(self):
"""None is returned unchanged."""
assert GmailService._tz_aware_expiry(None) is None
# ------------------------------------------------------------------
# get_refreshed_token naive expiry is normalised to UTC
# ------------------------------------------------------------------
def test_get_refreshed_token_naive_expiry_becomes_utc(self):
"""get_refreshed_token converts a naive expiry from google-auth to UTC-aware."""
service = GmailService(access_token="original-token")
# google-auth sets credentials.expiry as naive UTC
naive_expiry = datetime(2099, 1, 1, 0, 0, 0) # no tzinfo
service.credentials.token = "new-refreshed-token"
service.credentials.expiry = naive_expiry
result = service.get_refreshed_token()
assert result is not None
expiry = result["expiry"]
assert expiry is not None
assert expiry.tzinfo is not None, "expiry must be tz-aware for DB storage"
assert expiry.utcoffset().total_seconds() == 0
assert expiry.replace(tzinfo=None) == naive_expiry
# ------------------------------------------------------------------
# proactive_refresh naive expiry is normalised to UTC
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_proactive_refresh_naive_expiry_becomes_utc(self):
"""proactive_refresh converts a naive expiry from google-auth to UTC-aware."""
service = GmailService(
access_token="old-token",
refresh_token="refresh-token",
)
naive_expiry = datetime(2099, 6, 1, 0, 0, 0) # no tzinfo
def _fake_refresh(_request):
service.credentials.token = "new-token"
service.credentials.expiry = naive_expiry
with patch.object(service.credentials, "refresh", side_effect=_fake_refresh):
result = await service.proactive_refresh()
assert result["access_token"] == "new-token"
expiry = result["expiry"]
assert expiry is not None
assert expiry.tzinfo is not None, "expiry must be tz-aware for DB storage"
assert expiry.utcoffset().total_seconds() == 0
assert expiry.replace(tzinfo=None) == naive_expiry
+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
@@ -0,0 +1,886 @@
"""
Unit tests for the IMAP fetch logic in MailProcessor.
These tests verify that _fetch_imap_emails:
- Uses a plain SEARCH UNSEEN (not uid("search")) to get sequence numbers
- Resolves sequence numbers to UIDs via a lightweight FETCH (UID)
- Uses UID FETCH / UID STORE for all subsequent operations
- Batches stale-UID re-marking into a single STORE command
- Does NOT issue a per-message STORE for Seen (RFC822 sets it implicitly)
- Batches Deleted STORE into a single command when delete_after_forward=True
- Uses RFC 3501 parenthesised flag syntax, e.g. +FLAGS (\\Seen)
- Handles an empty UNSEEN result without error
- Handles individual message fetch failures gracefully
- Always attempts logout in the finally block
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, call, patch
from app.services.mail_processor import MailProcessor, MailFetchError
# ---------------------------------------------------------------------------
# Helpers to build lightweight fakes
# ---------------------------------------------------------------------------
def _make_account(
protocol="imap_ssl",
host="imap.example.com",
port=993,
username="user@example.com",
delete_after_forward=False,
account_id=1,
):
"""Return a minimal MailAccount-like mock."""
from app.models.database_models import MailProtocol
account = MagicMock()
account.id = account_id
account.host = host
account.port = port
account.username = username
account.delete_after_forward = delete_after_forward
account.protocol = (
MailProtocol.IMAP_SSL if protocol == "imap_ssl" else MailProtocol.IMAP
)
return account
def _make_imap_response(result="OK", lines=None):
"""Return an object that looks like an aioimaplib ImapResponse."""
resp = MagicMock()
resp.result = result
resp.lines = lines if lines is not None else [b""]
return resp
def _make_fetch_response(uid_str: str, email_bytes: bytes):
"""
Simulate the lines that aioimaplib returns for a UID FETCH (RFC822) call.
The typical structure is:
[b'<seq> (UID <uid> RFC822 {<size>}', <email_data>, b')']
"""
header = f"1 (UID {uid_str} RFC822 {{12345}}".encode()
return _make_imap_response(result="OK", lines=[header, email_bytes, b")"])
def _make_uid_list_response(seq_uid_pairs):
"""
Simulate the response from fetch(seq_string, "(UID)").
seq_uid_pairs is a list of (seq_num_str, uid_str) tuples. Each entry
produces a line like b'1 (UID 42)' which the production code parses with
a regex.
"""
lines = [f"{seq} (UID {uid})".encode() for seq, uid in seq_uid_pairs]
return _make_imap_response(result="OK", lines=lines)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestFetchImapEmailsUidCommands:
"""Verify UID-based command usage in _fetch_imap_emails."""
@pytest.fixture
def processor(self):
from app.services.mail_processor import MailProcessor
account = _make_account()
return MailProcessor(account=account, decrypted_password="secret")
@pytest.fixture
def mock_imap(self):
"""A mock aioimaplib client whose async methods are AsyncMock."""
client = MagicMock()
client.wait_hello_from_server = AsyncMock()
client.login = AsyncMock()
client.select = AsyncMock()
client.search = AsyncMock()
client.fetch = AsyncMock()
client.uid = AsyncMock()
client.logout = AsyncMock()
return client
async def test_uses_plain_search_not_uid_search(self, processor, mock_imap):
"""SEARCH must be issued as a plain SEARCH UNSEEN, not via uid()."""
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b""])
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
await processor._fetch_imap_emails(10, set())
# search("UNSEEN") must be called
mock_imap.search.assert_awaited_once_with("UNSEEN")
# uid("search", ...) must NOT be called
search_uid_calls = [
c for c in mock_imap.uid.call_args_list if c.args[0] == "search"
]
assert search_uid_calls == []
async def test_no_messages_returns_empty(self, processor, mock_imap):
"""Empty UNSEEN result should return empty lists without error."""
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b""])
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, uids = await processor._fetch_imap_emails(10, set())
assert emails == []
assert uids == []
async def test_fetches_new_message_via_uid_fetch(self, processor, mock_imap):
"""New messages should be fetched with UID FETCH, not plain FETCH."""
raw_email = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"42"])
mock_imap.fetch.return_value = _make_uid_list_response([("42", "42")])
def uid_side_effect(command, *args):
if command == "fetch":
return _make_fetch_response(args[0], raw_email)
return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
assert emails == [raw_email]
assert new_uids == ["42"]
# The fetch call should use UID FETCH
fetch_call = [c for c in mock_imap.uid.call_args_list if c.args[0] == "fetch"]
assert len(fetch_call) == 1
assert fetch_call[0] == call("fetch", "42", "(BODY.PEEK[])")
async def test_no_per_message_store_for_seen(self, processor, mock_imap):
"""RFC822 implicitly marks \\Seen; no extra STORE per message is needed."""
raw_email = b"From: a@b.com\r\n\r\nHello"
mock_imap.search.return_value = _make_imap_response(
result="OK", lines=[b"10 11 12"]
)
mock_imap.fetch.return_value = _make_uid_list_response(
[("10", "10"), ("11", "11"), ("12", "12")]
)
def uid_side_effect(command, *args):
if command == "fetch":
uid_str = args[0]
return _make_fetch_response(uid_str, raw_email)
return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
# No STORE command should have been called (delete_after_forward=False)
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
assert store_calls == [], "Expected no STORE commands for \\Seen"
assert len(emails) == 3
assert new_uids == ["10", "11", "12"]
async def test_stale_uids_batched_in_single_store(self, processor, mock_imap):
"""UIDs already in already_seen_uids must be re-marked in one batch STORE."""
# Two messages: one stale (already in DB), one new
raw_email = b"From: x@y.com\r\n\r\nNew mail"
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"5 6"])
mock_imap.fetch.return_value = _make_uid_list_response([("5", "5"), ("6", "6")])
def uid_side_effect(command, *args):
if command == "fetch":
return _make_fetch_response(args[0], raw_email)
return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(
10, already_seen_uids={"5"} # UID 5 is stale
)
# Only UID 6 is new
assert new_uids == ["6"]
assert len(emails) == 1
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
# Exactly one STORE for the stale UID with RFC 3501 parenthesised syntax
assert len(store_calls) == 1
assert store_calls[0] == call("store", "5", "+FLAGS", "(\\Seen)")
async def test_multiple_stale_uids_batched_together(self, processor, mock_imap):
"""Multiple stale UIDs should be sent as a comma-separated set."""
mock_imap.search.return_value = _make_imap_response(
result="OK", lines=[b"1 2 3 4"]
)
mock_imap.fetch.return_value = _make_uid_list_response(
[("1", "1"), ("2", "2"), ("3", "3"), ("4", "4")]
)
def uid_side_effect(command, *args):
if command == "fetch":
return _make_fetch_response(args[0], b"email data")
return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
await processor._fetch_imap_emails(10, already_seen_uids={"1", "2"})
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
assert len(store_calls) == 1
# The UID set string should contain both stale UIDs (order may vary)
uid_set_arg = store_calls[0].args[1]
parts = set(uid_set_arg.split(","))
assert parts == {"1", "2"}
# RFC 3501 parenthesised flag syntax
assert store_calls[0].args[3] == "(\\Seen)"
async def test_fetch_does_not_delete_after_forward(self):
"""_fetch_imap_emails must NOT issue \\Deleted STORE even when delete_after_forward=True.
Deletion is deferred to post_process_imap() so that only successfully
forwarded messages are removed from the source mailbox.
"""
from app.services.mail_processor import MailProcessor
account = _make_account(delete_after_forward=True)
processor = MailProcessor(account=account, decrypted_password="s")
mock_imap = MagicMock()
mock_imap.wait_hello_from_server = AsyncMock()
mock_imap.login = AsyncMock()
mock_imap.select = AsyncMock()
mock_imap.expunge = AsyncMock()
mock_imap.logout = AsyncMock()
mock_imap.search = AsyncMock(
return_value=_make_imap_response(result="OK", lines=[b"7 8"])
)
mock_imap.fetch = AsyncMock(
return_value=_make_uid_list_response([("7", "7"), ("8", "8")])
)
def uid_side_effect(command, *args):
if command == "fetch":
return _make_fetch_response(args[0], b"raw email")
return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
assert new_uids == ["7", "8"]
# No STORE for \\Deleted should be issued during fetch
delete_store_calls = [
c
for c in mock_imap.uid.call_args_list
if c.args[0] == "store" and "(\\Deleted)" in c.args
]
assert delete_store_calls == [], "fetch must not delete messages"
# expunge must NOT be called during fetch either
mock_imap.expunge.assert_not_awaited()
async def test_individual_fetch_failure_does_not_abort(self, processor, mock_imap):
"""A single message fetch error should be logged but not stop processing."""
raw_email = b"From: ok@example.com\r\n\r\nOK"
mock_imap.search.return_value = _make_imap_response(
result="OK", lines=[b"20 21"]
)
mock_imap.fetch.return_value = _make_uid_list_response(
[("20", "20"), ("21", "21")]
)
call_count = {"count": 0}
def uid_side_effect(command, *args):
if command == "fetch":
call_count["count"] += 1
if call_count["count"] == 1:
raise Exception("Connection dropped by server")
return _make_fetch_response(args[0], raw_email)
return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
# Second message should still be processed
assert len(emails) == 1
assert new_uids == ["21"]
async def test_logout_called_even_on_connection_error(self, processor, mock_imap):
"""logout() must be attempted even when the connection drops mid-session."""
mock_imap.search = AsyncMock(side_effect=Exception("BYE server gone"))
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
from app.services.mail_processor import MailFetchError
with pytest.raises(MailFetchError):
await processor._fetch_imap_emails(10, set())
mock_imap.logout.assert_awaited_once()
async def test_logout_failure_does_not_mask_error(self, processor, mock_imap):
"""If logout itself raises, the original MailFetchError should propagate."""
mock_imap.search = AsyncMock(side_effect=Exception("server gone"))
mock_imap.logout = AsyncMock(side_effect=Exception("logout also failed"))
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
from app.services.mail_processor import MailFetchError
with pytest.raises(MailFetchError):
await processor._fetch_imap_emails(10, set())
async def test_respects_max_count_limit(self, processor, mock_imap):
"""Only max_count messages should be processed."""
raw_email = b"From: a@b.com\r\n\r\nHi"
mock_imap.search.return_value = _make_imap_response(
result="OK", lines=[b"1 2 3 4 5"]
)
# After max_count=3, only seq 1,2,3 are resolved to UIDs
mock_imap.fetch.return_value = _make_uid_list_response(
[("1", "1"), ("2", "2"), ("3", "3")]
)
def uid_side_effect(command, *args):
if command == "fetch":
return _make_fetch_response(args[0], raw_email)
return _make_imap_response()
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(3, set())
assert len(emails) == 3
assert new_uids == ["1", "2", "3"]
# fetch for UIDs should have been called with the first 3 seq numbers
mock_imap.fetch.assert_awaited_once_with("1,2,3", "(UID)")
async def test_plain_imap_uses_imap4_not_ssl(self):
"""Non-SSL IMAP accounts must use IMAP4, not IMAP4_SSL."""
from app.services.mail_processor import MailProcessor
account = _make_account(protocol="imap")
processor = MailProcessor(account=account, decrypted_password="pw")
mock_imap = MagicMock()
mock_imap.wait_hello_from_server = AsyncMock()
mock_imap.login = AsyncMock()
mock_imap.select = AsyncMock()
mock_imap.logout = AsyncMock()
mock_imap.search = AsyncMock(
return_value=_make_imap_response(result="OK", lines=[b""])
)
mock_imap.uid = AsyncMock(
return_value=_make_imap_response(result="OK", lines=[b""])
)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4",
return_value=mock_imap,
) as mock_cls:
await processor._fetch_imap_emails(10, set())
mock_cls.assert_called_once()
async def test_whitespace_only_rfc822_body_is_skipped(self, processor, mock_imap):
"""A UID FETCH that returns only whitespace bytes must not be added to results.
T-Online (and potentially other servers) can return RFC822 responses
whose body is just CR LF or other whitespace. The extraction loop
must treat these as absent email data, not as a valid message.
"""
# Simulate a server that returns b"\r\n" instead of real email bytes
whitespace_response = _make_imap_response(
result="OK",
lines=[b"1 (UID 99 RFC822 {2}", b"\r\n", b")"],
)
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"99"])
mock_imap.fetch.return_value = _make_uid_list_response([("99", "99")])
mock_imap.uid = AsyncMock(
side_effect=lambda cmd, *args: (
whitespace_response if cmd == "fetch" else _make_imap_response()
)
)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
# Whitespace-only response must not produce a message
assert emails == []
assert new_uids == []
async def test_empty_bytes_rfc822_body_is_skipped(self, processor, mock_imap):
"""A UID FETCH that returns b'' as the body must not be added to results."""
empty_response = _make_imap_response(
result="OK",
lines=[b"1 (UID 77 RFC822 {0}", b"", b")"],
)
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"77"])
mock_imap.fetch.return_value = _make_uid_list_response([("77", "77")])
mock_imap.uid = AsyncMock(
side_effect=lambda cmd, *args: (
empty_response if cmd == "fetch" else _make_imap_response()
)
)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
assert emails == []
assert new_uids == []
async def test_bytearray_literal_data_is_accepted(self, processor, mock_imap):
"""aioimaplib stores IMAP literal data as bytearray, not bytes.
This is the root cause of emails appearing empty on IMAP servers such
as T-Online and GMX: the extraction loop previously skipped bytearray
objects because ``isinstance(bytearray(...), bytes)`` is False.
The fix accepts both bytes and bytearray and converts to bytes so the
rest of the pipeline receives plain bytes as expected.
"""
raw_email = b"From: user@t-online.de\r\nSubject: Real email\r\n\r\nBody"
# aioimaplib returns the RFC822 literal as bytearray in response.lines
bytearray_response = _make_imap_response(
result="OK",
lines=[
b"1 (UID 55 RFC822 {%d}" % len(raw_email),
bytearray(raw_email),
b")",
],
)
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"55"])
mock_imap.fetch.return_value = _make_uid_list_response([("55", "55")])
mock_imap.uid = AsyncMock(
side_effect=lambda cmd, *args: (
bytearray_response if cmd == "fetch" else _make_imap_response()
)
)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
# The email must be extracted and returned as plain bytes
assert new_uids == ["55"]
assert len(emails) == 1
assert emails[0] == raw_email
assert isinstance(emails[0], bytes)
async def test_bytearray_whitespace_literal_is_skipped(self, processor, mock_imap):
"""A bytearray literal that is whitespace-only must still be rejected."""
bytearray_ws_response = _make_imap_response(
result="OK",
lines=[b"1 (UID 56 RFC822 {2}", bytearray(b"\r\n"), b")"],
)
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"56"])
mock_imap.fetch.return_value = _make_uid_list_response([("56", "56")])
mock_imap.uid = AsyncMock(
side_effect=lambda cmd, *args: (
bytearray_ws_response if cmd == "fetch" else _make_imap_response()
)
)
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
assert emails == []
assert new_uids == []
# ---------------------------------------------------------------------------
# Additional edge-case tests for remaining branch coverage
# ---------------------------------------------------------------------------
class TestFetchImapEdgeCases:
"""Tests for edge cases in _fetch_imap_emails not covered above."""
async def test_search_ok_but_empty_split_returns_empty(self):
"""If SEARCH UNSEEN returns OK but lines[0].split() is empty, return []."""
account = _make_account()
processor = MailProcessor(account, "secret")
mock_imap = AsyncMock()
mock_imap.wait_hello_from_server = AsyncMock()
mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
# lines[0] is a non-empty string with only spaces => split() returns []
mock_imap.search = AsyncMock(
return_value=_make_imap_response("OK", lines=[b" "])
)
mock_imap.logout = AsyncMock()
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
assert emails == []
assert new_uids == []
async def test_stale_uid_store_failure_is_non_fatal(self):
"""If the UID STORE to re-mark stale UIDs fails, processing continues."""
account = _make_account()
processor = MailProcessor(account, "secret")
mock_imap = AsyncMock()
mock_imap.wait_hello_from_server = AsyncMock()
mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
mock_imap.search = AsyncMock(
return_value=_make_imap_response("OK", lines=[b"1"])
)
mock_imap.fetch = AsyncMock(
return_value=_make_uid_list_response([("1", "100")])
)
async def uid_side_effect(cmd, *args):
if cmd == "store":
raise Exception("store failed")
return _make_imap_response("OK")
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
mock_imap.logout = AsyncMock()
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, {"100"})
assert emails == []
assert new_uids == []
async def test_delete_failure_is_non_fatal(self):
"""If batch UID STORE \\Deleted fails, emails are still returned."""
email_bytes = b"From: a@b.com\r\nSubject: hi\r\n\r\nbody"
account = _make_account(delete_after_forward=True)
processor = MailProcessor(account, "secret")
mock_imap = AsyncMock()
mock_imap.wait_hello_from_server = AsyncMock()
mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
mock_imap.search = AsyncMock(
return_value=_make_imap_response("OK", lines=[b"1"])
)
mock_imap.fetch = AsyncMock(return_value=_make_uid_list_response([("1", "42")]))
async def uid_side_effect(cmd, *args):
if cmd == "fetch":
return _make_fetch_response("42", email_bytes)
if cmd == "store":
raise Exception("delete failed")
return _make_imap_response("OK")
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
mock_imap.logout = AsyncMock()
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
assert len(emails) == 1
assert new_uids == ["42"]
async def test_mail_fetch_error_is_re_raised_directly(self):
"""A MailFetchError raised inside the try block is re-raised, not wrapped."""
account = _make_account()
processor = MailProcessor(account, "secret")
mock_imap = AsyncMock()
mock_imap.wait_hello_from_server = AsyncMock()
mock_imap.login = AsyncMock(side_effect=MailFetchError("inner error"))
mock_imap.logout = AsyncMock()
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
with pytest.raises(MailFetchError, match="inner error"):
await processor._fetch_imap_emails(10, set())
async def test_response_lines_with_star_prefix_are_skipped(self):
"""Response lines starting with b'*' are filtered out."""
email_bytes = b"From: a@b.com\r\nSubject: test\r\n\r\nbody"
account = _make_account()
processor = MailProcessor(account, "secret")
mock_imap = AsyncMock()
mock_imap.wait_hello_from_server = AsyncMock()
mock_imap.login = AsyncMock(return_value=_make_imap_response("OK"))
mock_imap.select = AsyncMock(return_value=_make_imap_response("OK"))
mock_imap.search = AsyncMock(
return_value=_make_imap_response("OK", lines=[b"1"])
)
mock_imap.fetch = AsyncMock(return_value=_make_uid_list_response([("1", "99")]))
fetch_resp = _make_imap_response(
"OK",
lines=[
b"1 (UID 99 RFC822 {100}", # header containing RFC822
"some string line", # non-bytes/bytearray => skipped
b"* extra info", # starts with * => skipped
email_bytes, # actual data
b")", # closing paren => skipped
],
)
async def uid_side_effect(cmd, *args):
if cmd == "fetch":
return fetch_resp
return _make_imap_response("OK")
mock_imap.uid = AsyncMock(side_effect=uid_side_effect)
mock_imap.logout = AsyncMock()
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
emails, new_uids = await processor._fetch_imap_emails(10, set())
assert len(emails) == 1
assert emails[0] == email_bytes
assert new_uids == ["99"]
# ---------------------------------------------------------------------------
# post_process_imap tests
# ---------------------------------------------------------------------------
class TestPostProcessImap:
"""Tests for the post_process_imap() method."""
def _make_processor(self, delete_after_forward=False, protocol="imap_ssl"):
from app.services.mail_processor import MailProcessor
account = _make_account(
delete_after_forward=delete_after_forward, protocol=protocol
)
return MailProcessor(account=account, decrypted_password="secret")
def _mock_imap_client(self):
client = MagicMock()
client.wait_hello_from_server = AsyncMock()
client.login = AsyncMock()
client.select = AsyncMock()
client.uid = AsyncMock(return_value=_make_imap_response())
client.expunge = AsyncMock()
client.logout = AsyncMock()
return client
async def test_marks_seen_without_delete(self):
"""post_process_imap marks UIDs \\Seen when delete_after_forward=False."""
processor = self._make_processor(delete_after_forward=False)
mock_imap = self._mock_imap_client()
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
await processor.post_process_imap(["10", "11"])
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
assert len(store_calls) == 1
assert set(store_calls[0].args[1].split(",")) == {"10", "11"}
assert store_calls[0].args[3] == "(\\Seen)"
mock_imap.expunge.assert_not_awaited()
async def test_marks_seen_and_deleted_with_delete(self):
"""post_process_imap marks \\Seen + \\Deleted and expunges when configured."""
processor = self._make_processor(delete_after_forward=True)
mock_imap = self._mock_imap_client()
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
await processor.post_process_imap(["5", "6"])
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
assert len(store_calls) == 2
flags_used = {c.args[3] for c in store_calls}
assert "(\\Seen)" in flags_used
assert "(\\Deleted)" in flags_used
mock_imap.expunge.assert_awaited_once()
async def test_noop_on_empty_uid_list(self):
"""post_process_imap does nothing when the UID list is empty."""
processor = self._make_processor()
mock_imap = self._mock_imap_client()
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
await processor.post_process_imap([])
mock_imap.login.assert_not_awaited()
async def test_exception_is_swallowed_and_logged(self):
"""post_process_imap catches exceptions and does not propagate them."""
processor = self._make_processor()
mock_imap = self._mock_imap_client()
mock_imap.login = AsyncMock(side_effect=OSError("connection refused"))
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
await processor.post_process_imap(["1"]) # must not raise
mock_imap.logout.assert_awaited_once()
async def test_uses_imap4_for_non_ssl(self):
"""Plain IMAP accounts must use IMAP4, not IMAP4_SSL."""
from app.services.mail_processor import MailProcessor
account = _make_account(protocol="imap")
processor = MailProcessor(account=account, decrypted_password="pw")
mock_imap = MagicMock()
mock_imap.wait_hello_from_server = AsyncMock()
mock_imap.login = AsyncMock()
mock_imap.select = AsyncMock()
mock_imap.uid = AsyncMock(return_value=_make_imap_response())
mock_imap.logout = AsyncMock()
with (
patch(
"app.services.mail_processor.aioimaplib.IMAP4",
return_value=mock_imap,
) as mock_cls,
patch("app.services.mail_processor.aioimaplib.IMAP4_SSL") as mock_ssl_cls,
):
await processor.post_process_imap(["1"])
mock_cls.assert_called_once()
mock_ssl_cls.assert_not_called()
async def test_logout_called_even_on_error(self):
"""logout() is called in the finally block even when an exception occurs."""
processor = self._make_processor()
mock_imap = self._mock_imap_client()
mock_imap.uid = AsyncMock(side_effect=Exception("store failed"))
with patch(
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
return_value=mock_imap,
):
await processor.post_process_imap(["99"])
mock_imap.logout.assert_awaited_once()
# ---------------------------------------------------------------------------
# post_process_messages routing test
# ---------------------------------------------------------------------------
class TestPostProcessMessages:
"""post_process_messages() routes to the correct protocol handler."""
async def test_routes_to_imap_for_imap_ssl(self):
from app.services.mail_processor import MailProcessor
account = _make_account(protocol="imap_ssl")
processor = MailProcessor(account=account, decrypted_password="pw")
with (
patch.object(
processor, "post_process_imap", new_callable=AsyncMock
) as mock_imap,
patch.object(
processor, "post_process_pop3", new_callable=AsyncMock
) as mock_pop3,
):
await processor.post_process_messages(["1", "2"])
mock_imap.assert_awaited_once_with(["1", "2"])
mock_pop3.assert_not_awaited()
async def test_routes_to_pop3_for_pop3_ssl(self):
from app.services.mail_processor import MailProcessor
account = _make_account(protocol="imap_ssl")
account.protocol = __import__(
"app.models.database_models", fromlist=["MailProtocol"]
).MailProtocol.POP3_SSL
processor = MailProcessor(account=account, decrypted_password="pw")
with (
patch.object(
processor, "post_process_pop3", new_callable=AsyncMock
) as mock_pop3,
patch.object(
processor, "post_process_imap", new_callable=AsyncMock
) as mock_imap,
):
await processor.post_process_messages(["a"])
mock_pop3.assert_awaited_once_with(["a"])
mock_imap.assert_not_awaited()
@@ -0,0 +1,940 @@
"""
Unit tests for POP3 fetch, connection testing, and email forwarding in MailProcessor.
These tests cover the methods not exercised by test_mail_processor_imap.py:
- test_connection() routing to POP3 / IMAP helpers
- _test_pop3_connection() for POP3_SSL and plain POP3
- _test_imap_connection() for IMAP_SSL and plain IMAP
- fetch_emails() delegation to POP3 / IMAP
- _fetch_pop3_emails() end-to-end: UIDL, skip-seen, max_count, delete, errors
- forward_email() via STARTTLS and SSL, multipart / plain, error paths
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from app.models.database_models import MailProtocol
from app.services.mail_processor import (
MailProcessor,
MailFetchError,
MailForwardError,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_account(
protocol="pop3_ssl",
host="pop.example.com",
port=995,
username="user@example.com",
delete_after_forward=False,
account_id=1,
max_emails_per_check=50,
):
"""Return a minimal MailAccount-like mock."""
proto_map = {
"pop3_ssl": MailProtocol.POP3_SSL,
"pop3": MailProtocol.POP3,
"imap_ssl": MailProtocol.IMAP_SSL,
"imap": MailProtocol.IMAP,
}
account = MagicMock()
account.id = account_id
account.host = host
account.port = port
account.username = username
account.delete_after_forward = delete_after_forward
account.protocol = proto_map[protocol]
account.max_emails_per_check = max_emails_per_check
return account
# ---------------------------------------------------------------------------
# test_connection routing
# ---------------------------------------------------------------------------
class TestTestConnection:
"""test_connection() should delegate to POP3 or IMAP helpers."""
async def test_routes_to_pop3_for_pop3_ssl(self):
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
with patch.object(
proc, "_test_pop3_connection", new_callable=AsyncMock
) as mock:
mock.return_value = (True, "ok")
result = await proc.test_connection()
mock.assert_awaited_once()
assert result == (True, "ok")
async def test_routes_to_pop3_for_pop3(self):
account = _make_account(protocol="pop3")
proc = MailProcessor(account, "secret")
with patch.object(
proc, "_test_pop3_connection", new_callable=AsyncMock
) as mock:
mock.return_value = (True, "ok")
result = await proc.test_connection()
mock.assert_awaited_once()
assert result == (True, "ok")
async def test_routes_to_imap_for_imap_ssl(self):
account = _make_account(protocol="imap_ssl")
proc = MailProcessor(account, "secret")
with patch.object(
proc, "_test_imap_connection", new_callable=AsyncMock
) as mock:
mock.return_value = (True, "connected")
result = await proc.test_connection()
mock.assert_awaited_once()
assert result == (True, "connected")
async def test_routes_to_imap_for_imap(self):
account = _make_account(protocol="imap")
proc = MailProcessor(account, "secret")
with patch.object(
proc, "_test_imap_connection", new_callable=AsyncMock
) as mock:
mock.return_value = (True, "connected")
result = await proc.test_connection()
mock.assert_awaited_once()
assert result == (True, "connected")
async def test_returns_false_on_unexpected_exception(self):
account = _make_account(protocol="imap_ssl")
proc = MailProcessor(account, "secret")
with patch.object(
proc, "_test_imap_connection", new_callable=AsyncMock
) as mock:
mock.side_effect = RuntimeError("boom")
success, msg = await proc.test_connection()
assert success is False
assert "boom" in msg
# ---------------------------------------------------------------------------
# _test_pop3_connection
# ---------------------------------------------------------------------------
class TestTestPop3Connection:
"""Unit tests for _test_pop3_connection()."""
@patch("app.services.mail_processor.poplib")
async def test_pop3_ssl_success(self, mock_poplib):
"""POP3_SSL: successful connection reports message count."""
mock_conn = MagicMock()
mock_conn.stat.return_value = (42, 123456)
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
success, msg = await proc._test_pop3_connection()
assert success is True
assert "42 messages" in msg
mock_conn.user.assert_called_once_with("user@example.com")
mock_conn.pass_.assert_called_once_with("secret")
mock_conn.quit.assert_called_once()
@patch("app.services.mail_processor.poplib")
async def test_pop3_plain_success(self, mock_poplib):
"""Plain POP3: uses POP3 (not POP3_SSL)."""
mock_conn = MagicMock()
mock_conn.stat.return_value = (10, 5000)
mock_poplib.POP3.return_value = mock_conn
account = _make_account(protocol="pop3", port=110)
proc = MailProcessor(account, "secret")
success, msg = await proc._test_pop3_connection()
assert success is True
assert "10 messages" in msg
mock_poplib.POP3.assert_called_once()
mock_poplib.POP3_SSL.assert_not_called()
@patch("app.services.mail_processor.poplib")
async def test_pop3_auth_error(self, mock_poplib):
"""Authentication failure returns False with auth message."""
import poplib as real_poplib
mock_conn = MagicMock()
mock_conn.user.side_effect = real_poplib.error_proto("authentication failed")
mock_poplib.POP3_SSL.return_value = mock_conn
mock_poplib.error_proto = real_poplib.error_proto
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
success, msg = await proc._test_pop3_connection()
assert success is False
assert "Authentication rejected" in msg or "authentication" in msg.lower()
@patch("app.services.mail_processor.poplib")
async def test_pop3_protocol_error(self, mock_poplib):
"""Non-auth protocol error returns False with protocol error message."""
import poplib as real_poplib
mock_conn = MagicMock()
mock_conn.user.side_effect = real_poplib.error_proto("some protocol error")
mock_poplib.POP3_SSL.return_value = mock_conn
mock_poplib.error_proto = real_poplib.error_proto
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
success, msg = await proc._test_pop3_connection()
assert success is False
assert "POP3" in msg and "error" in msg.lower()
@patch("app.services.mail_processor.poplib")
async def test_pop3_generic_exception(self, mock_poplib):
"""Generic exception returns False with connection-failed message."""
import poplib as real_poplib
mock_poplib.error_proto = real_poplib.error_proto
mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
success, msg = await proc._test_pop3_connection()
assert success is False
assert msg # must be non-empty
# ---------------------------------------------------------------------------
# _test_imap_connection
# ---------------------------------------------------------------------------
class TestTestImapConnection:
"""Unit tests for _test_imap_connection()."""
@patch("app.services.mail_processor.aioimaplib")
async def test_imap_ssl_success(self, mock_aioimaplib):
"""IMAP_SSL: successful connection reports message count."""
mock_client = AsyncMock()
mock_aioimaplib.IMAP4_SSL.return_value = mock_client
# login response
login_resp = MagicMock()
login_resp.result = "OK"
mock_client.login.return_value = login_resp
# search response with 3 messages
search_resp = MagicMock()
search_resp.lines = [b"1 2 3"]
mock_client.search.return_value = search_resp
account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
proc = MailProcessor(account, "secret")
success, msg = await proc._test_imap_connection()
assert success is True
assert "3 messages" in msg
mock_client.wait_hello_from_server.assert_awaited_once()
mock_client.login.assert_awaited_once()
mock_client.select.assert_awaited_once_with("INBOX")
mock_client.logout.assert_awaited_once()
@patch("app.services.mail_processor.aioimaplib")
async def test_imap_plain_success(self, mock_aioimaplib):
"""Plain IMAP: uses IMAP4, not IMAP4_SSL."""
mock_client = AsyncMock()
mock_aioimaplib.IMAP4.return_value = mock_client
login_resp = MagicMock()
login_resp.result = "OK"
mock_client.login.return_value = login_resp
search_resp = MagicMock()
search_resp.lines = [b"1"]
mock_client.search.return_value = search_resp
account = _make_account(protocol="imap", host="imap.example.com", port=143)
proc = MailProcessor(account, "secret")
success, msg = await proc._test_imap_connection()
assert success is True
mock_aioimaplib.IMAP4.assert_called_once()
mock_aioimaplib.IMAP4_SSL.assert_not_called()
@patch("app.services.mail_processor.aioimaplib")
async def test_imap_auth_failure(self, mock_aioimaplib):
"""Authentication failure returns False with auth failure message."""
mock_client = AsyncMock()
mock_aioimaplib.IMAP4_SSL.return_value = mock_client
login_resp = MagicMock()
login_resp.result = "NO"
login_resp.lines = ["Invalid credentials"]
mock_client.login.return_value = login_resp
account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
proc = MailProcessor(account, "secret")
success, msg = await proc._test_imap_connection()
assert success is False
assert "Authentication" in msg or "rejected" in msg
@patch("app.services.mail_processor.aioimaplib")
async def test_imap_generic_exception(self, mock_aioimaplib):
"""Generic exception returns False with IMAP-connection-failed message."""
mock_aioimaplib.IMAP4_SSL.side_effect = OSError("network unreachable")
account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993)
proc = MailProcessor(account, "secret")
success, msg = await proc._test_imap_connection()
assert success is False
assert msg # must be non-empty
# ---------------------------------------------------------------------------
# fetch_emails routing and defaults
# ---------------------------------------------------------------------------
class TestFetchEmails:
"""fetch_emails() should route and fill defaults correctly."""
async def test_delegates_to_pop3_for_pop3_ssl(self):
account = _make_account(protocol="pop3_ssl", max_emails_per_check=25)
proc = MailProcessor(account, "secret")
with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
mock.return_value = ([b"email"], ["uid1"])
result = await proc.fetch_emails()
# Should use max_emails_per_check as default
mock.assert_awaited_once_with(25, set())
assert result == ([b"email"], ["uid1"])
async def test_delegates_to_imap_for_imap_ssl(self):
account = _make_account(protocol="imap_ssl")
proc = MailProcessor(account, "secret")
with patch.object(proc, "_fetch_imap_emails", new_callable=AsyncMock) as mock:
mock.return_value = ([], [])
await proc.fetch_emails(max_count=10, already_seen_uids={"u1"})
mock.assert_awaited_once_with(10, {"u1"})
async def test_uses_max_count_when_provided(self):
account = _make_account(protocol="pop3", max_emails_per_check=100)
proc = MailProcessor(account, "secret")
with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
mock.return_value = ([], [])
await proc.fetch_emails(max_count=5)
mock.assert_awaited_once_with(5, set())
async def test_uses_max_emails_per_check_when_no_max_count(self):
account = _make_account(protocol="pop3_ssl", max_emails_per_check=77)
proc = MailProcessor(account, "secret")
with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock:
mock.return_value = ([], [])
await proc.fetch_emails()
mock.assert_awaited_once_with(77, set())
# ---------------------------------------------------------------------------
# _fetch_pop3_emails
# ---------------------------------------------------------------------------
class TestFetchPop3Emails:
"""Unit tests for _fetch_pop3_emails()."""
def _make_pop3_mock(self, uid_entries, retr_data=None, retr_errors=None):
"""Build a mock POP3 connection.
Args:
uid_entries: list of (msg_num, uid_string) pairs
retr_data: dict mapping msg_num -> bytes to return from retr()
retr_errors: dict mapping msg_num -> exception for retr()
"""
mock_conn = MagicMock()
uidl_lines = [f"{n} {uid}".encode() for n, uid in uid_entries]
mock_conn.uidl.return_value = (b"+OK", uidl_lines, 0)
retr_data = retr_data or {}
retr_errors = retr_errors or {}
def retr_side_effect(msg_num):
if msg_num in retr_errors:
raise retr_errors[msg_num]
data = retr_data.get(msg_num, b"From: test\r\nSubject: hi\r\n\r\nbody")
return (b"+OK", data.split(b"\r\n"), len(data))
mock_conn.retr.side_effect = retr_side_effect
return mock_conn
@patch("app.services.mail_processor.poplib")
async def test_fetch_pop3_ssl_basic(self, mock_poplib):
"""POP3_SSL: fetches messages and returns email data + UIDs."""
mock_conn = self._make_pop3_mock(
uid_entries=[(1, "abc"), (2, "def")],
retr_data={
1: b"From: a@b.com\r\nSubject: A\r\n\r\nBody A",
2: b"From: c@d.com\r\nSubject: B\r\n\r\nBody B",
},
)
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(10, set())
assert len(emails) == 2
assert uids == ["abc", "def"]
mock_conn.quit.assert_called_once()
@patch("app.services.mail_processor.poplib")
async def test_fetch_pop3_plain(self, mock_poplib):
"""Plain POP3: uses POP3 (not POP3_SSL)."""
mock_conn = self._make_pop3_mock(uid_entries=[(1, "uid1")])
mock_poplib.POP3.return_value = mock_conn
account = _make_account(protocol="pop3", port=110)
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(10, set())
assert len(emails) == 1
assert uids == ["uid1"]
mock_poplib.POP3.assert_called_once()
mock_poplib.POP3_SSL.assert_not_called()
@patch("app.services.mail_processor.poplib")
async def test_skips_already_seen_uids(self, mock_poplib):
"""Already-seen UIDs are skipped."""
mock_conn = self._make_pop3_mock(
uid_entries=[(1, "seen1"), (2, "new1"), (3, "seen2")]
)
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(10, {"seen1", "seen2"})
assert uids == ["new1"]
assert len(emails) == 1
# retr should only be called for msg 2
mock_conn.retr.assert_called_once_with(2)
@patch("app.services.mail_processor.poplib")
async def test_respects_max_count(self, mock_poplib):
"""Only max_count messages are fetched."""
mock_conn = self._make_pop3_mock(
uid_entries=[(1, "a"), (2, "b"), (3, "c"), (4, "d")]
)
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(2, set())
assert len(emails) == 2
assert len(uids) == 2
@patch("app.services.mail_processor.poplib")
async def test_no_delete_during_fetch(self, mock_poplib):
"""_fetch_pop3_emails must NOT call dele() regardless of delete_after_forward.
Deletion is deferred to post_process_pop3() so that only successfully
forwarded messages are removed from the source mailbox.
"""
mock_conn = self._make_pop3_mock(uid_entries=[(1, "a"), (2, "b")])
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
proc = MailProcessor(account, "secret")
await proc._fetch_pop3_emails(10, set())
mock_conn.dele.assert_not_called()
@patch("app.services.mail_processor.poplib")
async def test_no_delete_when_disabled(self, mock_poplib):
"""delete_after_forward=False: no dele() calls."""
mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")])
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl", delete_after_forward=False)
proc = MailProcessor(account, "secret")
await proc._fetch_pop3_emails(10, set())
mock_conn.dele.assert_not_called()
@patch("app.services.mail_processor.poplib")
async def test_individual_retr_error_does_not_abort(self, mock_poplib):
"""A single message retr() failure doesn't stop the entire fetch."""
mock_conn = self._make_pop3_mock(
uid_entries=[(1, "a"), (2, "b"), (3, "c")],
retr_errors={2: Exception("corrupt message")},
)
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(10, set())
# Messages 1 and 3 should still be fetched
assert len(emails) == 2
assert "a" in uids
assert "c" in uids
assert "b" not in uids
@patch("app.services.mail_processor.poplib")
async def test_fetch_does_not_call_dele(self, mock_poplib):
"""_fetch_pop3_emails never calls dele() — deletion is in post_process_pop3."""
mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")])
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(10, set())
assert len(emails) == 1
mock_conn.dele.assert_not_called()
@patch("app.services.mail_processor.poplib")
async def test_connection_failure_raises_mail_fetch_error(self, mock_poplib):
"""Connection failure raises MailFetchError with non-empty message."""
import poplib as real_poplib
mock_poplib.error_proto = real_poplib.error_proto
mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
with pytest.raises(MailFetchError) as exc_info:
await proc._fetch_pop3_emails(10, set())
# Message should be non-empty and describe the failure
assert str(exc_info.value)
@patch("app.services.mail_processor.poplib")
async def test_empty_mailbox(self, mock_poplib):
"""Empty mailbox returns empty lists."""
mock_conn = self._make_pop3_mock(uid_entries=[])
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(10, set())
assert emails == []
assert uids == []
@patch("app.services.mail_processor.poplib")
async def test_uidl_parsing_handles_extra_whitespace(self, mock_poplib):
"""UIDL entries with extra whitespace in UID are stripped."""
mock_conn = MagicMock()
mock_conn.uidl.return_value = (b"+OK", [b"1 uid_with_space "], 0)
mock_conn.retr.return_value = (
b"+OK",
[b"From: x", b"", b"body"],
10,
)
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(10, set())
assert uids == ["uid_with_space"]
@patch("app.services.mail_processor.poplib")
async def test_malformed_uidl_entry_is_skipped(self, mock_poplib):
"""UIDL entry without a space (malformed) is silently skipped."""
mock_conn = MagicMock()
# One malformed entry (no space), one valid entry
mock_conn.uidl.return_value = (
b"+OK",
[b"malformed_no_space", b"2 valid_uid"],
0,
)
mock_conn.retr.return_value = (b"+OK", [b"From: x", b"", b"body"], 10)
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
emails, uids = await proc._fetch_pop3_emails(10, set())
# Only the valid entry should be processed
assert uids == ["valid_uid"]
assert len(emails) == 1
# ---------------------------------------------------------------------------
# post_process_pop3 tests
# ---------------------------------------------------------------------------
class TestPostProcessPop3:
"""Unit tests for post_process_pop3()."""
@patch("app.services.mail_processor.poplib")
async def test_deletes_only_forwarded_uids(self, mock_poplib):
"""Only successfully forwarded UIDs are deleted from the source mailbox."""
mock_conn = MagicMock()
mock_conn.uidl.return_value = (b"+OK", [b"1 uid-a", b"2 uid-b", b"3 uid-c"], 0)
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
proc = MailProcessor(account, "secret")
await proc.post_process_pop3(["uid-a", "uid-c"])
# Only messages 1 and 3 should be deleted (uid-a and uid-c)
assert mock_conn.dele.call_count == 2
mock_conn.dele.assert_any_call(1)
mock_conn.dele.assert_any_call(3)
mock_conn.quit.assert_called_once()
@patch("app.services.mail_processor.poplib")
async def test_noop_when_delete_after_forward_false(self, mock_poplib):
"""post_process_pop3 does nothing when delete_after_forward=False."""
mock_conn = MagicMock()
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl", delete_after_forward=False)
proc = MailProcessor(account, "secret")
await proc.post_process_pop3(["uid-a"])
mock_poplib.POP3_SSL.assert_not_called()
@patch("app.services.mail_processor.poplib")
async def test_noop_on_empty_uid_list(self, mock_poplib):
"""post_process_pop3 does nothing when the UID list is empty."""
mock_conn = MagicMock()
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
proc = MailProcessor(account, "secret")
await proc.post_process_pop3([])
mock_poplib.POP3_SSL.assert_not_called()
@patch("app.services.mail_processor.poplib")
async def test_dele_error_does_not_abort(self, mock_poplib):
"""A dele() error is logged but post_process_pop3 does not raise."""
mock_conn = MagicMock()
mock_conn.uidl.return_value = (b"+OK", [b"1 uid-a"], 0)
mock_conn.dele.side_effect = Exception("delete failed")
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
proc = MailProcessor(account, "secret")
await proc.post_process_pop3(["uid-a"]) # must not raise
mock_conn.quit.assert_called_once()
@patch("app.services.mail_processor.poplib")
async def test_connection_error_is_swallowed(self, mock_poplib):
"""A connection failure is logged but does not propagate."""
mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
proc = MailProcessor(account, "secret")
await proc.post_process_pop3(["uid-a"]) # must not raise
class TestForwardEmail:
"""Unit tests for forward_email()."""
SMTP_CONFIG = {
"host": "smtp.example.com",
"port": 587,
"username": "sender@example.com",
"password": "smtp_pass",
"use_tls": True,
}
SMTP_CONFIG_SSL = {
"host": "smtp.example.com",
"port": 465,
"username": "sender@example.com",
"password": "smtp_pass",
"use_tls": False,
}
SIMPLE_EMAIL = (
b"From: original@sender.com\r\n"
b"Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n"
b"Subject: Test Subject\r\n"
b"\r\n"
b"Hello, this is the body."
)
MULTIPART_EMAIL = (
b"From: original@sender.com\r\n"
b"Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n"
b"Subject: Multipart Test\r\n"
b"MIME-Version: 1.0\r\n"
b'Content-Type: multipart/mixed; boundary="boundary123"\r\n'
b"\r\n"
b"--boundary123\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"\r\n"
b"Plain text body.\r\n"
b"--boundary123--\r\n"
)
@patch("app.services.mail_processor.smtplib")
async def test_forward_starttls(self, mock_smtplib):
"""STARTTLS path: SMTP + starttls() is used."""
mock_server = MagicMock()
mock_smtplib.SMTP.return_value = mock_server
result = await MailProcessor.forward_email(
self.SIMPLE_EMAIL, "MyAccount", "dest@example.com", self.SMTP_CONFIG
)
assert result is True
mock_smtplib.SMTP.assert_called_once_with("smtp.example.com", 587, timeout=30)
mock_server.starttls.assert_called_once()
mock_server.login.assert_called_once_with("sender@example.com", "smtp_pass")
mock_server.send_message.assert_called_once()
mock_server.quit.assert_called_once()
@patch("app.services.mail_processor.smtplib")
async def test_forward_ssl(self, mock_smtplib):
"""SSL path: SMTP_SSL is used when use_tls=False."""
mock_server = MagicMock()
mock_smtplib.SMTP_SSL.return_value = mock_server
result = await MailProcessor.forward_email(
self.SIMPLE_EMAIL, "MyAccount", "dest@example.com", self.SMTP_CONFIG_SSL
)
assert result is True
mock_smtplib.SMTP_SSL.assert_called_once_with(
"smtp.example.com", 465, timeout=30
)
mock_server.starttls.assert_not_called()
mock_server.login.assert_called_once()
mock_server.send_message.assert_called_once()
@patch("app.services.mail_processor.smtplib")
async def test_forward_preserves_subject(self, mock_smtplib):
"""Forwarded email subject includes source account name and original subject."""
mock_server = MagicMock()
mock_smtplib.SMTP.return_value = mock_server
await MailProcessor.forward_email(
self.SIMPLE_EMAIL, "Work Mail", "dest@example.com", self.SMTP_CONFIG
)
sent_msg = mock_server.send_message.call_args[0][0]
assert "[Fwd from Work Mail]" in sent_msg["Subject"]
assert "Test Subject" in sent_msg["Subject"]
@patch("app.services.mail_processor.smtplib")
async def test_forward_sets_from_and_to(self, mock_smtplib):
"""Forwarded email has correct From/To headers."""
mock_server = MagicMock()
mock_smtplib.SMTP.return_value = mock_server
await MailProcessor.forward_email(
self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
)
sent_msg = mock_server.send_message.call_args[0][0]
assert sent_msg["From"] == "sender@example.com"
assert sent_msg["To"] == "dest@example.com"
@patch("app.services.mail_processor.smtplib")
async def test_forward_multipart_email(self, mock_smtplib):
"""Multipart email: extracts text/plain body."""
mock_server = MagicMock()
mock_smtplib.SMTP.return_value = mock_server
result = await MailProcessor.forward_email(
self.MULTIPART_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
)
assert result is True
sent_msg = mock_server.send_message.call_args[0][0]
# Body should contain original header info and plain text
payload = sent_msg.get_payload()
assert len(payload) > 0
@patch("app.services.mail_processor.smtplib")
async def test_forward_email_body_contains_header_info(self, mock_smtplib):
"""Forwarded body includes original From, Date, Subject, Source Account."""
mock_server = MagicMock()
mock_smtplib.SMTP.return_value = mock_server
await MailProcessor.forward_email(
self.SIMPLE_EMAIL, "WorkAccount", "dest@example.com", self.SMTP_CONFIG
)
sent_msg = mock_server.send_message.call_args[0][0]
# Get body from the MIME parts
body_part = sent_msg.get_payload()[0]
body_text = body_part.get_payload(decode=True).decode("utf-8")
assert "Originally from: original@sender.com" in body_text
assert "Source Account: WorkAccount" in body_text
assert "Hello, this is the body." in body_text
@patch("app.services.mail_processor.smtplib")
async def test_forward_smtp_error_raises_forward_error(self, mock_smtplib):
"""SMTP send failure raises MailForwardError."""
mock_server = MagicMock()
mock_server.login.side_effect = Exception("auth failed")
mock_smtplib.SMTP.return_value = mock_server
with pytest.raises(MailForwardError, match="Forward error"):
await MailProcessor.forward_email(
self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
)
@patch("app.services.mail_processor.smtplib")
async def test_forward_quit_error_does_not_mask_success(self, mock_smtplib):
"""If quit() fails after successful send, True is still returned."""
mock_server = MagicMock()
mock_server.quit.side_effect = Exception("quit error")
mock_smtplib.SMTP.return_value = mock_server
result = await MailProcessor.forward_email(
self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
)
assert result is True
@patch("app.services.mail_processor.smtplib")
async def test_forward_connection_error_raises_forward_error(self, mock_smtplib):
"""SMTP connection failure raises MailForwardError."""
mock_smtplib.SMTP.side_effect = OSError("connection refused")
with pytest.raises(MailForwardError, match="Forward error"):
await MailProcessor.forward_email(
self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG
)
@patch("app.services.mail_processor.smtplib")
async def test_forward_email_without_payload(self, mock_smtplib):
"""Email with no payload body is forwarded with just header info."""
mock_server = MagicMock()
mock_smtplib.SMTP.return_value = mock_server
empty_body_email = b"From: x@y.com\r\n" b"Subject: Empty\r\n" b"\r\n"
result = await MailProcessor.forward_email(
empty_body_email, "Acct", "dest@example.com", self.SMTP_CONFIG
)
assert result is True
@patch("app.services.mail_processor.smtplib")
async def test_forward_multipart_no_text_plain(self, mock_smtplib):
"""Multipart email with no text/plain part forwards with empty body."""
mock_server = MagicMock()
mock_smtplib.SMTP.return_value = mock_server
html_only_email = (
b"From: x@y.com\r\n"
b"Subject: HTML Only\r\n"
b"MIME-Version: 1.0\r\n"
b'Content-Type: multipart/mixed; boundary="bnd"\r\n'
b"\r\n"
b"--bnd\r\n"
b"Content-Type: text/html; charset=utf-8\r\n"
b"\r\n"
b"<p>HTML body</p>\r\n"
b"--bnd--\r\n"
)
result = await MailProcessor.forward_email(
html_only_email, "Acct", "dest@example.com", self.SMTP_CONFIG
)
assert result is True
sent_msg = mock_server.send_message.call_args[0][0]
body_part = sent_msg.get_payload()[0]
body_text = body_part.get_payload(decode=True).decode("utf-8")
# Body should have header info but no HTML content extracted
assert "Originally from: x@y.com" in body_text
# ---------------------------------------------------------------------------
# Retry logic tests
# ---------------------------------------------------------------------------
class TestFetchPop3Retry:
"""_fetch_pop3_emails retries on transient errors."""
@patch("app.services.mail_processor._resolve_ipv4_sync", return_value=None)
@patch("app.services.mail_processor.poplib")
@pytest.mark.asyncio
async def test_retries_on_eof_error(self, mock_poplib, _mock_resolve):
"""EOF error on first attempt triggers a retry; second attempt succeeds."""
import poplib as real_poplib
# First call raises EOF; second succeeds
good_conn = MagicMock()
good_conn.stat.return_value = (0, 0)
good_conn.uidl.return_value = (b"+OK", [], 0)
good_conn.quit.return_value = None
mock_poplib.error_proto = real_poplib.error_proto
mock_poplib.POP3_SSL.side_effect = [
real_poplib.error_proto("-ERR EOF"),
good_conn,
]
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
with patch(
"app.services.mail_processor.asyncio.sleep", new_callable=AsyncMock
) as mock_sleep:
emails, uids = await proc._fetch_pop3_emails(10, set())
assert emails == []
assert uids == []
# sleep should have been called once between attempt 1 and attempt 2
mock_sleep.assert_awaited_once()
@patch("app.services.mail_processor._resolve_ipv4_sync", return_value=None)
@patch("app.services.mail_processor.poplib")
@pytest.mark.asyncio
async def test_raises_after_max_attempts(self, mock_poplib, _mock_resolve):
"""All attempts fail with EOF → MailFetchError is raised."""
import poplib as real_poplib
mock_poplib.error_proto = real_poplib.error_proto
mock_poplib.POP3_SSL.side_effect = real_poplib.error_proto("-ERR EOF")
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
with patch("app.services.mail_processor.asyncio.sleep", new_callable=AsyncMock):
with pytest.raises(MailFetchError):
await proc._fetch_pop3_emails(10, set())
@patch("app.services.mail_processor._resolve_ipv4_sync", return_value=None)
@patch("app.services.mail_processor.poplib")
@pytest.mark.asyncio
async def test_no_retry_on_auth_error(self, mock_poplib, _mock_resolve):
"""Authentication errors are not retried (non-transient)."""
import poplib as real_poplib
conn = MagicMock()
mock_poplib.error_proto = real_poplib.error_proto
mock_poplib.POP3_SSL.return_value = conn
# user() succeeds; pass_() raises auth error
conn.user.return_value = b"+OK"
conn.pass_.side_effect = real_poplib.error_proto("-ERR Authentication failed")
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
with patch(
"app.services.mail_processor.asyncio.sleep", new_callable=AsyncMock
) as mock_sleep:
with pytest.raises(MailFetchError):
await proc._fetch_pop3_emails(10, set())
# No sleep = no retry
mock_sleep.assert_not_awaited()
@@ -0,0 +1,215 @@
"""
Unit tests for the notification service (services/notification_service.py).
All external dependencies (database, Apprise) are mocked so no real
infrastructure is needed.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from app.services.notification_service import (
send_user_notification,
send_admin_notification,
test_notification as _test_notification,
_send_apprise,
)
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_notification_config(
*,
id: int = 1,
user_id: int = 42,
apprise_url: str = "json://localhost",
is_enabled: bool = True,
notify_on_errors: bool = True,
notify_on_success: bool = False,
):
cfg = MagicMock()
cfg.id = id
cfg.user_id = user_id
cfg.apprise_url = apprise_url
cfg.is_enabled = is_enabled
cfg.notify_on_errors = notify_on_errors
cfg.notify_on_success = notify_on_success
return cfg
def _make_db(configs=None):
"""Return an AsyncMock db with execute returning the given config list."""
db = AsyncMock()
result = MagicMock()
scalars = MagicMock()
scalars.all.return_value = configs or []
result.scalars.return_value = scalars
db.execute = AsyncMock(return_value=result)
return db
# ── send_user_notification ────────────────────────────────────────────────────
class TestSendUserNotification:
async def test_returns_zero_when_no_configs(self):
db = _make_db(configs=[])
count = await send_user_notification(db, user_id=1, title="T", body="B")
assert count == 0
async def test_sends_to_error_channel(self):
cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
) as mock_send:
count = await send_user_notification(
db, user_id=42, title="Err", body="msg", notify_on_error=True
)
assert count == 1
mock_send.assert_awaited_once()
async def test_skips_error_channel_for_success_notification(self):
cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
) as mock_send:
count = await send_user_notification(
db, user_id=42, title="Ok", body="msg", notify_on_error=False
)
assert count == 0
mock_send.assert_not_awaited()
async def test_sends_to_success_channel(self):
cfg = _make_notification_config(notify_on_errors=False, notify_on_success=True)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
) as mock_send:
count = await send_user_notification(
db, user_id=42, title="Ok", body="msg", notify_on_error=False
)
assert count == 1
mock_send.assert_awaited_once()
async def test_failed_apprise_not_counted(self):
cfg = _make_notification_config(notify_on_errors=True)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=False),
):
count = await send_user_notification(db, user_id=42, title="T", body="B")
assert count == 0
async def test_exception_in_apprise_swallowed(self):
cfg = _make_notification_config(notify_on_errors=True)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(side_effect=Exception("boom")),
):
count = await send_user_notification(db, user_id=42, title="T", body="B")
assert count == 0
async def test_multiple_channels_counted_individually(self):
cfg1 = _make_notification_config(id=1, notify_on_errors=True)
cfg2 = _make_notification_config(id=2, notify_on_errors=True)
db = _make_db(configs=[cfg1, cfg2])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
):
count = await send_user_notification(db, user_id=42, title="T", body="B")
assert count == 2
# ── send_admin_notification ───────────────────────────────────────────────────
class TestSendAdminNotification:
async def test_returns_zero_when_no_configs(self):
db = _make_db(configs=[])
count = await send_admin_notification(db, title="T", body="B")
assert count == 0
async def test_sends_to_enabled_channel(self):
cfg = MagicMock()
cfg.id = 1
cfg.apprise_url = "json://localhost"
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
):
count = await send_admin_notification(db, title="T", body="B")
assert count == 1
async def test_exception_in_channel_swallowed(self):
cfg = MagicMock()
cfg.id = 1
cfg.apprise_url = "json://localhost"
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(side_effect=RuntimeError("oops")),
):
count = await send_admin_notification(db, title="T", body="B")
assert count == 0
# ── test_notification ─────────────────────────────────────────────────────────
class TestTestNotification:
async def test_success(self):
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
):
ok, msg = await _test_notification("json://localhost")
assert ok is True
assert "success" in msg.lower()
async def test_failure_from_apprise(self):
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=False),
):
ok, msg = await _test_notification("json://localhost")
assert ok is False
async def test_exception_returns_false(self):
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(side_effect=Exception("network error")),
):
ok, msg = await _test_notification("json://localhost")
assert ok is False
assert "error" in msg.lower()
# ── _send_apprise internal helper ─────────────────────────────────────────────
class TestSendApprise:
async def test_invalid_url_returns_false(self):
# Apprise.add() returns False for unrecognised schemes
with patch("app.services.notification_service.apprise") as mock_apprise_module:
ap_instance = MagicMock()
ap_instance.add.return_value = False
mock_apprise_module.Apprise.return_value = ap_instance
result = await _send_apprise("not-a-valid-url://", "T", "B")
assert result is False
async def test_valid_url_returns_true(self):
with patch("app.services.notification_service.apprise") as mock_apprise_module:
ap_instance = MagicMock()
ap_instance.add.return_value = True
ap_instance.async_notify = AsyncMock(return_value=True)
mock_apprise_module.Apprise.return_value = ap_instance
result = await _send_apprise("json://localhost", "T", "B")
assert result is True
@@ -0,0 +1,278 @@
"""
Unit tests for notification config endpoints (api/v1/endpoints/notifications.py).
All database interactions and auth dependencies are mocked.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import (
User,
NotificationConfig,
NotificationChannel,
SubscriptionTier,
)
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_user(**overrides) -> MagicMock:
defaults = dict(
id=1,
email="user@example.com",
full_name="Test User",
is_active=True,
is_superuser=False,
subscription_tier=SubscriptionTier.FREE,
subscription_status="active",
google_id=None,
oauth_provider=None,
last_login_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
stripe_customer_id=None,
stripe_subscription_id=None,
subscription_expires_at=None,
)
defaults.update(overrides)
u = MagicMock(spec=User)
for k, v in defaults.items():
setattr(u, k, v)
return u
def _make_notification_config(**overrides) -> MagicMock:
defaults = dict(
id=1,
user_id=1,
name="Test Notification",
apprise_url="json://localhost",
channel=NotificationChannel.WEBHOOK,
is_enabled=True,
config={},
notify_on_errors=True,
notify_on_success=False,
notify_threshold=3,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
cfg = MagicMock(spec=NotificationConfig)
for k, v in defaults.items():
setattr(cfg, k, v)
return cfg
def _scalar_one_or_none(value):
r = MagicMock()
r.scalar_one_or_none.return_value = value
return r
def _scalars_all(values):
r = MagicMock()
scalars = MagicMock()
scalars.all.return_value = values
r.scalars.return_value = scalars
return r
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def mock_db():
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.add = MagicMock()
db.delete = AsyncMock()
return db
@pytest.fixture
def current_user():
return _make_user()
@pytest.fixture
async def auth_client(app, current_user, mock_db):
async def _override_user():
return current_user
async def _override_db():
yield mock_db
app.dependency_overrides[get_current_active_user] = _override_user
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ── POST /notifications ───────────────────────────────────────────────────────
class TestCreateNotificationConfig:
async def test_creates_config_201(self, auth_client, mock_db, current_user):
created_cfg = _make_notification_config()
# db.refresh must populate the object returned from the endpoint
async def _refresh(obj):
for k, v in vars(created_cfg).items():
if not k.startswith("_"):
try:
setattr(obj, k, v)
except AttributeError:
pass
mock_db.refresh = AsyncMock(side_effect=_refresh)
response = await auth_client.post(
"/api/v1/notifications",
json={
"name": "My Webhook",
"apprise_url": "json://localhost",
"channel": "webhook",
"is_enabled": True,
"config": {},
"notify_on_errors": True,
"notify_on_success": False,
"notify_threshold": 3,
},
)
assert response.status_code == 201
async def test_unauthenticated_401(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/notifications",
json={
"name": "x",
"apprise_url": "json://localhost",
"channel": "webhook",
"config": {},
},
)
assert response.status_code == 401
# ── GET /notifications ────────────────────────────────────────────────────────
class TestListNotificationConfigs:
async def test_returns_list(self, auth_client, mock_db):
cfg1 = _make_notification_config(id=1)
cfg2 = _make_notification_config(id=2)
mock_db.execute = AsyncMock(return_value=_scalars_all([cfg1, cfg2]))
response = await auth_client.get("/api/v1/notifications")
assert response.status_code == 200
assert isinstance(response.json(), list)
assert len(response.json()) == 2
async def test_returns_empty_list(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalars_all([]))
response = await auth_client.get("/api/v1/notifications")
assert response.status_code == 200
assert response.json() == []
# ── GET /notifications/{id} ───────────────────────────────────────────────────
class TestGetNotificationConfig:
async def test_returns_config(self, auth_client, mock_db):
cfg = _make_notification_config(id=5)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await auth_client.get("/api/v1/notifications/5")
assert response.status_code == 200
async def test_404_when_not_found(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.get("/api/v1/notifications/999")
assert response.status_code == 404
# ── PUT /notifications/{id} ───────────────────────────────────────────────────
class TestUpdateNotificationConfig:
async def test_updates_config(self, auth_client, mock_db):
cfg = _make_notification_config(id=5)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
response = await auth_client.put(
"/api/v1/notifications/5",
json={"name": "Updated Name"},
)
assert response.status_code == 200
async def test_404_when_not_found(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.put(
"/api/v1/notifications/999",
json={"name": "Updated"},
)
assert response.status_code == 404
# ── DELETE /notifications/{id} ────────────────────────────────────────────────
class TestDeleteNotificationConfig:
async def test_deletes_config_204(self, auth_client, mock_db):
cfg = _make_notification_config(id=5)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await auth_client.delete("/api/v1/notifications/5")
assert response.status_code == 204
mock_db.delete.assert_awaited_once_with(cfg)
async def test_404_when_not_found(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.delete("/api/v1/notifications/999")
assert response.status_code == 404
# ── POST /notifications/test ──────────────────────────────────────────────────
class TestTestNotificationConfig:
async def test_test_success(self, auth_client):
with patch(
"app.api.v1.endpoints.notifications.test_notification",
new=AsyncMock(return_value=(True, "sent successfully")),
):
response = await auth_client.post(
"/api/v1/notifications/test",
json={"apprise_url": "json://localhost"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
async def test_test_failure(self, auth_client):
with patch(
"app.api.v1.endpoints.notifications.test_notification",
new=AsyncMock(return_value=(False, "delivery failed")),
):
response = await auth_client.post(
"/api/v1/notifications/test",
json={"apprise_url": "invalid://url"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
+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()
File diff suppressed because it is too large Load Diff
+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
+48 -7
View File
@@ -4,11 +4,11 @@ services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: pop3-postgres
container_name: inboxconverge-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: pop3_forwarder
POSTGRES_DB: inbox_converge
ports:
- "5432:5432"
volumes:
@@ -22,7 +22,7 @@ services:
# Redis for caching and Celery
redis:
image: redis:7-alpine
container_name: pop3-redis
container_name: inboxconverge-redis
ports:
- "6379:6379"
volumes:
@@ -38,7 +38,7 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: pop3-backend
container_name: inboxconverge-backend
ports:
- "8000:8000"
env_file:
@@ -58,7 +58,7 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: pop3-celery-worker
container_name: inboxconverge-celery-worker
env_file:
- ./backend/.env
depends_on:
@@ -75,7 +75,7 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: pop3-celery-beat
container_name: inboxconverge-celery-beat
env_file:
- ./backend/.env
depends_on:
@@ -92,15 +92,56 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: pop3-frontend
container_name: inboxconverge-frontend
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://backend:8000
- CONTACT_EMAIL=christian@inboxconverge.com
- APP_URL=https://inboxconverge.com
- APP_NAME=InboxConverge
depends_on:
- backend
restart: unless-stopped
# Prometheus metrics collection
prometheus:
image: prom/prometheus:v2.51.2
container_name: inboxconverge-prometheus
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=30d"
- "--web.enable-lifecycle"
depends_on:
- backend
restart: unless-stopped
# Grafana dashboards
grafana:
image: grafana/grafana:10.4.3
container_name: inboxconverge-grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/etc/grafana/dashboards:ro
depends_on:
- prometheus
restart: unless-stopped
volumes:
postgres_data:
redis_data:
prometheus_data:
grafana_data:
+3 -3
View File
@@ -1,13 +1,13 @@
version: '3.8'
services:
pop3-forwarder:
inboxconverge:
# Option 1: Build from source (default)
build: .
# Option 2: Use pre-built image from GitHub Container Registry
# Uncomment the line below and comment out 'build: .' to use pre-built image
# image: ghcr.io/christianlouis/pop_puller_to_gmail:latest
container_name: pop3-gmail-forwarder
# image: ghcr.io/christianlouis/inboxconverge:latest
container_name: inboxconverge
restart: unless-stopped
env_file:
- .env
+8 -8
View File
@@ -1,4 +1,4 @@
# POP3 Forwarder SaaS - Multi-Tenant Architecture
# InboxConverge - Multi-Tenant Architecture
This document describes the new multi-tenant SaaS architecture for the POP3/IMAP email forwarder.
@@ -20,7 +20,7 @@ The project has been transformed from a single-user Docker application into a fu
## 📁 Project Structure
```
pop_puller_to_gmail/
inboxconverge/
├── backend/ # FastAPI backend application
│ ├── app/
│ │ ├── api/ # API endpoints
@@ -50,7 +50,7 @@ pop_puller_to_gmail/
│ └── .env.example # Environment template
├── frontend/ # React/Next.js frontend (to be implemented)
├── docker-compose.new.yml # Docker Compose for all services
├── pop3_forwarder.py # Legacy single-user script
├── inbox_converge.py # Legacy single-user script
└── README.md # This file
```
@@ -68,8 +68,8 @@ pop_puller_to_gmail/
1. **Clone and navigate to repository**
```bash
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
cd pop_puller_to_gmail
git clone https://github.com/christianlouis/inboxconverge.git
cd inboxconverge
```
2. **Configure backend environment**
@@ -329,7 +329,7 @@ Coming soon: Kubernetes manifests and Helm charts.
## 🔄 Migration from Legacy Version
To migrate from the single-user `pop3_forwarder.py`:
To migrate from the single-user `inbox_converge.py`:
1. **Export existing configuration** from `.env` file
2. **Create user account** via API or admin panel
@@ -404,8 +404,8 @@ MIT License - See [LICENSE](../LICENSE) file
## 🆘 Support
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions
- **Issues**: https://github.com/christianlouis/inboxconverge/issues
- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
- **Email**: support@example.com
## 🙏 Acknowledgments
+1 -1
View File
@@ -1,6 +1,6 @@
# Coding Patterns and Best Practices
This document outlines the coding patterns, conventions, and best practices for the POP3 to Gmail Forwarder project.
This document outlines the coding patterns, conventions, and best practices for the InboxConverge project.
## Table of Contents
- [General Principles](#general-principles)
+7 -7
View File
@@ -1,6 +1,6 @@
# Deployment Checklist and Next Steps
This document provides a checklist for deploying the multi-tenant POP3 Forwarder with web interface.
This document provides a checklist for deploying the multi-tenant InboxConverge with web interface.
## 🚀 Pre-Deployment Checklist
@@ -48,7 +48,7 @@ This document provides a checklist for deploying the multi-tenant POP3 Forwarder
#### Database
- [ ] PostgreSQL 15+ instance running
- [ ] Database created: `pop3_forwarder`
- [ ] Database created: `inbox_converge`
- [ ] Connection details configured in backend/.env
- [ ] Backups configured
@@ -89,8 +89,8 @@ sudo certbot --nginx -d yourdomain.com -d api.yourdomain.com
```bash
# On production server
cd /opt
sudo git clone https://github.com/christianlouis/pop_puller_to_gmail.git
cd pop_puller_to_gmail
sudo git clone https://github.com/christianlouis/inboxconverge.git
cd inboxconverge
```
### Step 2: Configure Environment
@@ -195,9 +195,9 @@ curl -X POST https://api.yourdomain.com/api/v1/auth/register \
# Automated daily backup script
cat > /usr/local/bin/backup-pop3-db.sh << 'EOF'
#!/bin/bash
BACKUP_DIR=/var/backups/pop3_forwarder
BACKUP_DIR=/var/backups/inbox_converge
DATE=$(date +%Y%m%d_%H%M%S)
docker exec pop3-postgres pg_dump -U postgres pop3_forwarder | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
docker exec inboxconverge-postgres pg_dump -U postgres inbox_converge | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
find $BACKUP_DIR -type f -mtime +30 -delete
EOF
@@ -394,4 +394,4 @@ Use this checklist after deployment:
## 🎉 Congratulations!
If all checkboxes above are complete, your multi-tenant POP3 Forwarder with web interface is successfully deployed and ready to serve users!
If all checkboxes above are complete, your multi-tenant InboxConverge with web interface is successfully deployed and ready to serve users!
+50 -50
View File
@@ -1,6 +1,6 @@
# Deployment Guide
This guide walks you through deploying **POP3 to Gmail Forwarder** from scratch — whether you just want a single container pulling emails, a full multi-service SaaS stack with Docker Compose, or a production-grade Kubernetes setup.
This guide walks you through deploying **InboxConverge** from scratch — whether you just want a single container pulling emails, a full multi-service SaaS stack with Docker Compose, or a production-grade Kubernetes setup.
---
@@ -52,13 +52,13 @@ You will also need:
## Option 1 — Legacy Single-Container Deployment
The legacy mode runs a single Python script (`pop3_forwarder.py`) that polls POP3 mailboxes and forwards email via SMTP. No database, no web UI — just a container and an `.env` file.
The legacy mode runs a single Python script (`inbox_converge.py`) that polls POP3 mailboxes and forwards email via SMTP. No database, no web UI — just a container and an `.env` file.
### 1. Create the environment file
```bash
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
cd pop_puller_to_gmail
git clone https://github.com/christianlouis/inboxconverge.git
cd inboxconverge
cp .env.example .env
```
@@ -97,12 +97,12 @@ The repository ships `docker-compose.yml` for this mode. Here is the content for
version: "3.8"
services:
pop3-forwarder:
inbox-converge:
# Build from source
build: .
# Or use the pre-built image:
# image: ghcr.io/christianlouis/pop_puller_to_gmail:latest
container_name: pop3-gmail-forwarder
# image: ghcr.io/christianlouis/inboxconverge:latest
container_name: inboxconverge
restart: unless-stopped
env_file:
- .env
@@ -149,7 +149,7 @@ Save both values — you will need them below.
### 2. Create the backend environment file
```bash
cd pop_puller_to_gmail
cd inboxconverge
cp backend/.env.example backend/.env
```
@@ -157,7 +157,7 @@ Edit `backend/.env`:
```ini
# ── Database ──────────────────────────────────────────────
DATABASE_URL=postgresql+asyncpg://postgres:change-me@postgres:5432/pop3_forwarder
DATABASE_URL=postgresql+asyncpg://postgres:change-me@postgres:5432/inbox_converge
# ── Security (paste the values you generated above) ──────
SECRET_KEY=<your-64-char-hex-secret>
@@ -198,12 +198,12 @@ services:
# ── PostgreSQL ───────────────────────────────────────────
postgres:
image: postgres:15-alpine
container_name: pop3-postgres
container_name: inboxconverge-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: change-me # must match DATABASE_URL
POSTGRES_DB: pop3_forwarder
POSTGRES_DB: inbox_converge
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -219,7 +219,7 @@ services:
# ── Redis ────────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: pop3-redis
container_name: inboxconverge-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
@@ -235,7 +235,7 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: pop3-backend
container_name: inboxconverge-backend
restart: unless-stopped
ports:
- "8000:8000"
@@ -260,7 +260,7 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: pop3-celery-worker
container_name: inboxconverge-celery-worker
restart: unless-stopped
env_file:
- ./backend/.env
@@ -278,7 +278,7 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: pop3-celery-beat
container_name: inboxconverge-celery-beat
restart: unless-stopped
env_file:
- ./backend/.env
@@ -296,7 +296,7 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: pop3-frontend
container_name: inboxconverge-frontend
restart: unless-stopped
ports:
- "3000:3000"
@@ -358,7 +358,7 @@ Below is a set of example Kubernetes manifests to get you started. Adapt namespa
apiVersion: v1
kind: Namespace
metadata:
name: pop3-forwarder
name: inbox-converge
```
### Secrets
@@ -369,13 +369,13 @@ Store sensitive values in a Kubernetes Secret. In production, consider using an
apiVersion: v1
kind: Secret
metadata:
name: pop3-forwarder-secrets
namespace: pop3-forwarder
name: inbox-converge-secrets
namespace: inbox-converge
type: Opaque
stringData:
SECRET_KEY: "<your-64-char-hex-secret>"
ENCRYPTION_KEY: "<your-64-char-hex-encryption-key>"
DATABASE_URL: "postgresql+asyncpg://postgres:change-me@postgres:5432/pop3_forwarder"
DATABASE_URL: "postgresql+asyncpg://postgres:change-me@postgres:5432/inbox_converge"
REDIS_URL: "redis://redis:6379/0"
CELERY_BROKER_URL: "redis://redis:6379/0"
CELERY_RESULT_BACKEND: "redis://redis:6379/0"
@@ -396,7 +396,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: pop3-forwarder
namespace: inbox-converge
spec:
replicas: 1
selector:
@@ -416,11 +416,11 @@ spec:
- name: POSTGRES_USER
value: postgres
- name: POSTGRES_DB
value: pop3_forwarder
value: inbox_converge
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: pop3-forwarder-secrets
name: inbox-converge-secrets
key: POSTGRES_PASSWORD
volumeMounts:
- name: pgdata
@@ -439,7 +439,7 @@ apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: pop3-forwarder
namespace: inbox-converge
spec:
selector:
app: postgres
@@ -451,7 +451,7 @@ apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: pop3-forwarder
namespace: inbox-converge
spec:
accessModes: [ReadWriteOnce]
resources:
@@ -466,7 +466,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: pop3-forwarder
namespace: inbox-converge
spec:
replicas: 1
selector:
@@ -493,7 +493,7 @@ apiVersion: v1
kind: Service
metadata:
name: redis
namespace: pop3-forwarder
namespace: inbox-converge
spec:
selector:
app: redis
@@ -509,7 +509,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: pop3-forwarder
namespace: inbox-converge
spec:
replicas: 2
selector:
@@ -522,22 +522,22 @@ spec:
spec:
initContainers:
- name: run-migrations
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest
image: ghcr.io/christianlouis/inboxconverge-backend:latest
command: ["alembic", "upgrade", "head"]
envFrom:
- secretRef:
name: pop3-forwarder-secrets
name: inbox-converge-secrets
env:
- name: DEBUG
value: "false"
containers:
- name: backend
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest
image: ghcr.io/christianlouis/inboxconverge-backend:latest
ports:
- containerPort: 8000
envFrom:
- secretRef:
name: pop3-forwarder-secrets
name: inbox-converge-secrets
env:
- name: HOST
value: "0.0.0.0"
@@ -567,7 +567,7 @@ apiVersion: v1
kind: Service
metadata:
name: backend
namespace: pop3-forwarder
namespace: inbox-converge
spec:
selector:
app: backend
@@ -583,7 +583,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: celery-worker
namespace: pop3-forwarder
namespace: inbox-converge
spec:
replicas: 2
selector:
@@ -596,7 +596,7 @@ spec:
spec:
containers:
- name: worker
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest
image: ghcr.io/christianlouis/inboxconverge-backend:latest
command:
- celery
- -A
@@ -606,7 +606,7 @@ spec:
- --concurrency=2
envFrom:
- secretRef:
name: pop3-forwarder-secrets
name: inbox-converge-secrets
resources:
requests:
cpu: 250m
@@ -625,7 +625,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: celery-beat
namespace: pop3-forwarder
namespace: inbox-converge
spec:
replicas: 1 # Must be exactly 1
strategy:
@@ -640,7 +640,7 @@ spec:
spec:
containers:
- name: beat
image: ghcr.io/christianlouis/pop_puller_to_gmail-backend:latest
image: ghcr.io/christianlouis/inboxconverge-backend:latest
command:
- celery
- -A
@@ -649,7 +649,7 @@ spec:
- --loglevel=info
envFrom:
- secretRef:
name: pop3-forwarder-secrets
name: inbox-converge-secrets
resources:
requests:
cpu: 100m
@@ -666,7 +666,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: pop3-forwarder
namespace: inbox-converge
spec:
replicas: 2
selector:
@@ -679,7 +679,7 @@ spec:
spec:
containers:
- name: frontend
image: ghcr.io/christianlouis/pop_puller_to_gmail-frontend:latest
image: ghcr.io/christianlouis/inboxconverge-frontend:latest
ports:
- containerPort: 3000
env:
@@ -697,7 +697,7 @@ apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: pop3-forwarder
namespace: inbox-converge
spec:
selector:
app: frontend
@@ -714,8 +714,8 @@ The Ingress below assumes you have an Ingress controller installed (e.g., [ingre
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pop3-forwarder-ingress
namespace: pop3-forwarder
name: inbox-converge-ingress
namespace: inbox-converge
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
@@ -725,7 +725,7 @@ spec:
- hosts:
- your-domain.com
- api.your-domain.com
secretName: pop3-forwarder-tls
secretName: inbox-converge-tls
rules:
- host: your-domain.com
http:
@@ -754,7 +754,7 @@ spec:
If you manage many environments (staging, production, etc.) consider wrapping the manifests above into a Helm chart:
```text
helm/pop3-forwarder/
helm/inbox-converge/
├── Chart.yaml
├── values.yaml # defaults for all environments
├── values-staging.yaml
@@ -782,8 +782,8 @@ replicaCount:
frontend: 2
image:
backend: ghcr.io/christianlouis/pop_puller_to_gmail-backend
frontend: ghcr.io/christianlouis/pop_puller_to_gmail-frontend
backend: ghcr.io/christianlouis/inboxconverge-backend
frontend: ghcr.io/christianlouis/inboxconverge-frontend
tag: latest
ingress:
@@ -862,7 +862,7 @@ In production you should place a reverse proxy in front of the backend and front
### Example: nginx
```nginx
# /etc/nginx/sites-available/pop3-forwarder
# /etc/nginx/sites-available/inbox-converge
# Frontend
server {
@@ -969,7 +969,7 @@ If you prefer Traefik, add it as a service in your Compose file and use labels o
## Upgrading
```bash
cd pop_puller_to_gmail
cd inboxconverge
# Pull latest code
git pull origin main
+1 -1
View File
@@ -1,6 +1,6 @@
# Error Codes and Messages
This document catalogs all error codes used in the POP3 to Gmail Forwarder application.
This document catalogs all error codes used in the InboxConverge application.
## Error Code Format
+3 -3
View File
@@ -2,7 +2,7 @@
## 🎯 Mission Accomplished
This document summarizes the completion of the web interface and multitenancy features for the POP3 to Gmail Forwarder project.
This document summarizes the completion of the web interface and multitenancy features for the InboxConverge project.
## 📦 What Was Delivered
@@ -320,7 +320,7 @@ These are potential future improvements outside the current task:
### What Was Accomplished
✅ **Complete implementation of web interface and multitenancy features**
The POP3 to Gmail Forwarder now has:
The InboxConverge now has:
- A modern, responsive web interface
- Complete user authentication system
- Full mail account management capabilities
@@ -356,7 +356,7 @@ The implementation is **complete and ready for**:
## 👏 Thank You
This implementation represents a significant milestone in transforming the POP3 Forwarder from a simple script into a production-ready multi-tenant SaaS application. The web interface makes the service accessible to users of all technical levels, while maintaining the robust backend infrastructure.
This implementation represents a significant milestone in transforming the InboxConverge from a simple script into a production-ready multi-tenant SaaS application. The web interface makes the service accessible to users of all technical levels, while maintaining the robust backend infrastructure.
**The multitenancy and web interface implementation is now complete and ready for deployment!** 🎉
+11 -11
View File
@@ -1,6 +1,6 @@
# Implementation Guide
This guide provides step-by-step instructions for setting up and deploying the multi-tenant POP3 Forwarder SaaS application.
This guide provides step-by-step instructions for setting up and deploying the multi-tenant InboxConverge application.
## Table of Contents
@@ -36,8 +36,8 @@ This guide provides step-by-step instructions for setting up and deploying the m
```bash
# Clone repository
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
cd pop_puller_to_gmail
git clone https://github.com/christianlouis/inboxconverge.git
cd inboxconverge
# Create backend environment file
cp backend/.env.example backend/.env
@@ -49,7 +49,7 @@ Edit `backend/.env` with your settings:
```bash
# Minimum required for development
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/pop3_forwarder
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/inbox_converge
SECRET_KEY=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
GOOGLE_CLIENT_ID=your-client-id
@@ -176,7 +176,7 @@ ADMIN_EMAIL=admin@yourdomain.com
Use nginx or Traefik as reverse proxy:
```nginx
# /etc/nginx/sites-available/pop3-forwarder
# /etc/nginx/sites-available/inbox-converge
server {
listen 443 ssl http2;
server_name api.yourdomain.com;
@@ -202,7 +202,7 @@ cat > /etc/cron.daily/backup-postgres << 'EOF'
#!/bin/bash
BACKUP_DIR=/var/backups/postgres
DATE=$(date +%Y%m%d_%H%M%S)
docker exec pop3-postgres pg_dump -U postgres pop3_forwarder | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
docker exec inboxconverge-postgres pg_dump -U postgres inbox_converge | gzip > $BACKUP_DIR/backup_$DATE.sql.gz
find $BACKUP_DIR -type f -mtime +7 -delete # Keep 7 days
EOF
@@ -348,10 +348,10 @@ docker-compose -f docker-compose.new.yml exec celery-worker celery -A app.worker
```bash
# Check connections
docker exec pop3-postgres psql -U postgres -d pop3_forwarder -c "SELECT count(*) FROM pg_stat_activity;"
docker exec inboxconverge-postgres psql -U postgres -d inbox_converge -c "SELECT count(*) FROM pg_stat_activity;"
# Check table sizes
docker exec pop3-postgres psql -U postgres -d pop3_forwarder -c "
docker exec inboxconverge-postgres psql -U postgres -d inbox_converge -c "
SELECT
schemaname,
tablename,
@@ -376,7 +376,7 @@ docker-compose -f docker-compose.new.yml ps postgres
docker-compose -f docker-compose.new.yml logs postgres
# Test connection
docker exec pop3-postgres psql -U postgres -c "SELECT version();"
docker exec inboxconverge-postgres psql -U postgres -c "SELECT version();"
```
#### Celery Worker Not Processing
@@ -480,8 +480,8 @@ redis:
For additional help:
- **Documentation**: See [ARCHITECTURE.md](ARCHITECTURE.md)
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions
- **Issues**: https://github.com/christianlouis/inboxconverge/issues
- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
---
+2 -2
View File
@@ -1,6 +1,6 @@
# Migration Guide: Single-User to Multi-Tenant SaaS
This guide helps you migrate from the legacy single-user `pop3_forwarder.py` script to the new multi-tenant SaaS application.
This guide helps you migrate from the legacy single-user `inbox_converge.py` script to the new multi-tenant SaaS application.
## Overview
@@ -230,7 +230,7 @@ docker-compose -f docker-compose.yml down
# Archive old configuration
mkdir -p archive
mv pop3_forwarder.py archive/
mv inbox_converge.py archive/
mv .env.legacy.backup archive/
mv docker-compose.yml archive/docker-compose.legacy.yml
+10 -10
View File
@@ -1,6 +1,6 @@
# Quick Start Guide
Get your POP3 to Gmail forwarder running in under 10 minutes!
Get your InboxConverge instance running in under 10 minutes!
## Prerequisites
@@ -13,8 +13,8 @@ Get your POP3 to Gmail forwarder running in under 10 minutes!
### 1. Clone the Repository
```bash
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
cd pop_puller_to_gmail
git clone https://github.com/christianlouis/inboxconverge.git
cd inboxconverge
```
### 2. Generate Gmail App Password
@@ -23,7 +23,7 @@ cd pop_puller_to_gmail
2. Sign in to your Google Account
3. Select "App passwords" under Security
4. Choose "Mail" and "Other (Custom name)"
5. Enter "POP3 Forwarder" as the name
5. Enter "InboxConverge" as the name
6. Click "Generate"
7. **Copy the 16-character password** (you'll need this in step 3)
@@ -66,10 +66,10 @@ docker-compose logs -f
You should see:
```
pop3-gmail-forwarder | INFO - POP3 to Gmail Forwarder starting...
pop3-gmail-forwarder | INFO - Loaded POP3 account: ...
pop3-gmail-forwarder | INFO - Configuration validated successfully
pop3-gmail-forwarder | INFO - Starting email processing cycle
inboxconverge | INFO - InboxConverge starting...
inboxconverge | INFO - Loaded POP3 account: ...
inboxconverge | INFO - Configuration validated successfully
inboxconverge | INFO - Starting email processing cycle
```
### 6. Test the Forwarder
@@ -192,13 +192,13 @@ Restart after changes: `docker-compose restart`
## Need Help?
- Open an issue: https://github.com/christianlouis/pop_puller_to_gmail/issues
- Open an issue: https://github.com/christianlouis/inboxconverge/issues
- Check existing discussions
- Review troubleshooting section in README.md
## Success! 🎉
Your POP3 to Gmail forwarder is now running. Emails will be automatically forwarded every 5 minutes (or your configured interval).
Your InboxConverge instance is now running. Emails will be automatically forwarded every 5 minutes (or your configured interval).
**Remember**:
- The forwarder deletes emails from POP3 after successful forwarding
+4 -4
View File
@@ -39,8 +39,8 @@ This project has been **completely transformed** from a single-user Docker scrip
```bash
# Clone repository
git clone https://github.com/christianlouis/pop_puller_to_gmail.git
cd pop_puller_to_gmail
git clone https://github.com/christianlouis/inboxconverge.git
cd inboxconverge
# Configure environment
cp backend/.env.example backend/.env
@@ -330,8 +330,8 @@ MIT License - See [LICENSE](../LICENSE) file for details.
## 🆘 Support
- **Documentation**: See docs in repository
- **Issues**: https://github.com/christianlouis/pop_puller_to_gmail/issues
- **Discussions**: https://github.com/christianlouis/pop_puller_to_gmail/discussions
- **Issues**: https://github.com/christianlouis/inboxconverge/issues
- **Discussions**: https://github.com/christianlouis/inboxconverge/discussions
- **Email**: support@example.com (for Enterprise customers)
## 🎉 Acknowledgments
+1 -1
View File
@@ -1,7 +1,7 @@
# Roadmap
## Vision
Create a robust, scalable, and user-friendly POP3 to Gmail forwarding solution that serves as a complete replacement for Gmail's discontinued POP3 import feature.
Create a robust, scalable, and user-friendly InboxConverge email-forwarding solution that serves as a complete replacement for Gmail's discontinued POP3 import feature.
---
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview
Security analysis completed on February 1, 2026 for the Multi-Tenant POP3 Forwarder SaaS application.
Security analysis completed on February 1, 2026 for the Multi-Tenant InboxConverge application.
## CodeQL Security Scan
+1 -1
View File
@@ -25,7 +25,7 @@ This guide will help you test the complete multi-tenant web interface with the b
3. Edit the `.env` file and update the following critical values:
```bash
# Database - should point to Docker service
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/pop3_forwarder
DATABASE_URL=postgresql+asyncpg://postgres:password@postgres:5432/inbox_converge
# Redis - should point to Docker service
REDIS_URL=redis://redis:6379/0
+145 -8
View File
@@ -2,6 +2,103 @@
Comprehensive task breakdown for repository improvements and production readiness.
## ✅ Recently Completed
- [x] **IMAP/POP3 connection stability — IPv4 preference + DNS cache**: All
POP3 and IMAP connections now prefer IPv4 by pre-resolving hostnames with
`AF_INET` before connecting. This eliminates `ENETUNREACH` / `Network is
unreachable` and sporadic TLS timeouts caused by Docker containers attempting
IPv6 connections to servers that block IPv6. Successfully resolved IPv4
addresses are cached in-process; when `DNS_CACHE_FALLBACK_ENABLED=true`
(default) the cached address is used as a fallback if DNS subsequently fails
(`EAI_AGAIN`), keeping mail delivery alive through transient resolver outages.
Added `_resolve_ipv4` / `_resolve_ipv4_sync`, `_IMAP4SSLwithSNI`,
`_POP3SSLWithIPv4Pref`, `_POP3WithIPv4Pref`, `_make_pop3_conn`, and
`_make_imap_client` helpers. Transient DNS errors now report "Temporary DNS
failure" rather than "check that the server address is correct". Replaced
four deprecated `asyncio.get_event_loop()` calls with `get_running_loop()`.
- [x] **IMAP/POP3 diagnostics — Step 1: Friendly error messages**: Added
`_format_connection_error()` helper that converts raw OS/socket/SSL/POP3/IMAP
exceptions into human-readable sentences with host:port context. Applied at
every `raise MailFetchError`/`MailConnectionError` site. Fixes blank "IMAP
fetch error:" messages and cryptic DNS errno strings.
- [x] **IMAP/POP3 diagnostics — Step 2: Auto-clear stale error state**: Successful
fetches now always clear `last_error_message`/`last_error_at` and set
`status=ACTIVE`, even when some individual email forwards fail. Added
`POST /mail-accounts/{id}/clear-error` endpoint and "Clear" buttons on the
Accounts and Mailbox Activity pages.
- [x] **IMAP/POP3 diagnostics — Step 3: Per-account debug logging**: Added
`debug_logging` boolean column (migration `0002`), `MailDebugRecorder` class,
instrumented all connection phases (connect, auth, select, search, fetch UIDs,
per-message fetch, logout), persisted as `ProcessingLog[level=DEBUG]`.
Auto-disables after 5 runs **from when the flag was last enabled** (migration
`0003` adds `debug_logging_run_count` counter; the old 24-hour window is
replaced). Toggle in account edit form. Connection trace viewer in Mailbox
Activity logs page.
- [x] **POP3/IMAP retry + DNS 8.8.8.8 fallback**: Transient errors (`-ERR EOF`,
timeout, connection-reset) are retried up to 3 times (5 s fixed delay) in both
POP3 and IMAP paths. DNS resolution now falls back to a raw UDP query to
8.8.8.8:53 when both the system resolver and the in-process cache fail, keeping
connections alive even if the container's resolver is temporarily unavailable.
- [x] **Notification backoff**: Error notifications fire only once per consecutive
failure streak (`error_notification_sent` flag, migration `0003`). A recovery
notice is sent when the account processes successfully again, then the flag
resets so the next failure streak triggers a fresh alert.
- [x] **Google OAuth consent screen legal compliance**: Added English Privacy Policy (`/privacy`) with Google API Limited Use Disclosure, Terms of Service (`/terms`), legal footer links on the home page (resolves Google's "homepage has no privacy policy link" verification rejection), login page, and register page (consent text). Cross-link from `/datenschutz` to `/privacy` added.
- [x] **Fix Pydantic V2 deprecation warnings**: Replaced `.dict()` with `.model_dump()` in `admin.py` and `notifications.py`. Fixed `RuntimeWarning: coroutine never awaited` for `db.add()` in test mocks (`test_tasks.py`, `test_config_service.py`).
- [x] **Expanded backend test coverage**: Added 150 new unit tests across 10 new test files, increasing the backend test count from 361 to 511. New coverage includes `core/gdpr.py`, `utils/gmail_labels.py`, `services/notification_service.py`, `services/auth_service.py`, and API endpoints for auth, users, notifications, processing-runs/logs, app-settings, and version.
- [x] **Improved test coverage for `mail_processor.py`**: Added 46 new unit tests covering POP3 connection testing, POP3 email fetching, IMAP edge cases, email forwarding (STARTTLS/SSL), and `fetch_emails`/`test_connection` routing. Coverage increased from ~42% to 98%.
- [x] **Log noise reduction**: Suppressed `ignored untagged response` INFO messages from `aioimaplib` in Celery workers (set logger to WARNING). Eliminated repeated `file_cache is only supported with oauth2client<4.0.0` warnings from the Gmail API client by passing `cache_discovery=False` to `googleapiclient.discovery.build()`.
- [x] **ESLint fix**: Converted `frontend/jest.config.js` to `jest.config.mjs` (ES module syntax) to resolve `@typescript-eslint/no-require-imports` lint error.
- [x] **Codecov integration**: Added Codecov coverage reporting with `CODECOV_TOKEN` authentication. Set up Jest for frontend tests with lcov coverage, updated CI to collect and upload both backend (XML via pytest-cov) and frontend (lcov via Jest) coverage reports to Codecov with separate `backend` and `frontend` flags.
- [x] **IMAP: fix all emails appearing empty**`aioimaplib` stores RFC822 literal data as `bytearray`, not `bytes`. The extraction loop was checking `isinstance(line, bytes)` which returns `False` for `bytearray`, so every email body was silently skipped. Fixed to accept both types and convert to `bytes`. Affected T-Online, GMX, and all IMAP accounts.
- [x] **CI: fix safety scan EOF error** — replaced `safety scan --json` (Safety CLI v3 requires interactive login) with `pip-audit` (no auth required, maintained by PyPA).
- [x] **Security: upgrade fastapi/starlette and fix safety CI command** — Upgraded `fastapi` to `0.135.2` (pulls in `starlette>=1.0.0`) fixing 4 DoS CVEs in `starlette<=0.35.1`; replaced deprecated `safety check` with `safety scan`; added `.safety-policy.yml` to suppress unfixable `ecdsa` side-channel CVEs (maintainers won't fix).
- [x] **IMAP RFC 3501 flag syntax & aioimaplib UID SEARCH fix**: `_fetch_imap_emails`
now uses a plain `SEARCH UNSEEN` + `FETCH (UID)` to resolve sequence numbers to
stable UIDs (aioimaplib blocks `uid("search")`), and wraps all flag names in
parentheses (`+FLAGS (\Seen)`, `+FLAGS (\Deleted)`) as required by RFC 3501 to
prevent T-Online and other strict servers from dropping the connection with
"Too many invalid IMAP commands".
- [x] **CI pipeline fixes**: Added `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` to `ci.yml` (Node.js 20 deprecation), fixed Codecov `file:``files:` invalid input, replaced `<img>` with `<Image />` from `next/image` in `ProviderWizard.tsx` (ESLint no-img-element).
- [x] **IMAP reliability: switched to UID-based commands**`_fetch_imap_emails` now uses `UID SEARCH`, `UID FETCH`, and `UID STORE` throughout. Sequence numbers are volatile (they shift on expunge), causing "Too many invalid IMAP commands" on strict servers (e.g. T-Online). UIDs are stable. The per-message `STORE +FLAGS \Seen` (redundant — RFC822 sets it implicitly) and per-message `STORE +FLAGS \Deleted` are replaced with single batch commands. Stale already-seen UIDs are re-marked `\Seen` in one command. Logout is now in a `finally` block so a mid-session `BYE` is handled gracefully.
- [x] Fixed timezone display bug in Mailbox Activity and Admin Logs pages: ISO timestamps without a `Z` suffix were parsed as local time by JavaScript, shifting "Xm ago" / "Xh ago" displays and absolute dates by the client's UTC offset.
- [x] Fixed worker `send_user_notification` using rolled-back DB session causing `greenlet_spawn has not been called` errors; status/`last_check_at` now always committed before sending notifications via a fresh session.
- [x] **Dashboard redesign**: Replaced noisy "Recent Processing Runs" table with a per-account "Mailbox Status" view showing last-check status (OK/Error/Pending), relative timestamp, error messages, and lifetime counters. Stats cards updated to show all-time processed count and accounts-with-errors count.
- [x] **Provider logos now saved on account creation**: `provider_name` field added to `MailAccountCreate` and `MailAccountUpdate` schemas (backend and frontend). `ProviderWizard` now passes `provider_name` in its `onSelect` callback; `AddMailAccountModal` stores it so logos are displayed correctly on the accounts page.
- [x] **Domain-based logo fallback**: `ProviderLogoBanner` now falls back to email-domain matching when `provider_name` is absent, so all known providers (GMX, WEB.DE, T-Online, etc.) show their logo even on legacy accounts.
- [x] **Fetch button UX improvements**: The "fetch emails" button on the accounts page now shows a "Fetch" text label for clarity, a tooltip explaining its purpose, a spinning "Fetching…" state during the API call, and a brief green "Queued!" confirmation after success.
- [x] **Pull Now**: Added "Pull Now" button on Accounts page that immediately queues a `process_mail_account` Celery task via `POST /mail-accounts/{id}/pull-now`. Button shows spinner while in flight and is disabled for inactive accounts.
- [x] Fixed 21 mypy type errors: `Column[T]` vs native type mismatches in `notification_service.py`, `mail_processor.py`, `auth.py`, `tasks.py`, `providers.py`, `mail_accounts.py`, and `main.py` (`lifespan` parameter rename).
- [x] **Provider logos rework**: Logos now displayed as full-width banner strips at the top of each account card using `next/image fill + object-contain`. Handles all aspect ratios (1:1 square to 6:1 wordmark) without distortion. Proton Mail added.
- [x] **Proton Mail provider**: Added Proton Mail preset in backend and ProviderWizard frontend. Domains: proton.me, protonmail.com, protonmail.ch, pm.me. Auto-detect and IMAP/POP3 Bridge settings included.
- [x] Redesigned user-facing Logs page to mailbox-centric "Mailbox Activity" view: shows last check status per account + only successful pulls, suppressing noise from empty polling cycles.
- [x] Added `has_emails` filter to `GET /processing-runs` and `GET /mail-accounts/{id}/processing-runs` API endpoints.
- [x] Rename entire project to **InboxConverge**: all user-visible strings, Docker container/image names, DB defaults, monitoring, and docs updated.
- [x] Domain updated to `inboxconverge.com`; contact email defaults to `christian@inboxconverge.com`.
- [x] New configurable env vars: `CONTACT_EMAIL`, `APP_URL`, `NEXT_PUBLIC_APP_NAME`.
- [x] Fixed Black formatting failure in CI (`admin.py` reformatted).
- [x] Fixed `/processing-runs` endpoint 404s caused by duplicate path prefix in `logs.py`.
- [x] Added Semantic Release workflow (`release.yml`) for automatic versioning and GitHub Releases.
- [x] Added `pyproject.toml` with `[tool.semantic_release]` configuration.
- [x] Fixed GitOps `update-k8s-manifest` job: corrected image tag computation and `yq` patterns to use `registry.cklnet.com` (private registry) matching the actual k8s manifest image references, so SHA-pinned tags are properly applied on each deploy.
- [x] Added GitOps auto-deployment step in `ci.yml` to update preprod k8s manifest in `k8s-cluster-state` repo.
- [x] Fixed GitOps `update-k8s-manifest` job: added PAT availability check to skip gracefully when `GH_PAT` secret is not configured, fixing 403 "Write access to repository not granted" pipeline failure.
- [x] Fixed Celery `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_all_enabled_accounts` — all mail accounts were silently skipped on every scheduled run.
- [x] Fixed **Test Connection** always reporting success regardless of authentication outcome.
- [x] Added `POST /mail-accounts/{account_id}/test` endpoint to test existing accounts with stored credentials.
## 🔴 Critical - Security (In Progress)
### Completed ✅
@@ -11,11 +108,13 @@ Comprehensive task breakdown for repository improvements and production readines
- [x] Implement CSRF protection middleware
- [x] Document all error codes in docs/ERRORS.md
- [x] Create security ADR (Architecture Decision Records)
- [x] Upgrade `python-jose` 3.3.0 → 3.5.0 (algorithm confusion with OpenSSH ECDSA keys, CVE, affected < 3.4.0)
### In Progress 🔨
- [ ] Enable rate limiting per user/tier
- [x] Fix bare exception handlers throughout codebase
- [x] Update datetime usage to timezone-aware (`DateTime(timezone=True)` columns and `lambda: datetime.now(timezone.utc)` defaults; fixes `DBAPIError` from asyncpg on timezone-naive columns)
- [x] Fix `Exception terminating connection` in Celery workers: call `await engine.dispose()` inside task coroutine so pooled asyncpg connections are closed before the event loop is torn down
- [ ] Validate redirect_uri to prevent open redirect vulnerabilities
- [ ] Add per-user random salt for encryption (currently deterministic)
@@ -70,12 +169,19 @@ Comprehensive task breakdown for repository improvements and production readines
- [x] Write unit tests for schemas and validation
- [x] Write unit tests for application factory and core endpoints
- [x] Reach 50%+ test coverage (currently 59%)
- [x] Write tests for Celery tasks (96% coverage for `tasks.py`)
- [x] Write unit tests for admin endpoints (87 tests, 100% coverage on admin.py)
- [x] **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components and utility functions. Installed `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. New suites: `date-utils` (30 tests), API interceptors (9 tests), `AuthGuard` (6 tests), `QueryProvider` (2 tests), `DashboardLayout` (14 tests), `NotificationWizard` (32 tests), `ProviderWizard` (20 tests). Total frontend: 119 tests across 8 suites.
- [x] **AddMailAccountModal test coverage**: 28 tests covering create/edit mode rendering, provider wizard flow, form fields, auto-detect, test connection, submit mutations, error extraction, and modal interactions. Total frontend: 147 tests across 9 suites.
- [x] **Expanded `api.ts` test coverage**: Added 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`). Every exported method now has at least one test verifying correct HTTP method, URL, parameters, and return value. Total: 65 tests in `api.test.ts`.
### In Progress 🔨
- [ ] Write unit tests for authentication (target 80%+ coverage)
- [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions)
- [x] Write unit tests for mail account endpoints (30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs/logs)
- [x] Write unit tests for provider endpoints (23 tests covering presets, Gmail credential CRUD, import labels, authorize URL, debug email, OAuth callback)
- [x] **Expanded `gmail_service.py` test coverage**: 10 new tests covering `inject_email` HttpError branch, `get_or_create_label` (existing/create/HttpError/generic-error), `inject_debug_email` full flow, `get_refreshed_token` (changed/unchanged), and `service` property lazy initialization — 24 total tests in `test_gmail_service.py`
- [ ] Write unit tests for mail processing
- [ ] Write integration tests for API endpoints
- [ ] Write tests for Celery tasks
### Not Started 📋
- [ ] Add end-to-end tests
@@ -162,12 +268,21 @@ Comprehensive task breakdown for repository improvements and production readines
## 📊 Medium Priority - Observability
### Completed ✅
- [x] Add Prometheus metrics endpoint (`/metrics`) to FastAPI backend
- [x] Instrument HTTP layer (request count + latency histograms per method/endpoint/status)
- [x] Instrument mail-processing tasks (runs, emails fetched/forwarded/failed, duration)
- [x] Instrument Gmail API operations (inject, verify, get_profile, get_label — count + latency)
- [x] Track OAuth token refreshes and credential invalidation events
- [x] Instrument auth endpoints (logins, registrations, OAuth callbacks — by method/status)
- [x] Instrument Celery tasks (count + duration per task name)
- [x] Add Prometheus scrape config (`monitoring/prometheus.yml`)
- [x] Add Grafana auto-provisioned datasource and pre-built dashboard (`monitoring/grafana/`)
- [x] Add Prometheus + Grafana services to `docker-compose.new.yml` (Grafana on port 3001)
### Not Started 📋
- [ ] Add Prometheus metrics endpoints
- [ ] Integrate Sentry for error tracking
- [ ] Add structured logging with correlation IDs
- [ ] Create Grafana dashboard templates
- [ ] Document monitoring setup
- [x] Add structured logging with correlation IDs (per-email ProcessingLog entries now captured in DB)
- [ ] Add APM (Application Performance Monitoring)
- [ ] Set up uptime monitoring
- [ ] Create runbook for common issues
@@ -182,9 +297,13 @@ Comprehensive task breakdown for repository improvements and production readines
- [x] Account enable/disable toggle (UX + backend)
- [x] Per-user SMTP configuration (UX + backend)
- [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling
- [x] Configurable Gmail import labels (default `{{source_email}}` + `imported`, editable in Settings with reset-to-default action)
- [x] Decoupled Google Sign-In from Gmail API permissions: login now requests only basic profile scopes; Gmail access is granted separately via Settings
- [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking)
- [x] **Debug email**: "Send Debug Email" button in Settings injects a test message (from christian@docuelevate.org, dated today, labelled `test` + `imported`, placed in inbox) to verify end-to-end Gmail API delivery
- [x] **Logging & reporting**: per-email ProcessingLog capture in worker; user `/logs` page; admin `/admin/logs` page; GDPR masking utilities (`gdpr.py`)
- [ ] Implement GDPR data export endpoint
- [ ] Complete notification service integration (Apprise)
- [x] Complete notification service integration (Apprise)
- [ ] Add advanced email filtering
- [ ] Implement OAuth2 for Gmail (instead of App Passwords)
- [ ] Add attachment handling improvements
@@ -214,18 +333,35 @@ because the API client layer is missing.
- [x] Mail accounts list with CRUD operations + enable/disable toggle
- [x] Settings page — Profile, Gmail API connection, SMTP relay, Account info, Security
- [x] `AddMailAccountModal` component (auto-detect, test connection, all required fields, is_enabled checkbox)
- [x] Fix `AddMailAccountModal` edit mode: backend now returns `username` in `MailAccountResponse`; all fields (including protocol, host, port, use\_ssl, username) are editable in edit mode and pre-populated from the stored account; Auto-Detect is shown in edit mode too; only password is omitted from the update payload when left blank
- [x] `DashboardLayout` with responsive sidebar
- [x] `AuthGuard` for protected routes
- [x] Fix wizard grey screen (Tailwind v4 `bg-opacity``/75` syntax, modal restructure)
- [x] `/auth/gmail-callback` page for Gmail OAuth one-click flow
- [x] **`/logs` page** — user processing history: paginated runs table with expandable per-email log panel (subject, sender, size, status)
- [x] **Dashboard** — "Recent Processing Runs" now wired to real `/processing-runs` endpoint; shows account name and links to `/logs`
### Not Started 📋
- [ ] End-to-end testing of frontend against backend API
- [ ] Error boundary components
- [ ] Loading skeletons / proper loading states
- [ ] Notification preferences UI
- [x] Notification channels page (`/notifications`) with full CRUD, wizard, and test button
- [x] Apprise-powered notification wizard for Telegram, Discord, Slack, Email, Webhook, and custom URLs
- [x] Admin system alert channels section (`/admin` page) with full CRUD and test
- [ ] Subscription management / billing UI
### Admin Interface ✅
- [x] Admin section in sidebar (visible to superusers only)
- [x] Admin overview page (`/admin`) with system-wide stats
- [x] User management page (`/admin/users`) — list, edit, delete users; assign plans; promote/demote admin
- [x] Plan management page (`/admin/plans`) — full CRUD for subscription plans (mailboxes, emails/day, interval, pricing)
- [x] `ADMIN_EMAIL` env var with default `christian@inboxconverge.com`; admin auto-promoted on login and on every application startup (fixes pre-existing accounts)
- [x] `is_superuser` exposed in `/users/me` response
- [x] Admin badge (purple shield) shown in top bar for superusers
- [x] Fix blank page on direct navigation to `/admin*`: moved superuser guard inside `<AuthGuard>` so auth check always runs on fresh load
- [x] **`/admin/logs` page** — system-wide processing activity: expandable run table + flat per-email log table with GDPR-masked sender addresses; filterable by user ID, status, log level
---
## 📅 Milestone Timeline
@@ -324,7 +460,8 @@ because the API client layer is missing.
1. **Immediate** (Today):
- [x] Create `frontend/src/lib/api.ts` (frontend is broken without it)
- [x] Fix remaining security issues (bare excepts, datetime, redirect_uri)
- [ ] Add backend endpoint for processing runs (needed by dashboard)
- [x] Add backend endpoint for processing runs (needed by dashboard)
- [x] Build logging & reporting: per-email ProcessingLog capture, user `/logs` page, admin `/admin/logs` page, GDPR masking
2. **This Week**:
- [ ] Enable rate limiting
+2 -2
View File
@@ -1,6 +1,6 @@
# Web Interface Quick Start Guide
The POP3 to Gmail Forwarder now includes a modern web interface built with Next.js, making it easy to manage your email forwarding without API calls.
The InboxConverge now includes a modern web interface built with Next.js, making it easy to manage your email forwarding without API calls.
## 🌐 Accessing the Web Interface
@@ -104,7 +104,7 @@ frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: pop3-frontend
container_name: inboxconverge-frontend
ports:
- "3000:3000"
environment:
+1 -1
View File
@@ -126,7 +126,7 @@ elif version == 'v2':
def get_or_create_user_salt(user_id: int) -> bytes:
# Use deterministic salt based on user_id + global salt
# OR store random salt in database per user
return hashlib.sha256(f'pop3_forwarder_user_{user_id}'.encode()).digest()
return hashlib.sha256(f'inbox_converge_user_{user_id}'.encode()).digest()
```
## Security Best Practices
+1 -1
View File
@@ -64,7 +64,7 @@ async def lifespan(app: FastAPI):
# Shutdown: cleanup
app = FastAPI(
title="POP3 Forwarder API",
title="InboxConverge API",
lifespan=lifespan,
openapi_url="/api/openapi.json",
docs_url="/api/docs",
+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;
+24
View File
@@ -0,0 +1,24 @@
import nextJest from 'next/jest.js';
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig = {
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: ['**/*.test.(ts|tsx|js|jsx)', '**/*.test.ts', '**/*.test.tsx'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/layout.tsx',
'!src/**/page.tsx',
'!src/instrumentation.ts',
'!src/test-setup.ts',
],
};
export default createJestConfig(customJestConfig);
+5047 -794
View File
File diff suppressed because it is too large Load Diff
+24 -11
View File
@@ -5,25 +5,38 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"build:docker": "next build --webpack",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "jest",
"test:ci": "jest --coverage --coverageReporters=lcov --passWithNoTests"
},
"dependencies": {
"@tanstack/react-query": "^5.95.0",
"axios": "^1.13.6",
"lucide-react": "^0.577.0",
"next": "16.1.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "^5.0.12"
"@tanstack/react-query": "^5.100.10",
"axios": "^1.16.1",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"react": "19.2.5",
"react-dom": "19.2.5",
"zustand": "^5.0.13"
},
"overrides": {
"brace-expansion": "5.0.6",
"postcss": "8.5.15"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint": "^10",
"eslint-config-next": "16.2.5",
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.4.1",
"tailwindcss": "^4",
"typescript": "^5"
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 1000 400" xmlns="http://www.w3.org/2000/svg">
<path d="M156.19727 0 0 387.89062h109.33789l18.22266-54.66992h135.37109l18.22266 54.66992h109.33789L236.89844 0h-80.70117zm598.75585 0v387.89062h85.90821V0h-85.90821zM550.5957 85.908203c-89.81391 0-157.75976 69.768357-157.75976 156.197267 0 91.11491 70.80985 156.19726 157.75976 156.19726 86.94892 0 157.49805-65.08235 157.49805-156.19726 0-86.42891-67.68514-156.197267-157.49805-156.197267zM196.54883 122.35547l40.34961 130.16406h-80.70117l40.35156-130.16406zm354.04687 46.07812c38.78896-.26 70.54883 32.79992 70.54883 73.67188 0 40.61096-31.75987 73.67383-70.54883 73.67383-38.78896 0-70.54882-33.06287-70.54882-73.67383 0-40.87196 31.75986-73.67188 70.54882-73.67188zm394.73438 120.52149c-30.19797 0-54.66797 24.47-54.66797 54.66797 0 30.19797 24.47 54.66992 54.66797 54.66992 30.19797 0 54.66992-24.47195 54.66992-54.66992 0-30.19797-24.47195-54.66797-54.66992-54.66797z"/>
</svg>

After

Width:  |  Height:  |  Size: 1007 B

+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 761 256" enable-background="new 0 0 761 256" xml:space="preserve">
<g id="Kasten_00000045588940817225090170000014015454618541351608_">
<rect fill="#21314D" width="761" height="256"/>
</g>
<g id="Blade_00000056393830187627469590000001934641379702391954_">
<g id="Blade_00000115488034364365278570000014383065578570728376_">
<path id="Würfel_00000095330303590107368250000006829467438266176953_" fill="#84BC34" d="M113.1944,94.129
c-4.475-1.0229-9.2058-2.8129-24.9323-10.9958C67.4213,72.393,62.8184,71.6259,60.1334,75.9731
c-3.58,5.7536-0.2557,20.4574,14.0644,47.3076c9.3336,17.6445,24.0373,40.2754,26.7223,43.344
c0.6393,0.6393,1.2786,0.7672,1.9179,0.3836c0.6393-0.3836,0.895-0.895,0.3836-1.79
c-2.1736-4.0915-9.8451-19.9459-13.2972-28.1289c-8.1829-19.8181-10.1008-26.4667-7.2879-28.001
c0.7672-0.3836,1.79-0.1279,4.0915,1.1507c7.9272,4.2193,25.3159,17.6445,28.3845,26.9781
c1.2786,4.0915,2.0457,8.8222,2.3014,25.5717c0.3836,22.1195,2.0457,26.211,7.4158,26.211c7.16,0,19.1787-9.973,37.0788-35.0332
c11.7629-16.3659,25.5716-39.5083,26.978-43.2162c0.3836-0.7672,0-1.4064-0.5114-1.79c-0.6393-0.3836-1.2786-0.2557-1.79,0.5114
c-2.685,3.8358-13.553,17.9002-19.5623,24.8046c-14.3201,16.4937-19.5623,21.3524-22.3752,19.8181
c-0.7672-0.3836-1.0229-1.4064-1.0229-3.8358c0-8.5665,3.7079-29.2796,10.8679-36.3118
c3.0686-3.0686,7.2879-6.0094,22.7587-14.5759c20.4573-11.3794,23.398-14.7037,20.713-19.1788
c-3.58-5.7536-18.7951-10.4844-51.1432-12.4023c-21.0966-1.2786-49.6089-0.7672-53.7004-0.1279
c-0.895,0.1279-1.2786,0.6393-1.4064,1.2786c0,0.6393,0.3836,1.1507,1.4064,1.1507c4.8586,0.2557,23.5259,2.0457,32.8595,3.3243
c22.503,3.1965,29.6631,5.1143,29.6631,8.1829c0,0.7672-0.7672,1.5343-3.0686,2.685
C144.5197,89.0146,123.2952,96.3026,113.1944,94.129"/>
</g>
</g>
<g id="freenet">
<g id="Text_00000018233044646709815630000010139649164768858556_">
<path id="net_00000118388088504095158600000004163244688393149569_" fill="#FFFFFF" d="M606.3997,119.3691h-3.2249
c-12.8995,0-14.4171,1.5175-14.4171,12.899v1.8969h32.2488v-1.8969C621.0064,120.6969,619.2991,119.3691,606.3997,119.3691
M634.8544,146.4949h-46.2865c0,7.967,0.5691,10.433,2.4661,12.1402c1.7073,1.7072,3.9837,2.2763,12.1407,2.2763h3.2249
c7.9674,0,10.2437-0.3794,12.1407-2.2763c0.9485-0.9485,1.5176-1.8969,1.897-3.4144h14.2274
c-2.2764,13.468-9.2952,18.5897-25.0402,18.5897h-9.8644c-9.2952,0-14.9861-1.7072-19.1595-6.0701
c-4.1734-4.1732-5.8807-9.6742-5.8807-19.1588V131.699c0-9.4845,1.7073-14.9856,5.8807-19.1588
c4.363-4.3629,10.054-6.0701,19.1595-6.0701h9.8644c9.2952,0,14.9862,1.7072,19.1596,5.8804
c4.363,4.1732,5.8806,9.4845,5.8806,19.3485L634.8544,146.4949z M693.8507,173.6206h-13.6583
c-9.2952,0-14.7964-1.7072-19.1596-6.0701c-4.363-4.1732-5.8806-9.6742-5.8806-19.1588v-28.4536h-8.3467v-13.0887h8.3467V90.3464
h14.0377v16.5031h19.1596l-1.7073,13.0887h-17.4523v28.6433c0,10.0536,2.4661,11.9505,14.4171,11.9505h10.4334v13.0887
L693.8507,173.6206z M533.9347,106.6598c9.2952,0,14.7964,1.7072,19.1595,6.0701c4.3631,4.1732,5.8807,9.6742,5.8807,19.1588
v41.732h-14.0377v-39.266c0-7.7773-0.5691-10.2433-2.4661-12.1402c-1.7073-1.8969-3.9837-2.2763-12.1407-2.2763h-16.1244v53.6825
h-14.0377v-66.9608H533.9347L533.9347,106.6598z"/>
<path id="free_00000134962383033659826450000006705828997229569450_" fill="#FFFFFF" d="M277.4621,119.9381h-18.9699v53.6825
h-14.0377v-53.6825h-8.3467v-13.0887h8.3467c0.1897-9.8639,1.7073-14.6062,5.8807-18.7794
C254.6983,83.7072,260.3893,82,269.4948,82h13.6583v13.0887h-10.4334c-11.951,0-14.2274,1.3278-14.4171,11.5711h20.8669
L277.4621,119.9381L277.4621,119.9381z"/>
<path fill="#FFFFFF" d="M484.6131,131.8887c0-10.0536-1.7073-15.1753-5.8807-19.3484c-4.363-4.1732-10.054-5.8804-19.1596-5.8804
h-9.8643c-9.2952,0-14.9862,1.7072-19.1596,6.0701c-4.3631,4.3629-5.8807,9.8639-5.8807,19.1588v16.8825
c0,9.4845,1.7073,14.7959,5.8807,19.1588c4.363,4.3629,10.054,6.0701,19.1596,6.0701h9.8643
c15.5553,0,22.5741-5.1216,25.0403-18.5897h-14.2274c-0.3794,1.7072-0.9485,2.6557-1.897,3.4144
c-1.897,1.8969-4.1734,2.2763-12.1407,2.2763h-3.2249c-8.157,0-10.2437-0.5691-12.1407-2.2763
c-1.897-1.8969-2.4661-4.3629-2.4661-12.1402h46.2865v-14.7959L484.6131,131.8887z M470.5754,133.9753h-32.2488v-1.8969
c0-11.3815,1.7073-12.899,14.4171-12.899h3.2249c12.8995,0,14.6068,1.5175,14.4171,12.899L470.5754,133.9753L470.5754,133.9753z
M409.3027,131.8887c0-10.0536-1.7073-15.1753-5.8806-19.3484c-4.3631-4.1732-10.054-5.8804-19.1596-5.8804h-9.8643
c-9.2952,0-14.9862,1.7072-19.1596,6.0701c-4.363,4.3629-5.8807,9.8639-5.8807,19.1588v16.8825
c0,9.4845,1.7073,14.7959,5.8807,19.1588c4.363,4.3629,10.054,6.0701,19.1596,6.0701h9.8643
c15.5553,0,22.5742-5.1216,25.0403-18.5897h-14.2274c-0.3794,1.7072-0.9485,2.6557-1.897,3.4144
c-1.897,1.8969-4.1734,2.2763-12.1407,2.2763h-3.2249c-8.157,0-10.4334-0.5691-12.1407-2.2763
c-1.897-1.8969-2.4661-4.3629-2.4661-12.1402h46.2865C409.4925,146.6845,409.3027,131.8886,409.3027,131.8887z M395.265,133.9753
h-32.2488v-1.8969c0-11.3815,1.7073-12.899,14.4171-12.899h3.2249c12.8995,0,14.6068,1.5175,14.4171,12.899L395.265,133.9753
L395.265,133.9753z M290.9308,173.6206v-66.9608h24.6608c18.0214,0,24.8505,6.4495,25.2299,22.1938h-14.2274
c-0.1897-3.035-0.9485-5.1216-2.4661-6.8289c-1.897-2.0866-4.5528-2.466-12.5201-2.466h-6.8292v53.6825h-13.848L290.9308,173.6206
z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="52 42 88 66">
<path fill="#4285f4" d="M58 108h14V74L52 59v43c0 3.32 2.69 6 6 6"/>
<path fill="#34a853" d="M120 108h14c3.32 0 6-2.69 6-6V59l-20 15"/>
<path fill="#fbbc04" d="M120 48v26l20-15v-8c0-7.42-8.47-11.65-14.4-7.2"/>
<path fill="#ea4335" d="M72 74V48l24 18 24-18v26L96 92"/>
<path fill="#c5221f" d="M52 51v8l20 15V48l-5.6-4.2c-5.94-4.45-14.4-.22-14.4 7.2"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Created with Inkscape (http://www.inkscape.org/) by Marsupilami -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
id="svg3852"
width="1024"
height="347"
viewBox="-1.2 -1.2 125.08 42.4">
<defs
id="defs3854" />
<path
style="fill:#1c449b;fill-opacity:1"
id="path3685"
d="m 109.17,18.84 10.66,-17.75 -10.88,0 -5.67,9 -6.1,-9 -11.92,0 12.57,17.73 -12.57,20 11.92,0 6.8,-11.37 8.06,11.37 10.64,0 z M 69.75,1.09 61.66,21.28 53.99,1.09 l -9.67,0 -6.42,37.78 9.77,0 3.16,-21.77 0.1,0 8.66,21.77 3.91,0 9.07,-21.77 0.12,0 2.75,21.77 9.82,0 -5.72,-37.78 z m -49.8,16.71 0,7.68 8,0 C 27.53,29.79 24.27,32 20.05,32 13.63,32 10.16,26.09 10.16,20.25 10.16,14.41 13.49,8.4 19.9,8.4 c 3.94,0 6.76,2.38 8.11,5.95 l 9.26,-3.88 C 34.04,3.47 28.06,0 20.34,0 8.25,0 0,8 0,20.19 0,32 8.2,40 20,40 26.27,40 31.8,37.76 35.46,32.64 38.75,28 39.2,23.28 39.3,17.8 Z"
class="cls-1" />
</svg>
<!-- version: 20171223, original size: 122.68 40, border: 3% -->

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg width="90" height="31" xmlns="http://www.w3.org/2000/svg" class="apple-icloud-logo" aria-hidden="true"><g fill="none" fill-rule="nonzero"><path d="M77.005 23.215c1.568 0 2.767-.779 3.382-2.06h.061V23H83V8.204h-2.552v5.793h-.061c-.615-1.302-1.855-2.092-3.392-2.092-2.726 0-4.479 2.143-4.479 5.65v.01c0 3.497 1.742 5.65 4.489 5.65Zm.768-2.153c-1.64 0-2.654-1.333-2.654-3.497v-.01c0-2.163 1.025-3.496 2.654-3.496 1.568 0 2.675 1.374 2.675 3.496v.01c0 2.133-1.096 3.497-2.675 3.497Zm-13.05 2.153c1.64 0 2.757-.758 3.32-1.917h.052V23h2.552V12.13h-2.552v6.297c0 1.579-.933 2.635-2.398 2.635-1.455 0-2.173-.872-2.173-2.41v-6.521h-2.552v7.024c0 2.522 1.363 4.06 3.751 4.06Zm-10.826 0c3.187 0 5.257-2.122 5.257-5.65v-.02c0-3.507-2.1-5.64-5.267-5.64-3.157 0-5.248 2.154-5.248 5.64v.02c0 3.518 2.06 5.65 5.258 5.65Zm.01-2.06c-1.63 0-2.665-1.303-2.665-3.59v-.02c0-2.256 1.056-3.568 2.645-3.568 1.619 0 2.664 1.302 2.664 3.568v.02c0 2.277-1.035 3.59-2.644 3.59ZM44.137 23h2.55V8.204h-2.55V23Zm-8.357.256c3.402 0 5.913-2.102 6.292-5.137l.02-.102H39.5l-.031.102c-.482 1.825-1.804 2.84-3.69 2.84-2.572 0-4.232-2.07-4.232-5.362v-.01c0-3.282 1.65-5.343 4.233-5.343 1.926 0 3.228 1.056 3.658 2.748l.051.195h2.593l-.01-.103c-.39-3.014-2.89-5.137-6.292-5.137-4.243 0-6.938 2.912-6.938 7.64v.01c0 4.727 2.685 7.66 6.938 7.66ZM25.424 10.572a1.4 1.4 0 1 0 0-2.8c-.799 0-1.424.626-1.424 1.406 0 .759.625 1.394 1.424 1.394ZM24.144 23h2.551V12.13h-2.552V23Z" fill="#1D1D1F"></path><path d="M12.9 7.598c.608-.737 1.04-1.74 1.04-2.755 0-.14-.013-.28-.038-.394-.99.038-2.183.66-2.893 1.498-.559.635-1.079 1.65-1.079 2.666 0 .153.026.305.038.356.064.012.165.025.267.025.888 0 2.004-.597 2.664-1.396Zm.697 1.612c-1.484 0-2.69.901-3.464.901-.825 0-1.903-.85-3.197-.85C4.486 9.26 2 11.292 2 15.113c0 2.387.914 4.9 2.056 6.526.977 1.37 1.827 2.5 3.057 2.5 1.218 0 1.751-.812 3.261-.812 1.536 0 1.878.787 3.223.787 1.332 0 2.22-1.218 3.058-2.425.939-1.383 1.332-2.729 1.345-2.793-.076-.025-2.626-1.066-2.626-3.986 0-2.526 2.004-3.656 2.118-3.745-1.32-1.904-3.337-1.955-3.895-1.955Z" fill="#1D1D1F" opacity=".569"></path></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 401 401" id="logo"><path fill="#fff" d="M.4.9h400v400H.4z"></path><path d="M9.8 10.2h381.3v381.3H9.8V10.2zm265.8
122v-29.1h65.2v181.1h-35.7v-152h-29.5zm-31.2
58c1.7 20-1.1 30.8-3.4 36.2l-28.2-38.3c29.6-20.8
36.9-54.8 13-76.5-19.8-14-40.1-14-61
0-21 19.3-19.6 42.6 4.2 69.8-40.7
25-44.1 52.9-26.5 82.4 18.8 27.8 60.7
34.3 97.1 10.5l7.1 9.7H285l-24.4-31.1c10.1-10
15.9-31.9 13.9-62.6h-30.1v-.1zm-55.9
15.5c-19.5 13-25.6 26.8-16 42 10.9
12.2 28.3 11.8 47.5 0l-31.5-42zm5.8-42l-8-11.8c-5.7-11.9-2.5-21.9
9.7-21.9 13.2.7 17.5 9.5 8.8 23.5l-10.5 10.2zM45.1
132.3v-29.1h65.2v181.1H74.6v-152H45.1z" fill-rule="evenodd" clip-rule="evenodd" fill="#003d8f"></path></svg>

After

Width:  |  Height:  |  Size: 943 B

+203
View File
@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1040.8409"
height="742.6319"
viewBox="0 0 1040.8409 742.6319"
version="1.1"
id="svg1"
xml:space="preserve"
sodipodi:docname="Microsoft_Outlook_for_Windows_(23H2).svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showguides="true"
inkscape:zoom="0.68540009"
inkscape:cx="518.67515"
inkscape:cy="371.31597"
inkscape:window-width="1366"
inkscape:window-height="705"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g178" /><defs
id="defs1"><linearGradient
id="linearGradient35"
inkscape:collect="always"><stop
style="stop-color:#4de2fb;stop-opacity:1;"
offset="0"
id="stop35" /><stop
style="stop-color:#28aee8;stop-opacity:1;"
offset="1"
id="stop36" /></linearGradient><linearGradient
id="linearGradient5"
inkscape:collect="always"><stop
style="stop-color:#1582d8;stop-opacity:1;"
offset="0"
id="stop5" /><stop
style="stop-color:#0a64cc;stop-opacity:1;"
offset="1"
id="stop6" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5"
id="linearGradient6"
x1="749.08496"
y1="212.54776"
x2="738.25281"
y2="843.01855"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-100)" /><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter31"
x="-0.082073628"
y="-0.083710964"
width="1.173352"
height="1.1768103"><feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.380392"
flood-color="rgb(0,0,0)"
id="feFlood30" /><feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="22.291021"
id="feGaussianBlur30" /><feOffset
result="offset"
in="blur"
dx="6.000000"
dy="6.000000"
id="feOffset30" /><feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite30" /><feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite31" /></filter><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient35"
id="linearGradient36"
x1="1773.6024"
y1="939.29254"
x2="2272.7681"
y2="729.0332"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(982.3684)" /><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter150"
x="-0.055988274"
y="-0.098944267"
width="1.139125"
height="1.2259069"><feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.258824"
flood-color="rgb(0,0,0)"
id="feFlood149" /><feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="21.052631"
id="feGaussianBlur149" /><feOffset
result="offset"
in="blur"
dx="24.500000"
dy="14.307693"
id="feOffset149" /><feComposite
result="comp1"
operator="out"
in="flood"
in2="offset"
id="feComposite149" /><feComposite
result="comp2"
operator="atop"
in="comp1"
in2="SourceGraphic"
id="feComposite150" /></filter><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter178"
x="-0.098988717"
y="-0.15388756"
width="1.2403472"
height="1.3574102"><feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.258824"
flood-color="rgb(0,0,0)"
id="feFlood177" /><feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="31.600000"
id="feGaussianBlur177" /><feOffset
result="offset"
in="blur"
dx="32.461540"
dy="24.461538"
id="feOffset177" /><feComposite
result="comp1"
operator="out"
in="flood"
in2="offset"
id="feComposite177" /><feComposite
result="comp2"
operator="atop"
in="comp1"
in2="SourceGraphic"
id="feComposite178" /></filter></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-446.11368,-168.81981)"><g
id="g178"
transform="matrix(0.7210846,0,0,0.7210846,-938.36876,150.65219)"><path
style="display:inline;opacity:1;fill:#158fda;fill-opacity:1;stroke:none;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round"
d="m 2367.3337,1055.0767 h 732.6061 c 85.7128,0 155.1967,-69.48394 155.1967,-155.19671 V 374.94388 c 0,-45.60025 -22.8376,-87.46848 -62.4463,-110.06358 L 2809.398,46.917857 C 2758.4496,17.853896 2695.916,17.96081 2645.0673,47.198812 L 2210.7257,296.94524 v 601.52342 c 0,86.49223 70.1158,156.60804 156.608,156.60804 z"
id="path34"
sodipodi:nodetypes="ccccccscsc" /><path
style="opacity:1;fill:url(#linearGradient36);stroke:none;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round"
d="m 2512.5126,955.90485 c 4.0919,-47.14548 33.902,-88.17006 77.478,-106.6248 0,0 396.8646,-222.88902 593.633,-338.22376 60.9006,-35.69659 71.5129,-108.06656 71.5129,-108.06656 V 898.2074 c 0,86.63652 -70.2328,156.8693 -156.8693,156.8693 h -475.7159 c -56.6639,0 -104.1681,-42.8129 -110.0387,-99.17185 z"
id="path35"
sodipodi:nodetypes="ccscccsc" /><g
id="g42"
style="display:inline"
transform="translate(1651.4631)"><path
d="M 1519.877,251.2168 837.52547,628.32813 973.5841,710.78516 c 50.4883,30.59807 123.685,47.15596 175.1597,18.24806 l 363.2363,-203.9922 c 48.6498,-27.32142 91.6933,-72.27526 91.6933,-150.09714 0,-79.04771 -54.5195,-105.13695 -83.7964,-123.72708 z"
style="display:inline;opacity:1;fill:#01459b;fill-opacity:1;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round;filter:url(#filter178)"
id="path41"
sodipodi:nodetypes="ccsccsc" /><path
d="m 1079.793,26.322261 c -29.9013,-0.0714 -59.8212,7.48914 -86.73635,22.685551 L 416.91603,374.29883 635.43751,506.73242 1305.6445,128.9082 1166.4199,49.421871 c -26.8423,-15.32484 -56.7257,-23.02816 -86.6269,-23.09961 z"
style="opacity:1;fill:#26aee9;fill-opacity:1;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round"
id="path38" /><path
d="M 1305.6445,128.9082 635.4375,506.73242 854.61328,639.5625 1537.8821,262.45117 c -5.424,-4.0613 -12.0375,-7.82732 -18.0051,-11.23437 z"
style="opacity:1;fill:#0078d3;fill-opacity:1;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round;filter:url(#filter150)"
id="path40"
sodipodi:nodetypes="cccccc" /></g><g
id="g6"
style="display:inline"
transform="rotate(-0.9287484,1474.7334,-101601.24)"><path
style="display:inline;opacity:1;fill:url(#linearGradient6);stroke:none;stroke-width:13.8;stroke-linecap:round;stroke-linejoin:round;filter:url(#filter31)"
d="m 380.53529,208.22328 537.09931,8.64896 a 52.98464,52.98464 0 0 1 52.1209,54.03844 l -10.4693,522.88117 a 54.6032,54.6032 0 0 1 -55.4714,53.50306 l -531.12401,-8.55273 a 55.65563,55.65563 0 0 1 -54.74837,-56.76254 l 10.48154,-523.49416 a 51.2957,51.2957 0 0 1 52.11133,-50.2622 z"
id="path5" /><path
fill="#ffffff"
d="m 490.16342,435.72863 c 13.25707,-27.52059 34.65526,-50.55134 61.4478,-66.13255 29.67095,-16.55143 63.4538,-24.80264 97.62945,-23.84585 31.67488,-0.66941 62.93009,7.1541 90.35178,22.61579 25.78231,14.98096 46.54721,36.93136 59.77465,63.18764 14.40512,28.93194 21.58426,60.77252 20.95235,92.92329 0.69807,33.60039 -6.68849,66.89327 -21.56841,97.21171 -13.54236,27.19494 -34.96606,49.92756 -61.62353,65.38926 -6.0643,3.39337 -12.30713,6.42823 -18.69446,9.09614 -23.60846,9.861 -49.19125,14.70928 -75.03188,14.12058 -32.35158,0.76072 -64.29589,-7.15141 -92.34744,-22.87362 -26.00557,-15.00042 -47.03096,-36.97566 -60.56713,-63.30246 -14.49056,-28.51363 -21.75515,-59.99567 -21.18663,-91.80803 -0.60366,-33.31437 6.52861,-66.33062 20.86345,-96.5819 z m 65.49701,155.25283 c 7.06819,17.39811 19.05454,32.48715 34.56775,43.51674 15.80126,10.76035 34.72211,16.31173 53.99372,15.83973 20.52372,0.79094 40.75387,-4.94979 57.6329,-16.35472 15.31682,-10.99467 26.98825,-26.12467 33.56993,-43.51674 7.3576,-19.42313 10.98851,-39.99846 10.71081,-60.69999 0.2274,-20.89962 -3.18575,-41.68509 -10.09476,-61.47214 -6.10205,-17.86475 -17.39723,-33.61785 -32.51352,-45.3464 -16.4559,-11.94474 -36.63162,-18.00306 -57.13398,-17.15505 -19.68921,-0.49685 -39.03386,5.09818 -55.25548,15.98274 -15.77575,11.07592 -27.98953,26.29859 -35.21345,43.88804 -16.02521,40.31873 -16.10859,84.99973 -0.23498,125.37485 z"
id="path21"
sodipodi:nodetypes="ccccccccscccccccccccccccccc"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.680212" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

Some files were not shown because too many files have changed in this diff Show More