8079db7893
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
684 lines
24 KiB
Python
684 lines
24 KiB
Python
"""API endpoints for managing per-user integrations (sources and destinations).
|
|
|
|
Provides CRUD operations for :class:`~app.models.UserIntegration` records.
|
|
Each record represents one ingestion source (e.g. IMAP, Watch Folder) or
|
|
storage destination (e.g. S3, Dropbox, Google Drive) configured by a user.
|
|
|
|
Sensitive credentials are encrypted at rest using Fernet symmetric encryption
|
|
(keyed from ``SESSION_SECRET``) via :mod:`app.utils.encryption`. Credential
|
|
values are **never** returned in API responses.
|
|
|
|
Subscription quota enforcement
|
|
------------------------------
|
|
On creation, the endpoint checks the user's subscription tier limits:
|
|
|
|
* **Destinations** — ``max_storage_destinations`` from the plan.
|
|
* **Sources (IMAP)** — ``max_mailboxes`` from the plan.
|
|
|
|
Exceeding the quota returns HTTP 403 with an actionable error message.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
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 IntegrationDirection, IntegrationType, UserIntegration
|
|
from app.utils.encryption import decrypt_value, encrypt_value
|
|
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="/integrations", tags=["integrations"])
|
|
|
|
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"
|
|
|
|
# Source types that consume the mailbox quota
|
|
_MAILBOX_SOURCE_TYPES = {IntegrationType.IMAP}
|
|
|
|
|
|
def _get_max_destinations(tier: dict[str, Any]) -> int | None:
|
|
"""Return the maximum number of storage destinations allowed by *tier*.
|
|
|
|
Returns:
|
|
``None`` — unlimited (paid tiers with ``max_storage_destinations == 0``)
|
|
positive — the configured limit
|
|
"""
|
|
tier_id: str = tier.get("id", _FREE_TIER_ID)
|
|
max_dest: int = tier.get("max_storage_destinations", 0)
|
|
|
|
# Free tier: the value itself is the limit (e.g. 1)
|
|
if tier_id == _FREE_TIER_ID:
|
|
return max_dest if max_dest > 0 else 1 # safe default
|
|
|
|
# Paid tiers: 0 means unlimited
|
|
if max_dest == 0:
|
|
return None
|
|
|
|
return max_dest
|
|
|
|
|
|
def _get_max_sources(tier: dict[str, Any]) -> int | None:
|
|
"""Return the maximum number of IMAP source integrations 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, direction: str, integration_type: str) -> None:
|
|
"""Raise 403 if the user has reached their integration quota.
|
|
|
|
Quota rules:
|
|
* DESTINATION integrations are limited by ``max_storage_destinations``.
|
|
* SOURCE integrations of type IMAP are limited by ``max_mailboxes``.
|
|
* Other SOURCE types (WATCH_FOLDER, WEBHOOK) are not quota-limited yet.
|
|
"""
|
|
tier_id = get_user_tier_id(db, owner_id)
|
|
tier = get_tier(tier_id, db)
|
|
|
|
if direction == IntegrationDirection.DESTINATION:
|
|
max_dest = _get_max_destinations(tier)
|
|
if max_dest is not None:
|
|
current_count = (
|
|
db.query(UserIntegration)
|
|
.filter(
|
|
UserIntegration.owner_id == owner_id,
|
|
UserIntegration.direction == IntegrationDirection.DESTINATION,
|
|
)
|
|
.count()
|
|
)
|
|
if current_count >= max_dest:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=(
|
|
f"You have reached your plan limit of {max_dest} storage destination(s). "
|
|
"Please remove an existing destination or upgrade your plan."
|
|
),
|
|
)
|
|
|
|
elif direction == IntegrationDirection.SOURCE and integration_type in _MAILBOX_SOURCE_TYPES:
|
|
max_src = _get_max_sources(tier)
|
|
|
|
if max_src == 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 sources.",
|
|
)
|
|
|
|
if max_src is not None:
|
|
current_count = (
|
|
db.query(UserIntegration)
|
|
.filter(
|
|
UserIntegration.owner_id == owner_id,
|
|
UserIntegration.direction == IntegrationDirection.SOURCE,
|
|
UserIntegration.integration_type.in_(list(_MAILBOX_SOURCE_TYPES)),
|
|
)
|
|
.count()
|
|
)
|
|
if current_count >= max_src:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=(
|
|
f"You have reached your plan limit of {max_src} IMAP source(s). "
|
|
"Please remove an existing source or upgrade your plan."
|
|
),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_VALID_DIRECTIONS = IntegrationDirection.ALL
|
|
_VALID_TYPES = IntegrationType.ALL
|
|
|
|
|
|
class IntegrationCreate(BaseModel):
|
|
"""Schema for creating a new integration."""
|
|
|
|
direction: str = Field(..., description="'SOURCE' or 'DESTINATION'")
|
|
integration_type: str = Field(..., description="Integration type (e.g. 'IMAP', 'S3', 'DROPBOX')")
|
|
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label")
|
|
config: dict[str, Any] | None = Field(default=None, description="Non-sensitive configuration (JSON object)")
|
|
credentials: dict[str, Any] | None = Field(
|
|
default=None, description="Sensitive credentials (JSON object, encrypted at rest)"
|
|
)
|
|
is_active: bool = Field(default=True, description="Whether the integration is active")
|
|
|
|
|
|
class IntegrationUpdate(BaseModel):
|
|
"""Schema for updating an existing integration (all fields optional)."""
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
config: dict[str, Any] | None = None
|
|
credentials: dict[str, Any] | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
class IntegrationTestRequest(BaseModel):
|
|
"""Schema for testing an integration connection without saving it."""
|
|
|
|
integration_type: str = Field(..., description="Integration type (e.g. 'IMAP', 'S3', 'DROPBOX')")
|
|
config: dict[str, Any] | None = Field(default=None, description="Non-sensitive configuration")
|
|
credentials: dict[str, Any] | None = Field(default=None, description="Credentials for the connection test")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _validate_direction(direction: str) -> None:
|
|
"""Raise 400 if *direction* is not a known value."""
|
|
if direction not in _VALID_DIRECTIONS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid direction '{direction}'. Must be one of: {sorted(_VALID_DIRECTIONS)}",
|
|
)
|
|
|
|
|
|
def _validate_integration_type(integration_type: str) -> None:
|
|
"""Raise 400 if *integration_type* is not a known value."""
|
|
if integration_type not in _VALID_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid integration_type '{integration_type}'. Must be one of: {sorted(_VALID_TYPES)}",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serialisation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _to_response(integration: UserIntegration) -> dict[str, Any]:
|
|
"""Serialise a :class:`UserIntegration` row to a response dict.
|
|
|
|
Credentials are **never** included; only a boolean flag indicating
|
|
whether credentials have been configured is returned.
|
|
"""
|
|
config_data: dict[str, Any] | None = None
|
|
if integration.config:
|
|
try:
|
|
config_data = json.loads(integration.config)
|
|
except (json.JSONDecodeError, TypeError):
|
|
config_data = None
|
|
|
|
return {
|
|
"id": integration.id,
|
|
"owner_id": integration.owner_id,
|
|
"direction": integration.direction,
|
|
"integration_type": integration.integration_type,
|
|
"name": integration.name,
|
|
"config": config_data,
|
|
"has_credentials": bool(integration.credentials),
|
|
"is_active": integration.is_active,
|
|
"last_used_at": integration.last_used_at.isoformat() if integration.last_used_at else None,
|
|
"last_error": integration.last_error,
|
|
"created_at": integration.created_at.isoformat() if integration.created_at else None,
|
|
"updated_at": integration.updated_at.isoformat() if integration.updated_at else None,
|
|
}
|
|
|
|
|
|
def _encode_credentials(credentials: dict[str, Any] | None) -> str | None:
|
|
"""Serialise *credentials* dict to an encrypted JSON string for storage."""
|
|
if not credentials:
|
|
return None
|
|
plaintext = json.dumps(credentials)
|
|
return encrypt_value(plaintext)
|
|
|
|
|
|
def _decode_credentials(stored: str | None) -> dict[str, Any] | None:
|
|
"""Decrypt and deserialise stored credentials back to a dict.
|
|
|
|
Returns ``None`` when *stored* is empty or cannot be decoded.
|
|
"""
|
|
if not stored:
|
|
return None
|
|
plaintext = decrypt_value(stored)
|
|
if not plaintext:
|
|
return None
|
|
try:
|
|
return json.loads(plaintext)
|
|
except (json.JSONDecodeError, TypeError):
|
|
logger.error("Failed to decode credentials JSON after decryption")
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/", summary="List integrations for the current user")
|
|
def list_integrations(
|
|
request: Request,
|
|
db: DbSession,
|
|
owner_id: CurrentOwner,
|
|
direction: str | None = None,
|
|
integration_type: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return all integrations belonging to the authenticated user.
|
|
|
|
Optional query-string filters:
|
|
|
|
- ``direction`` — ``SOURCE`` or ``DESTINATION``
|
|
- ``integration_type`` — e.g. ``IMAP``, ``S3``, ``DROPBOX``
|
|
"""
|
|
query = db.query(UserIntegration).filter(UserIntegration.owner_id == owner_id)
|
|
|
|
if direction is not None:
|
|
_validate_direction(direction)
|
|
query = query.filter(UserIntegration.direction == direction)
|
|
|
|
if integration_type is not None:
|
|
_validate_integration_type(integration_type)
|
|
query = query.filter(UserIntegration.integration_type == integration_type)
|
|
|
|
integrations = query.order_by(UserIntegration.id).all()
|
|
return [_to_response(i) for i in integrations]
|
|
|
|
|
|
@router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a new integration")
|
|
def create_integration(
|
|
request: Request,
|
|
body: IntegrationCreate,
|
|
db: DbSession,
|
|
owner_id: CurrentOwner,
|
|
) -> dict[str, Any]:
|
|
"""Create a new source or destination integration for the current user.
|
|
|
|
``credentials`` are encrypted at rest using Fernet symmetric encryption
|
|
before being persisted and are **never** returned in API responses.
|
|
|
|
Quota is enforced against the user's subscription plan before the
|
|
integration is persisted.
|
|
"""
|
|
_validate_direction(body.direction)
|
|
_validate_integration_type(body.integration_type)
|
|
|
|
_check_quota(db, owner_id, body.direction, body.integration_type)
|
|
|
|
integration = UserIntegration(
|
|
owner_id=owner_id,
|
|
direction=body.direction,
|
|
integration_type=body.integration_type,
|
|
name=body.name,
|
|
config=json.dumps(body.config) if body.config is not None else None,
|
|
credentials=_encode_credentials(body.credentials),
|
|
is_active=body.is_active,
|
|
)
|
|
|
|
try:
|
|
db.add(integration)
|
|
db.commit()
|
|
db.refresh(integration)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info(
|
|
"User %s created %s integration %d (%s)",
|
|
owner_id,
|
|
body.direction,
|
|
integration.id,
|
|
body.integration_type,
|
|
)
|
|
return _to_response(integration)
|
|
|
|
|
|
@router.get("/{integration_id}", summary="Get a single integration")
|
|
def get_integration(
|
|
integration_id: int,
|
|
request: Request,
|
|
db: DbSession,
|
|
owner_id: CurrentOwner,
|
|
) -> dict[str, Any]:
|
|
"""Return a single integration by ID (must belong to the current user)."""
|
|
integration = (
|
|
db.query(UserIntegration)
|
|
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
|
|
.first()
|
|
)
|
|
if not integration:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
|
|
return _to_response(integration)
|
|
|
|
|
|
@router.put("/{integration_id}", summary="Update an integration")
|
|
def update_integration(
|
|
integration_id: int,
|
|
request: Request,
|
|
body: IntegrationUpdate,
|
|
db: DbSession,
|
|
owner_id: CurrentOwner,
|
|
) -> dict[str, Any]:
|
|
"""Update an existing integration. Only provided fields are changed.
|
|
|
|
When ``credentials`` is supplied the stored value is replaced in full
|
|
with the freshly encrypted version of the new credentials dict.
|
|
"""
|
|
integration = (
|
|
db.query(UserIntegration)
|
|
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
|
|
.first()
|
|
)
|
|
if not integration:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
|
|
|
|
if body.name is not None:
|
|
integration.name = body.name
|
|
if body.config is not None:
|
|
integration.config = json.dumps(body.config)
|
|
if body.credentials is not None:
|
|
integration.credentials = _encode_credentials(body.credentials)
|
|
if body.is_active is not None:
|
|
integration.is_active = body.is_active
|
|
|
|
# Reset last_error so the next operation gives a fresh result
|
|
integration.last_error = None
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(integration)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("User %s updated integration %d", owner_id, integration_id)
|
|
return _to_response(integration)
|
|
|
|
|
|
@router.delete("/{integration_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete an integration")
|
|
def delete_integration(
|
|
integration_id: int,
|
|
request: Request,
|
|
db: DbSession,
|
|
owner_id: CurrentOwner,
|
|
) -> None:
|
|
"""Delete an integration permanently."""
|
|
integration = (
|
|
db.query(UserIntegration)
|
|
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
|
|
.first()
|
|
)
|
|
if not integration:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
|
|
|
|
try:
|
|
db.delete(integration)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("User %s deleted integration %d", owner_id, integration_id)
|
|
|
|
|
|
@router.get("/{integration_id}/credentials", summary="Retrieve decrypted credentials for an integration")
|
|
def get_integration_credentials(
|
|
integration_id: int,
|
|
request: Request,
|
|
db: DbSession,
|
|
owner_id: CurrentOwner,
|
|
) -> dict[str, Any]:
|
|
"""Return the decrypted credentials dict for a saved integration.
|
|
|
|
This endpoint is intended for internal use by background tasks that need
|
|
to authenticate with a third-party service. Treat the response as
|
|
sensitive — it contains plaintext secrets.
|
|
"""
|
|
integration = (
|
|
db.query(UserIntegration)
|
|
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
|
|
.first()
|
|
)
|
|
if not integration:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
|
|
|
|
credentials = _decode_credentials(integration.credentials)
|
|
return {"credentials": credentials or {}}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connection test helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _test_imap_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Test an IMAP connection using the provided config and credentials."""
|
|
import imaplib
|
|
|
|
cfg = config or {}
|
|
creds = credentials or {}
|
|
host = cfg.get("host", "")
|
|
port = int(cfg.get("port", 993))
|
|
username = cfg.get("username", "")
|
|
password = creds.get("password", "")
|
|
use_ssl = cfg.get("use_ssl", True)
|
|
|
|
if not host or not username or not password:
|
|
return {"success": False, "message": "Missing required fields: host, username, and password"}
|
|
|
|
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": "IMAP connection successful"}
|
|
except OSError as exc:
|
|
logger.warning("IMAP network error for %s@%s: %s", username, host, exc)
|
|
return {"success": False, "message": "IMAP connection failed — check host, port, and network connectivity"}
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("IMAP error for %s@%s: %s", username, host, exc)
|
|
return {"success": False, "message": "IMAP authentication or connection failed"}
|
|
|
|
|
|
def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Test an S3 connection by calling HeadBucket."""
|
|
try:
|
|
import boto3
|
|
from botocore.exceptions import BotoCoreError, ClientError
|
|
except ImportError:
|
|
return {"success": False, "message": "boto3 is not installed"}
|
|
|
|
cfg = config or {}
|
|
creds = credentials or {}
|
|
bucket = cfg.get("bucket", "")
|
|
region = cfg.get("region", "us-east-1")
|
|
|
|
if not bucket:
|
|
return {"success": False, "message": "Missing required field: bucket"}
|
|
|
|
try:
|
|
client = boto3.client(
|
|
"s3",
|
|
region_name=region,
|
|
aws_access_key_id=creds.get("access_key_id", ""),
|
|
aws_secret_access_key=creds.get("secret_access_key", ""),
|
|
endpoint_url=cfg.get("endpoint_url"),
|
|
)
|
|
client.head_bucket(Bucket=bucket)
|
|
return {"success": True, "message": f"S3 bucket '{bucket}' is accessible"}
|
|
except (BotoCoreError, ClientError) as exc:
|
|
logger.warning("S3 connection error for bucket '%s': %s", bucket, exc)
|
|
return {"success": False, "message": "S3 connection failed — check bucket name, region, and credentials"}
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("S3 unexpected error for bucket '%s': %s", bucket, exc)
|
|
return {"success": False, "message": "S3 connection failed"}
|
|
|
|
|
|
def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Test a WebDAV/Nextcloud connection by issuing an HTTP PROPFIND."""
|
|
import urllib.request
|
|
|
|
cfg = config or {}
|
|
creds = credentials or {}
|
|
url = cfg.get("url", "")
|
|
username = creds.get("username", "")
|
|
password = creds.get("password", "")
|
|
|
|
if not url:
|
|
return {"success": False, "message": "Missing required field: url"}
|
|
|
|
# Only allow http/https to prevent file:// or other custom scheme attacks
|
|
import ipaddress
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ("http", "https"):
|
|
return {"success": False, "message": "URL must use http or https scheme"}
|
|
|
|
# Block requests to private/internal IPs to prevent SSRF
|
|
hostname = parsed.hostname or ""
|
|
if hostname:
|
|
from app.utils.network import is_private_ip
|
|
if is_private_ip(hostname):
|
|
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
|
|
|
|
try:
|
|
import base64
|
|
|
|
req = urllib.request.Request(url, method="PROPFIND") # noqa: S310
|
|
if username and password:
|
|
token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
req.add_header("Authorization", f"Basic {token}")
|
|
req.add_header("Depth", "0")
|
|
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
|
|
if resp.status < 400:
|
|
return {"success": True, "message": "WebDAV connection successful"}
|
|
return {"success": False, "message": f"WebDAV returned HTTP {resp.status}"}
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("WebDAV connection error for %s: %s", hostname, exc)
|
|
return {"success": False, "message": "WebDAV connection failed — check URL and credentials"}
|
|
|
|
|
|
_CONNECTION_TESTERS: dict[str, Any] = {
|
|
IntegrationType.IMAP: _test_imap_connection,
|
|
IntegrationType.S3: _test_s3_connection,
|
|
IntegrationType.WEBDAV: _test_webdav_connection,
|
|
IntegrationType.NEXTCLOUD: _test_webdav_connection,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test & quota endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/test", summary="Test an integration connection without saving")
|
|
def test_integration_connection(
|
|
request: Request,
|
|
body: IntegrationTestRequest,
|
|
owner_id: CurrentOwner,
|
|
) -> dict[str, Any]:
|
|
"""Test integration credentials without persisting anything.
|
|
|
|
Useful for the "Test connection" button in the UI before the user saves
|
|
a new integration. Returns ``{"success": bool, "message": str}``.
|
|
"""
|
|
_validate_integration_type(body.integration_type)
|
|
|
|
tester = _CONNECTION_TESTERS.get(body.integration_type)
|
|
if tester is None:
|
|
return {
|
|
"success": False,
|
|
"message": f"Connection testing is not yet supported for '{body.integration_type}'. "
|
|
"The integration can still be saved and will be validated on first use.",
|
|
}
|
|
|
|
return tester(body.config, body.credentials)
|
|
|
|
|
|
@router.get("/quota/", summary="Get integration quota information for the current user")
|
|
def get_integration_quota(
|
|
request: Request,
|
|
db: DbSession,
|
|
owner_id: CurrentOwner,
|
|
) -> dict[str, Any]:
|
|
"""Return the user's current integration usage vs. their plan quota.
|
|
|
|
Includes separate counts for destinations and IMAP sources.
|
|
"""
|
|
tier_id = get_user_tier_id(db, owner_id)
|
|
tier = get_tier(tier_id, db)
|
|
|
|
max_dest = _get_max_destinations(tier)
|
|
max_src = _get_max_sources(tier)
|
|
|
|
dest_count = (
|
|
db.query(UserIntegration)
|
|
.filter(
|
|
UserIntegration.owner_id == owner_id,
|
|
UserIntegration.direction == IntegrationDirection.DESTINATION,
|
|
)
|
|
.count()
|
|
)
|
|
|
|
src_count = (
|
|
db.query(UserIntegration)
|
|
.filter(
|
|
UserIntegration.owner_id == owner_id,
|
|
UserIntegration.direction == IntegrationDirection.SOURCE,
|
|
UserIntegration.integration_type.in_(list(_MAILBOX_SOURCE_TYPES)),
|
|
)
|
|
.count()
|
|
)
|
|
|
|
return {
|
|
"tier_id": tier_id,
|
|
"tier_name": tier.get("name", tier_id),
|
|
"destinations": {
|
|
"current_count": dest_count,
|
|
"max_allowed": max_dest,
|
|
"can_add": max_dest is None or dest_count < max_dest,
|
|
},
|
|
"sources": {
|
|
"current_count": src_count,
|
|
"max_allowed": max_src,
|
|
"can_add": max_src is None or (max_src > 0 and src_count < max_src),
|
|
},
|
|
}
|