Merge branch 'main' into copilot/ensure-full-test-coverage

This commit is contained in:
Christian Krakau-Louis
2026-04-07 12:28:35 +02:00
committed by GitHub
20 changed files with 2899 additions and 890 deletions
+13 -13
View File
@@ -26,7 +26,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -99,7 +99,7 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -139,7 +139,7 @@ jobs:
working-directory: frontend
- name: Upload backend coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./backend/coverage.xml
@@ -148,7 +148,7 @@ jobs:
fail_ci_if_error: false
- name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./frontend/coverage/lcov.info
@@ -165,7 +165,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -206,13 +206,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
@@ -220,7 +220,7 @@ jobs:
# Requires PRIVATE_REGISTRY_USERNAME and PRIVATE_REGISTRY_PASSWORD secrets
- name: Log in to private registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.PRIVATE_REGISTRY }}
username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }}
@@ -228,7 +228,7 @@ jobs:
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image_name }}
@@ -242,7 +242,7 @@ jobs:
- name: Build and push Docker image
id: build-push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
push: true
@@ -294,7 +294,7 @@ jobs:
- name: Checkout k8s-cluster-state
if: steps.pat-check.outputs.available == 'true'
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: ${{ env.K8S_STATE_REPO }}
token: ${{ secrets.GH_PAT }}
@@ -303,7 +303,7 @@ jobs:
- name: Update backend image tag in preprod manifest
if: steps.pat-check.outputs.available == 'true'
uses: mikefarah/yq@v4.44.6
uses: mikefarah/yq@v4.52.5
env:
IMAGE: ${{ steps.tag.outputs.backend_image }}
with:
@@ -313,7 +313,7 @@ jobs:
- name: Update frontend image tag in preprod manifest
if: steps.pat-check.outputs.available == 'true'
uses: mikefarah/yq@v4.44.6
uses: mikefarah/yq@v4.52.5
env:
IMAGE: ${{ steps.tag.outputs.frontend_image }}
with:
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
+160
View File
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list -->
## v0.6.3 (2026-04-07)
### Bug Fixes
- **lint**: Specify explicit React version to fix ESLint 10 compatibility
([`efa553c`](https://github.com/christianlouis/InboxConverge/commit/efa553c8d52b2e7595318b5e4897baf13b4d847d))
### Chores
- **ci**: Bump docker/login-action from 3 to 4
([`6fe2f9a`](https://github.com/christianlouis/InboxConverge/commit/6fe2f9ad951a75d7c43bbb596cce9af552b82ece))
- **deps**: Bump @tanstack/react-query in /frontend
([`466e852`](https://github.com/christianlouis/InboxConverge/commit/466e852c8bb587ed9f98198c8ef7a570e4cb1aec))
- **deps**: Bump @types/node from 25.5.0 to 25.5.2 in /frontend
([`cc1edd8`](https://github.com/christianlouis/InboxConverge/commit/cc1edd8f5d1893eeecc83afb7795983fe5181b71))
- **deps**: Bump aiosmtplib from 3.0.1 to 5.1.0 in /backend
([`284fbba`](https://github.com/christianlouis/InboxConverge/commit/284fbba47f1082cc52a3d23ec82ef24d443f1c17))
- **deps**: Bump alembic from 1.13.1 to 1.18.4 in /backend
([`bd51a5b`](https://github.com/christianlouis/InboxConverge/commit/bd51a5b541db60610976d784e3518b00b04d9b65))
- **deps**: Bump email-validator from 2.1.0.post1 to 2.3.0 in /backend
([`6f414e1`](https://github.com/christianlouis/InboxConverge/commit/6f414e175a117aa1faa57844797be33b258ac380))
- **deps**: Bump eslint from 9.39.4 to 10.2.0 in /frontend
([`4bb92ac`](https://github.com/christianlouis/InboxConverge/commit/4bb92ac9d030aef180b2b358f4e824068a6c5c74))
- **deps**: Bump eslint-config-next from 16.2.1 to 16.2.2 in /frontend
([`9d53b11`](https://github.com/christianlouis/InboxConverge/commit/9d53b117d4c10282c1525312e048a509fba4aa00))
- **deps**: Bump faker from 22.6.0 to 40.12.0 in /backend
([`9553fae`](https://github.com/christianlouis/InboxConverge/commit/9553fae6b7a06a1780672a8c3ecd1d98b945731d))
- **deps**: Bump google-auth-httplib2 from 0.3.0 to 0.3.1 in /backend
([`233595e`](https://github.com/christianlouis/InboxConverge/commit/233595e5ddf3caf135ff36ab15a702955868b131))
- **deps**: Bump google-auth-oauthlib from 1.2.0 to 1.3.1 in /backend
([`3bb7a7c`](https://github.com/christianlouis/InboxConverge/commit/3bb7a7cd460d01356c89914681d3bb1344231dae))
- **deps**: Bump lucide-react from 0.577.0 to 1.7.0 in /frontend
([`bfaa639`](https://github.com/christianlouis/InboxConverge/commit/bfaa639eedf97df6e81620590b1a21e6e780b1e3))
- **deps**: Bump next from 16.2.1 to 16.2.2 in /frontend
([`a5d8700`](https://github.com/christianlouis/InboxConverge/commit/a5d87001194b9f0764875975e0d792b85e073ceb))
- **deps**: Bump python-multipart from 0.0.22 to 0.0.24 in /backend
([`f479974`](https://github.com/christianlouis/InboxConverge/commit/f479974a5a432183de01a5c58264ff9e35376d7d))
- **deps**: Bump sqlalchemy from 2.0.48 to 2.0.49 in /backend
([`6283d1c`](https://github.com/christianlouis/InboxConverge/commit/6283d1c4b4a46afb43cbafbfbb570e7be23cdef8))
- **deps**: Bump stripe from 14.4.1 to 15.0.1 in /backend
([`582311a`](https://github.com/christianlouis/InboxConverge/commit/582311a18e6ec02ef90b6d049c709ef0057bb837))
- **deps**: Bump uvicorn from 0.42.0 to 0.43.0 in /backend
([`b2d044d`](https://github.com/christianlouis/InboxConverge/commit/b2d044d8c209853f5ee2e58ab7e6b701ba462015))
## v0.6.2 (2026-04-05)
### Code Style
- Reformat test_gdpr.py and test_gmail_labels.py to fix black CI check
([`7afa33f`](https://github.com/christianlouis/InboxConverge/commit/7afa33f3758d289bb0971e3f83bcc30b39e436f1))
## [Unreleased]
### Added
@@ -17,6 +86,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback
- Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs
- Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions
- **Backend test coverage expanded** (+150 tests, 361 → 511 total): added `test_gdpr.py` (GDPR masking utilities), `test_gmail_labels.py` (Gmail label helpers), `test_notification_service.py` (Apprise notification service), `test_auth_service.py` (OAuth service token exchange and token creation), `test_version_endpoint.py`, `test_auth_endpoints.py` (register, login, Google OAuth, authorize-url, domain helpers), `test_users_endpoints.py` (profile CRUD, SMTP config upsert/delete), `test_notifications_endpoints.py` (full CRUD + test-send), `test_logs_endpoints.py` (processing-runs pagination/filtering + run log retrieval), and `test_app_settings_endpoints.py` (list, upsert, delete, seed-defaults with bootstrap key guards).
## v0.6.1 (2026-04-05)
### Bug Fixes
- Upgrade pytest-asyncio to 1.3.0 for pytest 9.x compatibility
([`39052af`](https://github.com/christianlouis/InboxConverge/commit/39052af4e2a5df1dd29062b7dcced9b1f2f17baf))
### Chores
- **ci**: Bump actions/checkout from 4 to 6
([`68a6163`](https://github.com/christianlouis/InboxConverge/commit/68a6163fef8e81e250d6959d94be2174bf9fba31))
- **ci**: Bump codecov/codecov-action from 5 to 6
([`594e380`](https://github.com/christianlouis/InboxConverge/commit/594e3803013363c0360d32fc329e9b427fa62835))
- **ci**: Bump docker/build-push-action from 5 to 7
([`2c26c15`](https://github.com/christianlouis/InboxConverge/commit/2c26c156f63cc7335cf7799a443c63d734c047f7))
- **ci**: Bump docker/metadata-action from 5 to 6
([`0e87da8`](https://github.com/christianlouis/InboxConverge/commit/0e87da85a61b9c1a764313231b69882b9aa55137))
- **ci**: Bump mikefarah/yq from 4.44.6 to 4.52.5
([`f4c8e4a`](https://github.com/christianlouis/InboxConverge/commit/f4c8e4a1dc98d221eabd137bb71459ebec9de254))
- **deps**: Bump @tanstack/react-query in /frontend
([`348c347`](https://github.com/christianlouis/InboxConverge/commit/348c3477dcbb0c504681cc71df6d7d098ae378df))
- **deps**: Bump @types/node from 20.19.30 to 25.5.0 in /frontend
([`917dacc`](https://github.com/christianlouis/InboxConverge/commit/917dacc7cb9f1fa35a8dc2a72664b7d022039f2c))
- **deps**: Bump aiohttp from 3.13.3 to 3.13.4 in /backend
([`d919939`](https://github.com/christianlouis/InboxConverge/commit/d91993964783c6bfde48b6f115abb6d02b0eec5c))
- **deps**: Bump apprise from 1.7.1 to 1.9.9 in /backend
([`094b1e1`](https://github.com/christianlouis/InboxConverge/commit/094b1e177fcefc77b417a6715446fa13527c1a16))
- **deps**: Bump axios from 1.13.6 to 1.14.0 in /frontend
([`3b580c3`](https://github.com/christianlouis/InboxConverge/commit/3b580c36c751d962e4c164596ce65ef4e7ce5048))
- **deps**: Bump bcrypt from 4.3.0 to 5.0.0 in /backend
([`f0213a0`](https://github.com/christianlouis/InboxConverge/commit/f0213a0b03b05195329323d68a870a51e0a97150))
- **deps**: Bump celery from 5.6.2 to 5.6.3 in /backend
([`5992a9e`](https://github.com/christianlouis/InboxConverge/commit/5992a9e802686227675867a259771dcb3ecf41bb))
- **deps**: Bump httpx from 0.26.0 to 0.28.1 in /backend
([`c50cf6e`](https://github.com/christianlouis/InboxConverge/commit/c50cf6e0b32f2d115e9345054b777d3ab8a63f2b))
- **deps**: Bump next from 16.1.7 to 16.2.1 in /frontend
([`ec90cd6`](https://github.com/christianlouis/InboxConverge/commit/ec90cd600b03fd0aea64a9c395b11b7faaa0d52a))
- **deps**: Bump prometheus-client from 0.19.0 to 0.24.1 in /backend
([`77c8426`](https://github.com/christianlouis/InboxConverge/commit/77c84266fd5ea93f9c59e4ef3d9b37990f232883))
- **deps**: Bump pytest from 7.4.4 to 9.0.2 in /backend
([`4a6f93e`](https://github.com/christianlouis/InboxConverge/commit/4a6f93e5acf5a2f7766e25444a20e11a5918a2b6))
- **deps**: Bump python-dotenv from 1.0.0 to 1.2.2 in /backend
([`4dbe30b`](https://github.com/christianlouis/InboxConverge/commit/4dbe30b0eadb829e0d112b2c9ce877353b9d903e))
- **deps**: Bump redis from 7.3.0 to 7.4.0 in /backend
([`b914ac0`](https://github.com/christianlouis/InboxConverge/commit/b914ac0ecd8a14bb1c3a96a232de330622c93ac7))
- **deps**: Bump typescript from 5.9.3 to 6.0.2 in /frontend
([`726e089`](https://github.com/christianlouis/InboxConverge/commit/726e089c464b81e33ee872cd200b346cb996dcf6))
- **deps**: Bump uvicorn from 0.27.0 to 0.42.0 in /backend
([`ac6a837`](https://github.com/christianlouis/InboxConverge/commit/ac6a837522c1dcfee3fdc9b6e9dca2f94460053f))
### Documentation
- Fix CHANGELOG entry to only reference pytest-asyncio bump
([`0521c97`](https://github.com/christianlouis/InboxConverge/commit/0521c97394ff3f4e909f9b463bd15958ac06b76f))
- Fix secret key generation syntax in README quickstart
([`9c12555`](https://github.com/christianlouis/InboxConverge/commit/9c12555a8116e1db605d3fc3042de48d292b2be2))
- Rewrite README focused on Google 2026 POP/Gmailify shutdown replacement
([`06efeed`](https://github.com/christianlouis/InboxConverge/commit/06efeed7be70a2d1da3a9e55a63cb8c9556b8c31))
## [Unreleased]
### Changed
- Bumped `pytest-asyncio` from 0.23.3 to 1.3.0 to restore compatibility with pytest 9.0.2 in the backend test suite.
## v0.6.0 (2026-03-29)
@@ -45,6 +201,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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
- **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).
+65 -243
View File
@@ -1,284 +1,106 @@
# InboxConverge
[![CI Tests](https://github.com/christianlouis/inboxconverge/actions/workflows/test.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/test.yml)
[![Lint](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/lint.yml)
[![Security Scan](https://github.com/christianlouis/inboxconverge/actions/workflows/security.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/actions/workflows/security.yml)
[![Docker Build](https://github.com/christianlouis/inboxconverge/actions/workflows/docker-build.yml/badge.svg)](https://github.com/christianlouis/inboxconverge/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/inboxconverge/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/christianlouis/inboxconverge/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/inboxconverge.git
cd inboxconverge
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 |
## Releases
This project uses [Semantic Versioning](https://semver.org/) and
[Conventional Commits](https://www.conventionalcommits.org/) to automate
release numbering. Every push to `main` triggers the
[Semantic Release workflow](.github/workflows/release.yml), which:
1. Inspects commit messages since the last release.
2. Determines the next version number (`patch`, `minor`, or `major`) based on
the commit prefixes (`fix:`, `feat:`, `feat!:` / `BREAKING CHANGE`).
3. Creates a git tag (`vX.Y.Z`), updates the `version` field in
`pyproject.toml`, generates a GitHub Release with release notes, and
updates `CHANGELOG.md` — all automatically.
| Commit prefix | Version bump |
|---------------|-------------|
| `fix:`, `perf:` | Patch (`0.0.x`) |
| `feat:` | Minor (`0.x.0`) |
| `feat!:` / `BREAKING CHANGE` | Major (`x.0.0`) |
Browse all releases on the [Releases page](https://github.com/christianlouis/InboxConverge/releases).
| | |
|---|---|
| [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/inboxconverge/issues)
- [Discussions](https://github.com/christianlouis/inboxconverge/discussions)
MIT — see [LICENSE](LICENSE).
+20 -20
View File
@@ -1,59 +1,59 @@
# Core Framework
fastapi==0.135.2 # Updated: Fixed starlette DoS vulnerabilities (was 0.109.1); pulls in starlette>=1.0.0
uvicorn[standard]==0.27.0
uvicorn[standard]==0.43.0
pydantic==2.12.5
pydantic-settings==2.13.1
# Database
sqlalchemy==2.0.48 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
alembic==1.13.1
sqlalchemy==2.0.49 # Updated: Fixed Python 3.14 incompatibility with TypingOnly assertion (was 2.0.25)
alembic==1.18.4
psycopg2-binary==2.9.11
asyncpg==0.31.0
# Authentication
python-jose[cryptography]==3.5.0 # Updated: Fixed algorithm confusion with OpenSSH ECDSA keys (was 3.3.0)
bcrypt==4.3.0
python-multipart==0.0.22 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
bcrypt==5.0.0
python-multipart==0.0.24 # Updated: Fixed multiple vulnerabilities (was 0.0.6)
authlib==1.6.9 # Updated: Fixed OIDC hash binding, JWE RSA1_5 padding oracle, alg:none bypass, JWK header injection (was 1.6.6)
httpx==0.26.0
httpx==0.28.1
# Payment Processing
stripe==14.4.1
stripe==15.0.1
# Email & Mail Processing
aiosmtplib==3.0.1
aiohttp==3.13.3 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
aiosmtplib==5.1.0
aiohttp==3.13.4 # Updated: Fixed zip bomb, DoS, and directory traversal vulnerabilities (was 3.9.1)
aioimaplib==2.0.1
email-validator==2.1.0.post1
email-validator==2.3.0
# Gmail API (for direct email injection)
google-api-python-client==2.193.0
google-auth==2.49.1
google-auth-oauthlib==1.2.0
google-auth-httplib2==0.3.0
google-auth-oauthlib==1.3.1
google-auth-httplib2==0.3.1
# Job Queue & Cache
celery==5.6.2
redis==7.3.0
celery==5.6.3
redis==7.4.0
# Security & Encryption
cryptography==46.0.6 # Updated: Fixed NULL pointer dereference (was 42.0.0)
# Notifications
apprise==1.7.1
apprise==1.9.9
# Monitoring & Logging
prometheus-client==0.19.0
prometheus-client==0.24.1
python-json-logger==2.0.7
# Development & Testing
pytest==7.4.4
pytest-asyncio==0.23.3
pytest==9.0.2
pytest-asyncio==1.3.0
pytest-cov==4.1.0
faker==22.6.0
faker==40.12.0
# Utilities
python-dotenv==1.0.0
python-dotenv==1.2.2
schedule==1.2.0
tenacity==9.1.4
@@ -0,0 +1,241 @@
"""
Unit tests for app settings endpoints (api/v1/endpoints/app_settings.py).
All database interactions and auth dependencies are mocked.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.core.deps import get_current_superuser, get_current_user
from app.models.database_models import User, SubscriptionTier
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_admin_user(**overrides) -> MagicMock:
defaults = dict(
id=1,
email="admin@example.com",
full_name="Admin",
is_active=True,
is_superuser=True,
subscription_tier=SubscriptionTier.FREE,
subscription_status="active",
google_id=None,
oauth_provider=None,
last_login_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
stripe_customer_id=None,
stripe_subscription_id=None,
subscription_expires_at=None,
)
defaults.update(overrides)
u = MagicMock(spec=User)
for k, v in defaults.items():
setattr(u, k, v)
return u
def _make_setting(**overrides) -> MagicMock:
defaults = dict(
id=1,
key="SOME_SETTING",
value="some_value",
value_type="string",
description="A setting",
is_secret=False,
category="general",
)
defaults.update(overrides)
s = MagicMock()
for k, v in defaults.items():
setattr(s, k, v)
return s
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def mock_db():
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.add = MagicMock()
db.delete = AsyncMock()
return db
@pytest.fixture
def admin_user():
return _make_admin_user()
@pytest.fixture
async def admin_client(app, admin_user, mock_db):
async def _override_superuser():
return admin_user
async def _override_db():
yield mock_db
app.dependency_overrides[get_current_superuser] = _override_superuser
app.dependency_overrides[get_current_user] = _override_superuser
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ── GET /app-settings ─────────────────────────────────────────────────────────
class TestListSettings:
async def test_returns_list(self, admin_client):
setting = _make_setting()
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
new=AsyncMock(return_value=[setting]),
):
response = await admin_client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["key"] == "SOME_SETTING"
assert data[0]["value"] == "some_value"
async def test_secret_value_masked(self, admin_client):
setting = _make_setting(key="SECRET_KEY", value="supersecret", is_secret=True)
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
new=AsyncMock(return_value=[setting]),
):
response = await admin_client.get("/api/v1/settings")
assert response.status_code == 200
data = response.json()
assert data[0]["value"] == "********"
async def test_filters_by_category(self, admin_client):
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.list_all",
new=AsyncMock(return_value=[]),
):
response = await admin_client.get("/api/v1/settings?category=general")
assert response.status_code == 200
async def test_unauthenticated_401(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/settings")
assert response.status_code == 401
# ── PUT /app-settings/{key} ───────────────────────────────────────────────────
class TestUpsertSetting:
async def test_upsert_creates_or_updates(self, admin_client):
setting = _make_setting(key="MY_KEY", value="myval")
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.set",
new=AsyncMock(return_value=setting),
):
response = await admin_client.put(
"/api/v1/settings/MY_KEY",
json={
"key": "MY_KEY",
"value": "myval",
"value_type": "string",
"is_secret": False,
},
)
assert response.status_code == 200
data = response.json()
assert data["key"] == "MY_KEY"
assert data["value"] == "myval"
async def test_bootstrap_key_rejected_400(self, admin_client):
response = await admin_client.put(
"/api/v1/settings/SECRET_KEY",
json={"key": "SECRET_KEY", "value": "new_secret"},
)
assert response.status_code == 400
assert "bootstrap" in response.json()["detail"].lower()
async def test_database_url_rejected(self, admin_client):
response = await admin_client.put(
"/api/v1/settings/DATABASE_URL",
json={"key": "DATABASE_URL", "value": "postgresql://..."},
)
assert response.status_code == 400
async def test_secret_value_masked_in_response(self, admin_client):
setting = _make_setting(key="API_KEY", value="secret!", is_secret=True)
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.set",
new=AsyncMock(return_value=setting),
):
response = await admin_client.put(
"/api/v1/settings/API_KEY",
json={
"key": "API_KEY",
"value": "secret!",
"is_secret": True,
},
)
assert response.status_code == 200
assert response.json()["value"] == "********"
# ── DELETE /app-settings/{key} ────────────────────────────────────────────────
class TestDeleteSetting:
async def test_deletes_existing_setting(self, admin_client):
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.delete",
new=AsyncMock(return_value=True),
):
response = await admin_client.delete("/api/v1/settings/MY_KEY")
assert response.status_code == 204
async def test_404_when_not_found(self, admin_client):
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.delete",
new=AsyncMock(return_value=False),
):
response = await admin_client.delete("/api/v1/settings/MISSING_KEY")
assert response.status_code == 404
async def test_bootstrap_key_rejected_400(self, admin_client):
response = await admin_client.delete("/api/v1/settings/SECRET_KEY")
assert response.status_code == 400
# ── POST /app-settings/seed-defaults ─────────────────────────────────────────
class TestSeedDefaultSettings:
async def test_seeds_defaults(self, admin_client):
with patch(
"app.api.v1.endpoints.app_settings.ConfigService.seed_defaults",
new=AsyncMock(return_value=5),
):
response = await admin_client.post("/api/v1/settings/seed-defaults")
assert response.status_code == 200
data = response.json()
assert data["created"] == 5
assert "Seeded 5" in data["message"]
+371
View File
@@ -0,0 +1,371 @@
"""
Unit tests for authentication endpoints (api/v1/endpoints/auth.py).
All database interactions and the oauth_service are mocked so no real
PostgreSQL instance or Google credentials are needed.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.models.database_models import User, SubscriptionTier
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_user(**overrides) -> MagicMock:
defaults = dict(
id=1,
email="user@example.com",
hashed_password=None,
full_name="Test User",
is_active=True,
is_superuser=False,
subscription_tier=SubscriptionTier.FREE,
subscription_status="active",
google_id=None,
oauth_provider=None,
last_login_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
stripe_customer_id=None,
stripe_subscription_id=None,
subscription_expires_at=None,
)
defaults.update(overrides)
u = MagicMock(spec=User)
for k, v in defaults.items():
setattr(u, k, v)
return u
def _scalar_one_or_none(value):
r = MagicMock()
r.scalar_one_or_none.return_value = value
return r
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def mock_db():
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.add = MagicMock()
return db
@pytest.fixture
async def anon_client(app, mock_db):
"""Client with no auth (db mocked)."""
async def _override_db():
yield mock_db
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ── /register ─────────────────────────────────────────────────────────────────
class TestRegisterEndpoint:
async def test_register_new_user_201(self, anon_client, mock_db):
# DB returns no existing user
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
# db.refresh will be called with the new User object; we simulate it
# by setting the required response fields on that object.
from datetime import datetime, timezone
from app.models.database_models import SubscriptionTier
async def _refresh(obj):
obj.id = 99
obj.email = "new@example.com"
obj.full_name = None
obj.is_active = True
obj.subscription_tier = SubscriptionTier.FREE
obj.subscription_status = "active"
obj.created_at = datetime.now(timezone.utc)
obj.updated_at = datetime.now(timezone.utc)
mock_db.refresh = AsyncMock(side_effect=_refresh)
response = await anon_client.post(
"/api/v1/auth/register",
json={"email": "new@example.com", "password": "secretpassword"},
)
assert response.status_code == 201
async def test_register_existing_user_400(self, anon_client, mock_db):
existing = _make_user(email="taken@example.com")
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
response = await anon_client.post(
"/api/v1/auth/register",
json={"email": "taken@example.com", "password": "pass"},
)
assert response.status_code == 400
assert "already registered" in response.json()["detail"]
async def test_register_blocked_domain_403(self, app, mock_db):
"""When ALLOWED_DOMAINS is set, unknown domains should get 403."""
async def _override_db():
yield mock_db
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
with patch("app.api.v1.endpoints.auth.settings") as mock_settings:
mock_settings.ALLOWED_DOMAINS = ["allowed.com"]
mock_settings.DEFAULT_USER_TIER = "free"
mock_settings.ADMIN_EMAIL = None
async with AsyncClient(
transport=transport, base_url="http://test"
) as client:
response = await client.post(
"/api/v1/auth/register",
json={"email": "user@blocked.com", "password": "pass"},
)
app.dependency_overrides.clear()
assert response.status_code == 403
# ── /login ─────────────────────────────────────────────────────────────────────
class TestLoginEndpoint:
async def test_login_success_returns_tokens(self, anon_client, mock_db):
from app.core.security import get_password_hash
hashed = get_password_hash("correctpassword")
user = _make_user(email="user@example.com", hashed_password=hashed)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "user@example.com", "password": "correctpassword"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password_401(self, anon_client, mock_db):
from app.core.security import get_password_hash
hashed = get_password_hash("correctpassword")
user = _make_user(email="user@example.com", hashed_password=hashed)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "user@example.com", "password": "wrongpassword"},
)
assert response.status_code == 401
async def test_login_user_not_found_401(self, anon_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "unknown@example.com", "password": "pass"},
)
assert response.status_code == 401
async def test_login_inactive_user_403(self, anon_client, mock_db):
from app.core.security import get_password_hash
hashed = get_password_hash("password")
user = _make_user(
email="user@example.com", hashed_password=hashed, is_active=False
)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "user@example.com", "password": "password"},
)
assert response.status_code == 403
async def test_login_no_password_hash_401(self, anon_client, mock_db):
"""OAuth-only users have no hashed_password login should fail."""
user = _make_user(hashed_password=None)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await anon_client.post(
"/api/v1/auth/login",
data={"username": "user@example.com", "password": "pass"},
)
assert response.status_code == 401
# ── /google ────────────────────────────────────────────────────────────────────
class TestGoogleOAuthEndpoint:
async def test_google_oauth_unverified_email_400(self, anon_client, mock_db):
with patch(
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
new=AsyncMock(
return_value={
"email": "user@gmail.com",
"google_id": "g123",
"full_name": "Test",
"verified_email": False,
}
),
):
response = await anon_client.post(
"/api/v1/auth/google",
json={"code": "code", "redirect_uri": "http://localhost"},
)
assert response.status_code == 400
async def test_google_oauth_new_user_created(self, anon_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
with (
patch(
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
new=AsyncMock(
return_value={
"email": "google@example.com",
"google_id": "g123",
"full_name": "Google User",
"verified_email": True,
}
),
),
patch(
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
return_value={
"access_token": "tok",
"refresh_token": "ref",
"token_type": "bearer",
},
),
):
response = await anon_client.post(
"/api/v1/auth/google",
json={"code": "code", "redirect_uri": "http://localhost"},
)
assert response.status_code == 200
assert response.json()["access_token"] == "tok"
async def test_google_oauth_existing_user_logs_in(self, anon_client, mock_db):
existing = _make_user(email="google@example.com", google_id="g123")
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
with (
patch(
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
new=AsyncMock(
return_value={
"email": "google@example.com",
"google_id": "g123",
"full_name": "Google User",
"verified_email": True,
}
),
),
patch(
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
return_value={
"access_token": "tok2",
"refresh_token": "ref2",
"token_type": "bearer",
},
),
):
response = await anon_client.post(
"/api/v1/auth/google",
json={"code": "code", "redirect_uri": "http://localhost"},
)
assert response.status_code == 200
# ── /google/authorize-url ──────────────────────────────────────────────────────
class TestGoogleAuthorizeUrl:
async def test_returns_authorization_url(self, anon_client):
response = await anon_client.get(
"/api/v1/auth/google/authorize-url",
params={"redirect_uri": "http://localhost/callback"},
)
assert response.status_code == 200
data = response.json()
assert "authorization_url" in data
assert data["authorization_url"].startswith(
"https://accounts.google.com/o/oauth2/v2/auth"
)
async def test_url_contains_redirect_uri(self, anon_client):
redirect = "http://myapp.example.com/callback"
response = await anon_client.get(
"/api/v1/auth/google/authorize-url",
params={"redirect_uri": redirect},
)
assert response.status_code == 200
# ── helper functions (domain checks, tier, admin email) ───────────────────────
class TestAuthHelpers:
def test_domain_of(self):
from app.api.v1.endpoints.auth import _domain_of
assert _domain_of("user@Example.COM") == "example.com"
assert _domain_of("a@b.de") == "b.de"
def test_default_tier_fallback(self):
from app.api.v1.endpoints.auth import _default_tier
from app.models.database_models import SubscriptionTier
with patch("app.api.v1.endpoints.auth.settings") as ms:
ms.DEFAULT_USER_TIER = "invalid_tier"
tier = _default_tier()
assert tier == SubscriptionTier.FREE
def test_default_tier_valid(self):
from app.api.v1.endpoints.auth import _default_tier
from app.models.database_models import SubscriptionTier
with patch("app.api.v1.endpoints.auth.settings") as ms:
ms.DEFAULT_USER_TIER = "pro"
tier = _default_tier()
assert tier == SubscriptionTier.PRO
def test_is_admin_email_match(self):
from app.api.v1.endpoints.auth import _is_admin_email
with patch("app.api.v1.endpoints.auth.settings") as ms:
ms.ADMIN_EMAIL = "admin@example.com"
assert _is_admin_email("ADMIN@EXAMPLE.COM") is True
assert _is_admin_email("other@example.com") is False
def test_is_admin_email_none_config(self):
from app.api.v1.endpoints.auth import _is_admin_email
with patch("app.api.v1.endpoints.auth.settings") as ms:
ms.ADMIN_EMAIL = None
assert _is_admin_email("admin@example.com") is False
+192
View File
@@ -0,0 +1,192 @@
"""
Unit tests for the OAuth / auth service (services/auth_service.py).
External HTTP calls are mocked via httpx. No real network or DB needed.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import HTTPException
from app.services.auth_service import OAuthService
from app.models.database_models import User
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_user(id: int = 1, email: str = "user@example.com") -> MagicMock:
u = MagicMock(spec=User)
u.id = id
u.email = email
return u
def _mock_token_response(
access_token: str = "access123", refresh_token: str = "refresh456"
):
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in": 3600,
"scope": "openid email profile",
}
return resp
def _mock_user_info_response(
email: str = "user@google.com",
name: str = "Test User",
google_id: str = "g123",
verified: bool = True,
):
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"email": email,
"name": name,
"id": google_id,
"picture": "https://example.com/pic.jpg",
"verified_email": verified,
}
return resp
# ── OAuthService.get_google_user_info ────────────────────────────────────────
class TestGetGoogleUserInfo:
async def test_success(self):
svc = OAuthService()
token_resp = _mock_token_response()
user_info_resp = _mock_user_info_response()
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(return_value=token_resp)
mock_client.get = AsyncMock(return_value=user_info_resp)
with patch(
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
):
result = await svc.get_google_user_info(
code="authcode", redirect_uri="http://localhost/callback"
)
assert result["email"] == "user@google.com"
assert result["google_id"] == "g123"
assert result["verified_email"] is True
assert result["access_token"] == "access123"
async def test_token_exchange_fails(self):
svc = OAuthService()
bad_resp = MagicMock()
bad_resp.status_code = 400
bad_resp.text = "bad_request"
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(return_value=bad_resp)
with patch(
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
):
with pytest.raises(HTTPException) as exc_info:
await svc.get_google_user_info(
code="bad", redirect_uri="http://localhost"
)
assert exc_info.value.status_code == 400
async def test_no_access_token_in_response(self):
svc = OAuthService()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {} # no access_token
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(return_value=resp)
with patch(
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
):
with pytest.raises(HTTPException) as exc_info:
await svc.get_google_user_info(
code="c", redirect_uri="http://localhost"
)
assert exc_info.value.status_code == 400
async def test_user_info_fetch_fails(self):
svc = OAuthService()
token_resp = _mock_token_response()
bad_info = MagicMock()
bad_info.status_code = 500
bad_info.text = "server error"
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(return_value=token_resp)
mock_client.get = AsyncMock(return_value=bad_info)
with patch(
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
):
with pytest.raises(HTTPException) as exc_info:
await svc.get_google_user_info(
code="c", redirect_uri="http://localhost"
)
assert exc_info.value.status_code == 400
async def test_unexpected_exception_becomes_500(self):
svc = OAuthService()
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.post = AsyncMock(side_effect=RuntimeError("network down"))
with patch(
"app.services.auth_service.httpx.AsyncClient", return_value=mock_client
):
with pytest.raises(HTTPException) as exc_info:
await svc.get_google_user_info(
code="c", redirect_uri="http://localhost"
)
assert exc_info.value.status_code == 500
# ── OAuthService.create_tokens_for_user ──────────────────────────────────────
class TestCreateTokensForUser:
def test_returns_all_fields(self):
user = _make_user(id=7)
result = OAuthService.create_tokens_for_user(user)
assert "access_token" in result
assert "refresh_token" in result
assert result["token_type"] == "bearer"
def test_access_token_is_string(self):
user = _make_user(id=3)
result = OAuthService.create_tokens_for_user(user)
assert isinstance(result["access_token"], str)
assert len(result["access_token"]) > 0
def test_refresh_token_is_string(self):
user = _make_user(id=5)
result = OAuthService.create_tokens_for_user(user)
assert isinstance(result["refresh_token"], str)
assert len(result["refresh_token"]) > 0
def test_different_users_get_different_tokens(self):
u1 = _make_user(id=1)
u2 = _make_user(id=2)
tokens1 = OAuthService.create_tokens_for_user(u1)
tokens2 = OAuthService.create_tokens_for_user(u2)
assert tokens1["access_token"] != tokens2["access_token"]
+101
View File
@@ -0,0 +1,101 @@
"""
Unit tests for GDPR data masking utilities (core/gdpr.py).
No database or HTTP layer required pure function tests.
"""
from app.core.gdpr import mask_email, mask_name, mask_from_header
class TestMaskEmail:
def test_typical_address(self):
assert mask_email("john.doe@example.com") == "jo***@e***.com"
def test_short_local(self):
# local part has only 1 char; still returns that char + ***
result = mask_email("a@b.de")
assert result == "a***@b***.de"
def test_two_char_local(self):
result = mask_email("ab@x.io")
assert result == "ab***@x***.io"
def test_domain_with_subdomain_tld(self):
# rsplit('.', 1) splits on the last dot only
result = mask_email("user@mail.example.org")
assert result == "us***@m***.org"
def test_empty_string(self):
assert mask_email("") == "***"
def test_no_at_sign(self):
assert mask_email("notanemail") == "***"
def test_preserves_tld(self):
result = mask_email("hello@world.co.uk")
assert result.endswith(".uk")
def test_single_char_sld(self):
result = mask_email("user@x.com")
assert result == "us***@x***.com"
class TestMaskName:
def test_single_word(self):
assert mask_name("Alice") == "Al***"
def test_two_words(self):
assert mask_name("John Doe") == "Jo*** Do***"
def test_single_char_word(self):
result = mask_name("X")
assert result == "X***"
def test_empty_string(self):
assert mask_name("") == "***"
def test_three_words(self):
result = mask_name("Jean-Luc Picard")
# Two words separated by space
parts = result.split(" ")
assert len(parts) == 2
assert all(p.endswith("***") for p in parts)
def test_long_name(self):
result = mask_name("Alexander")
assert result == "Al***"
class TestMaskFromHeader:
def test_display_name_with_angle_email(self):
result = mask_from_header("John Doe <john.doe@example.com>")
assert "<" in result
assert "jo***" in result
assert "Jo***" in result
def test_plain_email(self):
result = mask_from_header("john.doe@example.com")
assert result == "jo***@e***.com"
def test_empty_string(self):
assert mask_from_header("") == "***"
def test_no_display_name_angle_email(self):
result = mask_from_header("<user@example.com>")
assert result == "us***@e***.com"
def test_fallback_no_email_pattern(self):
# String with no recognisable email → falls back to mask_name
result = mask_from_header("JustAName")
assert result == "Ju***"
def test_quoted_display_name(self):
result = mask_from_header('"Alice Smith" <alice@example.com>')
assert "Al***" in result
assert "al***" in result
def test_angle_email_no_display(self):
# Edge: angle brackets but empty display part
result = mask_from_header(" <admin@site.org>")
# No display name → returns masked email only
assert "@" in result
assert "***" in result
+183
View File
@@ -0,0 +1,183 @@
"""
Unit tests for Gmail label utilities (utils/gmail_labels.py).
Pure function tests no database or HTTP layer required.
"""
from app.utils.gmail_labels import (
DEFAULT_IMPORT_LABEL_TEMPLATES,
MAX_IMPORT_LABELS,
_normalize_string_list,
normalize_import_label_templates,
extract_granted_scopes,
extract_import_label_templates,
build_gmail_credential_scopes,
render_import_labels,
)
class TestNormalizeStringList:
def test_basic_dedup(self):
result = _normalize_string_list(["a", "A", "b"])
assert result == ["a", "b"]
def test_strips_whitespace(self):
result = _normalize_string_list([" hello ", "world"])
assert result == ["hello", "world"]
def test_empty_strings_filtered(self):
result = _normalize_string_list(["", " ", "real"])
assert result == ["real"]
def test_none_returns_empty(self):
assert _normalize_string_list(None) == []
def test_preserves_case_in_output(self):
# Case-insensitive dedup but preserves original casing
result = _normalize_string_list(["Hello", "hello"])
assert result == ["Hello"]
def test_order_preserved(self):
items = ["c", "a", "b"]
assert _normalize_string_list(items) == ["c", "a", "b"]
class TestNormalizeImportLabelTemplates:
def test_none_returns_defaults(self):
result = normalize_import_label_templates(None)
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
def test_empty_list_returns_defaults(self):
result = normalize_import_label_templates([])
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
def test_custom_templates(self):
result = normalize_import_label_templates(["archive", "inbox"])
assert result == ["archive", "inbox"]
def test_deduplicates(self):
result = normalize_import_label_templates(["tag", "TAG"])
assert result == ["tag"]
def test_returns_copy_of_defaults(self):
result = normalize_import_label_templates(None)
result.append("extra")
assert "extra" not in DEFAULT_IMPORT_LABEL_TEMPLATES
class TestExtractGrantedScopes:
def test_list_format(self):
result = extract_granted_scopes(["scope1", "scope2"])
assert result == ["scope1", "scope2"]
def test_list_filters_non_strings(self):
result = extract_granted_scopes(["valid", 42, None, "other"])
assert result == ["valid", "other"]
def test_dict_format(self):
data = {"granted_scopes": ["https://mail.google.com/", "openid"]}
result = extract_granted_scopes(data)
assert result == ["https://mail.google.com/", "openid"]
def test_dict_missing_granted_scopes(self):
result = extract_granted_scopes({})
assert result == []
def test_dict_non_list_granted_scopes(self):
result = extract_granted_scopes({"granted_scopes": "not-a-list"})
assert result == []
def test_unrecognized_type(self):
assert extract_granted_scopes(None) == []
assert extract_granted_scopes(42) == []
assert extract_granted_scopes("string") == []
class TestExtractImportLabelTemplates:
def test_dict_with_templates(self):
data = {"import_label_templates": ["archive", "imported"]}
result = extract_import_label_templates(data)
assert result == ["archive", "imported"]
def test_dict_missing_key(self):
result = extract_import_label_templates({})
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
def test_non_dict(self):
assert extract_import_label_templates(None) == DEFAULT_IMPORT_LABEL_TEMPLATES
assert extract_import_label_templates([]) == DEFAULT_IMPORT_LABEL_TEMPLATES
def test_empty_templates_falls_back_to_defaults(self):
result = extract_import_label_templates({"import_label_templates": []})
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
def test_filters_non_strings(self):
data = {"import_label_templates": ["valid", 123, None]}
result = extract_import_label_templates(data)
assert result == ["valid"]
class TestBuildGmailCredentialScopes:
def test_basic(self):
result = build_gmail_credential_scopes(
["https://mail.google.com/"], ["{{source_email}}", "imported"]
)
assert "granted_scopes" in result
assert "import_label_templates" in result
assert result["granted_scopes"] == ["https://mail.google.com/"]
def test_none_granted_scopes(self):
result = build_gmail_credential_scopes(None)
assert result["granted_scopes"] == []
assert result["import_label_templates"] == DEFAULT_IMPORT_LABEL_TEMPLATES
def test_deduplication(self):
result = build_gmail_credential_scopes(["scope", "SCOPE"])
assert result["granted_scopes"] == ["scope"]
class TestRenderImportLabels:
def test_source_email_substitution(self):
result = render_import_labels(["{{source_email}}"], "user@example.com")
assert result == ["user@example.com"]
def test_literal_template(self):
result = render_import_labels(["imported"], "user@example.com")
assert result == ["imported"]
def test_mixed_templates(self):
result = render_import_labels(
["{{source_email}}", "imported"], "user@example.com"
)
assert result == ["user@example.com", "imported"]
def test_deduplication(self):
result = render_import_labels(["tag", "TAG"], "user@example.com")
assert result == ["tag"]
def test_none_source_email(self):
# Template containing source_email placeholder with no email → empty string,
# gets stripped, filtered out.
result = render_import_labels(["{{source_email}}"], None)
assert result == []
def test_empty_source_email(self):
result = render_import_labels(["{{source_email}}"], "")
assert result == []
def test_none_templates_uses_defaults(self):
result = render_import_labels(None, "user@example.com")
assert "user@example.com" in result
assert "imported" in result
def test_whitespace_only_template_filtered(self):
# Whitespace-only entries are stripped to empty strings and then filtered
# by normalize_import_label_templates. When no valid templates remain,
# defaults are returned. The source_email template then renders to the
# source email, and "imported" is included too.
result = render_import_labels([" "], "user@example.com")
# normalize_import_label_templates falls back to defaults → includes
# {{source_email}} (renders to "user@example.com") and "imported"
assert result == ["user@example.com", "imported"]
def test_max_labels_constant(self):
assert MAX_IMPORT_LABELS == 10
+335
View File
@@ -0,0 +1,335 @@
"""
Unit tests for processing logs / runs endpoints (api/v1/endpoints/logs.py).
All database interactions and auth dependencies are mocked.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import (
User,
ProcessingRun,
ProcessingLog,
SubscriptionTier,
)
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_user(**overrides) -> MagicMock:
defaults = dict(
id=1,
email="user@example.com",
full_name="Test User",
is_active=True,
is_superuser=False,
subscription_tier=SubscriptionTier.FREE,
subscription_status="active",
google_id=None,
oauth_provider=None,
last_login_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
stripe_customer_id=None,
stripe_subscription_id=None,
subscription_expires_at=None,
)
defaults.update(overrides)
u = MagicMock(spec=User)
for k, v in defaults.items():
setattr(u, k, v)
return u
def _make_run(**overrides) -> MagicMock:
defaults = dict(
id=1,
mail_account_id=10,
started_at=datetime.now(timezone.utc),
completed_at=datetime.now(timezone.utc),
duration_seconds=1.5,
emails_fetched=3,
emails_forwarded=3,
emails_failed=0,
status="completed",
error_message=None,
)
defaults.update(overrides)
run = MagicMock(spec=ProcessingRun)
for k, v in defaults.items():
setattr(run, k, v)
return run
def _make_log(**overrides) -> MagicMock:
defaults = dict(
id=1,
timestamp=datetime.now(timezone.utc),
level="INFO",
message="processed",
email_subject="Hello",
email_from="sender@example.com",
success=True,
mail_account_id=10,
processing_run_id=1,
email_size_bytes=1024,
error_details=None,
)
defaults.update(overrides)
log = MagicMock(spec=ProcessingLog)
for k, v in defaults.items():
setattr(log, k, v)
return log
def _scalar_one(value):
r = MagicMock()
r.scalar_one.return_value = value
return r
def _scalar_one_or_none(value):
r = MagicMock()
r.scalar_one_or_none.return_value = value
return r
def _rows_all(rows):
r = MagicMock()
r.all.return_value = rows
return r
def _scalars_all(values):
r = MagicMock()
scalars = MagicMock()
scalars.all.return_value = values
r.scalars.return_value = scalars
return r
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def mock_db():
db = AsyncMock()
return db
@pytest.fixture
def current_user():
return _make_user()
@pytest.fixture
async def auth_client(app, current_user, mock_db):
async def _override_user():
return current_user
async def _override_db():
yield mock_db
app.dependency_overrides[get_current_active_user] = _override_user
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ── GET /logs ─────────────────────────────────────────────────────────────────
class TestListProcessingRuns:
async def test_returns_paginated_list(self, auth_client, mock_db):
run = _make_run()
# The endpoint queries total count then rows
# First execute → count, second execute → rows with joined columns
row = MagicMock()
row.ProcessingRun = run
row.name = "My Account"
row.email_address = "me@example.com"
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one(1), # count query
_rows_all([row]), # data query
]
)
response = await auth_client.get("/api/v1/processing-runs")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert data["total"] == 1
assert len(data["items"]) == 1
async def test_empty_result(self, auth_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one(0),
_rows_all([]),
]
)
response = await auth_client.get("/api/v1/processing-runs")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
async def test_unauthenticated_401(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/processing-runs")
assert response.status_code == 401
async def test_pagination_params(self, auth_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one(0),
_rows_all([]),
]
)
response = await auth_client.get("/api/v1/processing-runs?page=2&page_size=5")
assert response.status_code == 200
data = response.json()
assert data["page"] == 2
assert data["page_size"] == 5
async def test_filter_by_account_id(self, auth_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one(0),
_rows_all([]),
]
)
response = await auth_client.get("/api/v1/processing-runs?account_id=5")
assert response.status_code == 200
async def test_filter_by_status(self, auth_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one(0),
_rows_all([]),
]
)
response = await auth_client.get("/api/v1/processing-runs?status=completed")
assert response.status_code == 200
async def test_filter_has_emails(self, auth_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one(0),
_rows_all([]),
]
)
response = await auth_client.get("/api/v1/processing-runs?has_emails=true")
assert response.status_code == 200
# ── GET /logs/{run_id} ────────────────────────────────────────────────────────
class TestGetProcessingRun:
async def test_returns_run(self, auth_client, mock_db):
run = _make_run(id=42)
row = MagicMock()
row.ProcessingRun = run
row.name = "Account"
row.email_address = "me@example.com"
result = MagicMock()
result.one_or_none.return_value = row
mock_db.execute = AsyncMock(return_value=result)
response = await auth_client.get("/api/v1/processing-runs/42")
assert response.status_code == 200
data = response.json()
assert data["id"] == 42
async def test_404_when_not_found(self, auth_client, mock_db):
result = MagicMock()
result.one_or_none.return_value = None
mock_db.execute = AsyncMock(return_value=result)
response = await auth_client.get("/api/v1/processing-runs/999")
assert response.status_code == 404
# ── GET /logs/{run_id}/logs ───────────────────────────────────────────────────
class TestGetRunLogs:
async def test_returns_log_entries(self, auth_client, mock_db):
run = _make_run(id=1)
log_entry = _make_log(processing_run_id=1)
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one_or_none(run), # ownership check
_scalar_one(1), # count
_scalars_all([log_entry]), # log entries
]
)
response = await auth_client.get("/api/v1/processing-runs/1/logs")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) == 1
async def test_404_when_run_not_found(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.get("/api/v1/processing-runs/999/logs")
assert response.status_code == 404
async def test_empty_log_entries(self, auth_client, mock_db):
run = _make_run(id=1)
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one_or_none(run),
_scalar_one(0),
_scalars_all([]),
]
)
response = await auth_client.get("/api/v1/processing-runs/1/logs")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
# ── helper: _paginate ─────────────────────────────────────────────────────────
class TestPaginateHelper:
def test_single_page(self):
from app.api.v1.endpoints.logs import _paginate
result = _paginate(total=10, page=1, page_size=20)
assert result["total"] == 10
assert result["pages"] == 1
def test_multiple_pages(self):
from app.api.v1.endpoints.logs import _paginate
result = _paginate(total=25, page=2, page_size=10)
assert result["pages"] == 3
def test_zero_total(self):
from app.api.v1.endpoints.logs import _paginate
result = _paginate(total=0, page=1, page_size=20)
assert result["pages"] == 1
assert result["total"] == 0
@@ -0,0 +1,215 @@
"""
Unit tests for the notification service (services/notification_service.py).
All external dependencies (database, Apprise) are mocked so no real
infrastructure is needed.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from app.services.notification_service import (
send_user_notification,
send_admin_notification,
test_notification as _test_notification,
_send_apprise,
)
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_notification_config(
*,
id: int = 1,
user_id: int = 42,
apprise_url: str = "json://localhost",
is_enabled: bool = True,
notify_on_errors: bool = True,
notify_on_success: bool = False,
):
cfg = MagicMock()
cfg.id = id
cfg.user_id = user_id
cfg.apprise_url = apprise_url
cfg.is_enabled = is_enabled
cfg.notify_on_errors = notify_on_errors
cfg.notify_on_success = notify_on_success
return cfg
def _make_db(configs=None):
"""Return an AsyncMock db with execute returning the given config list."""
db = AsyncMock()
result = MagicMock()
scalars = MagicMock()
scalars.all.return_value = configs or []
result.scalars.return_value = scalars
db.execute = AsyncMock(return_value=result)
return db
# ── send_user_notification ────────────────────────────────────────────────────
class TestSendUserNotification:
async def test_returns_zero_when_no_configs(self):
db = _make_db(configs=[])
count = await send_user_notification(db, user_id=1, title="T", body="B")
assert count == 0
async def test_sends_to_error_channel(self):
cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
) as mock_send:
count = await send_user_notification(
db, user_id=42, title="Err", body="msg", notify_on_error=True
)
assert count == 1
mock_send.assert_awaited_once()
async def test_skips_error_channel_for_success_notification(self):
cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
) as mock_send:
count = await send_user_notification(
db, user_id=42, title="Ok", body="msg", notify_on_error=False
)
assert count == 0
mock_send.assert_not_awaited()
async def test_sends_to_success_channel(self):
cfg = _make_notification_config(notify_on_errors=False, notify_on_success=True)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
) as mock_send:
count = await send_user_notification(
db, user_id=42, title="Ok", body="msg", notify_on_error=False
)
assert count == 1
mock_send.assert_awaited_once()
async def test_failed_apprise_not_counted(self):
cfg = _make_notification_config(notify_on_errors=True)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=False),
):
count = await send_user_notification(db, user_id=42, title="T", body="B")
assert count == 0
async def test_exception_in_apprise_swallowed(self):
cfg = _make_notification_config(notify_on_errors=True)
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(side_effect=Exception("boom")),
):
count = await send_user_notification(db, user_id=42, title="T", body="B")
assert count == 0
async def test_multiple_channels_counted_individually(self):
cfg1 = _make_notification_config(id=1, notify_on_errors=True)
cfg2 = _make_notification_config(id=2, notify_on_errors=True)
db = _make_db(configs=[cfg1, cfg2])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
):
count = await send_user_notification(db, user_id=42, title="T", body="B")
assert count == 2
# ── send_admin_notification ───────────────────────────────────────────────────
class TestSendAdminNotification:
async def test_returns_zero_when_no_configs(self):
db = _make_db(configs=[])
count = await send_admin_notification(db, title="T", body="B")
assert count == 0
async def test_sends_to_enabled_channel(self):
cfg = MagicMock()
cfg.id = 1
cfg.apprise_url = "json://localhost"
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
):
count = await send_admin_notification(db, title="T", body="B")
assert count == 1
async def test_exception_in_channel_swallowed(self):
cfg = MagicMock()
cfg.id = 1
cfg.apprise_url = "json://localhost"
db = _make_db(configs=[cfg])
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(side_effect=RuntimeError("oops")),
):
count = await send_admin_notification(db, title="T", body="B")
assert count == 0
# ── test_notification ─────────────────────────────────────────────────────────
class TestTestNotification:
async def test_success(self):
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=True),
):
ok, msg = await _test_notification("json://localhost")
assert ok is True
assert "success" in msg.lower()
async def test_failure_from_apprise(self):
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(return_value=False),
):
ok, msg = await _test_notification("json://localhost")
assert ok is False
async def test_exception_returns_false(self):
with patch(
"app.services.notification_service._send_apprise",
new=AsyncMock(side_effect=Exception("network error")),
):
ok, msg = await _test_notification("json://localhost")
assert ok is False
assert "error" in msg.lower()
# ── _send_apprise internal helper ─────────────────────────────────────────────
class TestSendApprise:
async def test_invalid_url_returns_false(self):
# Apprise.add() returns False for unrecognised schemes
with patch("app.services.notification_service.apprise") as mock_apprise_module:
ap_instance = MagicMock()
ap_instance.add.return_value = False
mock_apprise_module.Apprise.return_value = ap_instance
result = await _send_apprise("not-a-valid-url://", "T", "B")
assert result is False
async def test_valid_url_returns_true(self):
with patch("app.services.notification_service.apprise") as mock_apprise_module:
ap_instance = MagicMock()
ap_instance.add.return_value = True
ap_instance.async_notify = AsyncMock(return_value=True)
mock_apprise_module.Apprise.return_value = ap_instance
result = await _send_apprise("json://localhost", "T", "B")
assert result is True
@@ -0,0 +1,278 @@
"""
Unit tests for notification config endpoints (api/v1/endpoints/notifications.py).
All database interactions and auth dependencies are mocked.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import (
User,
NotificationConfig,
NotificationChannel,
SubscriptionTier,
)
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_user(**overrides) -> MagicMock:
defaults = dict(
id=1,
email="user@example.com",
full_name="Test User",
is_active=True,
is_superuser=False,
subscription_tier=SubscriptionTier.FREE,
subscription_status="active",
google_id=None,
oauth_provider=None,
last_login_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
stripe_customer_id=None,
stripe_subscription_id=None,
subscription_expires_at=None,
)
defaults.update(overrides)
u = MagicMock(spec=User)
for k, v in defaults.items():
setattr(u, k, v)
return u
def _make_notification_config(**overrides) -> MagicMock:
defaults = dict(
id=1,
user_id=1,
name="Test Notification",
apprise_url="json://localhost",
channel=NotificationChannel.WEBHOOK,
is_enabled=True,
config={},
notify_on_errors=True,
notify_on_success=False,
notify_threshold=3,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
cfg = MagicMock(spec=NotificationConfig)
for k, v in defaults.items():
setattr(cfg, k, v)
return cfg
def _scalar_one_or_none(value):
r = MagicMock()
r.scalar_one_or_none.return_value = value
return r
def _scalars_all(values):
r = MagicMock()
scalars = MagicMock()
scalars.all.return_value = values
r.scalars.return_value = scalars
return r
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def mock_db():
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.add = MagicMock()
db.delete = AsyncMock()
return db
@pytest.fixture
def current_user():
return _make_user()
@pytest.fixture
async def auth_client(app, current_user, mock_db):
async def _override_user():
return current_user
async def _override_db():
yield mock_db
app.dependency_overrides[get_current_active_user] = _override_user
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ── POST /notifications ───────────────────────────────────────────────────────
class TestCreateNotificationConfig:
async def test_creates_config_201(self, auth_client, mock_db, current_user):
created_cfg = _make_notification_config()
# db.refresh must populate the object returned from the endpoint
async def _refresh(obj):
for k, v in vars(created_cfg).items():
if not k.startswith("_"):
try:
setattr(obj, k, v)
except AttributeError:
pass
mock_db.refresh = AsyncMock(side_effect=_refresh)
response = await auth_client.post(
"/api/v1/notifications",
json={
"name": "My Webhook",
"apprise_url": "json://localhost",
"channel": "webhook",
"is_enabled": True,
"config": {},
"notify_on_errors": True,
"notify_on_success": False,
"notify_threshold": 3,
},
)
assert response.status_code == 201
async def test_unauthenticated_401(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/notifications",
json={
"name": "x",
"apprise_url": "json://localhost",
"channel": "webhook",
"config": {},
},
)
assert response.status_code == 401
# ── GET /notifications ────────────────────────────────────────────────────────
class TestListNotificationConfigs:
async def test_returns_list(self, auth_client, mock_db):
cfg1 = _make_notification_config(id=1)
cfg2 = _make_notification_config(id=2)
mock_db.execute = AsyncMock(return_value=_scalars_all([cfg1, cfg2]))
response = await auth_client.get("/api/v1/notifications")
assert response.status_code == 200
assert isinstance(response.json(), list)
assert len(response.json()) == 2
async def test_returns_empty_list(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalars_all([]))
response = await auth_client.get("/api/v1/notifications")
assert response.status_code == 200
assert response.json() == []
# ── GET /notifications/{id} ───────────────────────────────────────────────────
class TestGetNotificationConfig:
async def test_returns_config(self, auth_client, mock_db):
cfg = _make_notification_config(id=5)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await auth_client.get("/api/v1/notifications/5")
assert response.status_code == 200
async def test_404_when_not_found(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.get("/api/v1/notifications/999")
assert response.status_code == 404
# ── PUT /notifications/{id} ───────────────────────────────────────────────────
class TestUpdateNotificationConfig:
async def test_updates_config(self, auth_client, mock_db):
cfg = _make_notification_config(id=5)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
response = await auth_client.put(
"/api/v1/notifications/5",
json={"name": "Updated Name"},
)
assert response.status_code == 200
async def test_404_when_not_found(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.put(
"/api/v1/notifications/999",
json={"name": "Updated"},
)
assert response.status_code == 404
# ── DELETE /notifications/{id} ────────────────────────────────────────────────
class TestDeleteNotificationConfig:
async def test_deletes_config_204(self, auth_client, mock_db):
cfg = _make_notification_config(id=5)
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await auth_client.delete("/api/v1/notifications/5")
assert response.status_code == 204
mock_db.delete.assert_awaited_once_with(cfg)
async def test_404_when_not_found(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.delete("/api/v1/notifications/999")
assert response.status_code == 404
# ── POST /notifications/test ──────────────────────────────────────────────────
class TestTestNotificationConfig:
async def test_test_success(self, auth_client):
with patch(
"app.api.v1.endpoints.notifications.test_notification",
new=AsyncMock(return_value=(True, "sent successfully")),
):
response = await auth_client.post(
"/api/v1/notifications/test",
json={"apprise_url": "json://localhost"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
async def test_test_failure(self, auth_client):
with patch(
"app.api.v1.endpoints.notifications.test_notification",
new=AsyncMock(return_value=(False, "delivery failed")),
):
response = await auth_client.post(
"/api/v1/notifications/test",
json={"apprise_url": "invalid://url"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
+247
View File
@@ -0,0 +1,247 @@
"""
Unit tests for user profile and SMTP config endpoints (api/v1/endpoints/users.py).
All database interactions and auth dependencies are mocked.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User, UserSmtpConfig, SubscriptionTier
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_user(**overrides) -> MagicMock:
defaults = dict(
id=1,
email="user@example.com",
full_name="Test User",
is_active=True,
is_superuser=False,
subscription_tier=SubscriptionTier.FREE,
subscription_status="active",
google_id=None,
oauth_provider=None,
last_login_at=None,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
stripe_customer_id=None,
stripe_subscription_id=None,
subscription_expires_at=None,
)
defaults.update(overrides)
u = MagicMock(spec=User)
for k, v in defaults.items():
setattr(u, k, v)
return u
def _make_smtp_config(**overrides) -> MagicMock:
defaults = dict(
id=1,
user_id=1,
host="smtp.example.com",
port=587,
username="user@example.com",
encrypted_password="encrypted",
use_tls=True,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
cfg = MagicMock(spec=UserSmtpConfig)
for k, v in defaults.items():
setattr(cfg, k, v)
return cfg
def _scalar_one_or_none(value):
r = MagicMock()
r.scalar_one_or_none.return_value = value
return r
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def mock_db():
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.add = MagicMock()
db.delete = AsyncMock()
return db
@pytest.fixture
def current_user():
return _make_user()
@pytest.fixture
async def auth_client(app, current_user, mock_db):
async def _override_user():
return current_user
async def _override_db():
yield mock_db
app.dependency_overrides[get_current_active_user] = _override_user
app.dependency_overrides[get_db] = _override_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ── GET /me ───────────────────────────────────────────────────────────────────
class TestGetMe:
async def test_returns_user_data(self, auth_client, current_user):
response = await auth_client.get("/api/v1/users/me")
assert response.status_code == 200
data = response.json()
assert data["email"] == current_user.email
async def test_unauthenticated_401(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/users/me")
assert response.status_code == 401
# ── PUT /me ───────────────────────────────────────────────────────────────────
class TestUpdateMe:
async def test_update_full_name(self, auth_client, mock_db, current_user):
mock_db.refresh = AsyncMock(
side_effect=lambda obj: setattr(obj, "full_name", "Updated Name")
)
response = await auth_client.put(
"/api/v1/users/me", json={"full_name": "Updated Name"}
)
assert response.status_code == 200
async def test_update_email(self, auth_client, mock_db, current_user):
mock_db.refresh = AsyncMock(
side_effect=lambda obj: setattr(obj, "email", "new@example.com")
)
response = await auth_client.put(
"/api/v1/users/me", json={"email": "new@example.com"}
)
assert response.status_code == 200
# ── GET /smtp-config ──────────────────────────────────────────────────────────
class TestGetSmtpConfig:
async def test_returns_config_when_exists(self, auth_client, mock_db):
cfg = _make_smtp_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await auth_client.get("/api/v1/users/smtp-config")
assert response.status_code == 200
data = response.json()
assert data["host"] == "smtp.example.com"
assert data["port"] == 587
assert "has_password" in data
# Password must not be exposed
assert "encrypted_password" not in data
async def test_returns_404_when_no_config(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.get("/api/v1/users/smtp-config")
assert response.status_code == 404
# ── PUT /smtp-config ──────────────────────────────────────────────────────────
class TestUpsertSmtpConfig:
async def test_creates_new_config(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
# db.refresh populates the new config object with required fields
async def _refresh(obj):
obj.id = 1
obj.user_id = 1
obj.host = "smtp.example.com"
obj.port = 587
obj.username = "user@example.com"
obj.encrypted_password = "encrypted"
obj.use_tls = True
obj.created_at = datetime.now(timezone.utc)
obj.updated_at = datetime.now(timezone.utc)
mock_db.refresh = AsyncMock(side_effect=_refresh)
from unittest.mock import patch
with patch(
"app.api.v1.endpoints.users.encrypt_credential", return_value="encrypted"
):
response = await auth_client.put(
"/api/v1/users/smtp-config",
json={
"host": "smtp.example.com",
"port": 587,
"username": "user@example.com",
"password": "secret",
"use_tls": True,
},
)
assert response.status_code == 200
async def test_updates_existing_config(self, auth_client, mock_db):
existing = _make_smtp_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
from unittest.mock import patch
with patch(
"app.api.v1.endpoints.users.encrypt_credential", return_value="encrypted"
):
response = await auth_client.put(
"/api/v1/users/smtp-config",
json={
"host": "newsmtp.example.com",
"port": 465,
"username": "newuser@example.com",
"password": "newpass",
"use_tls": False,
},
)
assert response.status_code == 200
# ── DELETE /smtp-config ───────────────────────────────────────────────────────
class TestDeleteSmtpConfig:
async def test_deletes_existing_config(self, auth_client, mock_db):
existing = _make_smtp_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
response = await auth_client.delete("/api/v1/users/smtp-config")
assert response.status_code == 204
mock_db.delete.assert_awaited_once_with(existing)
async def test_no_config_is_noop(self, auth_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await auth_client.delete("/api/v1/users/smtp-config")
assert response.status_code == 204
mock_db.delete.assert_not_awaited()
@@ -0,0 +1,42 @@
"""
Unit tests for the version endpoint (api/v1/endpoints/version.py).
"""
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import create_application
@pytest.fixture
def app():
return create_application()
class TestVersionEndpoint:
async def test_get_version_returns_200(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/version")
assert response.status_code == 200
async def test_get_version_has_version_key(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/version")
data = response.json()
assert "version" in data
async def test_get_version_has_build_date_key(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/version")
data = response.json()
assert "build_date" in data
async def test_version_is_string_or_none(self, app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/version")
data = response.json()
assert isinstance(data["version"], str) or data["version"] is None
+2
View File
@@ -4,6 +4,8 @@ Comprehensive task breakdown for repository improvements and production readines
## ✅ Recently Completed
- [x] **Expanded backend test coverage**: Added 150 new unit tests across 10 new test files, increasing the backend test count from 361 to 511. New coverage includes `core/gdpr.py`, `utils/gmail_labels.py`, `services/notification_service.py`, `services/auth_service.py`, and API endpoints for auth, users, notifications, processing-runs/logs, app-settings, and version.
- [x] **Improved test coverage for `mail_processor.py`**: Added 46 new unit tests covering POP3 connection testing, POP3 email fetching, IMAP edge cases, email forwarding (STARTTLS/SSL), and `fetch_emails`/`test_connection` routing. Coverage increased from ~42% to 98%.
- [x] **Log noise reduction**: Suppressed `ignored untagged response` INFO messages from `aioimaplib` in Celery workers (set logger to WARNING). Eliminated repeated `file_cache is only supported with oauth2client<4.0.0` warnings from the Gmail API client by passing `cache_discovery=False` to `googleapiclient.discovery.build()`.
+9
View File
@@ -13,6 +13,15 @@ const eslintConfig = defineConfig([
"build/**",
"next-env.d.ts",
]),
// Specify explicit React version to avoid eslint-plugin-react calling the
// removed context.getFilename() API when using version: 'detect' with ESLint 10.
{
settings: {
react: {
version: "19",
},
},
},
]);
export default eslintConfig;
+415 -604
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -11,10 +11,10 @@
"test:ci": "jest --coverage --coverageReporters=lcov --passWithNoTests"
},
"dependencies": {
"@tanstack/react-query": "^5.95.0",
"axios": "^1.13.6",
"lucide-react": "^0.577.0",
"next": "16.1.7",
"@tanstack/react-query": "^5.96.2",
"axios": "^1.14.0",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"zustand": "^5.0.12"
@@ -25,14 +25,14 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint": "^10",
"eslint-config-next": "16.2.2",
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.3.0",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^6"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "inboxconverge"
version = "0.6.0"
version = "0.6.3"
description = "Multi-account email forwarding and processing service"
readme = "README.md"
requires-python = ">=3.12"