215 Commits

Author SHA1 Message Date
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] 88e8abae4e Initial plan 2026-03-29 00:52:22 +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
118 changed files with 20553 additions and 970 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]
+120 -10
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 ──────────────────────────────────────────────────────
@@ -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
@@ -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
- name: Set up Node.js
uses: actions/setup-node@v6
with:
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@v5
with:
file: ./backend/coverage.xml
flags: unittests
name: codecov-umbrella
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@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./frontend/coverage/lcov.info
flags: frontend
name: frontend-coverage
fail_ci_if_error: false
# ── Phase 3: Security ──────────────────────────────────────────────────
@@ -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,9 +200,9 @@ 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
@@ -224,3 +251,86 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
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.PRIVATE_REGISTRY }}/inboxconverge/backend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "frontend_image=${{ env.PRIVATE_REGISTRY }}/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@v4
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.44.6
env:
IMAGE: ${{ steps.tag.outputs.backend_image }}
with:
cmd: |
yq -i '(.. | select(tag == "!!str") | select(test("^registry\\.cklnet\\.com/inboxconverge/backend:"))) = strenv(IMAGE)' \
k8s-cluster-state/apps/gmail-puller/preprod/gmail-puller-stack.yaml
- name: Update frontend image tag in preprod manifest
if: steps.pat-check.outputs.available == 'true'
uses: mikefarah/yq@v4.44.6
env:
IMAGE: ${{ steps.tag.outputs.frontend_image }}
with:
cmd: |
yq -i '(.. | select(tag == "!!str") | select(test("^registry\\.cklnet\\.com/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@v4
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"
+485 -148
View File
@@ -5,118 +5,500 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
<!-- version list -->
## v0.6.0 (2026-03-29)
### Chores
- Initial plan for frontend test coverage
([`7dd0b5f`](https://github.com/christianlouis/InboxConverge/commit/7dd0b5f6005b4d4c2db4cc0939fa6b4921ceff94))
### Features
- Add frontend test coverage for date-utils, api interceptors, AuthGuard, QueryProvider,
DashboardLayout
([`63839c3`](https://github.com/christianlouis/InboxConverge/commit/63839c3886445527258d988ad65f869cf46387a5))
- Add NotificationWizard + ProviderWizard tests, fix lint, update docs
([`769907a`](https://github.com/christianlouis/InboxConverge/commit/769907a65b956048a77e5714b998a45426d08bf4))
## v0.5.1 (2026-03-28)
### Bug Fixes
- Convert jest.config.js to jest.config.mjs (ES module syntax)
([`0a0836e`](https://github.com/christianlouis/InboxConverge/commit/0a0836e993721a5702582c78d5b86bd52da96e44))
## [Unreleased]
### Fixed
- Fixed three ESLint errors that caused CI to fail: removed unused `_setUser` store binding and unused `useAuthStore` import from `login/page.tsx`; replaced unused `_err` catch binding with a bare `catch {}` in `login/page.tsx`; removed a `useEffect` in `settings/page.tsx` that called `setProfileForm` synchronously (flagged by `react-hooks/set-state-in-effect`) — the effect was redundant because `useState` already initialises the form from the auth store's `user` object, which is the same value passed as `initialData` to `useQuery`.
- Fixed wizard to create new mail accounts showing a big grey screen: `bg-opacity-75` was removed in Tailwind CSS v4; replaced with the `/75` opacity modifier syntax (`bg-gray-500/75`) in `AddMailAccountModal` and `DashboardLayout` mobile overlay. Restructured the modal from the deprecated `inline-block align-bottom` centering trick to a proper flexbox layout with `relative z-10` on the modal content.
- Fixed mail account creation always failing with a backend validation error: `email_address` and `forward_to` are required fields in the backend schema but were missing from the `AddMailAccountModal` form. Added both fields to the form — `email_address` is auto-synced from the username input, and `forward_to` (destination Gmail address) is a new explicit field pre-populated from the logged-in user's email. Also added `delivery_method` selector and `delete_after_forward` checkbox.
- Implemented the Settings page (was a placeholder showing "coming soon"): now includes a Profile section to update name and email via `PUT /users/me`, an Account Information section showing subscription tier/status and member-since date, and a Security section.
- Fixed `sqlalchemy.exc.DBAPIError` raised by asyncpg when inserting timezone-aware `datetime.now(timezone.utc)` values into timezone-naive `DateTime` (TIMESTAMP WITHOUT TIME ZONE) columns: changed all `DateTime` column definitions in `database_models.py` to `DateTime(timezone=True)` (TIMESTAMP WITH TIME ZONE) and replaced all `default=datetime.utcnow` callable references with `default=lambda: datetime.now(timezone.utc)` for consistent, timezone-aware timestamps throughout the ORM.
- Fixed `ProgrammingError` (`cached statement plan is invalid`) raised by the asyncpg dialect during startup: SQLAlchemy's asyncpg wrapper maintains an LRU prepared-statement cache per connection (default size 100). When `Base.metadata.create_all()` executes `CREATE TYPE … AS ENUM` DDL inside a transaction, PostgreSQL invalidates the cached plans for that connection. The next enum-type existence check then fails because the dialect tries to reuse the now-stale prepared statement. Fix: set `prepared_statement_cache_size=0` in `connect_args` on `create_async_engine` to disable the cache entirely, which is the documented SQLAlchemy recommendation for DDL-at-startup scenarios.
- Fixed `UndefinedTableError` on first boot: the lifespan startup event now calls `Base.metadata.create_all()` via the async engine before attempting to seed default settings, so all tables are created automatically when the database is empty (e.g., fresh PostgreSQL container with no Alembic migrations run yet).
- Fixed frontend API calls being hardcoded to `http://localhost:8000` in production: `NEXT_PUBLIC_API_URL` is baked into the JavaScript bundle at Next.js build time, so it can never be overridden at container runtime. Replaced the `NEXT_PUBLIC_API_URL` mechanism with a Next.js Route Handler proxy at `/api/v1/[...path]` that reads `process.env.BACKEND_URL` at server startup and proxies all `/api/v1/*` requests to the real backend. The frontend Axios client now uses a relative base URL (`/api/v1`), which also eliminates the CORS issue since the browser only ever talks to the same-origin Next.js server. Update `BACKEND_URL=http://backend:8000` in `docker-compose.new.yml` (or your deployment env) to point the proxy at your backend.
- Fixed infinite spinning wheel on the home page: `authStore` no longer initialises `isLoading` as `true` unconditionally — it is now `false` when no access token exists in `localStorage`, so unauthenticated users see the landing page immediately instead of an endless spinner
- Home page now performs an auth check when a token is present in `localStorage`, redirecting authenticated users to the dashboard and clearing stale tokens on failure
- Wrapped `useSearchParams()` in a `Suspense` boundary in `frontend/src/app/auth/callback/page.tsx` to fix the Next.js build error: "useSearchParams() should be wrapped in a suspense boundary at page /auth/callback"
- Fixed TypeScript build error in `frontend/src/app/accounts/page.tsx`: replaced non-existent `account.username` with `account.email_address`, `account.last_checked_at` with `account.last_check_at`, and `account.last_error` with `account.last_error_message` (the backend intentionally excludes `username` from API responses for security)
- Fixed TypeScript error in `frontend/src/app/auth/callback/page.tsx`: `TokenResponse` doesn't include `user`; now fetches user via `userApi.getCurrentUser()` after OAuth token exchange
- Fixed TypeScript errors in `frontend/src/app/dashboard/page.tsx`: replaced non-existent `errors_count` with `emails_failed` on `ProcessingRun`
- Fixed TypeScript errors in `frontend/src/components/AddMailAccountModal.tsx`: removed invalid `account.username` access, added missing required fields to initial form state, and fixed autoDetect suggestions access
- Made `email_address`, `use_tls`, `forward_to` optional in the `MailAccountCreate` TypeScript interface to align with form usage
- Added typed suggestion fields to `autoDetect` return type in `api.ts`
- Excluded test files (`*.test.ts`, `*.spec.ts`) from TypeScript compilation in `tsconfig.json`
- Upgraded Node.js base image in `frontend/Dockerfile` from `node:18-alpine` to `node:20-alpine` to satisfy the Node.js >= 20.9.0 requirement for Next.js and fix Docker build failures
- Removed `actions/attest-build-provenance` step and associated `id-token: write` / `attestations: write` permissions from the CI `build` job — this action is not available for private user-owned repositories and caused every build to fail
- Downgraded `eslint` from `^10` to `^9` in the frontend to resolve `TypeError: contextOrFilename.getFilename is not a function` caused by ESLint 10 removing the `getFilename()` API used by `eslint-plugin-react` bundled in `eslint-config-next`
- Upgraded `sqlalchemy` from `2.0.25` to `2.0.48` to fix `AssertionError: Class ... directly inherits TypingOnly but has additional attributes` on Python 3.14 (`__static_attributes__`, `__firstlineno__`)
### Changed
- **README rewrite**: Replaced README with a focused, marketing-oriented page targeting users affected by Google's 2026 shutdown of Gmail POP import ("Check mail from other accounts") and Gmailify. Fixed broken CI badge links (pointed to non-existent workflow files; now correctly references `ci.yml`). Removed verbose technical implementation sections in favour of a clear problem statement, comparison table, 5-minute quick-start, and concise delivery-method summary.
### Added
- **Architecture Decision Records ADR-003 through ADR-010**: Added eight new ADRs covering FastAPI web framework (ADR-003), PostgreSQL database (ADR-004), Celery task retry strategy (ADR-005), key management in production (ADR-006), JWT authentication (ADR-007), Next.js frontend (ADR-008), Gmail API email delivery (ADR-009), and hybrid configuration model (ADR-010)
- `userApi.updateProfile()` method in `frontend/src/lib/api.ts` for updating user profile via `PUT /users/me`
- **Account enable/disable toggle**: `PATCH /mail-accounts/{id}/toggle` backend endpoint and a Power-icon toggle button on each account card in the UI. Disabled accounts are visually dimmed. Re-enabling an account that was in ERROR state resets its status to ACTIVE so the scheduler picks it up again.
- **`is_enabled` checkbox in edit modal**: The Add/Edit mail account form now includes an "Enabled" checkbox so the flag can be set when creating or editing an account.
- **Message deduplication tracking** (`DownloadedMessageId` table): Both POP3 and IMAP fetch paths now track downloaded message UIDs so the same message is never delivered twice, even when `delete_after_forward=False`.
- IMAP: messages are marked `\Seen` after fetching so they don't appear in future `UNSEEN` searches. DB UIDs provide a secondary guard.
- POP3: UIDL-based deduplication; messages are skipped if their UID is already in the DB.
- Old UID records are pruned by `cleanup_old_logs` after `days_to_keep` days.
- **Gmail API "one-click" OAuth grant flow**: New `GET /providers/gmail/authorize-url` and `POST /providers/gmail/callback` endpoints. The flow requests `gmail.insert + gmail.labels` scopes with `access_type=offline` so a long-lived refresh token is issued. A new `/auth/gmail-callback` frontend page handles the redirect from Google, exchanges the code, and redirects the user back to Settings.
- **Gmail token auto-refresh and persistence**: `GmailService` now records whether the `google-auth` library refreshed the access token during a Celery run. If it did, the Celery task writes the new access token and expiry back to `GmailCredential`, eliminating an unnecessary extra refresh call on the next run. A `401/403` or `invalid_grant` error during delivery marks `GmailCredential.is_valid = False` so the user is prompted to re-authorise.
- **Per-user SMTP relay configuration** (`UserSmtpConfig` table): New `GET/PUT/DELETE /users/smtp-config` endpoints let each user store their own SMTP relay (host, port, username, password, TLS flag). The Celery task checks for per-user SMTP first; falls back to the global `AppSetting` SMTP config if none is set.
- **Settings page Gmail & SMTP sections**: The Settings page now shows a "Gmail API Delivery" card with connection status, "Connect Gmail" / "Re-authorise" / "Disconnect" buttons, and a token-lifetime explanation. An "SMTP Fallback" card lets users save their own SMTP relay credentials.
- **Celery scheduling fix**: `process_all_enabled_accounts` previously only polled accounts with `status IN [ACTIVE, TESTING]`, causing ERROR-status accounts to be silently skipped forever. It now polls all `is_enabled = True` accounts regardless of status, so transient errors are retried automatically.
- **Backend URL logged at startup**: The Next.js server now logs the resolved `BACKEND_URL` (e.g. `[proxy] BACKEND_URL = http://backend:8000`) via `src/instrumentation.ts` when the server starts, making it easy to diagnose `ECONNREFUSED` proxy errors. The per-request error log now also includes the full target URL.
- **Dual-registry Docker deployment**: CI now builds separate backend and frontend images and pushes to both GHCR (`ghcr.io`) and private registry (`registry.cklnet.com`) using a matrix strategy
- **Database-backed configuration**: `AppSetting` model and `ConfigService` for hybrid config (DB-first, env-var fallback)
- Admin API endpoints for managing settings (`GET/PUT/DELETE /api/v1/settings`)
- Default settings seeded into database on first startup (SMTP, processing, Gmail API, notifications)
- Unit tests for `ConfigService` (24 tests covering resolution order, CRUD, SMTP helper, defaults)
- Gmail API delivery documentation with comparison table (Gmail API vs SMTP forwarding)
- GitHub issue templates (bug report, feature request, test needed)
- Pull request template with comprehensive checklist
- `docs/CODING_PATTERNS.md` with development best practices
- `docs/ERRORS.md` documenting all error codes
- `docs/adr/` directory with Architecture Decision Records
- `Makefile` with common development tasks
- `.pre-commit-config.yaml` for code quality enforcement
- `CHANGELOG.md` for version tracking
- Security validation for SECRET_KEY and ENCRYPTION_KEY on startup
- CSRF protection middleware
- Security headers middleware (X-Frame-Options, CSP, HSTS)
- Rate limiting per user/tier
- Comprehensive test infrastructure setup
- CI/CD pipeline for testing and security scanning
- Dependabot configuration for automated dependency updates (pip, npm, GitHub Actions, Docker)
- Copilot instructions requiring TODO.md and CHANGELOG.md updates
- Unit tests for security middleware (SecurityHeadersMiddleware, CSRFProtectionMiddleware)
- Unit tests for JWT token lifecycle (access tokens, refresh tokens, decode, edge cases)
- Unit tests for credential encryption edge cases (empty, long, unicode, special chars)
- Unit tests for FastAPI application factory and core endpoints (root, health, OpenAPI)
- Unit tests for Pydantic schema validation (users, mail accounts, notifications, subscriptions)
- Unit tests for JWT `sub` claim string encoding and token type verification
- Created `frontend/src/lib/api.ts` — API client module (fixes frontend compilation blocker)
- Reached 57% test coverage (up from 54%)
### Changed
- Configuration system now supports database-backed settings in addition to environment variables
- Celery tasks (`tasks.py`) use `ConfigService` for SMTP config instead of raw `os.getenv()` calls
- README updated with hybrid configuration docs, Gmail API vs SMTP comparison, and Apprise notifications
- Architecture docs updated to reflect Gmail API service, hybrid config, and new API endpoints
- Reorganized documentation into `docs/` directory
- Improved error handling with specific exception types
- Updated datetime usage to timezone-aware
- Enhanced logging with structured context
- Bumped Docker Python base image from 3.11-slim to 3.14-slim
- Bumped CI Python version from 3.11 to 3.14
- Bumped CI Node.js version from 18 to 20
- Bumped GitHub Actions: `actions/setup-python` v5 → v6, `actions/setup-node` v4 → v6, `docker/setup-buildx-action` v3 → v4, `codecov/codecov-action` v3 → v5
- Bumped backend dependencies: pydantic 2.5.3 → 2.12.5, pydantic-settings 2.1.0 → 2.13.1, psycopg2-binary 2.9.9 → 2.9.11, asyncpg 0.29.0 → 0.31.0, stripe 7.11.0 → 14.4.1, aioimaplib 1.0.1 → 2.0.1, google-auth-httplib2 0.2.0 → 0.3.0, celery 5.3.6 → 5.6.2, redis 5.0.1 → 7.3.0, tenacity 8.2.3 → 9.1.4
- Bumped frontend dependencies: react 19.2.3 → 19.2.4, @tanstack/react-query ^5.90.20 → ^5.95.0, axios ^1.13.5 → ^1.13.6, zustand ^5.0.11 → ^5.0.12, eslint ^9 → ^10, eslint-config-next 16.1.6 → 16.2.1
- Synced `frontend/package.json` `eslint-config-next` to `16.2.1` to match `package-lock.json` (resolves `npm ci` EUSAGE failure)
- **Celery tasks test coverage**: Added 40 unit tests for `backend/app/workers/tasks.py`, raising coverage from 9.22% to 96%. Tests cover `_as_utc` helper, `process_mail_account` (Gmail API and SMTP forwarding, credential revocation, empty-email detection, error handling), `process_all_enabled_accounts` (stale-run cleanup, interval checking), and `cleanup_old_logs` (data retention, stale-run recovery).
### Removed
- Removed CodeQL analysis from CI pipeline (was blocking builds)
- **Admin endpoint test coverage**: Added 87 unit tests for `admin.py` covering all 17 endpoints (stats, user CRUD, plan CRUD, notification config CRUD, notification testing, processing runs/logs with GDPR masking and pagination). Coverage improved from 24% to 100%.
- **Domain-based logo fallback for mail accounts**: `ProviderLogoBanner` now shows provider logos even for accounts that have no `provider_name` set, by extracting the domain from the email address and matching it against a new `DOMAIN_ICON_MAP`. Covers Gmail, GMX, WEB.DE, Yahoo Mail, AOL, T-Online, Outlook/Hotmail, IONOS, Freenet, iCloud, Posteo, and Proton Mail.
- **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components, utility functions, and API interceptors. Installed `@testing-library/react`, `@testing-library/jest-dom`, and `@testing-library/user-event`. New suites: `date-utils.test.ts` (30 tests), `api.test.ts` (9 tests), `AuthGuard.test.tsx` (6 tests), `QueryProvider.test.tsx` (2 tests), `DashboardLayout.test.tsx` (14 tests), `NotificationWizard.test.tsx` (32 tests), `ProviderWizard.test.tsx` (20 tests). Total frontend: 119 tests across 8 suites.
- **Improved `mail_processor.py` test coverage**: Added 46 unit tests for POP3 connection testing, POP3 email fetching, IMAP edge cases (stale UID store failure, delete failure, MailFetchError re-raise, star-prefix line filtering), email forwarding (STARTTLS/SSL/multipart), and routing methods. Statement coverage increased from ~42% to 98%.
### Fixed
- JWT `sub` claim now encoded as string per JWT spec (python-jose rejects integer subjects)
- `TokenPayload` schema `sub` field type changed from `int` to `str` for consistency
- Replaced deprecated `datetime.utcnow()` with `datetime.now(timezone.utc)` throughout backend
- Replaced deprecated FastAPI `@app.on_event()` handlers with modern `lifespan` context manager
- Replaced deprecated Pydantic `class Config` with `model_config = ConfigDict(...)` in all schemas
- Replaced deprecated Pydantic `.dict()` with `.model_dump()` in mail account updates
- Removed overly broad `except (GmailInjectionError, Exception)` in task error handler
- Bare exception handlers replaced with specific types
- Open redirect vulnerability in OAuth redirect_uri
- Default encryption keys security issue
- **Test fixtures**: Fixed `conftest.py` admin user fixture using non-existent `is_admin`/`is_verified` fields (should be `is_superuser`), and JWT `sub` claim using email instead of user ID.
- Convert `frontend/jest.config.js` to `jest.config.mjs` using ES module `import`/`export` syntax to resolve ESLint `@typescript-eslint/no-require-imports` error.
- Suppress noisy `ignored untagged response` INFO log lines from `aioimaplib` in Celery workers by setting the `aioimaplib` logger to WARNING level in `celery_app.py`.
- Eliminate `file_cache is only supported with oauth2client<4.0.0` warnings by passing `cache_discovery=False` to `googleapiclient.discovery.build()` in `gmail_service.py`.
## v0.5.0 (2026-03-28)
### Features
- **ci**: Add Codecov integration with frontend Jest coverage and CODECOV_TOKEN
([`0645bc0`](https://github.com/christianlouis/InboxConverge/commit/0645bc046227332ebe1fdbf05538500587515ebf))
## v0.4.5 (2026-03-28)
### Bug Fixes
- **ci**: Replace safety scan with pip-audit to fix CI EOF error
([`8152635`](https://github.com/christianlouis/InboxConverge/commit/8152635238ab4167dc3c63b31b4d54d85ace76a2))
## [Unreleased]
### Added
- Codecov integration: frontend Jest test coverage (lcov) and backend pytest-cov (XML) are now uploaded to Codecov with `CODECOV_TOKEN` authentication and separate `frontend`/`backend` flags.
### Fixed
- CI: replaced `safety scan --json` (requires interactive login in Safety CLI v3) with `pip-audit` to fix EOF error in the security scan job
## v0.4.4 (2026-03-28)
### Bug Fixes
- Show proper error message instead of [object Object] in mail account wizard
([`191fee9`](https://github.com/christianlouis/InboxConverge/commit/191fee9be4a2fd77d76ee089d09717db85deeca3))
## v0.4.3 (2026-03-28)
### Bug Fixes
- Apply parseUTC to dashboard, unify date helpers in date-utils.ts
([`db3ce61`](https://github.com/christianlouis/InboxConverge/commit/db3ce6146797e9e3a54c434b54215d131dd5ce65))
- Drop empty emails, clear stale errors on success, harden IMAP extraction
([`0720901`](https://github.com/christianlouis/InboxConverge/commit/0720901265c53d05609787166dfecbcc6b0d2b1b))
## [Unreleased]
### Security
- All dependencies updated to patched versions
- Security headers added to all API responses
- Input validation improved for all endpoints
- Credential handling audited and improved
- Upgraded `fastapi` from `0.109.1` to `0.135.2`, pulling in `starlette>=1.0.0` and fixing 4 Denial-of-Service vulnerabilities present in `starlette<=0.35.1`.
- Updated CI security scan command from deprecated `safety check` to `safety scan`.
- Added `.safety-policy.yml` to document and suppress the two unfixable `ecdsa` side-channel CVEs (64396, 64459) that the upstream maintainers have acknowledged cannot be resolved in pure Python.
### Fixed
- **Add mail account wizard showed `[object Object]`** — FastAPI validation
errors return `detail` as an array of objects, not a plain string. The
frontend error handler now inspects the type of `detail`: if it is a string
it is used directly; if it is an array the individual `msg` fields are joined;
otherwise the generic `Error.message` or a fallback string is shown. The fix
is applied to both the Save and Test-Connection error paths.
- **IMAP: fix all IMAP emails appearing empty** — `aioimaplib` stores RFC822
literal data as `bytearray`, not `bytes`. The FETCH extraction loop was
checking `isinstance(line, bytes)` which returns `False` for `bytearray`,
causing every email body to be silently skipped and the message to appear
empty. The check now accepts both types (`isinstance(line, (bytes,
bytearray))`) and converts the result to plain `bytes` before returning,
so the rest of the pipeline is unaffected. This affected every IMAP
account (T-Online, GMX, and others).
- **IMAP: fix T-Online BYE "Too many invalid IMAP commands"** — `UID STORE` flag
arguments now use RFC 3501required parentheses: `+FLAGS (\Seen)` and
`+FLAGS (\Deleted)`. Strict servers such as T-Online reject the bare
`+FLAGS \Seen` syntax as a `BAD` command and forcefully drop the connection.
- **IMAP: fix `aioimaplib` crash on `UID SEARCH`** — `aioimaplib`'s `.uid()`
wrapper explicitly blocks `"search"`, raising
`command UID only possible with COPY, FETCH, EXPUNGE (w/UIDPLUS) or STORE`.
The initial UNSEEN discovery now uses a plain `SEARCH UNSEEN` to obtain
sequence numbers, followed by a lightweight `FETCH (UID)` to resolve them
to stable UIDs; all subsequent operations (`FETCH`, `STORE`) continue to
use the UID form.
- **IMAP: filter whitespace-only RFC822 responses** — the FETCH extraction
loop now checks `email_data.strip()` so that trivially-empty byte payloads
(e.g. `\r\n`) returned by servers like T-Online are rejected at the
extraction layer rather than being forwarded as empty emails.
- **Tasks: drop completely empty emails with a warning** — after parsing
each fetched email, the processing loop now checks whether the message has
no subject, no From header, and no body. Such emails are silently dropped
(logged as `WARNING`, written to the processing log, UID recorded as seen
so the message is not retried). This handles cases where T-Online or
other servers emit genuinely empty RFC822 responses that may indicate a
server-side bug.
- **Status page: clear stale IMAP errors after a successful run** — on a
successful processing run (`emails_failed == 0`), the account's
`last_error_message` and `last_error_at` fields are now cleared.
Previously a transient IMAP error would remain visible on the dashboard
even after subsequent pulls completed without problems.
- **Dashboard: fix UTC hour-shift on "Last check" timestamps** — the dashboard
page was using `new Date(iso)` which treats timezone-naive ISO strings from
the backend as local time, shifting relative labels (e.g. "1h ago" instead
of "Just now") for users outside UTC. The dashboard now uses the same
`parseUTC()` helper that the logs page already applied. The helper functions
`formatRelative`, `formatDate`, and `formatDuration` have been consolidated
into `src/lib/date-utils.ts` and are imported by all pages.
## v0.4.2 (2026-03-28)
### Bug Fixes
- CI pipeline Node.js 20 deprecation, Codecov input, img lint errors
([`40c23c4`](https://github.com/christianlouis/InboxConverge/commit/40c23c43fe2e0c08eae8e714ce7782d0b1b12c29))
- Suppress bandit B104 false positive for 0.0.0.0 binding in config.py
([`c092dd9`](https://github.com/christianlouis/InboxConverge/commit/c092dd9ac62f17574345167f5a4a96ba7c15d65b))
### Code Style
- Apply black formatting to test_mail_processor_imap.py
([`3c8bb4b`](https://github.com/christianlouis/InboxConverge/commit/3c8bb4bfb6df209a1e90f628ab44b8af9f3601fd))
## v0.4.1 (2026-03-28)
### Bug Fixes
- Use UID-based IMAP commands and batch STORE operations to fix T-Online BYE errors
([`454452a`](https://github.com/christianlouis/InboxConverge/commit/454452a0b61cdc6176443be9a030effc4e1c9ba3))
## [Unreleased]
### Security
- Suppress bandit B104 false positive for `HOST = "0.0.0.0"` in `config.py`; binding to all interfaces is intentional for containerised deployments.
### Fixed
- CI: add `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` env to `ci.yml` to fix Node.js 20 deprecation warnings for `actions/checkout` and `actions/github-script`.
- CI: fix Codecov upload step — change invalid `file:` input to `files:` for `codecov/codecov-action@v5`.
- Frontend: replace `<img>` with `<Image />` from `next/image` in `ProviderWizard.tsx` to fix `no-img-element` ESLint errors.
- Fix timezone display in Mailbox Activity / Admin Logs pages: timestamps from the server were parsed as local time when no timezone indicator was present, causing relative times ("1h ago") and absolute dates to be shifted by the client's UTC offset.
- Worker tasks: use a fresh DB session for `send_user_notification` calls and move notifications after `db.commit()` to prevent the post-rollback `greenlet_spawn` SQLAlchemy error.
- Worker tasks: ensure `last_check_at` and error status are always committed before notifications, fixing accounts being endlessly re-queued after IMAP auth failures.
- Style: apply black formatting to `backend/tests/unit/test_mail_processor_imap.py` to fix CI black check failure.
## v0.4.0 (2026-03-28)
### Features
- **dashboard**: Replace per-run table with per-account Mailbox Status view
([`6afd7c8`](https://github.com/christianlouis/InboxConverge/commit/6afd7c871e7c694a93f119aa66f1b798c9b5b2bd))
## [Unreleased]
### Changed
- Dashboard "Recent Processing Runs" table replaced with a per-account **Mailbox Status** view: each account now shows its last-check status (OK / Error / Pending), relative last-check time, any error message, and lifetime processed/failed counters. The noisy per-run table is gone; full activity history remains available on the Logs page.
- Stats cards updated: "Emails Forwarded Today" → "Emails Processed" (all-time total from account records); "Errors" → "Accounts with Errors" (count of accounts currently showing an error).
- **IMAP: switched to UID-based commands** (`UID SEARCH`, `UID FETCH`, `UID STORE`) instead of volatile sequence-number-based commands. Sequence numbers shift whenever other messages are expunged, causing the wrong messages to be targeted and triggering "Too many invalid IMAP commands" errors on strict servers like T-Online. UIDs remain stable for the lifetime of a mailbox.
### Fixed
- Provider logos now appear on the Mail Accounts page: `provider_name` is correctly saved when creating accounts via the provider wizard and propagated through backend/frontend schemas.
- Fetch-emails button now shows a text label ("Fetch"), a descriptive tooltip, a "Fetching…" loading state, and a brief green "Queued!" confirmation after the action completes.
- **IMAP: eliminated redundant per-message `STORE +FLAGS \Seen`** — RFC822 FETCH implicitly marks a message as `\Seen` on IMAP servers, making the extra round-trip unnecessary. This reduces the total command count by *N* per polling cycle.
- **IMAP: batch `STORE +FLAGS \Deleted`** — when `delete_after_forward` is enabled, all successfully fetched UIDs are now marked for deletion in a single `UID STORE uid1,uid2,…` command instead of one command per message.
- **IMAP: batch re-marking of stale UIDs** — UIDs already tracked in the database that still appear as UNSEEN on the server (e.g. because a previous STORE failed) are now re-marked `\Seen` with a single batch command instead of one STORE per message.
- **IMAP: graceful BYE handling** — the IMAP client is now stored in a variable so the `finally` block always attempts a clean `logout()`. If the server has already sent `BYE` and closed the connection the logout failure is swallowed silently, preventing it from masking the original error.
## v0.3.2 (2026-03-28)
### Bug Fixes
- Resolve 21 mypy type errors across backend modules
([`0698eca`](https://github.com/christianlouis/InboxConverge/commit/0698eca62835d498135137176f4df7d0572cd033))
## v0.3.1 (2026-03-28)
### Bug Fixes
- Test connection always showed success; add per-account test endpoint
([`441e8b5`](https://github.com/christianlouis/InboxConverge/commit/441e8b54e958686fe52414393d715a18dfcda77c))
- Fixed 21 mypy type errors across `notification_service.py`, `mail_processor.py`, `auth.py`, `tasks.py`, `providers.py`, `mail_accounts.py`, and `main.py`
## v0.3.0 (2026-03-28)
### Features
- Mailbox-centric activity view, reduce log noise from empty polling cycles
([`dd4532f`](https://github.com/christianlouis/InboxConverge/commit/dd4532f6604e2e8c1fa27fd3da05cd5e62733842))
## v0.2.2 (2026-03-28)
### Bug Fixes
- **Test Connection always showed "success"**: frontend never checked the `success` field
returned by the backend — any HTTP 200 response (including `{success: false}`) was
displayed as "Connection successful!". Now the actual `success` value is checked and
authentication failures are shown as errors with the server's message.
- **Test Connection in edit mode required password re-entry**: added backend endpoint
`POST /mail-accounts/{account_id}/test` that decrypts and uses the stored credentials
so existing accounts can be tested without re-typing the password.
### Bug Fixes
- Resolve semantic-release CHANGELOG.md not updating properly
([`d3e0ca4`](https://github.com/christianlouis/InboxConverge/commit/d3e0ca4e33779372b53884e25cf0d5cc3478848c))
### Chores
- **deps**: Bump cryptography
([`2fd018a`](https://github.com/christianlouis/InboxConverge/commit/2fd018afa109a2fa9d087da8590323c9a9d520ed))
## [Unreleased]
### Added
- **Pull Now**: Added a "Pull Now" button (⟳) to each mail account card on the Accounts page. Clicking it immediately queues a Celery `process_mail_account` task for that account via the new `POST /mail-accounts/{id}/pull-now` backend endpoint. The button shows a spinner while the request is in flight and is disabled for inactive accounts.
- **Provider logos**: Provider logos (Gmail, GMX, WEB.DE, Outlook, Yahoo, AOL, T-Online, IONOS, Freenet, Posteo, iCloud, Proton Mail) are displayed as a full-width banner at the top of each account card. Using `next/image` with `fill` + `object-contain` ensures every logo from square icons to very wide wordmarks (up to 6:1 aspect ratio) renders correctly without distortion.
- **Proton Mail**: Added Proton Mail as a provider preset (backend + ProviderWizard). Supports IMAP and POP3 via Proton Mail Bridge (default ports 127.0.0.1:1143 / 127.0.0.1:1144). Domains: `proton.me`, `protonmail.com`, `protonmail.ch`, `pm.me`.
### Changed
- **Mailbox Activity view**: The user-facing "Logs" page has been redesigned to a mailbox-centric
layout (renamed "Mailbox Activity"). Each mail account is shown as a card with its last check
status and error (if any). Only runs that actually fetched emails are shown in the pull history,
eliminating noise from empty polling cycles. This mirrors Gmail's external POP pull UI.
- **Processing runs filter**: Added `has_emails` query parameter to `GET /processing-runs` and
`GET /mail-accounts/{id}/processing-runs`. When `has_emails=true`, only runs with
`emails_fetched > 0` are returned, allowing clients to suppress empty polling noise.
### Fixed
- **Processing log durations**: Runs that were killed by SIGKILL or failed before updating their
own status (e.g. missing SMTP credentials) now always record a correct `completed_at` and
`duration_seconds`. The error handler no longer accesses an expired SQLAlchemy ORM attribute
(`run.started_at`) after a session rollback, which previously caused the handler to crash and
left runs stuck in the `running` state indefinitely.
- **Celery datetime crash**: Fixed `TypeError: can't subtract offset-naive and offset-aware
datetimes` in `process_all_enabled_accounts` by wrapping `account.last_check_at` with the
existing `_as_utc()` helper before comparing against `datetime.now(timezone.utc)`. This
crash silently prevented every mail account from being processed on every scheduled run.
- **Per-account polling interval**: Changed the Celery beat schedule for
`process_all_enabled_accounts` from every 5 minutes (`*/5`) to every minute (`*`). The
per-account `check_interval_minutes` field already gates whether an account actually gets
processed, so accounts configured with a 1-minute interval are now polled as expected instead
of being limited to 5-minute effective intervals.
- **Processing log early-exit path**: When a mail account has no delivery method configured
(SMTP credentials missing and Gmail API not set up), the processing run now correctly sets
`completed_at`, `duration_seconds`, and `account.last_check_at`, preventing the account from
being re-queued on every scheduler tick and generating a flood of failed runs.
- **Stale-run detection moved to scheduler**: `process_all_enabled_accounts` (runs every 5 min)
now marks orphaned `running` runs as `failed` immediately. Previously this only happened in the
daily `cleanup_old_logs` task, meaning stale runs could show huge durations (hours/days).
- **Duration display rounding bug**: `formatDuration` in the frontend Processing Logs page now
uses `Math.floor` instead of `Math.round` for the seconds component, eliminating the "60s"
artefact that appeared for durations very close to a whole minute boundary.
### Changed
- **Decoupled Gmail permissions from Google Sign-In**: The "Sign in with Google" OAuth flow now only requests basic profile scopes (`openid`, `email`, `profile`) instead of also requesting Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`). Users can grant Gmail access separately via the "Connect Gmail" button in Settings. This results in a simpler, permission-free login experience.
## [0.2.1] - 2026-03-27
### Fixed
- **`SyntaxWarning` at startup**: Fixed invalid escape sequence `\S` in a docstring in `mail_processor.py` (changed to `\\S`). In Python 3.12+ this emits a `SyntaxWarning` and will become a `SyntaxError` in a future Python version.
- **Processing runs stuck in "running" state**: Fixed three related bugs in `tasks.py` that caused `ProcessingRun` records to remain in the `running` state indefinitely:
1. The exception handler now calls `await db.rollback()` before attempting to write the `failed` status, ensuring the SQLAlchemy session is in a clean state even when the original exception occurred during a DB flush/commit.
2. The error-handler `await db.commit()` is now wrapped in its own `try/except` so a commit failure inside the handler no longer propagates silently and leaves the run as `running`.
3. `account.last_check_at` is now updated in the error path, throttling re-dispatch by `process_all_enabled_accounts` and preventing a cascade of new `running` runs on every scheduler tick.
- **Stale "running" run cleanup**: `cleanup_old_logs` now marks any `ProcessingRun` that has been in the `running` state for longer than 35 minutes (Celery hard time-limit is 30 min) as `failed` with an explanatory message. This recovers runs left behind by OOM kills, container restarts, or other SIGKILL events.
## [0.2.0] - 2026-03-27
### Added
- **Automatic release numbers** (`pyproject.toml`): Added `version_toml = ["pyproject.toml:project.version"]` to `[tool.semantic_release]` so that `python-semantic-release` now writes the computed version back into the `project.version` field of `pyproject.toml` on every release. The field is initialised to `0.0.0` and will be bumped automatically from that point forward.
- **Release badge** (`README.md`): Added a dynamic "GitHub Release" shield that always shows the latest published release tag.
- **Releases section** (`README.md`): Added a "Releases" section explaining the Semantic Versioning / Conventional Commits workflow and the version-bump rules.
### Fixed
- **CI `update-k8s-manifest` job**: Fixed image tag computation and `yq` update patterns to target `registry.cklnet.com` (private registry) instead of `ghcr.io`. The k8s manifest uses private registry image references, so the previous GHCR-based patterns never matched and no tag updates were applied.
- **CI `update-k8s-manifest` job**: Enhanced the PAT validation step to verify the token actually has read access to the `k8s-cluster-state` repository (via a GitHub API probe) before attempting checkout, preventing a 403 "Write access to repository not granted" failure when the PAT exists but lacks the necessary repository access.
## [0.1.2] - 2026-03-27
### Fixed
- **CI `update-k8s-manifest` job**: Added a `Check if GH_PAT is configured` step that emits a warning and skips the GitOps steps when the `GH_PAT` secret is absent or empty, preventing a 403 "Write access to repository not granted" failure that blocked the pipeline when the secret was not set.
## [0.1.1] - 2026-03-27
### Fixed
- **CI `update-k8s-manifest` job**: Fixed checkout of `k8s-cluster-state` repo by adding `ref: main` to the `actions/checkout` step, preventing a "Not Found" 404 error caused by the action's API call to determine the default branch. Also corrected the image tag format from `main-<sha>` to `sha-<sha>` to match the tags actually generated by `docker/metadata-action@v5` with `type=sha`.
- **`ProgrammingError` on `notification_configs`**: Added Alembic migration `0001` that runs `ALTER TABLE notification_configs ADD COLUMN IF NOT EXISTS` for the `name` and `apprise_url` columns introduced by the Apprise PR. SQLAlchemy's `create_all` does not ALTER existing tables, so existing deployments were missing these columns and crashing at runtime. The migration is idempotent (`IF NOT EXISTS`) so it is safe for fresh installs too. `app/main.py` lifespan now runs `alembic upgrade head` after `create_all`.
- **`/logs` page 404**: Created missing Next.js page at `src/app/logs/page.tsx`. The user-facing "Logs" sidebar link was pointing to `/logs` but no page existed. The new page lists all processing runs with expandable per-email log details and pagination.
- **`/admin/logs` page 404**: Created missing Next.js page at `src/app/admin/logs/page.tsx`. The admin "Activity Logs" sidebar link was pointing to `/admin/logs` but no page existed. The new page shows all processing runs across all users with status filtering and pagination.
- **Black formatting**: `backend/app/api/v1/endpoints/admin.py` was not formatted correctly; reformatted to pass `black --check`.
## [0.1.0] - 2026-03-26
### Added
- **Semantic Release** (`release.yml`): Automated versioning and GitHub Release creation on every push to `main` using `python-semantic-release`. Reads conventional-commit prefixes (`feat:`, `fix:`, etc.) to determine the next version and updates `CHANGELOG.md`.
- **`pyproject.toml`**: Project metadata and `[tool.semantic_release]` configuration for `python-semantic-release`.
- **GitOps auto-deployment** (step in `ci.yml`): After a successful Docker build on `main`, a new `update-k8s-manifest` job checks out `christianlouis/k8s-cluster-state` (using the `GH_PAT` secret) and updates the backend and frontend image tags in `apps/gmail-puller/preprod/gmail-puller-stack.yaml` to the new `main-<sha>` image, then commits and pushes.
- **Processing logs & reporting** — users can now view the full history of polling runs and per-email delivery status:
- **`GET /processing-runs`** — paginated list of all processing runs for the authenticated user's mailboxes (filterable by account and status).
- **`GET /processing-runs/{id}`** — details for a single run.
- **`GET /processing-runs/{id}/logs`** — per-email log entries (subject, sender, size, delivery status, error details) for a given run.
- **`GET /mail-accounts/{id}/processing-runs`** — runs scoped to a single mailbox.
- **`GET /mail-accounts/{id}/logs`** — all per-email log entries for a single mailbox.
- **Admin log endpoints** (superuser only):
- **`GET /admin/processing-runs`** — all runs across every user, filterable by user ID, account ID, or status. Account and user email addresses are GDPR-pseudonymised.
- **`GET /admin/processing-logs`** — all per-email log entries system-wide, filterable by user, account, run, or log level. Sender (`From:`) headers are pseudonymised via `mask_from_header()`; subjects are shown as-is (user-owned content).
- **`backend/app/core/gdpr.py`** — GDPR masking utilities: `mask_email()`, `mask_name()`, `mask_from_header()` for pseudonymising PII in admin views.
- **Worker now writes `ProcessingLog` entries per email** — subject, sender, size, delivery outcome and error detail are captured for every email processed by `process_mail_account`.
- **`/logs` page** — user-facing log page with a paginated processing-run table; each row expands inline to show the per-email log for that run (subject, masked sender, size, status).
- **`/admin/logs` page** — admin view with two tabs: *Processing Runs* (expandable, fetches per-email logs on demand) and *Per-Email Logs* (flat table with GDPR-masked sender addresses). Filterable by user ID and status/level.
- **Sidebar navigation** — added *Logs* link (user) and *Activity Logs* link (admin) to `DashboardLayout`.
- **Admin overview** — added *Activity Logs* card to `/admin` page.
- **Dashboard** — "Recent Processing Runs" table now reads from the new `/processing-runs` endpoint; shows account name and a *View all logs* link.
- **Apprise alerting**: New `NotificationService` using [Apprise](https://github.com/caronc/apprise) for multi-channel push notifications (Telegram, Slack, Discord, webhooks, and 80+ other services via a single URL scheme).
- `send_user_notification` — sends to all enabled per-user Apprise channels on processing errors or failures.
- `send_admin_notification` — sends to all enabled admin-wide channels for system events.
- `test_notification` — validates an Apprise URL by dispatching a test message.
- **`NotificationConfig` model**: Added `name` (friendly label) and `apprise_url` (nullable Apprise URL) columns.
- **`AdminNotificationConfig` model**: New table (`admin_notification_configs`) for system-wide admin alert channels with `name`, `apprise_url`, `is_enabled`, `notify_on_errors`, `notify_on_system_events`, and `description` fields.
- **Notifications API** (`/api/v1/notifications`): Full CRUD endpoints (GET/POST/PUT/DELETE) plus a `/test` endpoint for user notification configs.
- **Admin Notifications API** (`/api/v1/admin/notifications`): Full CRUD + `/test` endpoints for admin notification configs, superuser-only.
- **Task integration**: `process_mail_account` now calls `send_user_notification` on Gmail credential revocation, per-email forwarding failures, and unhandled processing exceptions.
- **Configurable Gmail import labels**: Users can now define which Gmail labels are applied to imported messages from the Settings page. The default setup is opinionated: `{{source_email}}` (rendered to the mailbox address each message came from) plus `imported`, and a reset button restores those defaults instantly.
- **Prometheus metrics** (`/metrics` endpoint on the FastAPI backend, scraped every 15 s):
- **HTTP layer** — `http_requests_total` (counter, labelled `method`/`endpoint`/`status_code`) and `http_request_duration_seconds` (histogram). Path segments that are numeric IDs are normalised to `{id}` to avoid label-set explosion.
- **Mail processing** — `mail_processing_runs_total` (counter, by `status`: `completed` / `partial_failure` / `failed`), `mail_processing_emails_total` (counter, by `operation`: `fetched` / `forwarded` / `failed`), `mail_processing_duration_seconds` (histogram), `active_mail_accounts_total` (gauge — set each scheduler cycle).
- **Gmail API** — `gmail_api_requests_total` (counter, by `operation` and `status`), `gmail_api_duration_seconds` (histogram, by `operation`), `gmail_token_refreshes_total` (counter), `gmail_credentials_invalidated_total` (counter).
- **Authentication / OAuth** — `auth_logins_total` (counter, `method` × `status`), `auth_registrations_total` (counter, `method` × `status`), `oauth_callbacks_total` (counter, `provider` × `status`).
- **Celery tasks** — `celery_tasks_total` (counter, `task_name` × `status`) and `celery_task_duration_seconds` (histogram, by `task_name`).
- **All metrics** defined as module-level singletons in `backend/app/core/metrics.py` (imported by HTTP middleware, task workers, GmailService, and auth endpoints).
- **Prometheus service** added to `docker-compose.new.yml` (port 9090, 30-day retention, config from `monitoring/prometheus.yml`).
- **Grafana service** added to `docker-compose.new.yml` (port 3001, auto-provisioned datasource + pre-built dashboard). Default credentials: `admin` / `admin`.
- **Pre-built Grafana dashboard** (`monitoring/grafana/dashboards/inboxconverge.json`) with five sections: Mail Processing, Gmail API, Authentication & OAuth, HTTP API, and Celery Workers. Dashboard auto-refreshes every 30 s.
- **Admin interface**: Superusers now have access to a dedicated Admin section in the sidebar with three pages:
- **Admin Overview** (`/admin`): System-wide stats (total users, mail accounts, processing runs).
- **Manage Users** (`/admin/users`): Table of all registered users with their subscription tier, status, mail account count, and last login. Admins can edit any user's name, email, plan, active status, and promote/demote admin (superuser) privileges. Users can be deleted (with confirmation).
- **Manage Plans** (`/admin/plans`): Full CRUD for subscription plans—create, edit, and delete plans with fields for tier, name, pricing, max mailboxes, max emails/day, check interval, and support level.
- **Auto-promotion of admin email**: When the user whose email matches the `ADMIN_EMAIL` environment variable logs in or registers (via email/password or Google OAuth), they are automatically promoted to superuser. Default value is `christian@inboxconverge.com` (configurable via the `ADMIN_EMAIL` env var).
- **`is_superuser` field in API responses**: `GET /users/me` and all admin user endpoints now include `is_superuser` so the frontend can conditionally show admin UI.
- **New admin API endpoints** (all require superuser role):
- `GET /admin/users` List all users with mail account counts.
- `GET /admin/users/{id}` Get a single user's details.
- `PUT /admin/users/{id}` Update user details, plan, active status, and superuser flag.
- `DELETE /admin/users/{id}` Delete a user.
- `GET /admin/plans` List all subscription plans (including zero-price / inactive).
- `POST /admin/plans` Create a new subscription plan.
- `PUT /admin/plans/{id}` Update a subscription plan.
- `DELETE /admin/plans/{id}` Delete a subscription plan.
- **Admin badge in top bar**: Admin users see a purple shield icon and an "Admin" badge next to their email in the top navigation bar.
- **`DEFAULT_USER_TIER` env var**: Controls the subscription tier assigned to every new user on registration. Defaults to `free`. Set to `enterprise` (or any other tier) for B2B / Google Workspace installations where all employees should start on a zero-rate plan.
- **`ALLOWED_DOMAINS` env var**: Comma-separated list of permitted email domains (e.g. `company.com,subsidiary.com`). When set, only addresses from those domains may register or log in. Superusers always bypass this check. Empty (default) = no restriction (normal B2C mode).
- **Dynamic pricing section on landing page**: The home page now fetches `GET /subscriptions/plans` and renders a pricing section only when paid plans exist. In enterprise / all-zero-rate deployments the pricing section is silently hidden — the page just shows features and a "Get started free" CTA.
- **B2C copy and branding**: App renamed to **InboxConverge** throughout. Landing page hero, feature cards, how-it-works, and footer rewritten in a personal, consumer-friendly tone. Pricing updated to €0.99 / €1.99 / €2.99 per month for Good / Better / Best plans.
- **Impressum & Datenschutz pages**: Added `/impressum` (legal notice per § 5 TMG) and `/datenschutz` (comprehensive privacy policy covering GDPR/DSGVO, CCPA, LGPD, and other international regulations) as public pages. Footer links to both pages were added to the dashboard layout and the login page.
- **Gmail Debug Email**: New "Send Debug Email" button in the Gmail API settings section. When clicked, it injects a test email into the user's Gmail inbox via the Gmail API. The message includes the current date in the subject line and is automatically labelled with `test` and `imported`. Useful for verifying end-to-end Gmail API delivery without requiring a full mail-account polling cycle.
- **Unified Google OAuth flow**: Google Sign-In now requests all Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`) in the same consent screen, so users no longer need a separate "Connect Gmail" step after signing in with Google. Gmail credentials are stored automatically on successful sign-in.
- **Architecture Decision Records ADR-003 through ADR-010**: Added eight new ADRs covering FastAPI web framework (ADR-003), PostgreSQL database (ADR-004), Celery task retry strategy (ADR-005), key management in production (ADR-006), JWT authentication (ADR-007), Next.js frontend (ADR-008), Gmail API email delivery (ADR-009), and hybrid configuration model (ADR-010).
- **Account enable/disable toggle**: `PATCH /mail-accounts/{id}/toggle` backend endpoint and a Power-icon toggle button on each account card in the UI. Disabled accounts are visually dimmed. Re-enabling an account that was in ERROR state resets its status to ACTIVE so the scheduler picks it up again.
- **Message deduplication tracking** (`DownloadedMessageId` table): Both POP3 and IMAP fetch paths now track downloaded message UIDs so the same message is never delivered twice, even when `delete_after_forward=False`.
- **Gmail API "one-click" OAuth grant flow**: New `GET /providers/gmail/authorize-url` and `POST /providers/gmail/callback` endpoints with offline access and long-lived refresh tokens.
- **Gmail token auto-refresh and persistence**: `GmailService` now records whether the `google-auth` library refreshed the access token during a Celery run and persists any new access token back to `GmailCredential`, eliminating unnecessary extra refresh calls.
- **Per-user SMTP relay configuration** (`UserSmtpConfig` table): New `GET/PUT/DELETE /users/smtp-config` endpoints let each user store their own SMTP relay. The Celery task checks for per-user SMTP first; falls back to the global `AppSetting` SMTP config if none is set.
- **Settings page** — Gmail & SMTP sections: Shows a "Gmail API Delivery" card with connection status and connect/re-authorise/disconnect buttons, plus an "SMTP Fallback" card for per-user SMTP relay credentials.
- **Celery scheduling fix**: `process_all_enabled_accounts` now polls all `is_enabled = True` accounts regardless of status, so transient errors are retried automatically.
- **Backend URL logged at startup**: The Next.js server now logs the resolved `BACKEND_URL` via `src/instrumentation.ts` when the server starts, making it easy to diagnose `ECONNREFUSED` proxy errors.
- **Dual-registry Docker deployment**: CI now builds separate backend and frontend images and pushes to both GHCR (`ghcr.io`) and private registry (`registry.cklnet.com`) using a matrix strategy.
- **Database-backed configuration**: `AppSetting` model and `ConfigService` for hybrid config (DB-first, env-var fallback). Admin API endpoints for managing settings (`GET/PUT/DELETE /api/v1/settings`). Default settings seeded into database on first startup (SMTP, processing, Gmail API, notifications).
- Unit tests for `ConfigService` (24 tests covering resolution order, CRUD, SMTP helper, defaults).
- Security validation for `SECRET_KEY` and `ENCRYPTION_KEY` on startup.
- CSRF protection middleware and security headers middleware (X-Frame-Options, CSP, HSTS).
- Rate limiting per user/tier.
- Comprehensive test infrastructure setup, CI/CD pipeline, and Dependabot configuration for automated dependency updates.
### Changed
- **Project renamed to InboxConverge**: All user-visible strings, Docker container names, database defaults, Docker image paths, monitoring job names, Grafana dashboard titles, and documentation updated from the legacy names (`POP3 to Gmail Forwarder`, `InboxRescue`, `gmail-puller`, `pop3_forwarder`, etc.) to **InboxConverge** / `inboxconverge`.
- **Domain updated to `inboxconverge.com`**: All contact and administrative email addresses now default to `@inboxconverge.com` (e.g. `christian@inboxconverge.com`).
- **Configurable contact details**: Two new environment variables make contact information overridable at deployment time:
- `CONTACT_EMAIL` (default: `christian@inboxconverge.com`) — used by the frontend legal pages (Impressum, Datenschutz) and surfaced in the backend `Settings`.
- `APP_URL` (default: `https://inboxconverge.com`) — the canonical public URL of the deployment.
- `NEXT_PUBLIC_APP_NAME` (default: `InboxConverge`) — the application name shown in frontend legal-page titles; readable by Next.js server components at runtime.
- **Legacy script renamed**: `pop3_forwarder.py` → `inboxconverge.py`; root `Dockerfile` and `Makefile` updated accordingly.
- **Grafana dashboard file renamed**: `monitoring/grafana/dashboards/inboxrescue.json` → `inboxconverge.json`.
- **Note on encryption salt**: The internal PBKDF2 salt `b"pop3_forwarder_0"` in `backend/app/core/security.py` is intentionally **not** renamed — changing it would invalidate all existing encrypted credentials stored in the database.
- `NotificationConfigBase` schema: `name` field now has a default of `"My Notification"` (previously required); `apprise_url` is optional; `config` (channel-specific JSON) is optional with a default of `{}`.
- Configuration system now supports database-backed settings in addition to environment variables.
- Celery tasks (`tasks.py`) use `ConfigService` for SMTP config instead of raw `os.getenv()` calls.
- Bumped Docker Python base image from `3.11-slim` to `3.14-slim` and CI Python version from 3.11 to 3.14.
- Bumped CI Node.js version from 18 to 20.
- Bumped GitHub Actions: `actions/setup-python` v5 → v6, `actions/setup-node` v4 → v6, `docker/setup-buildx-action` v3 → v4, `codecov/codecov-action` v3 → v5.
- Bumped backend dependencies: pydantic 2.5.3 → 2.12.5, pydantic-settings 2.1.0 → 2.9.1, asyncpg 0.29.0 → 0.31.0, stripe 7.11.0 → 14.4.1, celery 5.3.6 → 5.6.2, redis 5.0.1 → 7.3.0.
- Bumped frontend dependencies: react 19.2.3 → 19.2.4, axios ^1.13.5 → ^1.13.6, eslint-config-next 16.1.6 → 16.2.1.
### Fixed
- **ESLint parse error in `DashboardLayout.tsx`**: Missing comma after `Bell` in the `lucide-react` named import caused a TypeScript parse error (`',' expected` at line 19). Added the missing comma.
- **`/processing-runs` endpoint 404s**: Routes in `logs.py` had a redundant `/processing-runs` path segment (the router was already mounted at `/processing-runs` in `api.py`). All three user-facing log endpoints now return correct results.
- **`NotificationConfigCreate` schema test failure**: `NotificationConfigBase.name` was a required field (`...`) but the unit test and the database column both use a default of `"My Notification"`. Changed the Pydantic field to `default="My Notification"` to match the DB default and allow callers to omit the field.
- Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis".
- **Mailbox limit always hit at 1**: The `subscription_plans` table was never seeded, so the limit check fell back to the env-var default. Fixed by seeding four default `SubscriptionPlan` rows at startup (Free, Good, Better, Best) and rewriting the limit check to look up the user's active plan from the DB first.
- **Zero-price plans hidden from public marketing**: `GET /subscriptions/plans` now only returns plans with `price_monthly > 0`.
- **TypeError: can't subtract offset-naive and offset-aware datetimes** in `process_mail_account` task when computing `duration_seconds`. After a database refresh, `started_at` may be returned as a naive datetime; it is now normalized to UTC before subtraction.
- **Admin user not seeing admin dashboard**: Added startup auto-promotion in `main.py` lifespan handler so users matching `ADMIN_EMAIL` are promoted to superuser on every application start.
- **Blank page on direct navigation to `/admin`, `/admin/users`, `/admin/plans`**: Moved superuser guard inside the `<AuthGuard>/<DashboardLayout>` tree so authentication always runs first.
- **Mailbox edit form**: Multiple fixes including username field pre-population, silent credential overwrite prevention, and all connection fields made editable.
- **Wizard grey screen**: `bg-opacity-75` replaced with `/75` opacity modifier syntax for Tailwind CSS v4 compatibility.
- **Mail account creation always failing**: Added missing `email_address` and `forward_to` required fields to the `AddMailAccountModal` form.
- **Settings page**: Implemented the Settings page (was a placeholder showing "coming soon").
- **`sqlalchemy.exc.DBAPIError`**: Fixed timezone-naive vs timezone-aware datetime mismatch by changing all `DateTime` columns to `DateTime(timezone=True)`.
- **`ProgrammingError` (cached statement plan is invalid)**: Disabled asyncpg prepared statement cache (`prepared_statement_cache_size=0`) to fix DDL-at-startup scenarios.
- **`UndefinedTableError` on first boot**: Lifespan startup event now calls `Base.metadata.create_all()` before attempting to seed default settings.
- **Frontend API calls hardcoded to `localhost:8000`**: Replaced `NEXT_PUBLIC_API_URL` mechanism with a Next.js Route Handler proxy at `/api/v1/[...path]` that reads `BACKEND_URL` at server startup.
- **Infinite spinning wheel on home page**: `authStore` no longer initialises `isLoading` as `true` unconditionally — it is now `false` when no access token exists in `localStorage`.
- **`useSearchParams()` Suspense boundary**: Wrapped `useSearchParams()` in a `Suspense` boundary in `frontend/src/app/auth/callback/page.tsx`.
- Various TypeScript build errors in accounts page, auth callback, and dashboard pages.
- **Node.js base image**: Upgraded from `node:18-alpine` to `node:20-alpine` to satisfy Next.js requirements.
- **Build attestation step removed**: `actions/attest-build-provenance` is not available for private user-owned repositories; removed it to fix CI.
- **ESLint downgraded**: Downgraded ESLint from `^10` to `^9` to fix `TypeError: contextOrFilename.getFilename is not a function`.
- **SQLAlchemy upgraded**: Upgraded from `2.0.25` to `2.0.48` to fix `AssertionError` on Python 3.14.
- JWT `sub` claim now encoded as string per JWT spec.
- Replaced deprecated `datetime.utcnow()` with `datetime.now(timezone.utc)` throughout backend.
- Replaced deprecated FastAPI `@app.on_event()` handlers with modern `lifespan` context manager.
- Replaced deprecated Pydantic `class Config` with `model_config = ConfigDict(...)` in all schemas.
- Open redirect vulnerability in OAuth `redirect_uri` fixed.
### Removed
- Removed CodeQL analysis from CI pipeline (was blocking builds).
### Security
- Upgraded `python-jose` from 3.3.0 to 3.5.0 to fix CVE: algorithm confusion vulnerability with OpenSSH ECDSA keys.
- Security headers added to all API responses.
- CSRF protection middleware added.
- Input validation improved for all endpoints.
- Credential handling audited and improved.
- Restricted overly permissive CORS `allow_methods` in backend.
## [1.0.0] - 2026-02-01
> **Note**: This was a pre-rewrite baseline version. The current v0.x series begins from v0.1.0 (2026-03-26).
### Added
- Multi-tenant SaaS backend with FastAPI
- JWT and OAuth2 (Google Sign-In) authentication
@@ -128,15 +510,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Apprise for multi-channel notifications
- Docker and docker-compose support
- Comprehensive API documentation with OpenAPI
- Extensive documentation (README, ARCHITECTURE, SECURITY_REPORT, etc.)
### Changed
- Upgraded from single-user script to multi-tenant platform
## [0.1.0] - 2025-12-15 (Legacy Version)
## [0.0.1] - 2025-12-15
> **Note**: Legacy single-user script release (previously labelled `[0.1.0] - 2025-12-15 (Legacy Version)`).
### Added
- Initial release of single-user pop3_forwarder.py script
- Initial release of single-user `inbox_converge.py` script
- Docker support with docker-compose
- Multiple POP3 account support
- Gmail forwarding via SMTP
@@ -144,49 +527,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Error notifications via Postmarkapp
- Environment-based configuration
- Basic logging
---
## Version History
- **[Unreleased]** - Current development (agentic coding improvements, security hardening)
- **[1.0.0]** - Multi-tenant SaaS platform (2026-02-01)
- **[0.1.0]** - Legacy single-user script (2025-12-15)
---
## How to Update This Changelog
### Categories
Use these standard categories:
- **Added** - New features
- **Changed** - Changes in existing functionality
- **Deprecated** - Soon-to-be removed features
- **Removed** - Removed features
- **Fixed** - Bug fixes
- **Security** - Vulnerability fixes
### Format
```markdown
## [Version] - YYYY-MM-DD
### Added
- New feature description (#issue-number)
### Fixed
- Bug fix description (#issue-number)
```
### Workflow
1. Add unreleased changes to `[Unreleased]` section
2. When releasing, move unreleased changes to new version section
3. Add version number, date, and comparison link
4. Create git tag: `git tag -a v1.0.0 -m "Release v1.0.0"`
---
**Maintained by**: Development Team
**Last Updated**: 2026-03-23
+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")
+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.dict())
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.dict(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]
)
+104 -11
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
@@ -112,6 +189,7 @@ async def google_oauth(
)
if not user_info.get("verified_email"):
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="error").inc()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email not verified with Google",
@@ -135,21 +213,36 @@ async def google_oauth(
# 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=SubscriptionTier.FREE,
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)
@@ -157,21 +250,21 @@ async def google_oauth(
# Create tokens
tokens = oauth_service.create_tokens_for_user(user)
OAUTH_CALLBACKS_TOTAL.labels(provider="google", status="success").inc()
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)."""
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]
)
+270 -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,7 @@ async def create_mail_account(
check_interval_minutes=account_in.check_interval_minutes,
max_emails_per_check=account_in.max_emails_per_check,
delete_after_forward=account_in.delete_after_forward,
provider_name=account_in.provider_name,
)
db.add(account)
@@ -146,9 +175,9 @@ 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)
for field, value in update_data.items():
setattr(account, field, value)
@@ -214,6 +243,36 @@ async def toggle_mail_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 +301,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 +346,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
)
+93 -3
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,7 +28,7 @@ async def create_notification_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Create notification configuration"""
"""Create a new notification configuration"""
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
db.add(config)
await db.commit()
@@ -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.dict(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)
+146 -8
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",
@@ -221,6 +250,10 @@ 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()
@@ -233,6 +266,7 @@ 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),
)
@@ -283,6 +317,33 @@ async def delete_gmail_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.",
)
credential.scopes = build_gmail_credential_scopes( # type: ignore[assignment]
extract_granted_scopes(credential.scopes),
_validated_import_label_templates(labels_in.import_label_templates),
)
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 +363,7 @@ async def get_gmail_authorize_url(
detail="Google OAuth2 is not configured on this server.",
)
scope = " ".join(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 +372,84 @@ 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.
"""
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:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No valid Gmail credentials found. Connect Gmail first.",
)
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:
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:
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()
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,
@@ -423,7 +558,10 @@ 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()
@@ -436,7 +574,7 @@ 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),
)
+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,
}
+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,
}
+35 -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
@@ -94,9 +99,18 @@ class Settings(BaseSettings):
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 +122,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
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
+49
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
@@ -289,6 +295,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)
@@ -557,6 +568,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 +613,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,
)
+196 -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,7 @@ 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)
class MailAccountCreate(MailAccountBase):
@@ -120,6 +122,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 +136,7 @@ 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)
class MailAccountResponse(MailAccountBase):
@@ -145,9 +155,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 +206,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 +228,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 +313,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 +332,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 +450,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
@@ -371,3 +504,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
+6 -1
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(
@@ -99,6 +100,10 @@ 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:
+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
+195 -1
View File
@@ -9,18 +9,31 @@ 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, timezone
from email.mime.text import MIMEText
from email.utils import format_datetime
from typing import Optional, Dict, Any
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",
]
@@ -71,7 +84,9 @@ class GmailService:
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 +126,7 @@ class GmailService:
loop = asyncio.get_event_loop()
_start = time.perf_counter()
try:
result = await loop.run_in_executor(
None,
@@ -120,6 +136,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,6 +152,9 @@ 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)}"
)
@@ -139,6 +162,9 @@ class GmailService:
# Surface 401 so callers can mark credentials as invalid
raise GmailInjectionError(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 +178,22 @@ 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 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 +206,176 @@ 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="get_profile", status="success"
).inc()
GMAIL_API_DURATION_SECONDS.labels(operation="get_profile").observe(_dur)
return result.get("emailAddress")
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 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
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
"""
Return the current access token and expiry if the token was refreshed
@@ -198,6 +391,7 @@ class GmailService:
"""
current_token = self.credentials.token
if current_token and current_token != self._initial_access_token:
GMAIL_TOKEN_REFRESHES_TOTAL.inc()
return {
"access_token": current_token,
"expiry": self.credentials.expiry,
+175 -51
View File
@@ -5,6 +5,7 @@ Supports both POP3 and IMAP protocols with secure connections.
import asyncio
import poplib
import re
import smtplib
import ssl
from email import parser
@@ -183,18 +184,18 @@ class MailProcessor:
if self.account.protocol == MailProtocol.POP3_SSL:
context = ssl.create_default_context()
pop_conn = poplib.POP3_SSL(
self.account.host,
self.account.port,
str(self.account.host),
int(self.account.port),
context=context,
timeout=30,
)
else:
pop_conn = poplib.POP3(
self.account.host, self.account.port, timeout=30
pop_conn = poplib.POP3( # type: ignore[assignment]
str(self.account.host), int(self.account.port), timeout=30
)
# Authenticate
pop_conn.user(self.account.username)
pop_conn.user(str(self.account.username))
pop_conn.pass_(self.password)
# Retrieve UIDL map: {msg_number: uid_string}
@@ -263,10 +264,31 @@ class MailProcessor:
async def _fetch_imap_emails(
self, max_count: int, already_seen_uids: Set[str]
) -> Tuple[List[bytes], List[str]]:
"""Fetch emails via IMAP, marking each message \Seen to prevent re-fetch."""
"""Fetch emails via IMAP using UID-based commands.
UIDs are stable identifiers that do not change when other messages are
expunged, unlike IMAP sequence numbers. Sequence-number-based FETCH /
STORE commands can target the wrong message mid-session and cause
servers to report "Too many invalid IMAP commands" (as seen with
t-online). This implementation:
* Uses a plain ``SEARCH UNSEEN`` to retrieve sequence numbers, then a
lightweight ``FETCH (UID)`` to resolve them to stable UIDs.
(``aioimaplib``'s ``.uid()`` wrapper does not support ``SEARCH``.)
* Uses ``UID FETCH`` and ``UID STORE`` for all subsequent operations.
* Re-marks already-processed UIDs as \\Seen in a single batch command
instead of one STORE per message.
* Relies on the implicit \\Seen flag set by RFC822 FETCH so no
additional per-message STORE is needed for newly fetched mail.
* Batches the \\Deleted STORE into a single command when
``delete_after_forward`` is enabled.
* Uses RFC 3501compliant parenthesised flag syntax, e.g.
``+FLAGS (\\Seen)``, required by strict servers such as T-Online.
"""
emails: List[bytes] = []
new_uids: List[str] = []
imap_client = None
try:
# Create IMAP client
if self.account.protocol == MailProtocol.IMAP_SSL:
@@ -282,70 +304,152 @@ class MailProcessor:
await imap_client.login(self.account.username, self.password)
await imap_client.select("INBOX")
# Search for unseen messages only
# Step 1: Standard sequence-based SEARCH for UNSEEN messages.
# aioimaplib's .uid() wrapper explicitly blocks "search", so we
# use the plain SEARCH command and resolve to UIDs in step 2.
response = await imap_client.search("UNSEEN")
message_ids = response.lines[0].split()
if (
response.result != "OK"
or not response.lines
or not response.lines[0].strip()
):
logger.info(f"Found 0 unread messages for account {self.account.id}")
# logout is handled by the finally block below
return emails, new_uids
# Limit to max_count
message_ids = message_ids[:max_count]
seq_nums: List[bytes] = response.lines[0].split()
if not seq_nums:
logger.info(f"Found 0 unread messages for account {self.account.id}")
return emails, new_uids
# Limit to max_count before doing any further work.
seq_nums = seq_nums[:max_count]
# Step 2: Resolve sequence numbers to stable UIDs with a
# lightweight FETCH (UID) so all subsequent commands are UID-based.
seq_string = b",".join(seq_nums).decode()
uid_resp = await imap_client.fetch(seq_string, "(UID)")
# Step 3: Parse UIDs from response lines.
# Each line looks like: b'1 (UID 42)'
all_unseen_uids: List[bytes] = []
for line in uid_resp.lines:
if isinstance(line, bytes):
m = re.search(rb"\bUID\s+(\d+)", line)
if m:
all_unseen_uids.append(m.group(1))
logger.info(
f"Found {len(message_ids)} unread messages for account {self.account.id}"
f"Found {len(all_unseen_uids)} unread messages for account "
f"{self.account.id}"
)
# Fetch each message
for msg_id in message_ids:
uid_str = msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id)
# Skip messages already tracked in our DB
# Step 4: Split into stale UIDs (already in our DB but still
# UNSEEN on the server — e.g. a previous STORE failed) and
# genuinely new UIDs.
stale_uid_bytes: List[bytes] = []
uids_to_fetch: List[bytes] = []
for uid in all_unseen_uids:
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
if uid_str in already_seen_uids:
logger.debug(
f"Skipping already-processed IMAP message {uid_str} "
f"for account {self.account.id}"
)
# Still mark as Seen so it doesn't show up in UNSEEN searches
await imap_client.store(msg_id, "+FLAGS", "\\Seen")
continue
stale_uid_bytes.append(uid)
else:
uids_to_fetch.append(uid)
# Re-mark stale UIDs as \Seen in a single batch STORE command so
# they stop appearing in UNSEEN searches without consuming one
# round-trip per message.
# RFC 3501 requires parentheses around flag names: +FLAGS (\Seen).
if stale_uid_bytes:
uid_set = b",".join(stale_uid_bytes).decode()
try:
response = await imap_client.fetch(msg_id, "(RFC822)")
await imap_client.uid("store", uid_set, "+FLAGS", "(\\Seen)")
except Exception as e:
logger.warning(
f"Failed to re-mark stale messages as \\Seen for account "
f"{self.account.id}: {e}"
)
# Extract email data from response
email_data = None
for line in response.lines:
if isinstance(line, bytes) and b"RFC822" in line:
# Find the email content
start_idx = line.find(b"{")
if start_idx != -1:
# Email data is in the next parts
continue
elif isinstance(line, bytes) and not line.startswith(b"*"):
email_data = line
break
# Fetch each new message. RFC822 FETCH implicitly sets \Seen on
# the server so no extra STORE per message is required.
fetched_uids: List[bytes] = []
for uid in uids_to_fetch:
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
try:
fetch_response = await imap_client.uid("fetch", uid_str, "(RFC822)")
if email_data:
# Extract raw email bytes from the FETCH response lines.
# A UID FETCH response looks like:
# [b'<seq> (UID <uid> RFC822 {<size>}', <email_bytes>, b')']
# Skip the header line (contains "RFC822") and grab the
# first substantive bytes value.
#
# aioimaplib stores IMAP literal data (the actual email
# content) as bytearray, not bytes. We must accept both
# types; bytes() converts bytearray so the rest of the
# pipeline always receives plain bytes.
email_data: Optional[bytes] = None
for line in fetch_response.lines:
if not isinstance(line, (bytes, bytearray)):
continue
if b"RFC822" in line:
continue
if line.startswith(b"*"):
continue
if line in (b")", b""):
continue
email_data = bytes(line)
break
if email_data and email_data.strip():
emails.append(email_data)
new_uids.append(uid_str)
# Always mark as Seen after fetching so the message is
# not picked up again on the next UNSEEN search.
await imap_client.store(msg_id, "+FLAGS", "\\Seen")
if self.account.delete_after_forward:
await imap_client.store(msg_id, "+FLAGS", "\\Deleted")
fetched_uids.append(uid)
else:
logger.warning(
f"No email data extracted for UID {uid_str} on "
f"account {self.account.id} "
f"(raw bytes: {len(email_data) if email_data else 0}); "
"this may indicate a server-side error producing empty "
"IMAP RFC822 responses"
)
except Exception as e:
logger.error(f"Error fetching message {msg_id}: {e}")
logger.error(
f"Error fetching message UID {uid_str} for account "
f"{self.account.id}: {e}"
)
# Expunge deleted messages
if self.account.delete_after_forward:
await imap_client.expunge()
await imap_client.logout()
# Batch-mark fetched messages for deletion if configured (one STORE
# command covers all UIDs instead of N individual commands).
# RFC 3501 requires parentheses around flag names: +FLAGS (\Deleted).
if self.account.delete_after_forward and fetched_uids:
uid_set = b",".join(fetched_uids).decode()
try:
await imap_client.uid("store", uid_set, "+FLAGS", "(\\Deleted)")
await imap_client.expunge()
except Exception as e:
logger.warning(
f"Failed to delete messages for account "
f"{self.account.id}: {e}"
)
except MailFetchError:
raise
except Exception as e:
logger.error(f"Error fetching IMAP emails: {e}")
logger.error(
f"Error fetching IMAP emails for account {self.account.id}: {e}"
)
raise MailFetchError(f"IMAP fetch error: {str(e)}")
finally:
# Always attempt a clean logout. If the server already sent BYE
# the logout call will fail silently rather than masking the real
# error.
if imap_client is not None:
try:
await imap_client.logout()
except Exception:
pass
return emails, new_uids
@@ -597,6 +701,26 @@ class MailServerAutoDetect:
"pop3_ssl": {"host": "pop.mail.de", "port": 995},
"imap_ssl": {"host": "imap.mail.de", "port": 993},
},
"proton.me": {
"name": "Proton Mail",
"imap_ssl": {"host": "127.0.0.1", "port": 1143},
"pop3_ssl": {"host": "127.0.0.1", "port": 1144},
},
"protonmail.com": {
"name": "Proton Mail",
"imap_ssl": {"host": "127.0.0.1", "port": 1143},
"pop3_ssl": {"host": "127.0.0.1", "port": 1144},
},
"protonmail.ch": {
"name": "Proton Mail",
"imap_ssl": {"host": "127.0.0.1", "port": 1143},
"pop3_ssl": {"host": "127.0.0.1", "port": 1144},
},
"pm.me": {
"name": "Proton Mail",
"imap_ssl": {"host": "127.0.0.1", "port": 1143},
"pop3_ssl": {"host": "127.0.0.1", "port": 1144},
},
}
@classmethod
@@ -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
+7 -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,9 @@ 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)
},
"cleanup-old-logs": {
"task": "app.workers.tasks.cleanup_old_logs",
+351 -13
View File
@@ -3,13 +3,24 @@ Celery tasks for background email processing.
"""
import asyncio
import email as email_lib
import time
from datetime import datetime, timedelta, timezone
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,
@@ -23,19 +34,39 @@ from app.models.database_models import (
from app.services.mail_processor import MailProcessor
from app.services.gmail_service import GmailService
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
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 +77,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 +90,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)
@@ -89,6 +126,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 +137,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
@@ -156,10 +195,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 +212,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,6 +309,7 @@ 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:
@@ -203,13 +329,48 @@ 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."
)
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}' have been revoked. Please re-authorize Gmail access in Settings.",
notify_on_error=True,
)
except Exception as notify_exc:
logger.warning(
f"Failed to send revocation notification: {notify_exc}"
)
logger.error(f"Error delivering email: {e}")
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,
)
)
# 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 +381,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 +409,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
@@ -248,6 +422,8 @@ async def process_mail_account(account_id: int):
if emails_failed == 0:
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
account.last_error_message = None # type: ignore[assignment]
account.last_error_at = None # type: ignore[assignment]
else:
account.status = AccountStatus.ERROR # type: ignore[assignment]
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
@@ -255,6 +431,38 @@ async def process_mail_account(account_id: int):
await db.commit()
# Send failure notification after the commit so the status is
# persisted even if the notification fails. Use a fresh session
# to avoid interfering with the (now-committed) main transaction.
if emails_failed > 0:
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 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 +471,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 +503,35 @@ 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".
if "account" in locals() and account is not None:
try:
async with async_session_maker() as notif_db:
await send_user_notification(
db=notif_db,
user_id=int(account.user_id),
title="InboxRescue: Mail Processing Error",
body=f"Error processing mail account '{account.name}': {e}",
notify_on_error=True,
)
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 +540,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 +579,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 +597,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 +688,20 @@ 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
)
+4 -3
View File
@@ -1,5 +1,5 @@
# Core Framework
fastapi==0.109.1 # Updated: Fixed ReDoS vulnerability (was 0.109.0)
fastapi==0.135.2 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0
uvicorn[standard]==0.27.0
pydantic==2.12.5
pydantic-settings==2.13.1
@@ -11,7 +11,8 @@ psycopg2-binary==2.9.11
asyncpg==0.31.0
# Authentication
python-jose[cryptography]==3.3.0
python-jose[cryptography]==3.5.0 # Updated: Fixed algorithm confusion with OpenSSH ECDSA keys (was 3.3.0)
bcrypt==4.3.0
python-multipart==0.0.22 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
authlib==1.6.9 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
@@ -37,7 +38,7 @@ celery==5.6.2
redis==7.3.0
# Security & Encryption
cryptography==46.0.5 # Updated: Fixed NULL pointer dereference (was 42.0.0)
cryptography==46.0.6 # Updated: Fixed NULL pointer dereference (was 42.0.0)
# Notifications
apprise==1.7.1
+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
+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,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
+50 -1
View File
@@ -3,8 +3,16 @@ Unit tests for Gmail service module.
"""
import pytest
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
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 +161,44 @@ 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"]
@@ -0,0 +1,711 @@
"""
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", "(RFC822)")
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_delete_after_forward_uses_single_batch_store(self):
"""delete_after_forward=True must issue one UID STORE \\Deleted command."""
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"]
store_calls = [c for c in mock_imap.uid.call_args_list if c.args[0] == "store"]
# Exactly one STORE for deletion covering both UIDs
assert len(store_calls) == 1
uid_set_arg = store_calls[0].args[1]
parts = set(uid_set_arg.split(","))
assert parts == {"7", "8"}
assert store_calls[0].args[2] == "+FLAGS"
# RFC 3501 parenthesised flag syntax
assert store_calls[0].args[3] == "(\\Deleted)"
# expunge must also be called
mock_imap.expunge.assert_awaited_once()
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"]
@@ -0,0 +1,782 @@
"""
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 failed" in msg
@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 protocol error" in msg
@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 "Connection failed" in msg
# ---------------------------------------------------------------------------
# _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 failed" 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 "IMAP connection failed" in msg
# ---------------------------------------------------------------------------
# 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_delete_after_forward(self, mock_poplib):
"""delete_after_forward=True issues dele() for fetched messages."""
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())
assert mock_conn.dele.call_count == 2
mock_conn.dele.assert_any_call(1)
mock_conn.dele.assert_any_call(2)
@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_delete_error_does_not_abort(self, mock_poplib):
"""A dele() error is logged but doesn't raise."""
mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")])
mock_conn.dele.side_effect = Exception("delete failed")
mock_poplib.POP3_SSL.return_value = mock_conn
account = _make_account(protocol="pop3_ssl", delete_after_forward=True)
proc = MailProcessor(account, "secret")
# Should not raise
emails, uids = await proc._fetch_pop3_emails(10, set())
assert len(emails) == 1
@patch("app.services.mail_processor.poplib")
async def test_connection_failure_raises_mail_fetch_error(self, mock_poplib):
"""Connection failure raises MailFetchError."""
mock_poplib.POP3_SSL.side_effect = OSError("connection refused")
account = _make_account(protocol="pop3_ssl")
proc = MailProcessor(account, "secret")
with pytest.raises(MailFetchError, match="POP3 fetch error"):
await proc._fetch_pop3_emails(10, set())
@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
# ---------------------------------------------------------------------------
# forward_email
# ---------------------------------------------------------------------------
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
File diff suppressed because it is too large Load Diff
+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
+87 -7
View File
@@ -2,6 +2,51 @@
Comprehensive task breakdown for repository improvements and production readiness.
## ✅ Recently Completed
- [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 +56,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 +117,14 @@ 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.
### In Progress 🔨
- [ ] Write unit tests for authentication (target 80%+ coverage)
- [ ] 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 +211,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 +240,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 +276,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 +403,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",
+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);
+4152 -21
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "jest",
"test:ci": "jest --coverage --coverageReporters=lcov --passWithNoTests"
},
"dependencies": {
"@tanstack/react-query": "^5.95.0",
@@ -19,11 +21,17 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@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": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.3.0",
"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

+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="263px" height="154px" viewBox="0 0 263 154" enable-background="new 0 0 263 154" xml:space="preserve">
<style>
.maintext {fill: #fff;}
.point {fill: #ee955c;}
</style>
<g>
<path class="maintext" d="M34.885,93.998l-8.927,35.462l-6.453,0.714l8.927-35.462L34.885,93.998z"/>
</g>
<g>
<path class="maintext" d="M53.396,91.948l-8.927,35.463l-6.453,0.714l8.927-35.463L53.396,91.948z"/>
<path class="maintext" d="M80.005,99.756c0.165,1.491,0.098,2.945-0.201,4.361c-0.3,1.417-0.908,2.703-1.825,3.857
c-0.918,1.154-2.185,2.136-3.801,2.943c-1.617,0.809-3.668,1.35-6.152,1.625l-1.653,0.183l1.341,12.113l-7.222,0.799l-3.77-34.058
l9.458-1.047c2.189-0.242,4.107-0.207,5.753,0.106c1.646,0.313,3.036,0.866,4.17,1.661c1.133,0.794,2.02,1.82,2.663,3.076
C79.407,96.635,79.82,98.094,80.005,99.756z M65.717,106.809l1.421-0.158c2.035-0.225,3.523-0.798,4.467-1.72
c0.943-0.923,1.314-2.292,1.113-4.108c-0.188-1.693-0.788-2.893-1.801-3.598c-1.014-0.705-2.507-0.949-4.479-0.73l-1.841,0.204
L65.717,106.809z"/>
<path class="maintext" d="M114.972,102.324c0.29,2.624,0.248,5.049-0.126,7.275c-0.376,2.227-1.106,4.179-2.193,5.855
c-1.088,1.678-2.545,3.041-4.37,4.092s-4.042,1.721-6.651,2.01c-2.608,0.288-4.919,0.119-6.93-0.507s-3.727-1.639-5.146-3.038
c-1.421-1.399-2.558-3.148-3.41-5.246c-0.854-2.099-1.427-4.469-1.719-7.108s-0.251-5.069,0.125-7.288s1.107-4.159,2.197-5.82
c1.09-1.662,2.547-3.014,4.373-4.057s4.052-1.71,6.677-2c2.608-0.289,4.914-0.124,6.916,0.496
c2.002,0.619,3.713,1.624,5.133,3.016c1.419,1.392,2.555,3.129,3.406,5.211C114.104,97.299,114.678,99.668,114.972,102.324z
M91.862,104.881c0.197,1.787,0.53,3.373,0.998,4.76s1.079,2.537,1.832,3.452c0.754,0.914,1.649,1.582,2.687,2.001
c1.037,0.42,2.23,0.555,3.582,0.406c1.382-0.154,2.536-0.549,3.465-1.186c0.928-0.637,1.651-1.484,2.171-2.539
c0.521-1.057,0.853-2.312,0.998-3.766c0.146-1.455,0.119-3.075-0.078-4.861s-0.526-3.373-0.986-4.76
c-0.46-1.388-1.06-2.543-1.799-3.469c-0.739-0.924-1.627-1.596-2.663-2.016c-1.037-0.419-2.239-0.554-3.605-0.402
c-1.352,0.149-2.495,0.543-3.43,1.182c-0.937,0.638-1.672,1.489-2.206,2.555s-0.878,2.326-1.031,3.781
C91.642,101.475,91.665,103.096,91.862,104.881z"/>
<path class="maintext" d="M140.364,107.225c0.168,1.521,0.06,2.921-0.325,4.197c-0.386,1.277-1.031,2.401-1.938,3.374
c-0.906,0.974-2.06,1.772-3.461,2.399c-1.399,0.627-3.041,1.043-4.919,1.252c-1.646,0.182-3.254,0.187-4.82,0.016
c-1.568-0.174-3.042-0.529-4.422-1.068l-0.743-6.709c0.785,0.275,1.577,0.537,2.374,0.787c0.798,0.25,1.604,0.463,2.418,0.641
c0.812,0.177,1.621,0.3,2.422,0.368s1.59,0.06,2.367-0.026c0.792-0.088,1.461-0.252,2.009-0.493s0.978-0.54,1.292-0.897
s0.529-0.766,0.643-1.227c0.114-0.461,0.142-0.955,0.084-1.483c-0.071-0.637-0.269-1.185-0.595-1.644s-0.757-0.875-1.294-1.248
c-0.536-0.373-1.174-0.723-1.908-1.051c-0.736-0.327-1.557-0.668-2.46-1.024c-0.756-0.294-1.605-0.655-2.549-1.085
c-0.944-0.431-1.853-0.994-2.723-1.691c-0.871-0.697-1.633-1.572-2.282-2.625c-0.651-1.052-1.062-2.354-1.234-3.907
c-0.169-1.522-0.067-2.913,0.304-4.173c0.371-1.258,0.978-2.355,1.817-3.289s1.9-1.691,3.18-2.273s2.742-0.964,4.389-1.146
c0.823-0.091,1.633-0.126,2.43-0.104c0.796,0.021,1.59,0.091,2.381,0.208c0.791,0.116,1.592,0.279,2.4,0.488
c0.81,0.21,1.646,0.462,2.513,0.76l-1.708,5.872c-0.734-0.249-1.419-0.464-2.053-0.646c-0.633-0.182-1.246-0.325-1.839-0.433
c-0.594-0.107-1.171-0.174-1.731-0.197c-0.562-0.025-1.121-0.006-1.68,0.056c-1.212,0.134-2.105,0.56-2.678,1.274
c-0.573,0.717-0.801,1.609-0.683,2.682c0.063,0.574,0.217,1.072,0.461,1.494c0.243,0.42,0.6,0.802,1.069,1.143
c0.471,0.342,1.065,0.676,1.786,1.006c0.72,0.328,1.59,0.695,2.61,1.102c1.232,0.492,2.375,1.007,3.425,1.543
c1.049,0.536,1.971,1.157,2.765,1.863c0.793,0.706,1.438,1.535,1.937,2.485C139.894,104.745,140.217,105.889,140.364,107.225z"/>
<path class="maintext" d="M159.019,114.732l-7.198,0.797l-3.104-28.047l-8.549,0.945l-0.666-6.01l24.273-2.687l0.666,6.011
l-8.526,0.943L159.019,114.732z"/>
<path class="maintext" d="M191.255,111.164l-19.614,2.172l-3.77-34.059l19.614-2.17l0.655,5.917l-12.394,1.372l0.828,7.479
l11.53-1.276l0.655,5.917l-11.53,1.276l0.972,8.782l12.393-1.372L191.255,111.164z"/>
<path class="maintext" d="M224.303,90.223c0.29,2.625,0.249,5.05-0.126,7.276c-0.374,2.227-1.105,4.179-2.192,5.855
c-1.088,1.677-2.544,3.04-4.369,4.091s-4.043,1.721-6.652,2.01s-4.919,0.12-6.93-0.506c-2.011-0.627-3.727-1.639-5.146-3.039
c-1.421-1.398-2.558-3.147-3.41-5.246c-0.854-2.098-1.427-4.468-1.719-7.107c-0.292-2.641-0.251-5.07,0.125-7.289
s1.107-4.158,2.197-5.82c1.089-1.661,2.547-3.013,4.373-4.056c1.826-1.043,4.052-1.71,6.677-2.001
c2.608-0.289,4.914-0.124,6.916,0.496c2.002,0.619,3.713,1.625,5.133,3.016c1.42,1.391,2.555,3.128,3.406,5.211
S224.009,87.566,224.303,90.223z M201.194,92.78c0.197,1.786,0.53,3.372,0.998,4.759s1.079,2.537,1.832,3.452
c0.754,0.915,1.649,1.582,2.687,2.002s2.23,0.555,3.582,0.405c1.382-0.153,2.537-0.548,3.465-1.186
c0.928-0.637,1.651-1.483,2.171-2.539c0.521-1.056,0.853-2.311,0.998-3.766c0.146-1.454,0.119-3.074-0.078-4.861
c-0.197-1.785-0.526-3.372-0.986-4.76c-0.46-1.387-1.06-2.543-1.799-3.468c-0.738-0.924-1.627-1.597-2.663-2.016
c-1.037-0.42-2.239-0.554-3.605-0.403c-1.352,0.15-2.495,0.544-3.43,1.182c-0.937,0.639-1.671,1.49-2.206,2.555
c-0.534,1.066-0.878,2.326-1.031,3.781S200.997,90.994,201.194,92.78z"/>
</g>
<g>
<path class="point" d="M229.975,103.507c-0.081-0.729-0.045-1.358,0.109-1.886c0.153-0.529,0.399-0.969,0.737-1.32
c0.339-0.352,0.748-0.625,1.23-0.82c0.481-0.195,1.01-0.324,1.584-0.387c0.544-0.061,1.067-0.049,1.572,0.037
c0.504,0.086,0.961,0.264,1.367,0.533c0.407,0.27,0.748,0.645,1.021,1.125c0.272,0.48,0.45,1.086,0.531,1.815
c0.077,0.699,0.034,1.31-0.128,1.83c-0.163,0.521-0.412,0.966-0.749,1.333c-0.336,0.367-0.741,0.652-1.215,0.854
c-0.473,0.201-0.979,0.332-1.523,0.393c-0.574,0.063-1.119,0.049-1.633-0.043c-0.513-0.093-0.974-0.282-1.383-0.566
c-0.408-0.285-0.746-0.664-1.01-1.139C230.223,104.792,230.052,104.206,229.975,103.507z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg width="506" height="86" viewBox="0 0 506 86" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M144 55.3119V69.1575H153.668V55.9167C153.668 54.6315 154.172 53.3895 155.085 52.4823C155.988 51.5751 157.223 51.0567 158.501 51.0567H168.416C173.035 51.0567 177.471 49.2099 180.737 45.9159C184.002 42.6327 185.839 38.1723 185.839 33.5284C185.839 28.8844 184.002 24.424 180.737 21.13C177.471 17.8468 173.035 16 168.405 16H144V33.3016H153.668V25.1476H167.761C169.952 25.1476 172.047 26.0224 173.593 27.5776C175.14 29.1328 176.01 31.2388 176.01 33.442C176.01 35.6452 175.14 37.7511 173.593 39.3063C172.047 40.8615 169.952 41.7363 167.761 41.7363H157.524C155.751 41.7363 153.99 42.0819 152.357 42.7731C150.714 43.4535 149.231 44.4579 147.974 45.7215C146.718 46.9851 145.729 48.4863 145.042 50.1279C144.354 51.7587 144 53.5299 144 55.3119Z" fill="#1B1340"/>
<path d="M187.332 69.1574V48.0651C187.332 39.4575 192.327 32.6103 202.328 32.6103C203.928 32.5887 205.529 32.7615 207.097 33.1395V41.8119C205.958 41.7363 204.981 41.7363 204.519 41.7363C199.223 41.7363 196.946 44.1771 196.946 49.1235V69.1574H187.332Z" fill="#1B1340"/>
<path d="M209.975 51.2728C209.975 40.6889 217.924 32.6213 228.988 32.6213C240.052 32.6213 248.001 40.6889 248.001 51.2728C248.001 61.8568 240.052 70 228.988 70C217.924 70 209.975 61.846 209.975 51.2728ZM238.527 51.2728C238.527 45.2573 234.51 40.9913 228.988 40.9913C223.456 40.9913 219.45 45.2465 219.45 51.2728C219.45 57.364 223.467 61.5544 228.988 61.5544C234.52 61.5544 238.527 57.3532 238.527 51.2728Z" fill="#1B1340"/>
<path d="M278.744 51.2728C278.744 40.6889 286.693 32.6213 297.757 32.6213C308.81 32.6213 316.759 40.6889 316.759 51.2728C316.759 61.8568 308.81 70 297.757 70C286.693 70 278.744 61.846 278.744 51.2728ZM307.285 51.2728C307.285 45.2573 303.268 40.9913 297.746 40.9913C292.225 40.9913 288.208 45.2465 288.208 51.2728C288.208 57.364 292.225 61.5544 297.746 61.5544C303.268 61.5544 307.285 57.3532 307.285 51.2728Z" fill="#1B1340"/>
<path d="M321.754 69.1576V48.8321C321.754 39.3929 327.738 32.6105 338.415 32.6105C349.017 32.6105 355 39.3821 355 48.8321V69.1576H345.461V49.5881C345.461 44.3393 343.109 41.0561 338.415 41.0561C333.721 41.0561 331.368 44.3285 331.368 49.5881V69.1576H321.754Z" fill="#1B1340"/>
<path d="M275.865 41.0663H265.489V54.3935C265.489 59.0375 267.154 61.1651 271.923 61.1651C272.374 61.1651 273.513 61.1651 274.952 61.0895V68.9303C272.987 69.4595 271.247 69.7727 269.345 69.7727C261.321 69.7727 255.864 64.9019 255.864 55.6895V41.0663H249.419V33.3768H251.031C251.664 33.3768 252.298 33.2472 252.878 33.0096C253.469 32.7612 253.995 32.4048 254.446 31.9512C254.898 31.4976 255.252 30.9684 255.499 30.3744C255.746 29.7804 255.864 29.154 255.864 28.5168V21.27H265.478V33.3768H275.855V41.0663H275.865Z" fill="#1B1340"/>
<path d="M374 17.3717H387.505L400.164 48.4366C401.292 51.0397 402.257 53.6969 403.071 56.4081H403.201C404.014 53.6969 404.98 51.0289 406.108 48.4366L418.767 17.3717H432.271V69.1754H422.487V34.4704C422.476 33.3254 422.531 32.1805 422.65 31.0463H422.487C422.183 32.2669 421.782 33.4658 421.272 34.6216L407.247 68.5705H399.133L385.064 34.6216C384.554 33.455 384.12 32.2669 383.773 31.0463H383.621C383.73 32.1805 383.784 33.3254 383.773 34.4704V69.1862H374V17.3717Z" fill="#6D4AFF"/>
<path d="M465.735 34.9997C468.566 36.4795 470.909 38.7478 472.482 41.5238C474.163 44.5374 475.009 47.9507 474.944 51.3963V69.1755H466.375L465.768 63.8396C464.65 65.773 463.012 67.35 461.038 68.4086C458.923 69.4995 456.558 70.0504 454.172 69.9964C451.124 70.0288 448.13 69.2187 445.516 67.6525C442.869 66.0539 440.721 63.7639 439.289 61.042C437.727 58.0392 436.946 54.7016 437.012 51.3207C436.968 47.9939 437.825 44.721 439.485 41.8262C441.112 39.0179 443.466 36.7064 446.318 35.1402C449.269 33.5091 452.588 32.6666 455.962 32.7098C459.357 32.645 462.719 33.4335 465.735 34.9997ZM462.6 58.9141C464.466 57.1427 465.388 54.6475 465.388 51.3207C465.507 48.5987 464.542 45.9416 462.72 43.9217C461.852 43.0144 460.81 42.3015 459.65 41.8046C458.489 41.3078 457.253 41.0593 455.994 41.0593C454.736 41.0593 453.489 41.3078 452.339 41.8046C451.178 42.3015 450.137 43.0144 449.269 43.9217C447.533 45.9956 446.579 48.6095 446.579 51.3099C446.579 54.0103 447.533 56.6242 449.269 58.6981C450.126 59.627 451.167 60.3507 452.339 60.8476C453.499 61.3336 454.758 61.5821 456.016 61.5497C457.231 61.5713 458.446 61.3444 459.585 60.8908C460.702 60.4479 461.732 59.7674 462.6 58.9141Z" fill="#6D4AFF"/>
<path d="M480.227 26.0777C479.652 25.5485 479.197 24.9112 478.882 24.1983C478.567 23.4854 478.415 22.7185 478.426 21.9408C478.415 21.1631 478.578 20.3854 478.882 19.6617C479.197 18.938 479.652 18.2899 480.227 17.7606C481.366 16.6373 482.906 16 484.512 16C486.117 16 487.657 16.6373 488.796 17.7606C489.371 18.3007 489.827 18.9488 490.13 19.6617C490.434 20.3854 490.597 21.1523 490.586 21.9408C490.597 22.7185 490.445 23.4854 490.13 24.1983C489.827 24.9112 489.371 25.5485 488.796 26.0777C487.646 27.1795 486.106 27.7952 484.512 27.7952C482.906 27.7952 481.377 27.1795 480.227 26.0777ZM489.339 69.1754H479.706V33.4767H489.339V69.1754Z" fill="#6D4AFF"/>
<path d="M506 69.1754H496.357V17.3717H506V69.1754Z" fill="#6D4AFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M83.4613 16.1551V86H95.0393C101.094 86 106 81.026 106 74.9015V2.47101C106 0.378398 103.596 -0.761032 102.003 0.575608L83.4613 16.1551Z" fill="url(#paint0_linear_11985_190892)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M67.3128 29.7407L46.1604 48.6618C42.5538 51.8829 37.1709 51.9596 33.4777 48.848L0 20.6691V2.482C0 0.38939 2.40441 -0.760996 3.99653 0.575643L45.998 35.8761C50.0595 39.2944 55.9514 39.2944 60.0129 35.8761L67.3128 29.7407Z" fill="url(#paint1_linear_11985_190892)"/>
<path d="M83.4613 16.1661L67.3128 29.7407L67.3236 29.7406L46.1604 48.6618C42.5538 51.8829 37.1709 51.9596 33.4777 48.848L0 20.6691V74.9015C0 81.0259 4.9063 86 10.9607 86L83.4613 86V16.1661Z" fill="url(#paint2_radial_11985_190892)"/>
<defs>
<linearGradient id="paint0_linear_11985_190892" x1="265.975" y1="141.754" x2="244.3" y2="-73.4041" gradientUnits="userSpaceOnUse">
<stop offset="0.271" stop-color="#E3D9FF"/>
<stop offset="1" stop-color="#7341FF"/>
</linearGradient>
<linearGradient id="paint1_linear_11985_190892" x1="62.6346" y1="86.7984" x2="18.553" y2="-87.2799" gradientUnits="userSpaceOnUse">
<stop stop-color="#E3D9FF"/>
<stop offset="1" stop-color="#7341FF"/>
</linearGradient>
<radialGradient id="paint2_radial_11985_190892" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(105.538 10.5272) scale(123.614 125.045)">
<stop offset="0.5561" stop-color="#6D4AFF"/>
<stop offset="0.9944" stop-color="#AA8EFF"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 299 69"><defs><style>.cls-1{fill:none;}.cls-2{fill:#fff;}.cls-3{clip-path:url(#clip-path);}.cls-4{fill:#1d1d1d;}.cls-5{fill:#e32178;}</style><clipPath id="clip-path"><rect class="cls-1" x="10.19" y="9.93" width="278.62" height="49.13"/></clipPath></defs><title>t-onlinede_logo</title><g id="Ebene_2" data-name="Ebene 2"><rect class="cls-2" width="299" height="69"/></g><g id="svg2"><g id="g10"><g id="g12"><g class="cls-3"><g id="g14"><g id="g20"><path id="path22" class="cls-4" d="M25.27,29.72V47.26c0,1.74,0,3.07.09,4a7.43,7.43,0,0,0,.75,3.2c1,1.62,3,2.44,6,2.44a16,16,0,0,0,3.42-.43v-4.1a8.91,8.91,0,0,1-2.38.43,2.61,2.61,0,0,1-2.51-1.34,10.77,10.77,0,0,1-.35-3.61V29.72h5.24V25.58H30.25v-10l-5,2.1.05,7.89H20.84v4.14ZM37.15,42.35h13V37h-13Zm21.68-12.6q-3.06,4.32-3.06,11.16,0,7.1,3.42,11.66t10,4.62a11.66,11.66,0,0,0,8.95-3.58q4.14-4.43,4.14-12.73,0-7.49-3.49-11.78a11.65,11.65,0,0,0-9.53-4.46,12,12,0,0,0-10.42,5.11m16,1.95c1.58,2.21,2.37,5.29,2.37,9.21q0,6.2-2.7,9.7a6.42,6.42,0,0,1-5.37,2.58,6.88,6.88,0,0,1-5.93-3c-1.6-2.28-2.41-5.37-2.41-9.28s.83-6.92,2.48-9.24a6.65,6.65,0,0,1,5.66-3,6.84,6.84,0,0,1,5.9,3.06M93.76,56.24V41.37A39.43,39.43,0,0,1,94,36.06a10,10,0,0,1,1.75-4.75,6.42,6.42,0,0,1,5.38-2.67,5.33,5.33,0,0,1,3.22,1,4.74,4.74,0,0,1,1.79,2.6,25.67,25.67,0,0,1,.49,6V56.24h5V36.42a28.52,28.52,0,0,0-.26-4.49,8.23,8.23,0,0,0-3.06-5.47,9.18,9.18,0,0,0-5.76-1.82,10.46,10.46,0,0,0-9.12,5.08V25.58H88.78V56.24Zm32-44.43h-5V56.24h5Zm14.55,0h-5v6.77h5Zm0,13.77h-5V56.24h5Zm14.42,30.66V41.37a41.18,41.18,0,0,1,.26-5.31,10,10,0,0,1,1.76-4.75,6.38,6.38,0,0,1,5.37-2.67,5.31,5.31,0,0,1,3.22,1,4.69,4.69,0,0,1,1.79,2.6,25.12,25.12,0,0,1,.49,6V56.24h5V36.42a28.52,28.52,0,0,0-.26-4.49,8.23,8.23,0,0,0-3.06-5.47,9.14,9.14,0,0,0-5.76-1.82,10.46,10.46,0,0,0-9.12,5.08V25.58h-4.65V56.24Zm50-18.84q-.75-6.48-4.2-9.77a11.24,11.24,0,0,0-8-3,11.08,11.08,0,0,0-9.28,4.52,18.77,18.77,0,0,0-3.49,11.66,19.75,19.75,0,0,0,3,11.29q3.42,5.09,10.09,5.08a10.84,10.84,0,0,0,9.31-4.56,14.58,14.58,0,0,0,2.51-6.18h-4.82a18.15,18.15,0,0,1-1.2,3.25,6.12,6.12,0,0,1-5.86,3.49q-5.17,0-7.13-5.57a17.34,17.34,0,0,1-.88-5.4h20.22c-.05-1.94-.15-3.54-.3-4.82m-19.86.81a16.69,16.69,0,0,1,1.67-5.83,6.49,6.49,0,0,1,6.05-3.74,6.37,6.37,0,0,1,6.15,4.14A16,16,0,0,1,200,38.21Zm61.78-26.4H243V30.66a9.13,9.13,0,0,0-1.79-2.74,9.78,9.78,0,0,0-7.43-3.28,10.71,10.71,0,0,0-8.92,4.46q-3.32,4.32-3.32,11.81,0,7.65,3.55,12.08a10.7,10.7,0,0,0,8.66,4.2q6.61,0,9.54-6.58v5.63h3.38Zm-6.08,19.24q2.56,3.64,2.57,10a16.81,16.81,0,0,1-2.57,9.7,7.53,7.53,0,0,1-6.51,3.42,7.37,7.37,0,0,1-6.38-3.45Q225.24,47,225.23,41a17.78,17.78,0,0,1,2.21-9.41A7.36,7.36,0,0,1,234,27.7a7.67,7.67,0,0,1,6.61,3.35m32.94-3.42a10.38,10.38,0,0,0-7.42-3,10.77,10.77,0,0,0-8.82,4.46q-3.55,4.51-3.55,11.78,0,6.93,3.06,11.39a11,11,0,0,0,9.57,4.92,10.47,10.47,0,0,0,8.92-4.39,14.63,14.63,0,0,0,2.38-5.54h-3.52q-2.15,6.87-7.75,6.87-5.18,0-7.55-5a18.21,18.21,0,0,1-1.46-7.36h20.73q0-9.82-4.59-14.13m-16.14,11.2q.58-6,3.38-8.89a7.29,7.29,0,0,1,5.34-2.34,6.4,6.4,0,0,1,3.51,1A8.9,8.9,0,0,1,273,32.29a15.89,15.89,0,0,1,1.5,6.54Z"/></g><path id="path24" class="cls-5" d="M217.62,57.08h-8v-8h8Z"/></g></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><path fill="#FFD800" d="M224 256H32c-17.6 0-32-14.4-32-32V32C0 14.4 14.4 0 32 0h192c17.6 0 32 14.4 32 32v192c0 17.6-14.4 32-32 32z"/><path fill="#333" d="M57.73 78.61c2.06.76 4.79-.61 6.77-.96 1.96-.34 10.01-2.15 8.49-.32-.85 1.03-3.06 1.04-5.03 1.61-2.73.8-7.11 1.26-7.28 4.77-.39 8.35 12.59 2.8 16.4 1.44 10.31-3.59 22.13-4.2 32.56-5.95 11.58-1.93 23.47-2.1 35.16-2.55 12.3-.47 25.53-.99 37.61.72 3.22.45 6.26.81 9.53 1.15 2.84.29 8.81 2.89 11.34 1.7 2.29-1.08 1.25-3.79 2.44-5.18 1.71-1.99 5.07-1.64 2.73-5.9-1.78-3.24-8.26-4.29-11.62-3.63-.34-2.05 1.23-1.14 1.64-2.2.59-1.53.59-3.9-.4-5.6-1.8-3.1-10.36-4.19-10.36-4.19-20.53-3.31-57.47-6.87-96.79-2.28-6.52.76-17.97 1.58-24.65 4.28-2.96 1.2-2.91 1.22-3.28 3.16-.35 2.76.97 5.97 4.29 5.28 6.35-1.31 17.57-3.12 20.78-3.94 15.86-4.04 68.64-3.25 75.44-1.64 4.2.9 27.28 1.65 27.84 5.88-53.86-2.23-74.42-2.21-122.79 3.54-4.04.67-6.47 1.55-9.65 2.63-3.95 1.34-6.6 6.17-1.17 8.18zm71.22-33.39c1.88.16 16.76.61 17.62-5.21.49-3.3-2.78-8.66-3.71-9.63-1.23-1.02-3.95 7.87-4.64-.87-.11-1.43-.47-3.63-1.15-4.98-.34-.67-.75-1.17-1.26-1.17-.32 0-.67.19-.82.29-.43.28-1.04 1.18-1.35 1.94-.84 2.08-1.21 10.1-3.29 10.23-2.26.15-2.19-5.14-1.89-6.67.05-.25-.01-.31-.11-.29-.22.06-.28.1-.46.16-1.36.52-2.91 2.1-3.02 2.22-2.13 2.18-5.01 7.89-3.56 10.51 1.51 2.69 6.05 3.33 7.64 3.47zm103.68 114.69c-.16-1.14-.49-1.84-1.27-2.89-2.8-3.84-15.98-6.4-15.98-6.36-6.62-1.45-14.23-2.69-22.74-3.73 1.21-5.75-.06-16.36-.09-20.05.17-6.59.03-23.62-.46-27.92-.38-4.05-.92-5.81-3.14-9.16-1.59-2.39-2.08-4.72-5.75-4.36-3.34.33-4.57 4.97-3.99 7.78l1.44 5.73c.6 1.19 2.22 38.34 1.85 41.57.03 1.57-.12 3.59-.16 5.3-8.67-.82-18.1-1.46-28.2-1.9l.4-.31c4.83-3.76 3.16-8.75 2.35-14.19-1.1-8.71-.26-33.42-1.3-36.4-.7-2.33-2.69-3.68-4.42-5.46-2.13-2.19-4.8-2.87-7.26-.35-2.41 2.18.6 33.39.77 47.1.22 2.87-.42 6.47.72 9.24-.93-.04-25.73-.55-27.81-.51.03-.48-.3-1.1-.53-1.57-.62-1.26-1.5-4.19-1.75-5.56-.41-2.24-.75-5.19-.86-7.9-.19-5.67.34-23.21.97-27.79.71-5.07 3.41-14.03-5.39-12.06-.95.21-5.61.79-6.86 1.77-1.45 1.14-1.31 3.99-1.36 5.57-.21 5.99-.87 24.86-.83 29.39.05 3.81.72 8.71 2.12 12.42.85 2.26 3.37 4.51 6 5.93-4.84.13-9.5.29-11.16.38-8.17.46-14.44.91-20.55 1.51-.04-.18-.04-.18-.08-.29-1.02-3.15.45-16.17 3.28-40.59.68-5.89-.35-6.12-3.94-9.44-2.06-1.9-3.8-3.19-7.37-.32-2.27 1.82-1.68 4.15-2.07 6.78-1.36 9.16-3.34 23.54-4.18 32.86-.24 2.57.12 8.46.78 12.03.06.17.03.36.12.54-3.55.48-7.12 1-11.15 1.61-9.81 1.52-18.48 1.9-24.38 4.54-3.62 1.63-3.83 2.17-4.68 3.49-.21.36-.38.77-.45 1.56-.01.1.07 1.4.17 1.79.2.78.58 1.5 1.11 2.1.58.64 1.33 1.12 2.16 1.37.45.14 3.18.37 3.47.4 6.58.66 10.21-.94 16.65-3.47 31.89-12.72 123.13-8.87 143.22-5.07 8.73 1.74 22.2 3.56 30.55 8.34 1.36.74 2.64 1.29 3.81 1.64.58.18 1.15.26 1.66.39 1.28.3 2.64.03 2.87-.02.46-.09.9-.22 1.3-.39.81-.34 1.46-.84 1.77-1.45.22-.43.5-1.27.63-1.96.09-.26.05-1.18-.01-1.66zM41.8 187.33h-8.47l7.46 38.09h9.8l5.7-25.79 5.75 25.79h9.8l7.83-38.09h-7.94l-4.79 29.14-6.23-29.14h-8.26l-6.34 29.04-4.31-29.04zm66.78 0H84.76v38.09h24.35v-6.39h-16.3v-9.48h13.9v-6.39h-13.9v-9.43h15.77v-6.4zm6.79 0v38.09h14.17c8.95 0 13.21-4.26 13.21-10.65 0-8.15-7.19-9.06-7.19-9.06s5.11-2.18 5.11-8.52c0-6.39-4.1-9.86-12.57-9.86h-12.73zm8.05 16.04V193.3h2.66c4.74 0 6.66 1.6 6.66 4.95 0 3.41-1.92 5.11-5.86 5.11l-3.46.01zm0 16.09v-10.28h4.9c4.1 0 6.34 1.6 6.34 5.01s-2.08 5.27-6.82 5.27h-4.42zm28.6 6.5c2.45 0 4.15-1.71 4.15-4.1 0-2.45-1.7-3.94-4.05-3.94-2.45 0-4.21 1.54-4.21 4.05.01 2.23 1.4 3.99 4.11 3.99zm11.06-38.63v38.09h9.48c13.11 0 19.82-7.46 19.82-19.39 0-11.67-5.91-18.7-19.02-18.7h-10.28zm8.05 31.7v-25.3h2.18c7.35 0 10.92 4.37 10.92 12.84 0 8.42-3.46 12.47-10.87 12.47l-2.23-.01zm51.1-31.7h-23.81v38.09h24.35v-6.39h-16.3v-9.48h13.9v-6.39h-13.9v-9.43h15.77v-6.4z"/></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg width="884" height="159" viewBox="0 0 884 159" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_5070)">
<path d="M587.97 126.5H556.859V41.4937H587.97V51.5258C592.562 44.2138 600.21 39.1147 611.091 39.1147C622.142 39.1147 630.302 44.0478 634.894 52.7158C640.841 42.6888 651.215 39.1147 661.413 39.1147C680.624 39.1147 690.993 51.8667 690.993 71.7557V126.5H659.882V80.2578C659.882 72.0978 656.143 67.5057 649.855 67.5057C643.562 67.5057 639.481 72.2687 639.481 80.5997V126.5H608.37V80.2578C608.37 72.0978 604.631 67.5057 598.343 67.5057C592.05 67.5057 587.969 72.2687 587.969 80.5997L587.97 126.5Z" fill="black"/>
<path d="M821.512 34.699C811.992 34.699 804.168 26.875 804.168 17.525C804.168 8.17496 811.992 0.355957 821.512 0.355957C831.369 0.355957 839.022 8.34596 839.022 17.525C839.022 26.704 831.369 34.699 821.512 34.699ZM806.039 126.5V41.494H837.15V126.5H806.039Z" fill="black"/>
<path d="M883.47 3.83105V126.5H852.359V3.83105H883.47Z" fill="black"/>
<path d="M745.063 101.559C735.054 101.559 727.415 93.925 727.415 84.085C727.415 74.24 735.054 66.606 745.063 66.606C754.903 66.606 762.542 74.24 762.542 84.085C762.542 93.925 754.903 101.559 745.063 101.559ZM761.855 49.4699C757.274 43.5339 748.797 39.4609 738.957 39.4609C715.712 39.4609 698.402 59.9909 698.402 84.0839C698.402 108.853 715.544 128.702 738.957 128.702C748.797 128.702 757.274 124.798 761.855 118.525V126.496H790.867V41.4959H761.855V49.4699Z" fill="black"/>
<path d="M144.234 101.562C134.225 101.562 126.586 93.9279 126.586 84.0879C126.586 74.2429 134.225 66.6089 144.234 66.6089C154.074 66.6089 161.713 74.2429 161.713 84.0879C161.713 93.9279 154.074 101.562 144.234 101.562ZM161.027 49.4738C156.446 43.5378 147.969 39.4648 138.129 39.4648C114.884 39.4648 97.5742 59.9959 97.5742 84.0879C97.5742 108.857 114.716 128.706 138.129 128.706C147.969 128.706 156.446 124.802 161.027 118.529V126.5H190.04V41.4998H161.027V49.4738Z" fill="#7D2EFF"/>
<path d="M252.307 39.465C242.635 39.465 235.177 43.2 229.917 49.813V3.83398H200.059V126.502H230.085V81.2C230.085 72.381 234.328 67.116 241.113 67.116C247.726 67.116 251.459 71.699 251.459 80.183V126.501H281.488V73.057C281.488 52.359 270.295 39.465 252.307 39.465Z" fill="#7D2EFF"/>
<path d="M429.267 101.225C419.427 101.225 412.306 93.5909 412.306 84.0879C412.306 74.5869 419.427 66.9478 429.267 66.9478C439.107 66.9478 446.238 74.5869 446.238 84.0879C446.238 93.5909 439.108 101.225 429.267 101.225ZM429.267 39.4648C403.313 39.4648 383.801 58.6369 383.801 84.0879C383.801 109.536 403.313 128.708 429.267 128.708C455.231 128.708 474.742 109.536 474.742 84.0879C474.743 58.6369 455.231 39.4648 429.267 39.4648Z" fill="#7D2EFF"/>
<path d="M333.236 101.225C323.396 101.225 316.275 93.5909 316.275 84.0879C316.275 74.5869 323.396 66.9478 333.236 66.9478C343.076 66.9478 350.206 74.5869 350.206 84.0879C350.207 93.5909 343.077 101.225 333.236 101.225ZM333.236 39.4648C307.282 39.4648 287.77 58.6369 287.77 84.0879C287.77 109.536 307.282 128.708 333.236 128.708C359.2 128.708 378.711 109.536 378.711 84.0879C378.712 58.6369 359.2 39.4648 333.236 39.4648Z" fill="#7D2EFF"/>
<path d="M72.2983 41.5L52.6173 91.383L33.1083 41.5H0.529297L36.8413 127.182L23.7763 158.056H55.6663L104.029 41.5H72.2983Z" fill="#7D2EFF"/>
<path d="M498.667 88.3271C487.124 88.3271 478.469 97.6591 478.469 108.52C478.469 119.205 486.785 128.029 497.99 128.029C509.523 128.029 518.179 118.868 518.179 107.838C518.178 96.9821 509.86 88.3271 498.667 88.3271Z" fill="#7D2EFF"/>
<path d="M521.225 3.83496L489.334 80.861H524.96L556.86 3.83496H521.225Z" fill="#7D2EFF"/>
</g>
<defs>
<clipPath id="clip0_1_5070">
<rect width="884" height="159" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+134 -5
View File
@@ -4,13 +4,96 @@ import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { mailAccountsApi, MailAccount } from '@/lib/api';
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power } from 'lucide-react';
import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import Image from 'next/image';
import { AddMailAccountModal } from '@/components/AddMailAccountModal';
// Map provider_name (from backend) to the SVG icon filename in /public/providers/
const PROVIDER_ICON_MAP: Record<string, string> = {
'Gmail': 'gmail',
'GMX': 'gmx',
'WEB.DE': 'webde',
'Outlook / Hotmail': 'outlook',
'Yahoo Mail': 'yahoo',
'AOL Mail': 'aol',
'T-Online': 'tonline',
'1&1 / IONOS': 'ionos',
'Freenet': 'freenet',
'Posteo': 'posteo',
'mail.de': 'mailde',
'iCloud Mail': 'icloud',
'Proton Mail': 'protonmail',
};
// Fallback: map email domains to SVG icon filenames for accounts without a provider_name
const DOMAIN_ICON_MAP: Record<string, string> = {
// Gmail
'gmail.com': 'gmail', 'googlemail.com': 'gmail',
// GMX
'gmx.de': 'gmx', 'gmx.net': 'gmx', 'gmx.at': 'gmx', 'gmx.ch': 'gmx', 'gmx.com': 'gmx',
// WEB.DE
'web.de': 'webde',
// Outlook / Hotmail
'outlook.com': 'outlook', 'hotmail.com': 'outlook', 'live.com': 'outlook',
'msn.com': 'outlook', 'outlook.de': 'outlook',
// Yahoo Mail
'yahoo.com': 'yahoo', 'yahoo.de': 'yahoo', 'yahoo.co.uk': 'yahoo', 'ymail.com': 'yahoo',
// AOL Mail
'aol.com': 'aol', 'aim.com': 'aol',
// T-Online
't-online.de': 'tonline',
// 1&1 / IONOS
'online.de': 'ionos', 'onlinehome.de': 'ionos', '1und1.de': 'ionos',
// Freenet
'freenet.de': 'freenet',
// iCloud Mail
'icloud.com': 'icloud', 'me.com': 'icloud', 'mac.com': 'icloud',
// Posteo
'posteo.de': 'posteo', 'posteo.net': 'posteo',
// Proton Mail
'proton.me': 'protonmail', 'protonmail.com': 'protonmail',
'protonmail.ch': 'protonmail', 'pm.me': 'protonmail',
};
/**
* Full-width logo banner rendered at the top of a card.
* Uses next/image fill + object-contain so every logo regardless of its
* native aspect ratio (1:1 square up to ~6:1 wordmark) fits correctly
* inside the fixed-height strip without distortion.
*
* Resolves the icon by first checking provider_name, then falling back to
* the email domain so that accounts created without a provider_name still
* show the correct logo.
*/
function ProviderLogoBanner({ providerName, email }: { providerName?: string | null; email?: string | null }) {
let icon = providerName ? PROVIDER_ICON_MAP[providerName] : undefined;
if (!icon && email) {
const atIndex = email.lastIndexOf('@');
const domain = atIndex !== -1 ? email.slice(atIndex + 1).toLowerCase() : undefined;
if (domain) icon = DOMAIN_ICON_MAP[domain];
}
if (!icon) return null;
const label = providerName ?? email?.split('@')[1] ?? 'provider';
return (
<div className="relative h-16 w-full bg-gray-50 border-b border-gray-100 overflow-hidden">
<Image
src={`/providers/${icon}.svg`}
alt={`${label} logo`}
fill
unoptimized
sizes="(max-width: 768px) 100vw, 33vw"
style={{ objectFit: 'contain', padding: '12px' }}
/>
</div>
);
}
export default function AccountsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingAccount, setEditingAccount] = useState<MailAccount | null>(null);
const [pullingIds, setPullingIds] = useState<Set<number>>(new Set());
const [successIds, setSuccessIds] = useState<Set<number>>(new Set());
const queryClient = useQueryClient();
const { data: accounts, isLoading } = useQuery({
@@ -55,6 +138,29 @@ export default function AccountsPage() {
}
};
const handlePullNow = async (id: number) => {
setPullingIds((prev) => new Set(prev).add(id));
try {
await mailAccountsApi.pullNow(id);
setSuccessIds((prev) => new Set(prev).add(id));
setTimeout(() => {
setSuccessIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 2000);
} catch {
alert('Failed to queue pull');
} finally {
setPullingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingAccount(null);
@@ -88,15 +194,18 @@ export default function AccountsPage() {
account.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
}`}
>
{/* Provider logo banner full-width strip that accommodates any aspect ratio */}
<ProviderLogoBanner providerName={account.provider_name} email={account.email_address} />
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-1">
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-1 truncate">
{account.name}
</h3>
<p className="text-sm text-gray-500">{account.email_address}</p>
<p className="text-sm text-gray-500 truncate">{account.email_address}</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 ml-2 flex-shrink-0">
{account.is_enabled ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
<CheckCircle className="h-3 w-3" />
@@ -164,6 +273,26 @@ export default function AccountsPage() {
>
<Power className="h-4 w-4" />
</button>
<button
onClick={() => handlePullNow(account.id)}
disabled={!account.is_enabled || pullingIds.has(account.id)}
title="Fetch new emails from this account now"
aria-label="Fetch emails now"
className={`flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 ${
successIds.has(account.id)
? 'text-green-600 bg-green-50 hover:bg-green-100'
: 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100'
}`}
>
<RefreshCw className={`h-4 w-4 ${pullingIds.has(account.id) ? 'animate-spin' : ''}`} />
<span>
{pullingIds.has(account.id)
? 'Fetching…'
: successIds.has(account.id)
? 'Queued!'
: 'Fetch'}
</span>
</button>
<button
onClick={() => handleEdit(account)}
className="flex-1 flex items-center justify-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
+211
View File
@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { adminApi, AdminProcessingRun } from '@/lib/api';
import { formatDate, formatDuration } from '@/lib/date-utils';
import {
Activity,
ChevronLeft,
ChevronRight,
CheckCircle,
XCircle,
RefreshCw,
} from 'lucide-react';
const STATUS_STYLES: Record<string, string> = {
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
partial_failure: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
};
export default function AdminLogsPage() {
const { user } = useAuthStore();
const router = useRouter();
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState('');
useEffect(() => {
if (user && !user.is_superuser) {
router.replace('/dashboard');
}
}, [user, router]);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-processing-runs', page, statusFilter],
queryFn: () =>
adminApi.listProcessingRuns({
page,
page_size: 25,
...(statusFilter ? { status: statusFilter } : {}),
}),
enabled: !!user?.is_superuser,
});
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Activity className="h-6 w-6 text-purple-600" />
Activity Logs
</h1>
<p className="mt-1 text-sm text-gray-500">
All email processing runs across every user account.
</p>
</div>
<div className="flex items-center gap-2">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">All statuses</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="partial_failure">Partial failure</option>
<option value="running">Running</option>
</select>
<button
onClick={() => refetch()}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
</div>
{/* Table */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : isError ? (
<div className="text-center py-16 text-red-500">
Failed to load activity logs. Please try again.
</div>
) : data && data.items.length > 0 ? (
<div className="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">User</th>
<th className="px-4 py-3">Account</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 text-right">Fetched</th>
<th className="px-4 py-3 text-right">Forwarded</th>
<th className="px-4 py-3 text-right">Failed</th>
<th className="px-4 py-3 text-right">Duration</th>
<th className="px-4 py-3">OK</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.items.map((run: AdminProcessingRun) => {
const statusClass =
STATUS_STYLES[run.status] ?? 'bg-gray-100 text-gray-800';
const ok = run.emails_failed === 0 && run.status !== 'failed';
return (
<tr key={run.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900 whitespace-nowrap">
{formatDate(run.started_at)}
</td>
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
{run.user_email ?? `#${run.user_id}`}
</td>
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
<div className="font-medium">{run.account_name ?? '—'}</div>
<div className="text-xs text-gray-400">{run.account_email ?? ''}</div>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${statusClass}`}
>
{run.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right whitespace-nowrap">
{run.emails_fetched}
</td>
<td className="px-4 py-3 text-sm text-gray-600 text-right whitespace-nowrap">
{run.emails_forwarded}
</td>
<td className="px-4 py-3 text-sm text-right whitespace-nowrap">
{run.emails_failed > 0 ? (
<span className="text-red-600">{run.emails_failed}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-right whitespace-nowrap">
{formatDuration(run.duration_seconds)}
</td>
<td className="px-4 py-3">
{ok ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{data.pages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
<span className="text-sm text-gray-500">
Page {data.page} of {data.pages} ({data.total} total runs)
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</button>
<button
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</button>
</div>
</div>
)}
</div>
) : (
<div className="text-center py-16 bg-white rounded-lg shadow border border-dashed border-gray-300">
<Activity className="mx-auto h-12 w-12 text-gray-300 mb-3" />
<h3 className="text-base font-semibold text-gray-700 mb-1">No activity yet</h3>
<p className="text-sm text-gray-500 max-w-xs mx-auto">
Processing run logs will appear here once mail accounts are active.
</p>
</div>
)}
</div>
</DashboardLayout>
</AuthGuard>
);
}
+553
View File
@@ -0,0 +1,553 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
adminApi,
adminNotificationsApi,
AdminNotificationConfig,
AdminNotificationConfigCreate,
AdminNotificationConfigUpdate,
} from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import {
Users,
Mail,
Activity,
Shield,
Bell,
Plus,
Edit2,
Trash2,
Send,
CheckCircle,
XCircle,
Loader2,
X,
} from 'lucide-react';
import Link from 'next/link';
// ── Admin Notification Modal ─────────────────────────────────────────────
interface AdminNotificationModalProps {
config?: AdminNotificationConfig | null;
onClose: () => void;
}
function AdminNotificationModal({ config, onClose }: AdminNotificationModalProps) {
const queryClient = useQueryClient();
const isEdit = !!config;
const [formData, setFormData] = useState<AdminNotificationConfigCreate>({
name: config?.name ?? '',
apprise_url: config?.apprise_url ?? '',
is_enabled: config?.is_enabled ?? true,
notify_on_errors: config?.notify_on_errors ?? true,
notify_on_system_events: config?.notify_on_system_events ?? true,
description: config?.description ?? '',
});
const [error, setError] = useState('');
const onSuccess = () => {
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] });
onClose();
};
const createMutation = useMutation({
mutationFn: (data: AdminNotificationConfigCreate) => adminNotificationsApi.create(data),
onSuccess,
onError: () => setError('Failed to save. Please check the Apprise URL and try again.'),
});
const updateMutation = useMutation({
mutationFn: (data: AdminNotificationConfigUpdate) =>
adminNotificationsApi.update(config!.id, data),
onSuccess,
onError: () => setError('Failed to save. Please check the Apprise URL and try again.'),
});
const isPending = createMutation.isPending || updateMutation.isPending;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!formData.name.trim() || !formData.apprise_url.trim()) {
setError('Name and Apprise URL are required.');
return;
}
if (isEdit) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between p-5 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
{isEdit ? 'Edit System Alert Channel' : 'Add System Alert Channel'}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
placeholder="e.g. Admin Telegram Alert"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Apprise URL</label>
<input
type="text"
value={formData.apprise_url}
onChange={(e) => setFormData((p) => ({ ...p, apprise_url: e.target.value }))}
placeholder="tgram://bot_token/chat_id/"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<p className="text-xs text-gray-500 mt-1">
Any valid{' '}
<a
href="https://apprise.readthedocs.io"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Apprise
</a>{' '}
notification URL.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description{' '}
<span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
type="text"
value={formData.description ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value || null }))}
placeholder="What is this channel used for?"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">Triggers</p>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_enabled}
onChange={(e) => setFormData((p) => ({ ...p, is_enabled: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<span className="text-sm text-gray-800">Enabled</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.notify_on_errors}
onChange={(e) => setFormData((p) => ({ ...p, notify_on_errors: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<span className="text-sm text-gray-800">Notify on errors</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.notify_on_system_events}
onChange={(e) =>
setFormData((p) => ({ ...p, notify_on_system_events: e.target.checked }))
}
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<span className="text-sm text-gray-800">Notify on system events</span>
</label>
</div>
{error && <p className="text-xs text-red-600 bg-red-50 border border-red-200 rounded p-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="flex-1 py-2 bg-purple-600 text-white rounded-md text-sm hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{isEdit ? 'Save Changes' : 'Add Channel'}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Admin Page ───────────────────────────────────────────────────────────
export default function AdminPage() {
const { user } = useAuthStore();
const router = useRouter();
const queryClient = useQueryClient();
const [showNotifModal, setShowNotifModal] = useState(false);
const [editingNotif, setEditingNotif] = useState<AdminNotificationConfig | null>(null);
const [testingNotifId, setTestingNotifId] = useState<number | null>(null);
const [notifTestResults, setNotifTestResults] = useState<
Record<number, { success: boolean; message: string }>
>({});
useEffect(() => {
if (user && !user.is_superuser) {
router.replace('/dashboard');
}
}, [user, router]);
const { data: stats, isLoading } = useQuery({
queryKey: ['admin-stats'],
queryFn: adminApi.getStats,
enabled: !!user?.is_superuser,
});
const { data: adminNotifications, isLoading: notifLoading } = useQuery({
queryKey: ['admin-notifications'],
queryFn: adminNotificationsApi.list,
enabled: !!user?.is_superuser,
});
const deleteNotifMutation = useMutation({
mutationFn: adminNotificationsApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] });
},
});
const handleEditNotif = (config: AdminNotificationConfig) => {
setEditingNotif(config);
setShowNotifModal(true);
};
const handleDeleteNotif = async (id: number) => {
if (!confirm('Delete this system alert channel?')) return;
try {
await deleteNotifMutation.mutateAsync(id);
} catch {
alert('Failed to delete channel');
}
};
const handleTestNotif = async (config: AdminNotificationConfig) => {
setTestingNotifId(config.id);
try {
const result = await adminNotificationsApi.test(config.apprise_url);
setNotifTestResults((prev) => ({ ...prev, [config.id]: result }));
} catch {
setNotifTestResults((prev) => ({
...prev,
[config.id]: { success: false, message: 'Test request failed' },
}));
} finally {
setTestingNotifId(null);
setTimeout(() => {
setNotifTestResults((prev) => {
const next = { ...prev };
delete next[config.id];
return next;
});
}, 5000);
}
};
const handleCloseNotifModal = () => {
setShowNotifModal(false);
setEditingNotif(null);
};
// AuthGuard must always render so it can fetch the current user and handle
// unauthenticated redirects. The early-return that was here prevented
// AuthGuard from ever mounting on a direct navigation to /admin, leaving a
// permanent blank page. The superuser guard is now applied inside the
// layout so that the auth check always runs first.
return (
<AuthGuard>
<DashboardLayout>
{!user?.is_superuser ? (
// Shown briefly while AuthGuard resolves the current user, or while
// the non-superuser redirect in the useEffect at the top of this
// component fires (router.replace('/dashboard')).
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="h-6 w-6 text-purple-600" />
Admin Overview
</h1>
<p className="mt-1 text-sm text-gray-500">
System-wide statistics and management tools.
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-purple-100">
<Users className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Total Users</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_users ?? '—'}</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-blue-100">
<Mail className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Mail Accounts</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_mail_accounts ?? '—'}</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-green-100">
<Activity className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Processing Runs</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_processing_runs ?? '—'}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<Link
href="/admin/users"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
>
<div className="p-3 rounded-full bg-purple-100 group-hover:bg-purple-200 transition-colors">
<Users className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Manage Users</p>
<p className="text-sm text-gray-500">View, edit, assign plans, promote to admin</p>
</div>
</Link>
<Link
href="/admin/plans"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
>
<div className="p-3 rounded-full bg-blue-100 group-hover:bg-blue-200 transition-colors">
<Mail className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Manage Plans</p>
<p className="text-sm text-gray-500">Create and configure subscription plans</p>
</div>
</Link>
<Link
href="/admin/logs"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
>
<div className="p-3 rounded-full bg-green-100 group-hover:bg-green-200 transition-colors">
<Activity className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Activity Logs</p>
<p className="text-sm text-gray-500">Processing runs and per-email logs across all users</p>
</div>
</Link>
</div>
{/* System Alert Channels */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Bell className="h-5 w-5 text-purple-600" />
System Alert Channels
</h2>
<p className="text-sm text-gray-500 mt-0.5">
Admin-level channels that receive system-wide error and event notifications.
</p>
</div>
<button
onClick={() => {
setEditingNotif(null);
setShowNotifModal(true);
}}
className="flex items-center px-3 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4 mr-1.5" />
Add Channel
</button>
</div>
{notifLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
</div>
) : adminNotifications && adminNotifications.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{adminNotifications.map((config) => {
const testResult = notifTestResults[config.id];
const isTesting = testingNotifId === config.id;
return (
<div
key={config.id}
className={`bg-white rounded-lg shadow border transition-opacity ${
config.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
}`}
>
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">
{config.name}
</p>
{config.description && (
<p className="text-xs text-gray-500 mt-0.5 truncate">
{config.description}
</p>
)}
</div>
<span
className={`ml-2 flex-shrink-0 flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
config.is_enabled
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{config.is_enabled ? (
<CheckCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{config.is_enabled ? 'On' : 'Off'}
</span>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{config.notify_on_errors && (
<span className="px-1.5 py-0.5 rounded text-xs bg-red-50 text-red-700 border border-red-200">
On Errors
</span>
)}
{config.notify_on_system_events && (
<span className="px-1.5 py-0.5 rounded text-xs bg-purple-50 text-purple-700 border border-purple-200">
System Events
</span>
)}
</div>
{testResult && (
<div
className={`mb-3 p-2 rounded text-xs flex items-center gap-1.5 ${
testResult.success
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
{testResult.success ? (
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 flex-shrink-0" />
)}
{testResult.message}
</div>
)}
<div className="flex items-center gap-2 pt-3 border-t border-gray-100">
<button
onClick={() => handleTestNotif(config)}
disabled={isTesting}
title="Send test notification"
className="flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100 disabled:opacity-50 transition-colors"
>
{isTesting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5 mr-1" />
)}
{isTesting ? '…' : 'Test'}
</button>
<button
onClick={() => handleEditNotif(config)}
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
Edit
</button>
<button
onClick={() => handleDeleteNotif(config.id)}
disabled={deleteNotifMutation.isPending}
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 disabled:opacity-50 transition-colors"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-10 bg-white rounded-lg shadow border border-dashed border-gray-300">
<Bell className="mx-auto h-8 w-8 text-gray-300 mb-2" />
<p className="text-sm text-gray-500">No system alert channels configured yet.</p>
<button
onClick={() => {
setEditingNotif(null);
setShowNotifModal(true);
}}
className="mt-3 inline-flex items-center px-3 py-1.5 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4 mr-1.5" />
Add First Channel
</button>
</div>
)}
</div>
</div>
)}
{showNotifModal && (
<AdminNotificationModal config={editingNotif} onClose={handleCloseNotifModal} />
)}
</DashboardLayout>
</AuthGuard>
);
}
+430
View File
@@ -0,0 +1,430 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
adminApi,
SubscriptionPlan,
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
} from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Shield, Plus, Pencil, Trash2, X, Check } from 'lucide-react';
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
const DEFAULT_FORM: SubscriptionPlanCreate = {
tier: 'free',
name: '',
description: '',
price_monthly: 0,
price_yearly: undefined,
max_mail_accounts: 1,
max_emails_per_day: 1000,
check_interval_minutes: 5,
support_level: 'community',
is_active: true,
};
function PlanFormModal({
plan,
onClose,
onCreate,
onUpdate,
}: {
plan: SubscriptionPlan | null;
onClose: () => void;
onCreate?: (data: SubscriptionPlanCreate) => void;
onUpdate?: (data: SubscriptionPlanUpdate) => void;
}) {
const isEdit = plan !== null;
const [form, setForm] = useState<SubscriptionPlanCreate>(
plan
? {
tier: plan.tier,
name: plan.name,
description: plan.description ?? '',
price_monthly: plan.price_monthly,
price_yearly: plan.price_yearly ?? undefined,
max_mail_accounts: plan.max_mail_accounts,
max_emails_per_day: plan.max_emails_per_day,
check_interval_minutes: plan.check_interval_minutes,
support_level: plan.support_level,
is_active: plan.is_active,
}
: DEFAULT_FORM
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6 overflow-y-auto max-h-[90vh]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
{isEdit ? 'Edit Plan' : 'Create Plan'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
{!isEdit && (
<div>
<label className="block text-sm font-medium text-gray-700">Tier</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.tier}
onChange={(e) => setForm({ ...form, tier: e.target.value })}
>
{TIERS.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
rows={2}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.description ?? ''}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Monthly Price ($)</label>
<input
type="number"
min={0}
step={0.01}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.price_monthly}
onChange={(e) => setForm({ ...form, price_monthly: parseFloat(e.target.value) || 0 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Yearly Price ($)</label>
<input
type="number"
min={0}
step={0.01}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.price_yearly ?? ''}
onChange={(e) =>
setForm({
...form,
price_yearly: e.target.value ? parseFloat(e.target.value) : undefined,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Max Mailboxes</label>
<input
type="number"
min={1}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.max_mail_accounts}
onChange={(e) =>
setForm({ ...form, max_mail_accounts: parseInt(e.target.value) || 1 })
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Max Emails/Day</label>
<input
type="number"
min={1}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.max_emails_per_day}
onChange={(e) =>
setForm({ ...form, max_emails_per_day: parseInt(e.target.value) || 1 })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Check Interval (min)</label>
<input
type="number"
min={1}
max={1440}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.check_interval_minutes}
onChange={(e) =>
setForm({ ...form, check_interval_minutes: parseInt(e.target.value) || 5 })
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Support Level</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.support_level}
onChange={(e) => setForm({ ...form, support_level: e.target.value })}
>
{['community', 'email', 'priority'].map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
</div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
Active (visible to users)
</label>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => {
if (isEdit && onUpdate) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tier: _tier, ...updateFields } = form;
onUpdate(updateFields);
} else if (!isEdit && onCreate) {
onCreate(form);
}
}}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
>
{isEdit ? 'Save Changes' : 'Create Plan'}
</button>
</div>
</div>
</div>
);
}
export default function AdminPlansPage() {
const { user } = useAuthStore();
const router = useRouter();
const queryClient = useQueryClient();
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
useEffect(() => {
if (user && !user.is_superuser) {
router.replace('/dashboard');
}
}, [user, router]);
const { data: plans, isLoading } = useQuery({
queryKey: ['admin-plans'],
queryFn: adminApi.listPlans,
enabled: !!user?.is_superuser,
});
const createMutation = useMutation({
mutationFn: (data: SubscriptionPlanCreate) => adminApi.createPlan(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
setShowCreate(false);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: SubscriptionPlanUpdate }) =>
adminApi.updatePlan(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
setEditingPlan(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminApi.deletePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
setDeleteConfirm(null);
},
});
// AuthGuard must always render (see admin/page.tsx for explanation).
return (
<AuthGuard>
<DashboardLayout>
{!user?.is_superuser ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="h-6 w-6 text-purple-600" />
Manage Plans
</h1>
<p className="mt-1 text-sm text-gray-500">
Configure subscription plans, limits, and pricing.
</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
>
<Plus className="h-4 w-4" />
New Plan
</button>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{['Tier', 'Name', 'Price/mo', 'Mailboxes', 'Emails/day', 'Interval', 'Support', 'Status', 'Actions'].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{(plans ?? []).map((p) => (
<tr key={p.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
{p.tier}
</span>
</td>
<td className="px-4 py-3 text-sm font-medium text-gray-900">{p.name}</td>
<td className="px-4 py-3 text-sm text-gray-500">
${p.price_monthly.toFixed(2)}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{p.max_mail_accounts}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{p.max_emails_per_day.toLocaleString()}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{p.check_interval_minutes}m
</td>
<td className="px-4 py-3 text-sm text-gray-500 capitalize">
{p.support_level}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
p.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-500'
}`}
>
{p.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingPlan(p)}
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
title="Edit plan"
>
<Pencil className="h-4 w-4" />
</button>
{deleteConfirm === p.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => deleteMutation.mutate(p.id)}
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
title="Confirm delete"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(p.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
title="Delete plan"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{(plans ?? []).length === 0 && (
<div className="text-center py-12 text-gray-500 text-sm">
No plans found. Create one to get started.
</div>
)}
</div>
)}
</div>
</div>
{showCreate && (
<PlanFormModal
plan={null}
onClose={() => setShowCreate(false)}
onCreate={(data) => createMutation.mutate(data)}
/>
)}
{editingPlan && (
<PlanFormModal
plan={editingPlan}
onClose={() => setEditingPlan(null)}
onUpdate={(data) =>
updateMutation.mutate({ id: editingPlan.id, data })
}
/>
)}
</>
)}
</DashboardLayout>
</AuthGuard>
);
}
+312
View File
@@ -0,0 +1,312 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi, AdminUser, AdminUserUpdate } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Shield, Pencil, Trash2, X, Check } from 'lucide-react';
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
const STATUSES = ['active', 'canceled', 'past_due'];
function EditUserModal({
user,
onClose,
onSave,
}: {
user: AdminUser;
onClose: () => void;
onSave: (data: AdminUserUpdate) => void;
}) {
const [form, setForm] = useState<AdminUserUpdate>({
full_name: user.full_name ?? '',
email: user.email,
is_active: user.is_active,
is_superuser: user.is_superuser,
subscription_tier: user.subscription_tier,
subscription_status: user.subscription_status,
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Edit User</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Full Name</label>
<input
type="text"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.full_name ?? ''}
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.email ?? ''}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Subscription Tier</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.subscription_tier ?? 'free'}
onChange={(e) => setForm({ ...form, subscription_tier: e.target.value })}
>
{TIERS.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Subscription Status</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.subscription_status ?? 'active'}
onChange={(e) => setForm({ ...form, subscription_status: e.target.value })}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ')}
</option>
))}
</select>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={form.is_active ?? true}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
Active
</label>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={form.is_superuser ?? false}
onChange={(e) => setForm({ ...form, is_superuser: e.target.checked })}
/>
Admin (superuser)
</label>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => onSave(form)}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
>
Save Changes
</button>
</div>
</div>
</div>
);
}
export default function AdminUsersPage() {
const { user: currentUser } = useAuthStore();
const router = useRouter();
const queryClient = useQueryClient();
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
useEffect(() => {
if (currentUser && !currentUser.is_superuser) {
router.replace('/dashboard');
}
}, [currentUser, router]);
const { data: users, isLoading } = useQuery({
queryKey: ['admin-users'],
queryFn: () => adminApi.listUsers(),
enabled: !!currentUser?.is_superuser,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: AdminUserUpdate }) =>
adminApi.updateUser(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setEditingUser(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminApi.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setDeleteConfirm(null);
},
});
// AuthGuard must always render (see admin/page.tsx for explanation).
return (
<AuthGuard>
<DashboardLayout>
{!currentUser?.is_superuser ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="h-6 w-6 text-purple-600" />
Manage Users
</h1>
<p className="mt-1 text-sm text-gray-500">
View all registered users, assign plans, and manage admin privileges.
</p>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{['User', 'Plan', 'Status', 'Accounts', 'Last Login', 'Role', 'Actions'].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{(users ?? []).map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium text-gray-900">{u.full_name || '—'}</p>
<p className="text-xs text-gray-500">{u.email}</p>
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
{u.subscription_tier}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
u.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{u.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{u.mail_account_count}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{u.last_login_at
? new Date(u.last_login_at).toLocaleDateString()
: '—'}
</td>
<td className="px-4 py-3">
{u.is_superuser ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<Shield className="h-3 w-3" />
Admin
</span>
) : (
<span className="text-xs text-gray-400">User</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingUser(u)}
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
title="Edit user"
>
<Pencil className="h-4 w-4" />
</button>
{u.id !== currentUser?.id && (
deleteConfirm === u.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => deleteMutation.mutate(u.id)}
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
title="Confirm delete"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(u.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
title="Delete user"
>
<Trash2 className="h-4 w-4" />
</button>
)
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{(users ?? []).length === 0 && (
<div className="text-center py-12 text-gray-500 text-sm">No users found.</div>
)}
</div>
)}
</div>
</div>
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSave={(data) => updateMutation.mutate({ id: editingUser.id, data })}
/>
)}
</>
)}
</DashboardLayout>
</AuthGuard>
);
}
+26 -2
View File
@@ -2,7 +2,7 @@
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { authApi, userApi } from '@/lib/api';
import { authApi, gmailApi, userApi } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
@@ -17,6 +17,7 @@ function AuthCallbackContent() {
const handleCallback = async () => {
const code = searchParams.get('code');
const error = searchParams.get('error');
const state = searchParams.get('state');
if (error) {
setStatus('error');
@@ -32,8 +33,31 @@ function AuthCallbackContent() {
return;
}
const redirectUri = `${window.location.origin}/auth/callback`;
// Gmail reconnect flow: user was already logged in and clicked
// "Connect Gmail" in Settings. The authorize URL includes state=gmail_connect.
if (state === 'gmail_connect') {
try {
await gmailApi.saveCallback(code, redirectUri);
setStatus('success');
setMessage('Gmail connected successfully! Redirecting to settings…');
setTimeout(() => router.push('/settings'), 1500);
} catch (err: unknown) {
const detail =
err instanceof Error && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data
?.detail
: null;
setStatus('error');
setMessage(detail || 'Failed to connect Gmail. Please try again.');
setTimeout(() => router.push('/settings'), 3000);
}
return;
}
// Normal Google Sign-In flow
try {
const redirectUri = `${window.location.origin}/auth/callback`;
const response = await authApi.googleAuth(code, redirectUri);
localStorage.setItem('access_token', response.access_token);
+110 -116
View File
@@ -3,14 +3,18 @@
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query';
import { mailAccountsApi, processingRunsApi } from '@/lib/api';
import {
Mail,
Send,
CheckCircle,
import { mailAccountsApi, MailAccount } from '@/lib/api';
import { formatRelative } from '@/lib/date-utils';
import Link from 'next/link';
import {
Mail,
Send,
CheckCircle,
AlertCircle,
TrendingUp,
Clock
Clock,
XCircle,
AlertTriangle,
Inbox,
} from 'lucide-react';
interface StatCardProps {
@@ -18,22 +22,15 @@ interface StatCardProps {
value: string | number;
icon: React.ComponentType<{ className?: string }>;
iconColor: string;
trend?: string;
}
function StatCard({ title, value, icon: Icon, iconColor, trend }: StatCardProps) {
function StatCard({ title, value, icon: Icon, iconColor }: StatCardProps) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
{trend && (
<div className="mt-2 flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
<span className="text-green-600">{trend}</span>
</div>
)}
</div>
<div className={`p-3 rounded-full ${iconColor}`}>
<Icon className="h-8 w-8 text-white" />
@@ -43,27 +40,78 @@ function StatCard({ title, value, icon: Icon, iconColor, trend }: StatCardProps)
);
}
function AccountStatusRow({ account }: { account: MailAccount }) {
const hasError = !!account.last_error_message;
const lastChecked = account.last_check_at;
return (
<div className="px-5 py-4 border-b border-gray-100 last:border-b-0">
<div className="flex items-start justify-between gap-4">
{/* Left: name + email */}
<div className="flex items-center gap-3 min-w-0">
<Inbox className="h-4 w-4 text-blue-400 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{account.name}</p>
<p className="text-xs text-gray-400 truncate">{account.email_address}</p>
</div>
</div>
{/* Right: status badge + last check */}
<div className="text-right shrink-0">
{hasError ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600">
<XCircle className="h-3.5 w-3.5" />
Error
</span>
) : lastChecked ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600">
<CheckCircle className="h-3.5 w-3.5" />
OK
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-400">
<Clock className="h-3.5 w-3.5" />
Pending
</span>
)}
<p className="text-xs text-gray-400 mt-0.5">
{formatRelative(lastChecked)}
</p>
</div>
</div>
{/* Error message */}
{hasError && (
<div className="mt-2 flex items-start gap-1.5 p-2 bg-red-50 border border-red-200 rounded">
<AlertTriangle className="h-3.5 w-3.5 text-red-500 shrink-0 mt-0.5" />
<p className="text-xs text-red-700 line-clamp-2">{account.last_error_message}</p>
</div>
)}
{/* Lifetime counters (only when there's activity) */}
{(account.total_emails_processed > 0 || account.total_emails_failed > 0) && (
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
<span>{account.total_emails_processed.toLocaleString()} processed</span>
{account.total_emails_failed > 0 && (
<span className="text-red-500">{account.total_emails_failed.toLocaleString()} failed</span>
)}
</div>
)}
</div>
);
}
export default function DashboardPage() {
const { data: accounts } = useQuery({
const { data: accounts, isLoading: accountsLoading } = useQuery({
queryKey: ['mail-accounts'],
queryFn: mailAccountsApi.list,
});
const { data: runs, isLoading: runsLoading } = useQuery({
queryKey: ['processing-runs'],
queryFn: () => processingRunsApi.list(),
});
const stats = {
totalAccounts: accounts?.length || 0,
activeAccounts: accounts?.filter((a) => a.is_enabled).length || 0,
emailsToday: runs
?.filter((r) => {
const today = new Date().toDateString();
return new Date(r.started_at).toDateString() === today;
})
.reduce((sum, r) => sum + r.emails_forwarded, 0) || 0,
errors: runs?.filter((r) => r.emails_failed > 0).length || 0,
totalProcessed: accounts?.reduce((sum, a) => sum + a.total_emails_processed, 0) || 0,
accountsWithErrors: accounts?.filter((a) => !!a.last_error_message).length || 0,
};
return (
@@ -79,8 +127,8 @@ export default function DashboardPage() {
iconColor="bg-blue-500"
/>
<StatCard
title="Emails Forwarded Today"
value={stats.emailsToday}
title="Emails Processed"
value={stats.totalProcessed.toLocaleString()}
icon={Send}
iconColor="bg-green-500"
/>
@@ -91,98 +139,44 @@ export default function DashboardPage() {
iconColor="bg-purple-500"
/>
<StatCard
title="Errors"
value={stats.errors}
title="Accounts with Errors"
value={stats.accountsWithErrors}
icon={AlertCircle}
iconColor="bg-red-500"
iconColor={stats.accountsWithErrors > 0 ? 'bg-red-500' : 'bg-gray-400'}
/>
</div>
{/* Recent Processing Runs */}
{/* Mailbox Status Overview */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Recent Processing Runs</h3>
</div>
<div className="overflow-x-auto">
{runsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : runs && runs.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Account
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Fetched
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Forwarded
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Errors
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{runs.slice(0, 10).map((run) => {
const account = accounts?.find((a) => a.id === run.mail_account_id);
return (
<tr key={run.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{account?.name || `Account ${run.mail_account_id}`}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<Clock className="h-4 w-4 mr-1 text-gray-400" />
{new Date(run.started_at).toLocaleString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
run.status === 'completed'
? 'bg-green-100 text-green-800'
: run.status === 'failed'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{run.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{run.emails_fetched}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{run.emails_forwarded}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{run.emails_failed > 0 ? (
<span className="text-red-600 font-medium">{run.emails_failed}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="text-center py-12">
<p className="text-gray-500">No processing runs yet</p>
</div>
)}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Mailbox Status</h3>
<Link href="/logs" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
View activity & history
</Link>
</div>
{accountsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : accounts && accounts.length > 0 ? (
<div>
{accounts.map((account) => (
<AccountStatusRow key={account.id} account={account} />
))}
</div>
) : (
<div className="text-center py-12">
<Clock className="mx-auto h-10 w-10 text-gray-300 mb-3" />
<p className="text-sm text-gray-500">No mail accounts configured yet.</p>
<Link
href="/accounts"
className="mt-3 inline-block text-sm text-blue-600 hover:text-blue-800 font-medium"
>
Add your first account
</Link>
</div>
)}
</div>
</div>
</DashboardLayout>
+386
View File
@@ -0,0 +1,386 @@
import Link from 'next/link';
import type { Metadata } from 'next';
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
export const metadata: Metadata = {
title: `Datenschutz ${APP_NAME}`,
};
const LAST_UPDATED = 'March 26, 2026';
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
export default function DatenschutzPage() {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Datenschutzhinweis
</h1>
<p className="text-sm text-gray-500 mb-8">
Letzte Aktualisierung: {LAST_UPDATED}
</p>
{/* TOC */}
<nav className="mb-8 p-4 bg-gray-50 rounded-md text-sm text-blue-700 space-y-1">
<p className="font-semibold text-gray-700 mb-2">Inhalt</p>
{[
'Verantwortlicher',
'Geltungsbereich dieser Datenschutzerklärung',
'Datenerhebung & Zwecke',
'Datenminimierung & Zweckbindung',
'Verwendung von Cookies & ähnlichen Technologien',
'Drittanbieter-Dienste',
'Internationale Datenübertragungen',
'Datenaufbewahrung',
'Datensicherheit',
'Ihre Rechte (EU / EWR / UK / Schweiz)',
'Zusätzliche Rechte Vereinigte Staaten (CCPA/CPRA)',
'Zusätzliche Rechte Kanada (PIPEDA / Gesetz 25)',
'Zusätzliche Rechte Lateinamerika (LGPD & andere)',
'Zusätzliche Rechte Asien-Pazifik & Japan',
'Zusätzliche Rechte Ukraine',
'Aktualisierungen zu dieser Datenschutzerklärung',
].map((title, i) => (
<div key={i}>
<a href={`#section-${i + 1}`} className="hover:underline">
{i + 1}. {title}
</a>
</div>
))}
</nav>
{/* Sections */}
<section id="section-1" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
1. Verantwortlicher
</h2>
<p className="text-gray-700 mb-3">
Der für die Verarbeitung Ihrer personenbezogenen Daten gemäß der
EU-Datenschutz-Grundverordnung (DSGVO) und gleichwertiger
Datenschutzgesetze weltweit verantwortliche Verantwortliche ist:
</p>
<p className="text-gray-700 mb-3">
Christian Louis IT Beratung<br />
Alter Steinweg 3, 20459 Hamburg, Deutschland
</p>
<p className="text-gray-700">
Kontakt-E-Mail:{' '}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
{CONTACT_EMAIL}
</a>
</p>
<p className="text-gray-700 mt-2">
Bei allen datenschutzbezogenen Anfragen (Zugriff, Löschung,
Berichtigung, Widerspruch oder Beschwerden) kontaktieren Sie uns
bitte unter der obenstehenden E-Mail-Adresse. Wir werden innerhalb
von 30 Tagen (oder dem durch geltendes Recht vorgeschriebenen
Zeitraum) antworten.
</p>
</section>
<section id="section-2" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
2. Geltungsbereich dieser Datenschutzerklärung
</h2>
<p className="text-gray-700">
Dieser Hinweis gilt für die {APP_NAME}-Webanwendung. Er gilt
für alle Nutzer weltweit, einschließlich derjenigen in der
Europäischen Union (EU), dem Europäischen Wirtschaftsraum (EWR),
Deutschland, dem Vereinigten Königreich (UK), der Schweiz, der
Ukraine, den Vereinigten Staaten (US), Kanada, Lateinamerika,
dem asiatisch-pazifischen Raum und Japan.
</p>
</section>
<section id="section-3" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
3. Datenerhebung &amp; Zwecke
</h2>
<p className="text-gray-700 mb-2">
<strong>Benutzerauthentifizierung:</strong> Wir verwenden OAuth 2.0
(Google) und optionale lokale Authentifizierung. Über OAuth können
wir Ihren Namen, Ihre E-Mail-Adresse und Ihr Profilbild erhalten.
</p>
<p className="text-gray-700 mb-2">
<strong>E-Mail-Verarbeitung:</strong> E-Mails, die von Ihren
konfigurierten POP3-Konten abgerufen werden, werden ausschließlich
für den Zweck der Weiterleitung an Gmail verarbeitet. Der
E-Mail-Inhalt wird nicht länger gespeichert, als es betrieblich
notwendig ist.
</p>
<p className="text-gray-700 mb-2">
<strong>Audit-Protokolle:</strong> Wir führen begrenzte Protokolle
(Aktionsart, Zeitstempel, Benutzeridentifikator), um die Integrität
und Sicherheit des Dienstes zu gewährleisten.
</p>
<p className="text-gray-700">
<strong>Rechtsgrundlage (DSGVO Art. 6):</strong> (1)(b)
Vertragsdurchführung, (1)(c) Rechtliche Verpflichtung, (1)(f)
Berechtigte Interessen.
</p>
</section>
<section id="section-4" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
4. Datenminimierung &amp; Zweckbindung
</h2>
<ul className="list-disc list-inside text-gray-700 space-y-1">
<li>
Wir erheben nur die minimalen personenbezogenen Daten, die zur
Durchführung des Dienstes erforderlich sind.
</li>
<li>
E-Mail-Inhalte werden ausschließlich für den von Ihnen
initiierten Weiterleitungszweck verarbeitet.
</li>
<li>
Es wird keine Werbung, Verhaltensverfolgung oder Profilierung
durchgeführt.
</li>
<li>
Es werden keine Tracking-Cookies oder Analysescripts geladen.
</li>
</ul>
</section>
<section id="section-5" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
5. Verwendung von Cookies &amp; ähnlichen Technologien
</h2>
<p className="text-gray-700">
Der Dienst verwendet nur unbedingt erforderliche Sitzungscookies,
um Ihre authentifizierte Sitzung aufrechtzuerhalten. Diese Cookies
sind für die Funktion des Dienstes unerlässlich und sind von den
Anforderungen an vorherige Zustimmung gemäß der EU-E-Privacy-
Richtlinie (Art. 5(3)) ausgenommen. Wir verwenden keine
Analyse-Cookies, Werbe-Cookies oder Tracking-Pixel.
</p>
</section>
<section id="section-6" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
6. Drittanbieter-Dienste
</h2>
<p className="text-gray-700 mb-2">
<strong>OAuth-Anbieter (Google):</strong> Wenn Sie sich über OAuth
authentifizieren, verarbeitet der jeweilige Anbieter Ihre
Anmeldeinformationen gemäß seiner eigenen Datenschutzrichtlinie.
</p>
<p className="text-gray-700 mb-2">
<strong>Gmail API:</strong> E-Mails werden über die Gmail API in
Ihr Gmail-Konto eingespielt. Ihre OAuth-Zugangsdaten werden
verschlüsselt gespeichert.
</p>
<p className="text-gray-700">
<strong>Kein Verkauf oder Teilen für Werbung:</strong> Wir
verkaufen, vermieten oder teilen Ihre personenbezogenen Daten
nicht mit Dritten zu Werbungs- oder Marketingzwecken.
</p>
</section>
<section id="section-7" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
7. Internationale Datenübertragungen
</h2>
<p className="text-gray-700">
Wenn personenbezogene Daten außerhalb des EWR übertragen werden,
verlassen wir uns auf geeignete Schutzmaßnahmen, einschließlich
Standardvertragsklauseln (SCCs) gemäß EU-Beschluss 2021/914/EU
sowie Angemessenheitsentscheidungen der Europäischen Kommission.
</p>
</section>
<section id="section-8" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
8. Datenaufbewahrung
</h2>
<ul className="list-disc list-inside text-gray-700 space-y-1">
<li>
<strong>Sitzungsdaten:</strong> Wird gelöscht, wenn Sie sich
abmelden oder nach Ablauf der Sitzung.
</li>
<li>
<strong>Kontokonfiguration & Metadaten:</strong> Für die Dauer
Ihrer Nutzung des Dienstes aufbewahrt.
</li>
<li>
<strong>Audit-Protokolle:</strong> Für bis zu 90 Tage
aufbewahrt.
</li>
<li>
<strong>OAuth-Token:</strong> In verschlüsselter Form gespeichert
und jederzeit über Ihren OAuth-Anbieter widerrufbar.
</li>
</ul>
</section>
<section id="section-9" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
9. Datensicherheit
</h2>
<ul className="list-disc list-inside text-gray-700 space-y-1">
<li>Verschlüsselung von Anmeldeinformationen im Ruhezustand.</li>
<li>
Transport Layer Security (TLS/HTTPS) für alle Kommunikationen.
</li>
<li>
Rollenbasierte Zugriffskontrollen zum Schutz personenbezogener
Daten.
</li>
<li>CSRF-Schutz für alle zustandsändernden Anfragen.</li>
</ul>
</section>
<section id="section-10" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
10. Ihre Rechte (EU / EWR / UK / Schweiz)
</h2>
<ul className="list-disc list-inside text-gray-700 space-y-1">
<li>
<strong>Recht auf Zugang (Art. 15):</strong> Kopie der über Sie
gespeicherten Daten.
</li>
<li>
<strong>Recht auf Berichtigung (Art. 16):</strong> Korrektur
ungenauer Daten.
</li>
<li>
<strong>Recht auf Löschung (Art. 17):</strong> Löschung Ihrer
persönlichen Daten.
</li>
<li>
<strong>Recht auf Einschränkung (Art. 18):</strong> Vorübergehende
Aussetzung der Verarbeitung.
</li>
<li>
<strong>Recht auf Datenübertragbarkeit (Art. 20):</strong> Daten
in maschinenlesbarem Format.
</li>
<li>
<strong>Widerspruchsrecht (Art. 21):</strong> Widerspruch gegen
Verarbeitung auf Basis berechtigter Interessen.
</li>
</ul>
<p className="text-gray-700 mt-3">
Um eines dieser Rechte auszuüben, kontaktieren Sie uns unter{' '}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
{CONTACT_EMAIL}
</a>
. Beschwerden können an die zuständige Datenschutzbehörde (in
Deutschland: BfDI) gerichtet werden.
</p>
</section>
<section id="section-11" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
11. Zusätzliche Rechte Vereinigte Staaten (CCPA / CPRA)
</h2>
<p className="text-gray-700">
Wenn Sie ein Bewohner Kaliforniens oder eines anderen US-
Bundesstaates mit anwendbarer Datenschutzgesetzgebung sind, haben
Sie das Recht auf Auskunft, Löschung, Berichtigung und Widerspruch
gegen den Verkauf persönlicher Daten. Wir verkaufen oder teilen
keine personenbezogenen Informationen. Kontakt:{' '}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
{CONTACT_EMAIL}
</a>
.
</p>
</section>
<section id="section-12" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
12. Zusätzliche Rechte Kanada (PIPEDA / Gesetz 25)
</h2>
<p className="text-gray-700">
Wenn Sie sich in Kanada befinden, haben Sie das Recht auf Zugang,
Korrektur und Widerruf der Zustimmung gemäß PIPEDA und den
provinziellen Datenschutzgesetzen. Kontakt:{' '}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
{CONTACT_EMAIL}
</a>
.
</p>
</section>
<section id="section-13" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
13. Zusätzliche Rechte Lateinamerika (LGPD &amp; andere)
</h2>
<p className="text-gray-700">
Brasilianische Nutzer haben gemäß LGPD (Lei 13.709/2018) das Recht
auf Bestätigung der Verarbeitung, Zugang, Berichtigung, Löschung
und Datenübertragbarkeit. Nutzer in anderen lateinamerikanischen
Ländern können entsprechende Rechte gemäß nationalem Recht ausüben.
Kontakt:{' '}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
{CONTACT_EMAIL}
</a>
.
</p>
</section>
<section id="section-14" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
14. Zusätzliche Rechte Asien-Pazifik &amp; Japan
</h2>
<p className="text-gray-700">
Nutzer in Japan (APPI), Australien (Privacy Act 1988), Südkorea
(PIPA) sowie anderen APJ-Märkten (Singapur PDPA, Neuseeland, Indien
DPDP) können entsprechende Datenschutzrechte ausüben. Kontakt:{' '}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
{CONTACT_EMAIL}
</a>
.
</p>
</section>
<section id="section-15" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
15. Zusätzliche Rechte Ukraine
</h2>
<p className="text-gray-700">
Nutzer in der Ukraine sind nach dem ukrainischen Gesetz &bdquo;Über den
Schutz persönlicher Daten&ldquo; (Nr. 2297-VI) geschützt. Ihre Rechte
umfassen Zugang, Berichtigung, Sperrung, Löschung und das Recht
auf Widerspruch gegen die Verarbeitung. Kontakt:{' '}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
{CONTACT_EMAIL}
</a>
.
</p>
</section>
<section id="section-16" className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-3">
16. Aktualisierungen zu dieser Datenschutzerklärung
</h2>
<p className="text-gray-700">
Wir können diese Mitteilung von Zeit zu Zeit aktualisieren. Das
Datum &bdquo;Letzte Aktualisierung&ldquo; am Anfang dieser Seite gibt an, wann
die Mitteilung zuletzt überarbeitet wurde. Bei wesentlichen
Änderungen werden wir die Nutzer durch eine Benachrichtigung in der
Anwendung oder per E-Mail informieren.
</p>
<p className="text-gray-700 mt-3">
Bei Fragen oder Bedenken kontaktieren Sie uns unter{' '}
<a href={`mailto:${CONTACT_EMAIL}`} className="text-blue-600 hover:text-blue-500">
{CONTACT_EMAIL}
</a>
.
</p>
</section>
<div className="border-t border-gray-200 pt-4 flex gap-4 text-sm text-gray-500">
<Link href="/impressum" className="hover:text-blue-600">
Impressum
</Link>
<Link href="/login" className="hover:text-blue-600">
Zurück zur Anmeldung
</Link>
</div>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import Link from 'next/link';
import type { Metadata } from 'next';
const APP_NAME = process.env.APP_NAME ?? 'InboxConverge';
const CONTACT_EMAIL = process.env.CONTACT_EMAIL ?? 'christian@inboxconverge.com';
export const metadata: Metadata = {
title: `Impressum ${APP_NAME}`,
};
export default function ImpressumPage() {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Impressum</h1>
<section className="mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-2">
Angaben gemäß § 5 TMG
</h2>
<p className="text-gray-700">
Christian Louis IT Beratung<br />
Alter Steinweg 3<br />
20459 Hamburg<br />
Deutschland
</p>
</section>
<section className="mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-2">
Vertreten durch
</h2>
<p className="text-gray-700">Christian Krakau-Louis</p>
</section>
<section className="mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-2">Kontakt</h2>
<p className="text-gray-700">
Fax: +49 40 97074609<br />
E-Mail:{' '}
<a
href={`mailto:${CONTACT_EMAIL}`}
className="text-blue-600 hover:text-blue-500"
>
{CONTACT_EMAIL}
</a>
</p>
</section>
<section className="mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-2">
Umsatzsteuer-Identifikationsnummer
</h2>
<p className="text-gray-700">
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:
<br />
DE202899017
</p>
</section>
<section className="mb-8">
<p className="text-gray-700">
Dieses Projekt ist Teil einer Open-Source-Initiative. Alle Rechte
vorbehalten.
</p>
</section>
<div className="border-t border-gray-200 pt-4 flex gap-4 text-sm text-gray-500">
<Link href="/datenschutz" className="hover:text-blue-600">
Datenschutz
</Link>
<Link href="/login" className="hover:text-blue-600">
Zurück zur Anmeldung
</Link>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "POP3 Forwarder - Automatic Email Forwarding to Gmail",
description: "Forward your POP3 emails to Gmail automatically with our secure and reliable service",
title: "InboxConverge — your old inboxes, delivered to Gmail",
description: "Poll your legacy POP3 and IMAP mailboxes and have everything land quietly in Gmail. Set it once, forget it exists.",
};
export default function RootLayout({
+10 -1
View File
@@ -46,7 +46,7 @@ export default function LoginPage() {
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
POP3 to Gmail Forwarder
InboxConverge
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to your account
@@ -149,6 +149,15 @@ export default function LoginPage() {
</p>
</div>
</form>
<div className="mt-6 flex justify-center gap-4 text-xs text-gray-400">
<Link href="/impressum" className="hover:text-gray-600 transition-colors">
Impressum
</Link>
<Link href="/datenschutz" className="hover:text-gray-600 transition-colors">
Datenschutz
</Link>
</div>
</div>
</div>
);
+356
View File
@@ -0,0 +1,356 @@
'use client';
import { useState } from 'react';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query';
import { processingRunsApi, mailAccountsApi, MailAccount, ProcessingRun, ProcessingLog } from '@/lib/api';
import { formatRelative, formatDate, formatDuration } from '@/lib/date-utils';
import {
Inbox,
ChevronLeft,
ChevronRight,
CheckCircle,
XCircle,
Clock,
RefreshCw,
ChevronDown,
ChevronUp,
AlertTriangle,
} from 'lucide-react';
function RunDetailRow({ run }: { run: ProcessingRun }) {
const [expanded, setExpanded] = useState(false);
const [logsPage, setLogsPage] = useState(1);
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['run-logs', run.id, logsPage],
queryFn: () => processingRunsApi.getLogs(run.id, { page: logsPage, page_size: 20 }),
enabled: expanded,
});
return (
<>
<tr
className="hover:bg-gray-50 cursor-pointer text-sm"
onClick={() => setExpanded((v) => !v)}
>
<td className="px-4 py-2 text-gray-700 whitespace-nowrap">{formatDate(run.started_at)}</td>
<td className="px-4 py-2 text-right text-gray-900 font-medium">{run.emails_fetched}</td>
<td className="px-4 py-2 text-right text-gray-700">{run.emails_forwarded}</td>
<td className="px-4 py-2 text-right">
{run.emails_failed > 0 ? (
<span className="text-red-600 font-medium">{run.emails_failed}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
<td className="px-4 py-2 text-right text-gray-500">{formatDuration(run.duration_seconds)}</td>
<td className="px-4 py-2 text-right">
{expanded ? (
<ChevronUp className="h-3.5 w-3.5 text-gray-400 inline" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-gray-400 inline" />
)}
</td>
</tr>
{expanded && (
<tr>
<td colSpan={6} className="bg-gray-50 px-6 py-3 border-b border-gray-100">
{run.error_message && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{run.error_message}
</div>
)}
{logsLoading ? (
<div className="flex items-center gap-2 text-sm text-gray-500 py-1">
<RefreshCw className="h-4 w-4 animate-spin" /> Loading
</div>
) : logsData && logsData.items.length > 0 ? (
<>
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 uppercase tracking-wide">
<th className="text-left pb-1 pr-4">Time</th>
<th className="text-left pb-1 pr-4">Subject</th>
<th className="text-left pb-1 pr-4">From</th>
<th className="text-left pb-1">Status</th>
</tr>
</thead>
<tbody>
{logsData.items.map((log: ProcessingLog) => (
<tr key={log.id} className="border-t border-gray-100">
<td className="py-1 pr-4 text-gray-500 whitespace-nowrap">
{formatDate(log.timestamp)}
</td>
<td className="py-1 pr-4 text-gray-700 max-w-xs truncate">
{log.email_subject ?? '—'}
</td>
<td className="py-1 pr-4 text-gray-500 max-w-xs truncate">
{log.email_from ?? '—'}
</td>
<td className="py-1">
{log.success ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
) : (
<XCircle className="h-3.5 w-3.5 text-red-500" />
)}
</td>
</tr>
))}
</tbody>
</table>
{logsData.pages > 1 && (
<div className="flex items-center gap-2 mt-2 text-xs text-gray-500">
<button
onClick={(e) => {
e.stopPropagation();
setLogsPage((p) => Math.max(1, p - 1));
}}
disabled={logsPage === 1}
className="p-1 rounded hover:bg-gray-200 disabled:opacity-40"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span>Page {logsPage} of {logsData.pages}</span>
<button
onClick={(e) => {
e.stopPropagation();
setLogsPage((p) => Math.min(logsData.pages, p + 1));
}}
disabled={logsPage === logsData.pages}
className="p-1 rounded hover:bg-gray-200 disabled:opacity-40"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
) : (
<p className="text-xs text-gray-400 py-1">No per-email logs for this run.</p>
)}
</td>
</tr>
)}
</>
);
}
function MailboxCard({
account,
runs,
}: {
account: MailAccount;
runs: ProcessingRun[];
}) {
const [showHistory, setShowHistory] = useState(false);
const hasError = !!account.last_error_message;
const lastChecked = account.last_check_at;
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Mailbox header */}
<div className="px-5 py-4 flex items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Inbox className="h-5 w-5 text-blue-500 shrink-0" />
<div className="min-w-0">
<p className="font-semibold text-gray-900 truncate">{account.name}</p>
<p className="text-xs text-gray-500 truncate">{account.email_address}</p>
</div>
</div>
{/* Last attempt status */}
<div className="text-right shrink-0">
{hasError ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600">
<XCircle className="h-4 w-4" />
Error
</span>
) : lastChecked ? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600">
<CheckCircle className="h-4 w-4" />
OK
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium text-gray-400">
<Clock className="h-4 w-4" />
Pending
</span>
)}
<p className="text-xs text-gray-400 mt-0.5">
Last check: {formatRelative(lastChecked)}
</p>
</div>
</div>
{/* Error message */}
{hasError && (
<div className="mx-5 mb-3 p-2 bg-red-50 border border-red-200 rounded flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-xs text-red-700">{account.last_error_message}</p>
</div>
)}
{/* Successful pulls summary */}
<div className="border-t border-gray-100 px-5 py-3">
{runs.length === 0 ? (
<p className="text-xs text-gray-400">No emails fetched yet.</p>
) : (
<>
<button
onClick={() => setShowHistory((v) => !v)}
className="flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
{showHistory ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{runs.length} successful pull{runs.length !== 1 ? 's' : ''}
{runs[0] && (
<span className="text-gray-400 font-normal text-xs ml-1">
last {formatRelative(runs[0].started_at)}
</span>
)}
</button>
{showHistory && (
<div className="mt-3 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
<th className="px-4 pb-2">Date</th>
<th className="px-4 pb-2 text-right">Fetched</th>
<th className="px-4 pb-2 text-right">Forwarded</th>
<th className="px-4 pb-2 text-right">Failed</th>
<th className="px-4 pb-2 text-right">Duration</th>
<th className="px-4 pb-2" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{runs.map((run) => (
<RunDetailRow key={run.id} run={run} />
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
);
}
export default function LogsPage() {
const [runsPage, setRunsPage] = useState(1);
const { data: accounts, isLoading: accountsLoading } = useQuery({
queryKey: ['mail-accounts'],
queryFn: mailAccountsApi.list,
});
// Fetch recent successful runs (emails_fetched > 0) across all accounts
const { data: runsData, isLoading: runsLoading, isError, refetch } = useQuery({
queryKey: ['processing-runs-meaningful', runsPage],
queryFn: () =>
processingRunsApi.list({ page: runsPage, page_size: 100, has_emails: true }),
});
const isLoading = accountsLoading || runsLoading;
// Group runs by account id
const runsByAccount = (runsData?.items ?? []).reduce<Record<number, ProcessingRun[]>>(
(acc, run) => {
if (!acc[run.mail_account_id]) acc[run.mail_account_id] = [];
acc[run.mail_account_id].push(run);
return acc;
},
{}
);
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Inbox className="h-6 w-6 text-blue-600" />
Mailbox Activity
</h1>
<p className="mt-1 text-sm text-gray-500">
Last check status and successful email pulls for each mailbox.
</p>
</div>
<button
onClick={() => refetch()}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : isError ? (
<div className="text-center py-16 text-red-500">
Failed to load mailbox activity. Please try again.
</div>
) : accounts && accounts.length > 0 ? (
<>
<div className="space-y-4">
{accounts.map((account) => (
<MailboxCard
key={account.id}
account={account}
runs={runsByAccount[account.id] ?? []}
/>
))}
</div>
{/* Pagination for runs (only shown when there are multiple pages) */}
{runsData && runsData.pages > 1 && (
<div className="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-lg">
<span className="text-sm text-gray-500">
Showing page {runsData.page} of {runsData.pages} ({runsData.total} successful pulls)
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setRunsPage((p) => Math.max(1, p - 1))}
disabled={runsPage === 1}
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</button>
<button
onClick={() => setRunsPage((p) => Math.min(runsData.pages, p + 1))}
disabled={runsPage === runsData.pages}
className="flex items-center px-3 py-1.5 text-sm text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</button>
</div>
</div>
)}
</>
) : (
<div className="text-center py-16 bg-white rounded-lg shadow border border-dashed border-gray-300">
<Clock className="mx-auto h-12 w-12 text-gray-300 mb-3" />
<h3 className="text-base font-semibold text-gray-700 mb-1">No mail accounts yet</h3>
<p className="text-sm text-gray-500 max-w-xs mx-auto">
Add a mail account to start seeing activity here.
</p>
</div>
)}
</div>
</DashboardLayout>
</AuthGuard>
);
}
+347
View File
@@ -0,0 +1,347 @@
'use client';
import { useState } from 'react';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notificationsApi, NotificationConfig, NotificationConfigCreate, NotificationConfigUpdate } from '@/lib/api';
import { NotificationWizard } from '@/components/NotificationWizard';
import { Plus, Edit2, Trash2, Bell, Send, CheckCircle, XCircle, Loader2 } from 'lucide-react';
const CHANNEL_DISPLAY: Record<string, { icon: string; label: string; color: string }> = {
telegram: { icon: '🤖', label: 'Telegram', color: 'bg-blue-100 text-blue-800' },
discord: { icon: '💬', label: 'Discord', color: 'bg-indigo-100 text-indigo-800' },
slack: { icon: '💼', label: 'Slack', color: 'bg-yellow-100 text-yellow-800' },
email: { icon: '📧', label: 'Email', color: 'bg-green-100 text-green-800' },
webhook: { icon: '🔗', label: 'Webhook', color: 'bg-purple-100 text-purple-800' },
custom: { icon: '⚙️', label: 'Custom', color: 'bg-gray-100 text-gray-800' },
};
export default function NotificationsPage() {
const [showWizard, setShowWizard] = useState(false);
const [editingConfig, setEditingConfig] = useState<NotificationConfig | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [testResults, setTestResults] = useState<Record<number, { success: boolean; message: string }>>({});
const queryClient = useQueryClient();
const { data: notifications, isLoading } = useQuery({
queryKey: ['notifications'],
queryFn: notificationsApi.list,
});
const createMutation = useMutation({
mutationFn: (data: NotificationConfigCreate) => notificationsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
setShowWizard(false);
setEditingConfig(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: NotificationConfigUpdate }) =>
notificationsApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
setShowWizard(false);
setEditingConfig(null);
},
});
const deleteMutation = useMutation({
mutationFn: notificationsApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const toggleMutation = useMutation({
mutationFn: ({ id, is_enabled }: { id: number; is_enabled: boolean }) =>
notificationsApi.update(id, { is_enabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const handleWizardComplete = (config: {
name: string;
channel: string;
apprise_url: string;
notify_on_errors: boolean;
notify_on_success: boolean;
}) => {
if (editingConfig) {
updateMutation.mutate({ id: editingConfig.id, data: config });
} else {
createMutation.mutate(config);
}
};
const handleEdit = (config: NotificationConfig) => {
setEditingConfig(config);
setShowWizard(true);
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this notification channel?')) return;
try {
await deleteMutation.mutateAsync(id);
} catch {
alert('Failed to delete notification channel');
}
};
const handleTest = async (config: NotificationConfig) => {
if (!config.apprise_url) return;
setTestingId(config.id);
try {
const result = await notificationsApi.test(config.apprise_url);
setTestResults((prev) => ({ ...prev, [config.id]: result }));
} catch {
setTestResults((prev) => ({
...prev,
[config.id]: { success: false, message: 'Test request failed' },
}));
} finally {
setTestingId(null);
setTimeout(() => {
setTestResults((prev) => {
const next = { ...prev };
delete next[config.id];
return next;
});
}, 5000);
}
};
const handleOpenWizard = () => {
setEditingConfig(null);
setShowWizard(true);
};
const handleCancelWizard = () => {
setShowWizard(false);
setEditingConfig(null);
};
if (showWizard) {
return (
<AuthGuard>
<DashboardLayout>
<div className="max-w-xl mx-auto">
<div className="bg-white rounded-xl shadow-lg p-6">
<NotificationWizard
onComplete={handleWizardComplete}
onCancel={handleCancelWizard}
initialData={
editingConfig
? {
name: editingConfig.name,
channel: editingConfig.channel,
apprise_url: editingConfig.apprise_url,
notify_on_errors: editingConfig.notify_on_errors,
notify_on_success: editingConfig.notify_on_success,
}
: null
}
/>
</div>
</div>
</DashboardLayout>
</AuthGuard>
);
}
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Bell className="h-6 w-6 text-blue-600" />
Notification Channels
</h1>
<p className="mt-1 text-sm text-gray-500">
Get alerts when emails are processed or errors occur.
</p>
</div>
<button
onClick={handleOpenWizard}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4 mr-2" />
Add Channel
</button>
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : notifications && notifications.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{notifications.map((config) => {
const channel = CHANNEL_DISPLAY[config.channel] ?? CHANNEL_DISPLAY.custom;
const testResult = testResults[config.id];
const isTesting = testingId === config.id;
return (
<div
key={config.id}
className={`bg-white rounded-lg shadow border overflow-hidden transition-opacity ${
config.is_enabled ? 'border-gray-200' : 'border-gray-200 opacity-60'
}`}
>
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-2xl flex-shrink-0">{channel.icon}</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
{config.name}
</h3>
<span
className={`inline-block mt-0.5 px-2 py-0.5 rounded-full text-xs font-medium ${channel.color}`}
>
{channel.label}
</span>
</div>
</div>
<button
onClick={() =>
toggleMutation.mutate({
id: config.id,
is_enabled: !config.is_enabled,
})
}
disabled={toggleMutation.isPending}
title={config.is_enabled ? 'Disable' : 'Enable'}
className={`ml-2 flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
config.is_enabled
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
>
{config.is_enabled ? (
<CheckCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{config.is_enabled ? 'On' : 'Off'}
</button>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{config.notify_on_errors && (
<span className="px-2 py-0.5 rounded text-xs bg-red-50 text-red-700 border border-red-200">
On Errors
</span>
)}
{config.notify_on_success && (
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 border border-green-200">
On Success
</span>
)}
{!config.notify_on_errors && !config.notify_on_success && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-50 text-gray-500 border border-gray-200">
No triggers set
</span>
)}
</div>
{testResult && (
<div
className={`mb-3 p-2 rounded text-xs flex items-center gap-1.5 ${
testResult.success
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
{testResult.success ? (
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 flex-shrink-0" />
)}
{testResult.message}
</div>
)}
<div className="flex items-center gap-2 pt-3 border-t border-gray-100">
<button
onClick={() => handleTest(config)}
disabled={isTesting || !config.apprise_url}
title="Send test notification"
className="flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100 disabled:opacity-50 transition-colors"
>
{isTesting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5 mr-1" />
)}
{isTesting ? '…' : 'Test'}
</button>
<button
onClick={() => handleEdit(config)}
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 transition-colors"
>
<Edit2 className="h-3.5 w-3.5 mr-1" />
Edit
</button>
<button
onClick={() => handleDelete(config.id)}
disabled={deleteMutation.isPending}
className="flex-1 flex items-center justify-center px-2.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 disabled:opacity-50 transition-colors"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-16 bg-white rounded-lg shadow border border-dashed border-gray-300">
<Bell className="mx-auto h-12 w-12 text-gray-300 mb-3" />
<h3 className="text-base font-semibold text-gray-700 mb-1">
No notification channels yet
</h3>
<p className="text-sm text-gray-500 mb-4 max-w-xs mx-auto">
Add a channel to receive alerts when emails are processed or errors occur.
</p>
<button
onClick={handleOpenWizard}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm font-medium"
>
<Plus className="h-4 w-4 mr-2" />
Add Your First Channel
</button>
</div>
)}
{/* Info box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-900 mb-1">About Notifications</h4>
<p className="text-xs text-blue-700 leading-relaxed">
Notifications are powered by{' '}
<a
href="https://github.com/caronc/apprise"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-900"
>
Apprise
</a>
, which supports 80+ notification services including Telegram, Discord, Slack, email,
and many more. Each channel can be configured independently with different triggers.
</p>
</div>
</div>
</DashboardLayout>
</AuthGuard>
);
}
+124 -39
View File
@@ -4,8 +4,61 @@ import { useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import { userApi } from '@/lib/api';
import { Mail, ArrowRight, Shield, Zap, Clock } from 'lucide-react';
import { userApi, SubscriptionPlan } from '@/lib/api';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { Mail, ArrowRight, Shield, Zap, Clock, Check } from 'lucide-react';
async function fetchPublicPlans(): Promise<SubscriptionPlan[]> {
const res = await api.get<SubscriptionPlan[]>('/subscriptions/plans');
return res.data;
}
function PricingCard({ plan }: { plan: SubscriptionPlan }) {
const yearlyMonthly = plan.price_yearly
? (plan.price_yearly / 12).toFixed(2)
: null;
return (
<div className="bg-white rounded-xl shadow-md p-8 flex flex-col border border-gray-100 hover:shadow-lg transition-shadow">
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
<p className="mt-2 text-sm text-gray-500 flex-1">{plan.description}</p>
<div className="mt-6">
<span className="text-4xl font-extrabold text-gray-900">
{plan.price_monthly.toFixed(2)}
</span>
<span className="text-gray-500">/month</span>
{yearlyMonthly && (
<p className="text-xs text-green-600 mt-1">
or {yearlyMonthly}/mo billed yearly ({plan.price_yearly?.toFixed(2)})
</p>
)}
</div>
<ul className="mt-6 space-y-2 text-sm text-gray-600">
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500 shrink-0" />
{plan.max_mail_accounts === 1
? '1 mailbox'
: `Up to ${plan.max_mail_accounts} mailboxes`}
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500 shrink-0" />
Checked every {plan.check_interval_minutes} minute{plan.check_interval_minutes > 1 ? 's' : ''}
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500 shrink-0" />
Up to {plan.max_emails_per_day.toLocaleString()} emails/day
</li>
</ul>
<Link
href="/register"
className="mt-8 block text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Get started
</Link>
</div>
);
}
export default function Home() {
const router = useRouter();
@@ -29,6 +82,12 @@ export default function Home() {
});
}, [router, setUser, setLoading]);
const { data: plans } = useQuery({
queryKey: ['public-plans'],
queryFn: fetchPublicPlans,
staleTime: 5 * 60 * 1000,
});
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -37,6 +96,8 @@ export default function Home() {
);
}
const hasPaidPlans = plans && plans.length > 0;
return (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
{/* Header */}
@@ -45,7 +106,7 @@ export default function Home() {
<div className="flex justify-between items-center py-4">
<div className="flex items-center">
<Mail className="h-8 w-8 text-blue-600 mr-2" />
<h1 className="text-2xl font-bold text-gray-900">POP3 Forwarder</h1>
<h1 className="text-2xl font-bold text-gray-900">InboxConverge</h1>
</div>
<div className="flex items-center gap-4">
<Link
@@ -58,31 +119,37 @@ export default function Home() {
href="/register"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Sign Up
Sign Up Free
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
{/* Hero */}
<div className="text-center">
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
Forward Your POP3 Emails to Gmail
<br />
<span className="text-blue-600">Automatically</span>
Your old inboxes,{' '}
<span className="text-blue-600">delivered to Gmail.</span>
</h2>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
Connect your POP3 email accounts and automatically forward all messages to Gmail.
Simple, secure, and reliable email forwarding service.
<p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto">
You know the ones that GMX account from 2009, the old ISP address your
bank still sends to, the Hotmail you gave out in school. InboxConverge
quietly polls them all and drops everything into your Gmail. Set it once,
forget it exists.
</p>
<div className="flex items-center justify-center gap-4">
<p className="text-base text-gray-500 mb-8 max-w-xl mx-auto">
No forwarding rules to configure. No email clients to keep open.
Just your mail, where you actually read it.
</p>
<div className="flex items-center justify-center gap-4 flex-wrap">
<Link
href="/register"
className="flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-lg font-medium"
>
Get Started Free
Get Started it&apos;s free
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
<Link
@@ -101,11 +168,11 @@ export default function Home() {
<Zap className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Auto-Detection
Auto-detects everything
</h3>
<p className="text-gray-600">
Automatically detect POP3 server settings from your email address.
Quick and easy setup in minutes.
Type your old email address and InboxConverge figures out the server
settings. No Googling port numbers required.
</p>
</div>
@@ -114,11 +181,11 @@ export default function Home() {
<Clock className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Scheduled Checks
Runs in the background
</h3>
<p className="text-gray-600">
Set custom check intervals for each account. From every minute to once a day,
you control the frequency.
Checks your old inboxes on a schedule you choose from every minute
to once a day. New mail appears in Gmail as if it was always there.
</p>
</div>
@@ -127,11 +194,11 @@ export default function Home() {
<Shield className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Secure & Private
Your passwords stay yours
</h3>
<p className="text-gray-600">
Your credentials are encrypted and secure. We use SSL/TLS for all connections
and OAuth2 for Gmail.
Credentials are encrypted at rest and never shared. All connections
use SSL/TLS and Gmail delivery uses OAuth2 no app passwords needed.
</p>
</div>
</div>
@@ -139,53 +206,71 @@ export default function Home() {
{/* How It Works */}
<div className="mt-20">
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
How It Works
Three steps and you&apos;re done
</h3>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center">
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
1
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Connect Accounts
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
<p className="text-gray-600">
Add your POP3 email accounts with auto-detected settings
Paste the email address InboxConverge auto-detects the POP3/IMAP
settings in seconds.
</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
2
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Authorize Gmail
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
<p className="text-gray-600">
Sign in with Google to allow forwarding to your Gmail
Sign in with Google once. InboxConverge delivers mail directly into
your inbox using the Gmail API no SMTP relay needed.
</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
3
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Relax & Enjoy
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Close the tab</h4>
<p className="text-gray-600">
Emails are automatically forwarded. Monitor activity from your dashboard
Seriously, that&apos;s it. Your mail arrives automatically from now on.
Check the dashboard whenever you like, but you won&apos;t need to.
</p>
</div>
</div>
</div>
{/* Pricing — only rendered when paid plans exist (hidden in enterprise/all-free mode) */}
{hasPaidPlans && (
<div className="mt-24">
<h3 className="text-3xl font-bold text-center text-gray-900 mb-4">
Pricing
</h3>
<p className="text-center text-gray-500 mb-12 max-w-xl mx-auto">
Start free. Upgrade if you need more inboxes or faster checks.
Cancel any time no questions, no fuss.
</p>
<div className={`grid gap-8 ${plans.length === 1 ? 'max-w-sm mx-auto' : plans.length === 2 ? 'md:grid-cols-2 max-w-2xl mx-auto' : 'md:grid-cols-3'}`}>
{plans.map((plan) => (
<PricingCard key={plan.id} plan={plan} />
))}
</div>
<p className="mt-8 text-center text-sm text-gray-500">
All paid plans include a{' '}
<span className="font-medium">free tier</span> when you first sign up
no credit card required.
</p>
</div>
)}
</main>
{/* Footer */}
<footer className="mt-20 border-t border-gray-200 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<p className="text-center text-gray-600">
© 2024 POP3 Forwarder. Secure email forwarding service.
<p className="text-center text-gray-600 text-sm">
© {new Date().getFullYear()} InboxConverge made for people, not enterprises.
</p>
</div>
</footer>
+195 -28
View File
@@ -15,8 +15,113 @@ import {
Server,
AlertTriangle,
XCircle,
Bug,
RotateCcw,
Tags,
} from 'lucide-react';
const DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES = ['{{source_email}}', 'imported'];
function parseImportLabelTemplates(input: string): string[] {
return input
.split('\n')
.map((value) => value.trim())
.filter((value, index, values) => value.length > 0 && values.indexOf(value) === index);
}
function GmailImportLabelsForm({
gmailCredential,
onSave,
isSaving,
}: {
gmailCredential: {
gmail_email: string;
import_label_templates: string[];
default_import_label_templates: string[];
};
onSave: (labels: string[]) => void;
isSaving: boolean;
}) {
const [labelsInput, setLabelsInput] = useState(
gmailCredential.import_label_templates.join('\n')
);
const defaultTemplates =
gmailCredential.default_import_label_templates.length > 0
? gmailCredential.default_import_label_templates
: DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES;
const parsedLabels = parseImportLabelTemplates(labelsInput);
const isDefaultSelection =
parsedLabels.length === defaultTemplates.length &&
parsedLabels.every((value, index) => value === defaultTemplates[index]);
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="mb-3 flex items-center gap-2">
<Tags className="h-4 w-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">Import labels</h3>
</div>
<p className="text-sm text-gray-600">
One label is created per line. We recommend keeping{' '}
<code className="rounded bg-white px-1 py-0.5 text-xs text-gray-700">
{'{{source_email}}'}
</code>{' '}
so each imported message is tagged with the mailbox it came from, plus a
catch-all label like <strong>imported</strong>.
</p>
<p className="mt-2 text-xs text-gray-500">
Example: a mail pulled from <strong>billing@example.com</strong> will be
labeled as <strong>billing@example.com</strong> when{' '}
<code className="rounded bg-white px-1 py-0.5 text-xs text-gray-700">
{'{{source_email}}'}
</code>{' '}
is present.
</p>
<textarea
value={labelsInput}
onChange={(e) => setLabelsInput(e.target.value)}
rows={4}
className="mt-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`{{source_email}}\nimported`}
/>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>Suggested defaults:</span>
{defaultTemplates.map((label) => (
<span
key={label}
className="rounded-full border border-gray-200 bg-white px-2 py-1 text-gray-700"
>
{label}
</span>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => onSave(parsedLabels)}
disabled={isSaving}
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Save label setup
</button>
<button
type="button"
onClick={() => setLabelsInput(defaultTemplates.join('\n'))}
disabled={isSaving || isDefaultSelection}
className="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm text-gray-700 ring-1 ring-gray-300 transition-colors hover:bg-gray-50 disabled:opacity-50"
>
<RotateCcw className="h-4 w-4" />
Reset defaults
</button>
</div>
<p className="mt-3 text-xs text-gray-500">
Connected Gmail target: <strong>{gmailCredential.gmail_email}</strong>
</p>
</div>
);
}
export default function SettingsPage() {
return (
<AuthGuard>
@@ -104,6 +209,29 @@ function SettingsContent() {
},
});
const [debugEmailResult, setDebugEmailResult] = useState<string | null>(null);
const [gmailLabelsSaved, setGmailLabelsSaved] = useState(false);
const sendDebugEmailMutation = useMutation({
mutationFn: gmailApi.sendDebugEmail,
onSuccess: () => {
setDebugEmailResult('success');
setTimeout(() => setDebugEmailResult(null), 5000);
},
onError: () => {
setDebugEmailResult('error');
setTimeout(() => setDebugEmailResult(null), 5000);
},
});
const updateGmailLabelsMutation = useMutation({
mutationFn: gmailApi.updateImportLabels,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gmail-credential'] });
setGmailLabelsSaved(true);
setTimeout(() => setGmailLabelsSaved(false), 3000);
},
});
const saveSmtpMutation = useMutation({
mutationFn: smtpApi.save,
onSuccess: () => {
@@ -175,7 +303,7 @@ function SettingsContent() {
const handleConnectGmail = async () => {
try {
const redirectUri = `${window.location.origin}/auth/gmail-callback`;
const redirectUri = `${window.location.origin}/auth/callback`;
const url = await gmailApi.getAuthorizeUrl(redirectUri);
window.location.href = url;
} catch (error) {
@@ -302,35 +430,75 @@ function SettingsContent() {
)}
{!gmailLoading && gmailConnected && (
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="text-sm font-medium text-gray-900">
Connected as <span className="font-semibold">{gmailCredential.gmail_email}</span>
</p>
{gmailCredential.last_verified_at && (
<p className="text-xs text-gray-500">
Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="text-sm font-medium text-gray-900">
Connected as <span className="font-semibold">{gmailCredential.gmail_email}</span>
</p>
)}
{gmailCredential.last_verified_at && (
<p className="text-xs text-gray-500">
Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()}
</p>
)}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => sendDebugEmailMutation.mutate()}
disabled={sendDebugEmailMutation.isPending}
title="Inject a test email into your Gmail inbox"
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-amber-50 text-amber-700 rounded-md hover:bg-amber-100 transition-colors disabled:opacity-50"
>
{sendDebugEmailMutation.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Sending</>
) : (
<><Bug className="h-4 w-4" />Send Debug Email</>
)}
</button>
<button
onClick={handleConnectGmail}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
>
Re-authorise
</button>
<button
onClick={handleDisconnectGmail}
disabled={disconnectGmailMutation.isPending}
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors disabled:opacity-50"
>
Disconnect
</button>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleConnectGmail}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
>
Re-authorise
</button>
<button
onClick={handleDisconnectGmail}
disabled={disconnectGmailMutation.isPending}
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors disabled:opacity-50"
>
Disconnect
</button>
</div>
{debugEmailResult === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
<CheckCircle className="h-4 w-4 flex-shrink-0" />
Debug email injected successfully. Check your Gmail inbox for the
configured import labels plus a <strong className="mx-1">test</strong>{' '}
label.
</div>
)}
{debugEmailResult === 'error' && (
<div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
Failed to inject debug email. Check that Gmail API access is still valid.
</div>
)}
<GmailImportLabelsForm
key={gmailCredential.updated_at}
gmailCredential={gmailCredential}
isSaving={updateGmailLabelsMutation.isPending}
onSave={(labels) => updateGmailLabelsMutation.mutate(labels)}
/>
{gmailLabelsSaved && (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
<CheckCircle className="h-4 w-4 flex-shrink-0" />
Gmail import labels saved.
</div>
)}
</div>
)}
@@ -549,4 +717,3 @@ function SettingsContent() {
</div>
);
}
+98 -43
View File
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { mailAccountsApi, MailAccount, MailAccountCreate } from '@/lib/api';
import { mailAccountsApi, MailAccount, MailAccountCreate, MailAccountUpdate } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { X, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { ProviderWizard } from './ProviderWizard';
@@ -17,10 +17,11 @@ type WizardStep = 'provider' | 'form';
export function AddMailAccountModal({ account, onClose }: AddMailAccountModalProps) {
const queryClient = useQueryClient();
const { user } = useAuthStore();
const isEditMode = !!account;
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const [autoDetecting, setAutoDetecting] = useState(false);
const [wizardStep, setWizardStep] = useState<WizardStep>(account ? 'form' : 'provider');
const [wizardStep, setWizardStep] = useState<WizardStep>(isEditMode ? 'form' : 'provider');
const [formData, setFormData] = useState<MailAccountCreate>({
name: account?.name || '',
@@ -28,7 +29,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
protocol: account?.protocol || 'pop3_ssl',
host: account?.host || '',
port: account?.port || 995,
username: '',
username: account?.username || '',
password: '',
use_ssl: account?.use_ssl ?? true,
use_tls: account?.use_tls ?? false,
@@ -38,15 +39,22 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
check_interval_minutes: account?.check_interval_minutes || 5,
max_emails_per_check: account?.max_emails_per_check || 50,
delete_after_forward: account?.delete_after_forward ?? true,
provider_name: account?.provider_name ?? null,
});
const onMutationSuccess = () => {
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
onClose();
};
const createMutation = useMutation({
mutationFn: (data: MailAccountCreate) =>
account ? mailAccountsApi.update(account.id, data) : mailAccountsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mail-accounts'] });
onClose();
},
mutationFn: (data: MailAccountCreate) => mailAccountsApi.create(data),
onSuccess: onMutationSuccess,
});
const updateMutation = useMutation({
mutationFn: (data: MailAccountUpdate) => mailAccountsApi.update(account!.id, data),
onSuccess: onMutationSuccess,
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
@@ -68,7 +76,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
});
};
const handleProviderSelect = (config: { name: string; protocol: string; host: string; port: number; use_ssl: boolean }) => {
const handleProviderSelect = (config: { name: string; provider_name: string; protocol: string; host: string; port: number; use_ssl: boolean }) => {
setFormData((prev) => ({
...prev,
name: config.name,
@@ -76,6 +84,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
host: config.host,
port: config.port,
use_ssl: config.use_ssl,
provider_name: config.provider_name,
}));
setWizardStep('form');
};
@@ -107,43 +116,88 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
}
};
const handleTestConnection = async () => {
if (!formData.username || !formData.password || !formData.host) {
alert('Please fill in username, password, and host');
return;
const extractErrorMessage = (error: unknown, fallback: string): string => {
interface FastAPIValidationError { msg: string; loc?: unknown[]; type?: string }
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
const msgs = (detail as FastAPIValidationError[]).map((d) => d?.msg ?? JSON.stringify(d));
return msgs.join('; ');
}
if (error instanceof Error && error.message) return error.message;
return fallback;
};
const handleTestConnection = async () => {
setTestStatus('testing');
setTestMessage('');
try {
await mailAccountsApi.test({
protocol: formData.protocol,
host: formData.host,
port: formData.port,
username: formData.username,
password: formData.password,
use_ssl: formData.use_ssl,
});
setTestStatus('success');
setTestMessage('Connection successful!');
let result: { success: boolean; message: string };
if (isEditMode && !formData.password && account?.id) {
// Edit mode with no new password entered — test using stored credentials
result = await mailAccountsApi.testExisting(account.id);
} else {
if (!formData.username || !formData.password || !formData.host) {
setTestStatus('error');
setTestMessage('Please fill in username, password, and host');
return;
}
result = await mailAccountsApi.test({
protocol: formData.protocol,
host: formData.host,
port: formData.port,
username: formData.username,
password: formData.password,
use_ssl: formData.use_ssl,
});
}
if (result.success) {
setTestStatus('success');
setTestMessage(result.message);
} else {
setTestStatus('error');
setTestMessage(result.message || 'Connection failed');
}
} catch (error) {
setTestStatus('error');
const errorMessage = error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null;
setTestMessage(errorMessage || 'Connection failed');
setTestMessage(extractErrorMessage(error, 'Connection failed'));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createMutation.mutateAsync(formData);
if (isEditMode) {
// Send all fields so the user can change any aspect of the account.
// Password is the only exception: omit it when blank so the stored
// credential is preserved.
const updateData: MailAccountUpdate = {
name: formData.name,
email_address: formData.email_address,
protocol: formData.protocol,
host: formData.host,
port: formData.port,
use_ssl: formData.use_ssl,
use_tls: formData.use_tls,
username: formData.username,
forward_to: formData.forward_to,
delivery_method: formData.delivery_method,
is_enabled: formData.is_enabled,
check_interval_minutes: formData.check_interval_minutes,
max_emails_per_check: formData.max_emails_per_check,
delete_after_forward: formData.delete_after_forward,
};
if (formData.password) {
updateData.password = formData.password;
}
await updateMutation.mutateAsync(updateData);
} else {
await createMutation.mutateAsync(formData);
}
} catch (error) {
const errorMessage = error instanceof Error && 'response' in error
? (error as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null;
alert(errorMessage || 'Failed to save account');
alert(extractErrorMessage(error, 'Failed to save account'));
}
};
@@ -157,7 +211,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
<div className="bg-white px-6 pt-6 pb-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">
{account ? 'Edit Mail Account' : 'Add Mail Account'}
{isEditMode ? 'Edit Mail Account' : 'Add Mail Account'}
</h3>
<button
type="button"
@@ -168,14 +222,14 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</button>
</div>
{wizardStep === 'provider' && !account ? (
{wizardStep === 'provider' && !isEditMode ? (
<ProviderWizard
onSelect={handleProviderSelect}
onManual={() => setWizardStep('form')}
/>
) : (
<div className="space-y-4">
{!account && (
{!isEditMode && (
<button
type="button"
onClick={() => setWizardStep('provider')}
@@ -210,7 +264,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
name="username"
value={formData.username}
onChange={handleChange}
required
required={!isEditMode}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="user@example.com"
/>
@@ -223,6 +277,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
{autoDetecting ? 'Detecting...' : 'Auto-Detect'}
</button>
</div>
</div>
<div>
@@ -234,9 +289,9 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
name="password"
value={formData.password}
onChange={handleChange}
required={!account}
required={!isEditMode}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={account ? 'Leave blank to keep current password' : 'Password'}
placeholder={isEditMode ? 'Leave blank to keep current password' : 'Password'}
/>
</div>
@@ -267,7 +322,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
name="host"
value={formData.host}
onChange={handleChange}
required
required={!isEditMode}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="pop.gmail.com"
/>
@@ -282,7 +337,7 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
name="port"
value={formData.port}
onChange={handleChange}
required
required={!isEditMode}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@@ -446,10 +501,10 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro
</button>
<button
type="submit"
disabled={createMutation.isPending}
disabled={createMutation.isPending || updateMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Saving...' : 'Save'}
{(createMutation.isPending || updateMutation.isPending) ? 'Saving...' : 'Save'}
</button>
</div>
</div>
+145
View File
@@ -0,0 +1,145 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { AuthGuard } from './AuthGuard';
// Mock next/navigation
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}));
// Mock userApi
const mockGetCurrentUser = jest.fn();
jest.mock('@/lib/api', () => ({
userApi: {
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
},
}));
// Mock authStore
const mockSetUser = jest.fn();
const mockSetLoading = jest.fn();
let mockUser: Record<string, unknown> | null = null;
let mockIsLoading = true;
jest.mock('@/store/authStore', () => ({
useAuthStore: () => ({
user: mockUser,
isLoading: mockIsLoading,
setUser: mockSetUser,
setLoading: mockSetLoading,
}),
}));
const mockUserData = {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
is_active: true,
is_superuser: false,
subscription_tier: 'free',
subscription_status: 'active',
created_at: '2024-01-01T00:00:00Z',
};
describe('AuthGuard', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
mockUser = null;
mockIsLoading = true;
});
it('should show loading spinner while isLoading is true', () => {
mockIsLoading = true;
localStorage.setItem('access_token', 'test-token');
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
// Should show spinner (via animate-spin class)
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render children when user is authenticated', () => {
mockUser = mockUserData;
mockIsLoading = false;
localStorage.setItem('access_token', 'test-token');
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
it('should render nothing when not loading and no user', () => {
mockUser = null;
mockIsLoading = false;
const { container } = render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
expect(container.innerHTML).toBe('');
});
it('should redirect to /login when no token exists', async () => {
// No token in localStorage
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockSetLoading).toHaveBeenCalledWith(false);
expect(mockPush).toHaveBeenCalledWith('/login');
});
});
it('should fetch user data when token exists', async () => {
localStorage.setItem('access_token', 'valid-token');
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockGetCurrentUser).toHaveBeenCalled();
expect(mockSetUser).toHaveBeenCalledWith(mockUserData);
});
});
it('should redirect to /login when API call fails', async () => {
localStorage.setItem('access_token', 'expired-token');
mockGetCurrentUser.mockRejectedValue(new Error('Unauthorized'));
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalledWith(null);
expect(mockPush).toHaveBeenCalledWith('/login');
});
});
});
@@ -0,0 +1,248 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { DashboardLayout } from './DashboardLayout';
// Mock next/navigation
const mockPush = jest.fn();
let mockPathname = '/dashboard';
jest.mock('next/navigation', () => ({
usePathname: () => mockPathname,
useRouter: () => ({ push: mockPush }),
}));
// Mock next/link to render a simple anchor
jest.mock('next/link', () => {
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
// Mock @tanstack/react-query
let mockVersionInfo: Record<string, string> | null = null;
jest.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: mockVersionInfo }),
}));
// Mock lucide-react icons as simple spans
jest.mock('lucide-react', () => {
const iconNames = [
'LayoutDashboard', 'Mail', 'Settings', 'LogOut', 'Menu', 'X',
'User', 'Shield', 'Users', 'CreditCard', 'Bell', 'Inbox', 'Activity',
];
const icons: Record<string, React.FC<{ className?: string }>> = {};
iconNames.forEach((name) => {
icons[name] = ({ className }: { className?: string }) => (
<span data-testid={`icon-${name}`} className={className} />
);
});
return icons;
});
// Mock authStore
const mockLogout = jest.fn();
let mockUser: Record<string, unknown> | null = null;
jest.mock('@/store/authStore', () => ({
useAuthStore: () => ({
user: mockUser,
logout: mockLogout,
}),
}));
// Mock versionApi
jest.mock('@/lib/api', () => ({
versionApi: {
get: jest.fn(),
},
}));
const regularUser = {
id: 1,
email: 'user@example.com',
full_name: 'Regular User',
is_active: true,
is_superuser: false,
subscription_tier: 'free',
subscription_status: 'active',
created_at: '2024-01-01T00:00:00Z',
};
const adminUser = {
...regularUser,
id: 2,
email: 'admin@example.com',
full_name: 'Admin User',
is_superuser: true,
};
describe('DashboardLayout', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPathname = '/dashboard';
mockUser = regularUser;
mockVersionInfo = null;
});
it('should render the InboxConverge branding', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// Desktop sidebar has the branding
expect(screen.getAllByText('InboxConverge').length).toBeGreaterThan(0);
});
it('should render main navigation items', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getAllByText('Dashboard').length).toBeGreaterThan(0);
expect(screen.getAllByText('Mail Accounts').length).toBeGreaterThan(0);
expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
expect(screen.getAllByText('Mailbox Activity').length).toBeGreaterThan(0);
expect(screen.getAllByText('Settings').length).toBeGreaterThan(0);
});
it('should render children in main content area', () => {
render(
<DashboardLayout>
<div data-testid="page-content">Page Content</div>
</DashboardLayout>
);
expect(screen.getByTestId('page-content')).toBeInTheDocument();
expect(screen.getByText('Page Content')).toBeInTheDocument();
});
it('should display user info in the top bar', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('Regular User')).toBeInTheDocument();
expect(screen.getByText('user@example.com')).toBeInTheDocument();
});
it('should not show admin navigation for regular users', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText('Admin Overview')).not.toBeInTheDocument();
expect(screen.queryByText('Manage Users')).not.toBeInTheDocument();
expect(screen.queryByText('Manage Plans')).not.toBeInTheDocument();
expect(screen.queryByText('Activity Logs')).not.toBeInTheDocument();
});
it('should show admin navigation for superusers', () => {
mockUser = adminUser;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getAllByText('Admin Overview').length).toBeGreaterThan(0);
expect(screen.getAllByText('Manage Users').length).toBeGreaterThan(0);
expect(screen.getAllByText('Manage Plans').length).toBeGreaterThan(0);
expect(screen.getAllByText('Activity Logs').length).toBeGreaterThan(0);
});
it('should show Admin badge for superusers', () => {
mockUser = adminUser;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The Admin badge has a specific class for styling
const adminBadges = screen.getAllByText('Admin');
const badge = adminBadges.find((el) => el.classList.contains('bg-purple-100'));
expect(badge).toBeInTheDocument();
});
it('should not show Admin badge for regular users', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
it('should call logout and redirect on Logout button click', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// Click the first Logout button (desktop sidebar)
const logoutButtons = screen.getAllByText('Logout');
fireEvent.click(logoutButtons[0]);
expect(mockLogout).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith('/login');
});
it('should display the current page title based on pathname', () => {
mockPathname = '/accounts';
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The top bar should show "Mail Accounts" as the h2 heading
const headings = screen.getAllByText('Mail Accounts');
// At least one should be a heading in the top bar
expect(headings.length).toBeGreaterThan(0);
});
it('should show version info in the footer when available', () => {
mockVersionInfo = { version: '1.2.3', build_date: '2024-06-15T12:00:00Z' };
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
it('should not show version info when not available', () => {
mockVersionInfo = null;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument();
});
it('should render footer links', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('Impressum')).toBeInTheDocument();
expect(screen.getByText('Datenschutz')).toBeInTheDocument();
});
it('should open mobile sidebar on menu button click', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The mobile menu button has a Menu icon
const menuButton = screen.getByTestId('icon-Menu').closest('button');
expect(menuButton).toBeInTheDocument();
fireEvent.click(menuButton!);
// After clicking, the close (X) button should appear
expect(screen.getByTestId('icon-X')).toBeInTheDocument();
});
});

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