feat(imap): add per-user IMAP ingestion accounts with quota enforcement

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 13:01:14 +00:00
parent f3c85f73c3
commit 43bcfe5653
12 changed files with 1922 additions and 0 deletions
+2
View File
@@ -16,6 +16,7 @@ from app.api.dropbox import router as dropbox_router
from app.api.duplicates import router as duplicates_router from app.api.duplicates import router as duplicates_router
from app.api.files import router as files_router from app.api.files import router as files_router
from app.api.google_drive import router as google_drive_router from app.api.google_drive import router as google_drive_router
from app.api.imap_accounts import router as imap_accounts_router
from app.api.logs import router as logs_router from app.api.logs import router as logs_router
from app.api.onboarding import router as onboarding_router from app.api.onboarding import router as onboarding_router
from app.api.onedrive import router as onedrive_router from app.api.onedrive import router as onedrive_router
@@ -68,3 +69,4 @@ router.include_router(plans_router)
router.include_router(onboarding_router) router.include_router(onboarding_router)
router.include_router(billing_router) router.include_router(billing_router)
router.include_router(pipelines_router) router.include_router(pipelines_router)
router.include_router(imap_accounts_router)
+350
View File
@@ -0,0 +1,350 @@
"""API endpoints for managing per-user IMAP ingestion accounts.
Provides CRUD operations for a user's IMAP accounts, quota enforcement
against their subscription plan's ``max_mailboxes`` limit, and a
test-connection endpoint so users can verify credentials before saving.
"""
import imaplib
import logging
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import UserImapAccount
from app.utils.subscription import get_tier, get_user_tier_id
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/imap-accounts", tags=["imap-accounts"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if owner_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Quota helpers
# ---------------------------------------------------------------------------
_FREE_TIER_ID = "free"
def _get_max_mailboxes(tier: dict[str, Any]) -> int | None:
"""Return the maximum number of IMAP accounts allowed by *tier*.
Returns:
``None`` — unlimited (paid tiers with ``max_mailboxes == 0``)
``0`` — no mailboxes allowed (free tier)
positive — the configured limit
"""
tier_id: str = tier.get("id", _FREE_TIER_ID)
max_mb: int = tier.get("max_mailboxes", 0)
# Free tier: 0 means "no access" (not "unlimited")
if tier_id == _FREE_TIER_ID:
return 0
# Paid tiers: 0 means unlimited
if max_mb == 0:
return None
return max_mb
def _check_quota(db: Session, owner_id: str) -> None:
"""Raise 403 if the user has reached their IMAP account quota."""
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id, db)
max_mb = _get_max_mailboxes(tier)
if max_mb == 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=("Your current plan does not include email ingestion. Upgrade to a paid plan to add IMAP accounts."),
)
if max_mb is not None:
current_count = db.query(UserImapAccount).filter(UserImapAccount.owner_id == owner_id).count()
if current_count >= max_mb:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"You have reached your plan limit of {max_mb} IMAP account(s). "
"Please delete an existing account or upgrade your plan."
),
)
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ImapAccountCreate(BaseModel):
"""Schema for creating a new IMAP account."""
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label")
host: str = Field(..., min_length=1, max_length=255, description="IMAP server hostname")
port: int = Field(default=993, ge=1, le=65535, description="IMAP server port")
username: str = Field(..., min_length=1, max_length=255, description="IMAP login username")
password: str = Field(..., min_length=1, max_length=1024, description="IMAP login password")
use_ssl: bool = Field(default=True, description="Use SSL/TLS connection")
delete_after_process: bool = Field(default=False, description="Delete emails from mailbox after processing")
is_active: bool = Field(default=True, description="Whether to poll this mailbox")
class ImapAccountUpdate(BaseModel):
"""Schema for updating an existing IMAP account (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
host: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = Field(default=None, min_length=1, max_length=255)
password: str | None = Field(default=None, min_length=1, max_length=1024)
use_ssl: bool | None = None
delete_after_process: bool | None = None
is_active: bool | None = None
class ImapTestRequest(BaseModel):
"""Schema for testing an IMAP connection without saving it."""
host: str = Field(..., min_length=1, max_length=255)
port: int = Field(default=993, ge=1, le=65535)
username: str = Field(..., min_length=1, max_length=255)
password: str = Field(..., min_length=1, max_length=1024)
use_ssl: bool = Field(default=True)
# ---------------------------------------------------------------------------
# Serialisation helpers
# ---------------------------------------------------------------------------
def _to_response(acct: UserImapAccount) -> dict[str, Any]:
"""Serialize a ``UserImapAccount`` row to a response dict.
Passwords are never included in responses.
"""
return {
"id": acct.id,
"owner_id": acct.owner_id,
"name": acct.name,
"host": acct.host,
"port": acct.port,
"username": acct.username,
"use_ssl": acct.use_ssl,
"delete_after_process": acct.delete_after_process,
"is_active": acct.is_active,
"last_checked_at": acct.last_checked_at.isoformat() if acct.last_checked_at else None,
"last_error": acct.last_error,
"created_at": acct.created_at.isoformat() if acct.created_at else None,
"updated_at": acct.updated_at.isoformat() if acct.updated_at else None,
}
# ---------------------------------------------------------------------------
# Connection test helper
# ---------------------------------------------------------------------------
def _test_imap_connection(host: str, port: int, username: str, password: str, use_ssl: bool) -> dict[str, Any]:
"""Attempt to connect and log in to the IMAP server.
Returns a dict with ``{"success": bool, "message": str}``.
"""
try:
if use_ssl:
mail = imaplib.IMAP4_SSL(host, port)
else:
mail = imaplib.IMAP4(host, port)
mail.login(username, password)
mail.logout()
return {"success": True, "message": "Connection successful"}
except OSError as exc:
logger.warning("IMAP network error for %s@%s: %s", username, host, exc)
return {"success": False, "message": f"Connection error: {exc}"}
except Exception as exc: # noqa: BLE001
logger.warning("IMAP error for %s@%s: %s", username, host, exc)
return {"success": False, "message": f"IMAP error: {exc}"}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/", summary="List IMAP accounts for the current user")
def list_imap_accounts(request: Request, db: DbSession, owner_id: CurrentOwner) -> list[dict[str, Any]]:
"""Return all IMAP accounts belonging to the authenticated user."""
accounts = db.query(UserImapAccount).filter(UserImapAccount.owner_id == owner_id).order_by(UserImapAccount.id).all()
return [_to_response(a) for a in accounts]
@router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a new IMAP account")
def create_imap_account(
request: Request, body: ImapAccountCreate, db: DbSession, owner_id: CurrentOwner
) -> dict[str, Any]:
"""Create a new IMAP ingestion account for the current user.
Quota is enforced against the user's subscription plan's ``max_mailboxes``
limit before the account is persisted.
"""
_check_quota(db, owner_id)
acct = UserImapAccount(
owner_id=owner_id,
name=body.name,
host=body.host,
port=body.port,
username=body.username,
password=body.password,
use_ssl=body.use_ssl,
delete_after_process=body.delete_after_process,
is_active=body.is_active,
)
try:
db.add(acct)
db.commit()
db.refresh(acct)
except Exception:
db.rollback()
raise
logger.info("User %s created IMAP account %d (%s)", owner_id, acct.id, body.host)
return _to_response(acct)
@router.get("/{account_id}", summary="Get a single IMAP account")
def get_imap_account(account_id: int, request: Request, db: DbSession, owner_id: CurrentOwner) -> dict[str, Any]:
"""Return a single IMAP account by ID (must belong to the current user)."""
acct = (
db.query(UserImapAccount).filter(UserImapAccount.id == account_id, UserImapAccount.owner_id == owner_id).first()
)
if not acct:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="IMAP account not found")
return _to_response(acct)
@router.put("/{account_id}", summary="Update an IMAP account")
def update_imap_account(
account_id: int,
request: Request,
body: ImapAccountUpdate,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Update an existing IMAP account. Only provided fields are changed."""
acct = (
db.query(UserImapAccount).filter(UserImapAccount.id == account_id, UserImapAccount.owner_id == owner_id).first()
)
if not acct:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="IMAP account not found")
if body.name is not None:
acct.name = body.name
if body.host is not None:
acct.host = body.host
if body.port is not None:
acct.port = body.port
if body.username is not None:
acct.username = body.username
if body.password is not None:
acct.password = body.password
if body.use_ssl is not None:
acct.use_ssl = body.use_ssl
if body.delete_after_process is not None:
acct.delete_after_process = body.delete_after_process
if body.is_active is not None:
acct.is_active = body.is_active
# Reset last_error so the next poll gives a fresh result
acct.last_error = None
acct.updated_at = datetime.now(timezone.utc)
try:
db.commit()
db.refresh(acct)
except Exception:
db.rollback()
raise
logger.info("User %s updated IMAP account %d", owner_id, account_id)
return _to_response(acct)
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete an IMAP account")
def delete_imap_account(account_id: int, request: Request, db: DbSession, owner_id: CurrentOwner) -> None:
"""Delete an IMAP account permanently."""
acct = (
db.query(UserImapAccount).filter(UserImapAccount.id == account_id, UserImapAccount.owner_id == owner_id).first()
)
if not acct:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="IMAP account not found")
try:
db.delete(acct)
db.commit()
except Exception:
db.rollback()
raise
logger.info("User %s deleted IMAP account %d", owner_id, account_id)
@router.post("/{account_id}/test", summary="Test an existing IMAP account's connection")
def test_saved_imap_account(account_id: int, request: Request, db: DbSession, owner_id: CurrentOwner) -> dict[str, Any]:
"""Test the connection for an already-saved IMAP account."""
acct = (
db.query(UserImapAccount).filter(UserImapAccount.id == account_id, UserImapAccount.owner_id == owner_id).first()
)
if not acct:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="IMAP account not found")
return _test_imap_connection(acct.host, acct.port, acct.username, acct.password, acct.use_ssl)
@router.post("/test", summary="Test an IMAP connection without saving")
def test_imap_connection(request: Request, body: ImapTestRequest, owner_id: CurrentOwner) -> dict[str, Any]:
"""Test IMAP credentials without persisting anything.
Useful for the "Test connection" button in the UI before the user saves
a new account.
"""
return _test_imap_connection(body.host, body.port, body.username, body.password, body.use_ssl)
@router.get("/quota/", summary="Get IMAP account quota information for the current user")
def get_imap_quota(request: Request, db: DbSession, owner_id: CurrentOwner) -> dict[str, Any]:
"""Return the user's current IMAP account usage vs. their plan quota."""
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id, db)
max_mb = _get_max_mailboxes(tier)
current_count = db.query(UserImapAccount).filter(UserImapAccount.owner_id == owner_id).count()
return {
"current_count": current_count,
"max_mailboxes": max_mb, # None = unlimited, 0 = not allowed
"can_add": max_mb is None or (max_mb > 0 and current_count < max_mb),
"tier_id": tier_id,
"tier_name": tier.get("name", tier_id),
}
+47
View File
@@ -376,6 +376,53 @@ class PipelineStep(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class UserImapAccount(Base):
"""Per-user IMAP ingestion account.
Each row represents one IMAP mailbox that a user wants DocuElevate to
poll for document attachments. The periodic ``pull_all_inboxes`` Celery
task iterates over all active accounts and processes any new emails.
Quota enforcement: the user's subscription plan's ``max_mailboxes`` field
controls how many accounts a user may configure (0 = unlimited for paid
plans; free tier is not permitted any accounts).
"""
__tablename__ = "user_imap_accounts"
id = Column(Integer, primary_key=True, index=True)
# Stable owner identifier — matches FileRecord.owner_id
owner_id = Column(String, nullable=False, index=True)
# Human-readable label chosen by the user (e.g. "Work Gmail", "Scanner mailbox")
name = Column(String(255), nullable=False)
# IMAP connection settings
host = Column(String(255), nullable=False)
port = Column(Integer, nullable=False, default=993)
username = Column(String(255), nullable=False)
# Password stored in plain text — the admin is responsible for access control
password = Column(String(1024), nullable=False)
use_ssl = Column(Boolean, nullable=False, default=True)
# Processing options
# When True, emails are deleted from the mailbox after their attachments are processed
delete_after_process = Column(Boolean, nullable=False, default=False)
# When False the account is not polled by the periodic task (but not deleted)
is_active = Column(Boolean, nullable=False, default=True)
# Last time this mailbox was successfully polled
last_checked_at = Column(DateTime(timezone=True), nullable=True)
# Last error message if the most recent poll failed (NULL = last poll succeeded)
last_error = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class BackupRecord(Base): class BackupRecord(Base):
"""Tracks database backup files and their retention metadata. """Tracks database backup files and their retention metadata.
+67
View File
@@ -15,6 +15,20 @@ from app.tasks.convert_to_pdf import convert_to_pdf # new conversion task
from app.tasks.process_document import process_document # Updated import from app.tasks.process_document import process_document # Updated import
from app.utils.allowed_types import ALLOWED_EXTENSIONS, ALLOWED_MIME_TYPES from app.utils.allowed_types import ALLOWED_EXTENSIONS, ALLOWED_MIME_TYPES
# Database session for per-user IMAP accounts (imported lazily to avoid circular imports)
_db_session_factory = None
def _get_db_session():
"""Return a new SQLAlchemy session (lazy import to avoid startup issues)."""
global _db_session_factory # noqa: PLW0603
if _db_session_factory is None:
from app.database import SessionLocal
_db_session_factory = SessionLocal
return _db_session_factory()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Initialize Redis connection using Celery's Redis settings # Initialize Redis connection using Celery's Redis settings
@@ -82,6 +96,10 @@ def pull_all_inboxes():
Periodic Celery task that checks all configured IMAP mailboxes Periodic Celery task that checks all configured IMAP mailboxes
and fetches attachments from new emails. and fetches attachments from new emails.
Ensures only one instance runs at a time using Redis-based locking. Ensures only one instance runs at a time using Redis-based locking.
Processes:
1. System-level mailboxes configured via environment variables (IMAP1, IMAP2).
2. Per-user IMAP accounts stored in the ``user_imap_accounts`` database table.
""" """
if not acquire_lock(): if not acquire_lock():
logger.info("Skipping execution: Another instance is running.") logger.info("Skipping execution: Another instance is running.")
@@ -112,12 +130,61 @@ def pull_all_inboxes():
delete_after_process=settings.imap2_delete_after_process, delete_after_process=settings.imap2_delete_after_process,
) )
# Per-user IMAP accounts from the database
_pull_user_imap_accounts()
logger.info("Finished pull_all_inboxes") logger.info("Finished pull_all_inboxes")
finally: finally:
release_lock() release_lock()
def _pull_user_imap_accounts() -> None:
"""Iterate over all active per-user IMAP accounts and pull their inboxes."""
try:
from app.models import UserImapAccount
db = _get_db_session()
try:
accounts = db.query(UserImapAccount).filter(UserImapAccount.is_active.is_(True)).all()
logger.info("Processing %d per-user IMAP account(s)", len(accounts))
for acct in accounts:
mailbox_key = f"user_{acct.owner_id}_{acct.id}"
try:
pull_inbox(
mailbox_key=mailbox_key,
host=acct.host,
port=acct.port,
username=acct.username,
password=acct.password,
use_ssl=acct.use_ssl,
delete_after_process=acct.delete_after_process,
)
# Record successful poll
acct.last_checked_at = datetime.now(timezone.utc)
acct.last_error = None
db.commit()
except Exception as exc: # noqa: BLE001
error_msg = str(exc)[:500]
logger.error(
"Error pulling user IMAP account %d (%s@%s): %s",
acct.id,
acct.username,
acct.host,
error_msg,
)
try:
acct.last_checked_at = datetime.now(timezone.utc)
acct.last_error = error_msg
db.commit()
except Exception: # noqa: BLE001
db.rollback()
finally:
db.close()
except Exception as exc: # noqa: BLE001
logger.error("Failed to process per-user IMAP accounts: %s", exc)
def check_and_pull_mailbox( def check_and_pull_mailbox(
mailbox_key: str, mailbox_key: str,
host: str | None, host: str | None,
+2
View File
@@ -14,6 +14,7 @@ from app.views.filemanager import router as filemanager_router
from app.views.general import router as general_router from app.views.general import router as general_router
from app.views.google_drive import router as google_drive_router from app.views.google_drive import router as google_drive_router
from app.views.help import router as help_router # Built-in help / How-To docs from app.views.help import router as help_router # Built-in help / How-To docs
from app.views.imap_accounts import router as imap_accounts_router
from app.views.license_routes import router as license_router # Add the license router from app.views.license_routes import router as license_router # Add the license router
from app.views.onboarding import router as onboarding_router from app.views.onboarding import router as onboarding_router
from app.views.onedrive import router as onedrive_router from app.views.onedrive import router as onedrive_router
@@ -46,4 +47,5 @@ router.include_router(subscriptions_router) # Pricing + subscription pages
router.include_router(plans_router) # Admin Plan Designer router.include_router(plans_router) # Admin Plan Designer
router.include_router(onboarding_router) # User onboarding wizard router.include_router(onboarding_router) # User onboarding wizard
router.include_router(pipelines_router) # Processing pipelines router.include_router(pipelines_router) # Processing pipelines
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
router.include_router(help_router) # Built-in help / How-To docs router.include_router(help_router) # Built-in help / How-To docs
+63
View File
@@ -0,0 +1,63 @@
"""User-facing view for the per-user IMAP ingestion dashboard."""
import logging
from fastapi import Request
from sqlalchemy.orm import Session
from app.models import UserImapAccount
from app.utils.subscription import get_tier, get_user_tier_id
from app.utils.user_scope import get_current_owner_id
from app.views.base import APIRouter, Depends, get_db, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_max_mailboxes(tier: dict) -> int | None:
"""Mirror of the quota helper from the API module — avoids a circular import."""
tier_id: str = tier.get("id", "free")
max_mb: int = tier.get("max_mailboxes", 0)
if tier_id == "free":
return 0
if max_mb == 0:
return None
return max_mb
@router.get("/imap-accounts")
@require_login
async def imap_accounts_page(request: Request, db: Session = Depends(get_db)):
"""IMAP ingestion account management page for the current user."""
owner_id = get_current_owner_id(request)
accounts: list[UserImapAccount] = []
current_count = 0
max_mailboxes: int | None = 0
can_add = False
tier_name = "Free"
tier_id = "free"
if owner_id:
accounts = (
db.query(UserImapAccount).filter(UserImapAccount.owner_id == owner_id).order_by(UserImapAccount.id).all()
)
current_count = len(accounts)
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id, db)
tier_name = tier.get("name", tier_id)
max_mailboxes = _get_max_mailboxes(tier)
can_add = max_mailboxes is None or (max_mailboxes > 0 and current_count < max_mailboxes)
return templates.TemplateResponse(
"imap_accounts.html",
{
"request": request,
"accounts": accounts,
"current_count": current_count,
"max_mailboxes": max_mailboxes,
"can_add": can_add,
"tier_id": tier_id,
"tier_name": tier_name,
},
)
+33
View File
@@ -107,6 +107,39 @@ DocuElevate acts as an email *client* that automatically retrieves document atta
> **HP Scanners and MFPs (Scan to Email)**: Configure your scanner's "Scan to Email" feature to send scanned documents to a dedicated email account. Point DocuElevate at that mailbox using the IMAP settings. DocuElevate will retrieve the scanned PDFs automatically — no manual forwarding required. > **HP Scanners and MFPs (Scan to Email)**: Configure your scanner's "Scan to Email" feature to send scanned documents to a dedicated email account. Point DocuElevate at that mailbox using the IMAP settings. DocuElevate will retrieve the scanned PDFs automatically — no manual forwarding required.
#### Per-User IMAP Accounts (Email Ingestion Dashboard)
In addition to the system-wide IMAP mailboxes configured by the administrator via environment variables, each user can configure their own personal IMAP accounts directly from the **Email Ingestion** page (`/imap-accounts`).
**To add a personal IMAP account:**
1. Navigate to **Email Ingestion** in the top navigation bar.
2. Click **Add Account**.
3. Fill in:
- **Label** — a friendly name for this account (e.g. "Work Gmail", "Scanner inbox")
- **IMAP Host** — your mail server hostname (e.g. `imap.gmail.com`)
- **Port** — typically `993` for SSL or `143` for plain/STARTTLS
- **Username** — usually your full email address
- **Password** — your email password or app-specific password
4. Select **Use SSL/TLS** (recommended).
5. Click **Test Connection** to verify the credentials before saving.
6. Click **Add Account** to save.
**Account options:**
- **Active** — when checked, the mailbox is polled on each cycle; uncheck to pause without deleting.
- **Delete emails after processing** — when checked, processed emails are permanently deleted from the mailbox instead of being marked as read/labelled.
**Quota limits** are determined by your subscription plan:
| Plan | IMAP accounts |
|--------------|---------------|
| Free | None (not available) |
| Starter | 1 |
| Professional | 3 |
| Power | Unlimited |
The quota bar on the Email Ingestion page shows your current usage against your plan limit. If you have reached the limit, delete an existing account or upgrade your plan.
### Watch Folders (Automatic Folder Ingestion) ### Watch Folders (Automatic Folder Ingestion)
Watch folders allow DocuElevate to automatically monitor directories for new files and ingest them without any manual action. Watch folders allow DocuElevate to automatically monitor directories for new files and ingest them without any manual action.
+10
View File
@@ -106,6 +106,11 @@
{% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}> {% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>
<i class="fas fa-project-diagram mr-1 text-gray-400" aria-hidden="true"></i>Pipelines <i class="fas fa-project-diagram mr-1 text-gray-400" aria-hidden="true"></i>Pipelines
</a> </a>
<a href="/imap-accounts"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/imap-accounts' %}aria-current="page"{% endif %}>
<i class="fas fa-envelope-open-text mr-1 text-gray-400" aria-hidden="true"></i>Email Ingestion
</a>
<!-- Admin dropdown shown only for admin users via JS --> <!-- Admin dropdown shown only for admin users via JS -->
<div id="adminMenuContainer" class="relative hidden"> <div id="adminMenuContainer" class="relative hidden">
@@ -270,6 +275,11 @@
{% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}> {% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>
<i class="fas fa-project-diagram mr-2 text-gray-400" aria-hidden="true"></i>Pipelines <i class="fas fa-project-diagram mr-2 text-gray-400" aria-hidden="true"></i>Pipelines
</a> </a>
<a href="/imap-accounts"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/imap-accounts' %}aria-current="page"{% endif %}>
<i class="fas fa-envelope-open-text mr-2 text-gray-400" aria-hidden="true"></i>Email Ingestion
</a>
<!-- Admin section in mobile menu shown only for admin users via JS --> <!-- Admin section in mobile menu shown only for admin users via JS -->
<div id="mobileAdminSection" class="hidden"> <div id="mobileAdminSection" class="hidden">
+731
View File
@@ -0,0 +1,731 @@
{% extends "base.html" %}
{% block title %}Email Ingestion (IMAP) DocuElevate{% endblock %}
{% block content %}
<div
class="container mx-auto px-4 py-8"
x-data="imapAccountsApp()"
x-init="init()"
>
<!-- ── Header ───────────────────────────────────────────────────────────── -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-envelope-open-text text-blue-500" aria-hidden="true"></i>
Email Ingestion (IMAP)
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
Configure IMAP mailboxes to automatically ingest document attachments.
DocuElevate polls each active account at regular intervals.
</p>
</div>
<button
type="button"
@click="openCreateModal()"
:disabled="!canAdd"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
style="min-height:44px;"
:aria-disabled="!canAdd"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Account
</button>
</div>
<!-- ── Quota banner ─────────────────────────────────────────────────────── -->
<div class="mb-6">
<template x-if="quota.max_mailboxes === 0">
<!-- Free tier: no access -->
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3" role="alert">
<i class="fas fa-lock text-amber-500 mt-0.5" aria-hidden="true"></i>
<div>
<p class="font-semibold text-amber-800">Email ingestion not available on your plan</p>
<p class="text-sm text-amber-700 mt-0.5">
The <strong x-text="quota.tier_name"></strong> plan does not include IMAP ingestion.
<a href="/subscription" class="underline hover:text-amber-900">Upgrade your plan</a>
to unlock this feature.
</p>
</div>
</div>
</template>
<template x-if="quota.max_mailboxes !== 0">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow px-5 py-4 flex items-center gap-4">
<i class="fas fa-inbox text-blue-400 text-xl" aria-hidden="true"></i>
<div class="flex-1">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
IMAP accounts:
<strong x-text="quota.current_count"></strong>
<template x-if="quota.max_mailboxes !== null">
<span> / <strong x-text="quota.max_mailboxes"></strong></span>
</template>
<template x-if="quota.max_mailboxes === null">
<span class="text-gray-400"> / unlimited</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Plan: <span x-text="quota.tier_name"></span>
<a href="/subscription" class="ml-2 underline hover:text-blue-600">Manage subscription</a>
</p>
</div>
<!-- Mini usage bar (only when a limit exists) -->
<template x-if="quota.max_mailboxes !== null && quota.max_mailboxes > 0">
<div class="w-32">
<div class="bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full transition-all"
:class="quota.current_count >= quota.max_mailboxes ? 'bg-red-500' : 'bg-blue-500'"
:style="`width:${Math.min(100, quota.current_count / quota.max_mailboxes * 100)}%`"
role="progressbar"
:aria-valuenow="quota.current_count"
:aria-valuemin="0"
:aria-valuemax="quota.max_mailboxes"
:aria-label="`${quota.current_count} of ${quota.max_mailboxes} IMAP accounts used`"
></div>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- ── Alert banner ─────────────────────────────────────────────────────── -->
<div aria-live="polite" aria-atomic="true">
<div x-show="alert.show && alert.type !== 'error'" x-transition class="mb-4" role="status">
<div class="bg-green-50 border-green-400 text-green-800 border-l-4 p-4 rounded dark:bg-opacity-10">
<p class="font-semibold" x-text="alert.title"></p>
<p class="text-sm" x-text="alert.message"></p>
</div>
</div>
</div>
<div aria-live="assertive" aria-atomic="true">
<div x-show="alert.show && alert.type === 'error'" x-transition class="mb-4" role="alert">
<div class="bg-red-50 border-red-400 text-red-800 border-l-4 p-4 rounded dark:bg-opacity-10">
<p class="font-semibold" x-text="alert.title"></p>
<p class="text-sm" x-text="alert.message"></p>
</div>
</div>
</div>
<!-- ── Loading ──────────────────────────────────────────────────────────── -->
<template x-if="loading">
<div class="text-center py-12 text-gray-400">
<i class="fas fa-spinner fa-spin text-3xl mb-3" aria-hidden="true"></i>
<p>Loading accounts…</p>
</div>
</template>
<!-- ── Empty state ──────────────────────────────────────────────────────── -->
<template x-if="!loading && accounts.length === 0 && quota.max_mailboxes !== 0">
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow">
<i class="fas fa-envelope-open-text text-5xl text-gray-300 mb-4" aria-hidden="true"></i>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">No IMAP accounts yet</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6">
Add an IMAP account and DocuElevate will automatically pull document attachments from that mailbox.
</p>
<button
type="button"
@click="openCreateModal()"
class="inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md"
style="min-height:44px;"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Your First Account
</button>
</div>
</template>
<!-- ── Account cards ────────────────────────────────────────────────────── -->
<template x-if="!loading && accounts.length > 0">
<div class="space-y-4">
<template x-for="acct in accounts" :key="acct.id">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden border border-gray-100 dark:border-gray-700">
<div class="p-5 flex flex-col sm:flex-row sm:items-center gap-4">
<!-- Icon + info -->
<div class="flex items-start gap-3 flex-1 min-w-0">
<div class="mt-0.5">
<i
class="fas fa-circle text-xs"
:class="acct.is_active ? 'text-green-500' : 'text-gray-300'"
:title="acct.is_active ? 'Active' : 'Paused'"
aria-hidden="true"
></i>
</div>
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-gray-900 dark:text-white truncate" x-text="acct.name"></h2>
<p class="text-sm text-gray-500 dark:text-gray-400 truncate">
<span x-text="acct.username"></span>@<span x-text="acct.host"></span>:<span x-text="acct.port"></span>
<span x-show="acct.use_ssl" class="ml-1 inline-flex items-center text-xs font-medium text-green-700 bg-green-50 px-1.5 py-0.5 rounded">
<i class="fas fa-lock mr-1" aria-hidden="true"></i>SSL
</span>
</p>
<!-- Status row -->
<div class="mt-1.5 flex flex-wrap gap-2 text-xs">
<template x-if="!acct.is_active">
<span class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
<i class="fas fa-pause mr-1" aria-hidden="true"></i>Paused
</span>
</template>
<template x-if="acct.delete_after_process">
<span class="inline-flex items-center px-2 py-0.5 rounded bg-orange-50 text-orange-700">
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete after process
</span>
</template>
<template x-if="acct.last_checked_at">
<span class="text-gray-400" :title="acct.last_checked_at">
<i class="fas fa-clock mr-1" aria-hidden="true"></i>Last polled: <span x-text="formatDate(acct.last_checked_at)"></span>
</span>
</template>
<template x-if="acct.last_error">
<span class="inline-flex items-center px-2 py-0.5 rounded bg-red-50 text-red-700" :title="acct.last_error">
<i class="fas fa-exclamation-circle mr-1" aria-hidden="true"></i>Error
</span>
</template>
</div>
<!-- Last error detail (collapsible) -->
<template x-if="acct.last_error">
<p class="mt-2 text-xs text-red-600 bg-red-50 rounded px-2 py-1 break-words" x-text="acct.last_error"></p>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
@click="testSavedAccount(acct)"
:disabled="testingId === acct.id"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
style="min-height:36px;"
:aria-label="`Test connection for ${acct.name}`"
>
<template x-if="testingId === acct.id">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
</template>
<template x-if="testingId !== acct.id">
<i class="fas fa-plug mr-1" aria-hidden="true"></i>
</template>
Test
</button>
<button
type="button"
@click="openEditModal(acct)"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:36px;"
:aria-label="`Edit ${acct.name}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i>Edit
</button>
<button
type="button"
@click="confirmDelete(acct)"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-red-200 text-red-600 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500"
style="min-height:36px;"
:aria-label="`Delete ${acct.name}`"
>
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete
</button>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
<!-- ── Create / Edit modal ──────────────────────────────────────────────────── -->
<div
x-show="modalOpen"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
role="dialog"
aria-modal="true"
:aria-labelledby="editingAccount ? 'modal-title-edit' : 'modal-title-create'"
@keydown.escape.window="closeModal()"
style="display:none;"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-y-auto max-h-[90vh]"
@click.stop
>
<div class="px-6 pt-6 pb-2 flex items-center justify-between">
<h2
:id="editingAccount ? 'modal-title-edit' : 'modal-title-create'"
class="text-lg font-semibold text-gray-900 dark:text-white"
x-text="editingAccount ? 'Edit IMAP Account' : 'Add IMAP Account'"
></h2>
<button
type="button"
@click="closeModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
aria-label="Close modal"
>
<i class="fas fa-times text-xl" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="saveAccount()" class="px-6 pb-6 pt-4 space-y-4">
<!-- Name -->
<div>
<label for="acct-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
Label <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="acct-name"
type="text"
x-model="form.name"
placeholder="e.g. Work Gmail, Scanner inbox"
required
maxlength="255"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
/>
</div>
<!-- Host + Port -->
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label for="acct-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
IMAP Host <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="acct-host"
type="text"
x-model="form.host"
placeholder="imap.example.com"
required
maxlength="255"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
/>
</div>
<div>
<label for="acct-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
Port <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="acct-port"
type="number"
x-model.number="form.port"
min="1"
max="65535"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
/>
</div>
</div>
<!-- Username -->
<div>
<label for="acct-username" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
Username / Email <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="acct-username"
type="text"
x-model="form.username"
placeholder="you@example.com"
required
maxlength="255"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
autocomplete="username"
/>
</div>
<!-- Password -->
<div>
<label for="acct-password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
Password
<span x-show="editingAccount" class="text-xs text-gray-400 font-normal">(leave blank to keep current)</span>
<span x-show="!editingAccount" class="text-red-500" aria-hidden="true">*</span>
</label>
<div class="relative">
<input
id="acct-password"
:type="showPassword ? 'text' : 'password'"
x-model="form.password"
:required="!editingAccount"
maxlength="1024"
class="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
:aria-required="!editingAccount"
autocomplete="current-password"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 px-3 text-gray-400 hover:text-gray-600 focus:outline-none"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
>
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- SSL + Delete toggles -->
<div class="flex flex-col sm:flex-row gap-4">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" x-model="form.use_ssl" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span class="text-sm text-gray-700 dark:text-gray-200">Use SSL/TLS</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" x-model="form.delete_after_process" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span class="text-sm text-gray-700 dark:text-gray-200">Delete emails after processing</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" x-model="form.is_active" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span class="text-sm text-gray-700 dark:text-gray-200">Active (poll this mailbox)</span>
</label>
</div>
<!-- Test connection result -->
<template x-if="testResult">
<div
class="text-sm px-3 py-2 rounded border"
:class="testResult.success ? 'bg-green-50 border-green-200 text-green-800' : 'bg-red-50 border-red-200 text-red-800'"
role="status"
aria-live="polite"
>
<i :class="testResult.success ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1" aria-hidden="true"></i>
<span x-text="testResult.message"></span>
</div>
</template>
<!-- Form errors -->
<template x-if="formError">
<p class="text-sm text-red-600" role="alert" x-text="formError"></p>
</template>
<!-- Actions -->
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
<button
type="button"
@click="testFromForm()"
:disabled="testing"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
style="min-height:40px;"
>
<template x-if="testing">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<template x-if="!testing">
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
</template>
Test Connection
</button>
<div class="flex gap-2">
<button
type="button"
@click="closeModal()"
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:40px;"
>
Cancel
</button>
<button
type="submit"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-blue-600 hover:bg-blue-700 text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
style="min-height:40px;"
>
<template x-if="saving">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<span x-text="editingAccount ? 'Save Changes' : 'Add Account'"></span>
</button>
</div>
</div>
</form>
</div>
</div>
<!-- ── Delete confirmation modal ────────────────────────────────────────────── -->
<div
x-show="deleteModalOpen"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
@keydown.escape.window="deleteModalOpen = false"
style="display:none;"
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm p-6" @click.stop>
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Delete IMAP Account?
</h2>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
Are you sure you want to delete
<strong x-text="accountToDelete ? accountToDelete.name : ''"></strong>?
This action cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
@click="deleteModalOpen = false; accountToDelete = null"
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
style="min-height:40px;"
>
Cancel
</button>
<button
type="button"
@click="deleteAccount()"
:disabled="deleting"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-red-600 hover:bg-red-700 text-white focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50"
style="min-height:40px;"
>
<template x-if="deleting">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
Delete
</button>
</div>
</div>
</div>
<script>
function imapAccountsApp() {
return {
accounts: {{ accounts | tojson }},
quota: {
current_count: {{ current_count }},
max_mailboxes: {{ max_mailboxes | tojson }},
can_add: {{ 'true' if can_add else 'false' }},
tier_id: {{ tier_id | tojson }},
tier_name: {{ tier_name | tojson }},
},
loading: false,
alert: { show: false, type: '', title: '', message: '' },
// Modal state
modalOpen: false,
editingAccount: null,
form: { name: '', host: '', port: 993, username: '', password: '', use_ssl: true, delete_after_process: false, is_active: true },
showPassword: false,
saving: false,
testing: false,
testResult: null,
formError: null,
// Delete state
deleteModalOpen: false,
accountToDelete: null,
deleting: false,
// Test (saved account) state
testingId: null,
get canAdd() {
return this.quota.can_add;
},
init() {
// Nothing to do — data is server-rendered
},
formatDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
},
showAlert(type, title, message) {
this.alert = { show: true, type, title, message };
setTimeout(() => { this.alert.show = false; }, 5000);
},
openCreateModal() {
this.editingAccount = null;
this.form = { name: '', host: '', port: 993, username: '', password: '', use_ssl: true, delete_after_process: false, is_active: true };
this.showPassword = false;
this.testResult = null;
this.formError = null;
this.modalOpen = true;
},
openEditModal(acct) {
this.editingAccount = acct;
this.form = {
name: acct.name,
host: acct.host,
port: acct.port,
username: acct.username,
password: '', // never pre-fill password
use_ssl: acct.use_ssl,
delete_after_process: acct.delete_after_process,
is_active: acct.is_active,
};
this.showPassword = false;
this.testResult = null;
this.formError = null;
this.modalOpen = true;
},
closeModal() {
this.modalOpen = false;
this.editingAccount = null;
this.testResult = null;
this.formError = null;
},
async saveAccount() {
this.formError = null;
this.saving = true;
try {
const payload = { ...this.form };
// For edits: omit password if blank (backend keeps existing)
if (this.editingAccount && payload.password === '') {
delete payload.password;
}
let resp;
if (this.editingAccount) {
resp = await fetch(`/api/imap-accounts/${this.editingAccount.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
body: JSON.stringify(payload),
});
} else {
resp = await fetch('/api/imap-accounts/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
body: JSON.stringify(payload),
});
}
const data = await resp.json();
if (!resp.ok) {
this.formError = data.detail || 'Failed to save account.';
return;
}
// Update local list
if (this.editingAccount) {
const idx = this.accounts.findIndex(a => a.id === data.id);
if (idx !== -1) this.accounts.splice(idx, 1, data);
} else {
this.accounts.push(data);
this.quota.current_count += 1;
// Re-compute canAdd
if (this.quota.max_mailboxes !== null && this.quota.max_mailboxes > 0) {
this.quota.can_add = this.quota.current_count < this.quota.max_mailboxes;
}
}
this.closeModal();
this.showAlert('success', 'Saved', this.editingAccount ? 'Account updated.' : 'Account added successfully.');
} catch (err) {
this.formError = 'Network error. Please try again.';
} finally {
this.saving = false;
}
},
async testFromForm() {
this.testing = true;
this.testResult = null;
try {
const payload = {
host: this.form.host,
port: this.form.port,
username: this.form.username,
password: this.form.password || (this.editingAccount ? '__SAVED__' : ''),
use_ssl: this.form.use_ssl,
};
// If editing and no new password, test the saved account directly
if (this.editingAccount && !this.form.password) {
const resp = await fetch(`/api/imap-accounts/${this.editingAccount.id}/test`, {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
});
const data = await resp.json();
this.testResult = data;
return;
}
const resp = await fetch('/api/imap-accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
body: JSON.stringify(payload),
});
const data = await resp.json();
this.testResult = data;
} catch {
this.testResult = { success: false, message: 'Network error during test.' };
} finally {
this.testing = false;
}
},
async testSavedAccount(acct) {
this.testingId = acct.id;
try {
const resp = await fetch(`/api/imap-accounts/${acct.id}/test`, {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
});
const data = await resp.json();
if (data.success) {
this.showAlert('success', `${acct.name}: Connection OK`, data.message);
} else {
this.showAlert('error', `${acct.name}: Connection Failed`, data.message);
}
} catch {
this.showAlert('error', 'Test Failed', 'Network error.');
} finally {
this.testingId = null;
}
},
confirmDelete(acct) {
this.accountToDelete = acct;
this.deleteModalOpen = true;
},
async deleteAccount() {
if (!this.accountToDelete) return;
this.deleting = true;
try {
const resp = await fetch(`/api/imap-accounts/${this.accountToDelete.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': getCsrfToken() },
});
if (resp.ok || resp.status === 204) {
this.accounts = this.accounts.filter(a => a.id !== this.accountToDelete.id);
this.quota.current_count = Math.max(0, this.quota.current_count - 1);
if (this.quota.max_mailboxes !== null && this.quota.max_mailboxes > 0) {
this.quota.can_add = this.quota.current_count < this.quota.max_mailboxes;
}
this.showAlert('success', 'Deleted', `"${this.accountToDelete.name}" has been removed.`);
this.deleteModalOpen = false;
this.accountToDelete = null;
} else {
const data = await resp.json();
this.showAlert('error', 'Delete Failed', data.detail || 'Could not delete account.');
}
} catch {
this.showAlert('error', 'Delete Failed', 'Network error.');
} finally {
this.deleting = false;
}
},
};
}
function getCsrfToken() {
// Read CSRF token from meta tag injected by base template, or from cookie
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) return meta.getAttribute('content');
return '';
}
</script>
{% endblock %}
@@ -0,0 +1,46 @@
"""Add user_imap_accounts table for per-user IMAP ingestion
Revision ID: 022_add_user_imap_accounts
Revises: 021_add_backup_records
Create Date: 2026-03-08
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "022_add_user_imap_accounts"
down_revision: Union[str, None] = "021_add_backup_records"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create user_imap_accounts table for per-user IMAP ingestion."""
op.create_table(
"user_imap_accounts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.String(), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("host", sa.String(255), nullable=False),
sa.Column("port", sa.Integer(), nullable=False, server_default="993"),
sa.Column("username", sa.String(255), nullable=False),
sa.Column("password", sa.String(1024), nullable=False),
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("delete_after_process", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_user_imap_accounts_id", "user_imap_accounts", ["id"])
op.create_index("ix_user_imap_accounts_owner_id", "user_imap_accounts", ["owner_id"])
def downgrade() -> None:
"""Drop user_imap_accounts table."""
op.drop_index("ix_user_imap_accounts_owner_id", "user_imap_accounts")
op.drop_index("ix_user_imap_accounts_id", "user_imap_accounts")
op.drop_table("user_imap_accounts")
+1
View File
@@ -66,6 +66,7 @@ from app.models import ( # noqa: F401, E402
PipelineStep, PipelineStep,
ProcessingLog, ProcessingLog,
SavedSearch, SavedSearch,
UserImapAccount,
UserProfile, UserProfile,
WebhookConfig, WebhookConfig,
) )
+570
View File
@@ -0,0 +1,570 @@
"""Tests for the per-user IMAP accounts API (app/api/imap_accounts.py)."""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import SubscriptionPlan, UserImapAccount, UserProfile
# ---------------------------------------------------------------------------
# Test data constants
# ---------------------------------------------------------------------------
_OWNER = "test_user@example.com"
_BASIC_ACCOUNT = {
"name": "Test Mailbox",
"host": "imap.example.com",
"port": 993,
"username": "user@example.com",
"password": "s3cr3t", # noqa: S105
"use_ssl": True,
"delete_after_process": False,
"is_active": True,
}
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
@pytest.fixture()
def imap_engine():
"""In-memory SQLite engine for IMAP account tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def imap_session(imap_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=imap_engine)
session = Session()
yield session
session.close()
def _make_client(imap_engine, owner_id: str = _OWNER):
"""Return a TestClient that injects *owner_id* as the authenticated user."""
from app.api.imap_accounts import _get_owner_id
from app.main import app
def override_db():
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_owner():
return owner_id
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = override_owner
return app, override_db, override_owner
def _make_anon_client(imap_engine):
"""Return a TestClient without an authenticated user (401 expected)."""
from app.main import app
def override_db():
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
return app
@pytest.fixture()
def imap_client(imap_engine):
"""TestClient authenticated as _OWNER."""
from app.api.imap_accounts import _get_owner_id
from app.main import app
def override_db():
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_owner():
return _OWNER
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = override_owner
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.clear()
def _make_profile(session, owner: str = _OWNER, tier: str = "starter") -> UserProfile:
"""Create a UserProfile."""
profile = UserProfile(user_id=owner, subscription_tier=tier)
session.add(profile)
session.commit()
session.refresh(profile)
return profile
def _make_plan(session, tier: str = "starter", max_mailboxes: int = 1) -> SubscriptionPlan:
"""Create a SubscriptionPlan row."""
plan = SubscriptionPlan(
plan_id=tier,
name=tier.title(),
price_monthly=2.99,
price_yearly=28.99,
max_mailboxes=max_mailboxes,
is_active=True,
)
session.add(plan)
session.commit()
session.refresh(plan)
return plan
# ---------------------------------------------------------------------------
# Unit tests quota helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetMaxMailboxes:
"""Unit tests for the _get_max_mailboxes helper."""
def test_free_tier_returns_zero(self):
from app.api.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "free", "max_mailboxes": 0}) == 0
def test_paid_tier_with_explicit_limit(self):
from app.api.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "starter", "max_mailboxes": 1}) == 1
def test_paid_tier_unlimited(self):
from app.api.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "business", "max_mailboxes": 0}) is None
def test_professional_three(self):
from app.api.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "professional", "max_mailboxes": 3}) == 3
# ---------------------------------------------------------------------------
# Integration tests list endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestListImapAccounts:
"""Tests for GET /api/imap-accounts/."""
def test_list_empty(self, imap_client, imap_session):
"""Listing accounts for a user with none returns empty list."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=1)
resp = imap_client.get("/api/imap-accounts/")
assert resp.status_code == 200
assert resp.json() == []
def test_list_returns_own_accounts_only(self, imap_client, imap_session):
"""Only the current user's accounts are returned."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=2)
acct = UserImapAccount(owner_id=_OWNER, name="Mine", host="h", port=993, username="u", password="p")
other = UserImapAccount(
owner_id="other@example.com",
name="Theirs",
host="h2",
port=993,
username="u",
password="p",
)
imap_session.add_all([acct, other])
imap_session.commit()
resp = imap_client.get("/api/imap-accounts/")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "Mine"
def test_list_requires_authentication(self, imap_engine):
"""Unauthenticated requests return 401."""
from fastapi import HTTPException, status
from app.api.imap_accounts import _get_owner_id
from app.main import app
def raise_401():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
def override_db():
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = raise_401
try:
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
resp = c.get("/api/imap-accounts/")
assert resp.status_code == 401
finally:
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Integration tests create endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestCreateImapAccount:
"""Tests for POST /api/imap-accounts/."""
def test_create_success(self, imap_client, imap_session):
"""A valid create request returns 201 and the new account."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=2)
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == _BASIC_ACCOUNT["name"]
assert data["host"] == _BASIC_ACCOUNT["host"]
assert "password" not in data
def test_create_increments_count(self, imap_client, imap_session):
"""After creation, the count in the database increases."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=2)
imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
count = imap_session.query(UserImapAccount).filter(UserImapAccount.owner_id == _OWNER).count()
assert count == 1
def test_create_blocked_on_free_tier(self, imap_client, imap_session):
"""Free-tier users cannot add any IMAP accounts."""
_make_profile(imap_session, tier="free")
_make_plan(imap_session, tier="free", max_mailboxes=0)
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
assert resp.status_code == 403
assert "plan" in resp.json()["detail"].lower()
def test_create_blocked_at_quota_limit(self, imap_client, imap_session):
"""Users at the quota limit receive a 403."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=1)
existing = UserImapAccount(owner_id=_OWNER, name="Existing", host="h", port=993, username="u", password="p")
imap_session.add(existing)
imap_session.commit()
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
assert resp.status_code == 403
def test_create_unlimited_on_power_tier(self, imap_engine, imap_session):
"""Power-tier users can add multiple accounts (unlimited)."""
from app.api.imap_accounts import _get_owner_id
from app.main import app
def override_db():
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_owner():
return _OWNER
_make_profile(imap_session, tier="business")
_make_plan(imap_session, tier="business", max_mailboxes=0)
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = override_owner
try:
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
resp1 = c.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
resp2 = c.post("/api/imap-accounts/", json={**_BASIC_ACCOUNT, "name": "Second"})
assert resp1.status_code == 201
assert resp2.status_code == 201
finally:
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Integration tests update endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestUpdateImapAccount:
"""Tests for PUT /api/imap-accounts/{id}."""
def _create_account(self, session, owner: str = _OWNER) -> UserImapAccount:
acct = UserImapAccount(owner_id=owner, name="Original", host="h", port=993, username="u", password="p")
session.add(acct)
session.commit()
session.refresh(acct)
return acct
def test_update_name(self, imap_client, imap_session):
"""Updating name only changes the name."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=1)
acct = self._create_account(imap_session)
resp = imap_client.put(f"/api/imap-accounts/{acct.id}", json={"name": "Updated"})
assert resp.status_code == 200
assert resp.json()["name"] == "Updated"
assert resp.json()["host"] == "h"
def test_update_not_found(self, imap_client, imap_session):
"""Updating a non-existent account returns 404."""
resp = imap_client.put("/api/imap-accounts/9999", json={"name": "x"})
assert resp.status_code == 404
def test_cannot_update_other_users_account(self, imap_client, imap_session):
"""A user cannot update another user's account."""
other_acct = self._create_account(imap_session, owner="other@example.com")
resp = imap_client.put(f"/api/imap-accounts/{other_acct.id}", json={"name": "Hijacked"})
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Integration tests delete endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteImapAccount:
"""Tests for DELETE /api/imap-accounts/{id}."""
def _create_account(self, session) -> UserImapAccount:
acct = UserImapAccount(owner_id=_OWNER, name="ToDelete", host="h", port=993, username="u", password="p")
session.add(acct)
session.commit()
session.refresh(acct)
return acct
def test_delete_success(self, imap_client, imap_session):
"""Deleting an account returns 204 and removes the DB row."""
acct = self._create_account(imap_session)
resp = imap_client.delete(f"/api/imap-accounts/{acct.id}")
assert resp.status_code == 204
remaining = imap_session.query(UserImapAccount).filter(UserImapAccount.id == acct.id).first()
assert remaining is None
def test_delete_not_found(self, imap_client, imap_session):
"""Deleting a non-existent account returns 404."""
resp = imap_client.delete("/api/imap-accounts/9999")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Integration tests test-connection endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestImapConnectionTest:
"""Tests for POST /api/imap-accounts/test and /{id}/test."""
def test_test_connection_success(self, imap_client, imap_session):
"""A successful IMAP login returns success=True."""
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
mock_test.return_value = {"success": True, "message": "Connection successful"}
resp = imap_client.post(
"/api/imap-accounts/test",
json={
"host": "imap.example.com",
"port": 993,
"username": "u@example.com",
"password": "pass", # noqa: S106
"use_ssl": True,
},
)
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_test_connection_failure(self, imap_client, imap_session):
"""A failed IMAP login returns success=False with a message."""
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
mock_test.return_value = {
"success": False,
"message": "IMAP error: authentication failed",
}
resp = imap_client.post(
"/api/imap-accounts/test",
json={
"host": "imap.example.com",
"port": 993,
"username": "u@example.com",
"password": "wrong", # noqa: S106
"use_ssl": True,
},
)
assert resp.status_code == 200
assert resp.json()["success"] is False
def test_test_saved_account(self, imap_client, imap_session):
"""Testing a saved account calls the connection helper with stored credentials."""
acct = UserImapAccount(
owner_id=_OWNER,
name="Saved",
host="imap.test.com",
port=993,
username="u",
password="p",
)
imap_session.add(acct)
imap_session.commit()
imap_session.refresh(acct)
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
mock_test.return_value = {"success": True, "message": "Connection successful"}
resp = imap_client.post(f"/api/imap-accounts/{acct.id}/test")
assert resp.status_code == 200
assert resp.json()["success"] is True
mock_test.assert_called_once_with("imap.test.com", 993, "u", "p", True)
# ---------------------------------------------------------------------------
# Integration tests quota endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestImapQuota:
"""Tests for GET /api/imap-accounts/quota/."""
def test_quota_free_tier(self, imap_client, imap_session):
"""Free-tier user reports max_mailboxes=0 and can_add=False."""
_make_profile(imap_session, tier="free")
_make_plan(imap_session, tier="free", max_mailboxes=0)
resp = imap_client.get("/api/imap-accounts/quota/")
assert resp.status_code == 200
data = resp.json()
assert data["max_mailboxes"] == 0
assert data["can_add"] is False
def test_quota_starter_tier(self, imap_client, imap_session):
"""Starter-tier user with no accounts reports can_add=True."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=1)
resp = imap_client.get("/api/imap-accounts/quota/")
assert resp.status_code == 200
data = resp.json()
assert data["max_mailboxes"] == 1
assert data["can_add"] is True
assert data["current_count"] == 0
def test_quota_unlimited(self, imap_client, imap_session):
"""Power-tier user reports max_mailboxes=None and can_add=True."""
_make_profile(imap_session, tier="business")
_make_plan(imap_session, tier="business", max_mailboxes=0)
resp = imap_client.get("/api/imap-accounts/quota/")
assert resp.status_code == 200
data = resp.json()
assert data["max_mailboxes"] is None
assert data["can_add"] is True
# ---------------------------------------------------------------------------
# Unit tests _test_imap_connection helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestTestImapConnection:
"""Unit tests for the _test_imap_connection helper (no network calls)."""
def test_success(self):
"""Successful login returns success=True."""
from app.api.imap_accounts import _test_imap_connection
mock_mail = MagicMock()
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
result = _test_imap_connection(
"imap.example.com",
993,
"user",
"pass",
use_ssl=True, # noqa: S106
)
assert result["success"] is True
mock_mail.login.assert_called_once_with("user", "pass")
mock_mail.logout.assert_called_once()
def test_auth_error(self):
"""An exception raised by IMAP4_SSL returns success=False."""
from app.api.imap_accounts import _test_imap_connection
with patch("imaplib.IMAP4_SSL", side_effect=Exception("auth failed")):
result = _test_imap_connection(
"imap.example.com",
993,
"user",
"badpass",
use_ssl=True, # noqa: S106
)
assert result["success"] is False
assert "auth failed" in result["message"]
def test_network_error(self):
"""An OSError returns success=False with a network error message."""
from app.api.imap_accounts import _test_imap_connection
with patch("imaplib.IMAP4", side_effect=OSError("connection refused")):
result = _test_imap_connection(
"bad-host",
143,
"user",
"pass",
use_ssl=False, # noqa: S106
)
assert result["success"] is False
assert "connection refused" in result["message"].lower()