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:
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -66,6 +66,7 @@ from app.models import ( # noqa: F401, E402
|
|||||||
PipelineStep,
|
PipelineStep,
|
||||||
ProcessingLog,
|
ProcessingLog,
|
||||||
SavedSearch,
|
SavedSearch,
|
||||||
|
UserImapAccount,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
WebhookConfig,
|
WebhookConfig,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user