Spaces:
Paused
Paused
fix: use database for Mail.tm API routes, fix PostgreSQL SSL handling
Browse files- routes.py: Replace legacy JSON store with SQLAlchemy database lookup.
The /token endpoint now checks the database first, avoiding unnecessary
IMAP validation for admin-imported and CI-registered accounts.
- database.py: Handle Neon PostgreSQL sslmode=require properly by
stripping sslmode from URL and passing SSL context via connect_args.
- Dockerfile: Add comment clarifying DATABASE_URL override for PostgreSQL.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dockerfile +1 -0
- outlook2api/database.py +20 -2
- outlook2api/routes.py +23 -11
Dockerfile
CHANGED
|
@@ -10,6 +10,7 @@ COPY pyproject.toml .
|
|
| 10 |
|
| 11 |
RUN mkdir -p /tmp/data
|
| 12 |
|
|
|
|
| 13 |
ENV DATABASE_URL=sqlite+aiosqlite:////tmp/data/outlook2api.db
|
| 14 |
ENV OUTLOOK2API_PORT=7860
|
| 15 |
|
|
|
|
| 10 |
|
| 11 |
RUN mkdir -p /tmp/data
|
| 12 |
|
| 13 |
+
# Default to SQLite; override with DATABASE_URL env var for PostgreSQL persistence
|
| 14 |
ENV DATABASE_URL=sqlite+aiosqlite:////tmp/data/outlook2api.db
|
| 15 |
ENV OUTLOOK2API_PORT=7860
|
| 16 |
|
outlook2api/database.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
"""SQLAlchemy async database setup and models."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
| 4 |
import uuid
|
| 5 |
from datetime import datetime, timezone
|
|
|
|
| 6 |
|
| 7 |
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, select, func
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
@@ -51,15 +53,31 @@ def _get_db_url() -> str:
|
|
| 51 |
# Convert postgres:// to postgresql+asyncpg://
|
| 52 |
if url.startswith("postgres://"):
|
| 53 |
url = url.replace("postgres://", "postgresql+asyncpg://", 1)
|
| 54 |
-
elif url.startswith("postgresql://"):
|
| 55 |
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
return url
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
async def init_db() -> None:
|
| 60 |
global _engine, _session_factory
|
| 61 |
url = _get_db_url()
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
_engine = create_async_engine(url, echo=False, connect_args=connect_args)
|
| 64 |
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
| 65 |
async with _engine.begin() as conn:
|
|
|
|
| 1 |
"""SQLAlchemy async database setup and models."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import ssl as _ssl
|
| 5 |
import uuid
|
| 6 |
from datetime import datetime, timezone
|
| 7 |
+
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
| 8 |
|
| 9 |
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, select, func
|
| 10 |
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
|
|
| 53 |
# Convert postgres:// to postgresql+asyncpg://
|
| 54 |
if url.startswith("postgres://"):
|
| 55 |
url = url.replace("postgres://", "postgresql+asyncpg://", 1)
|
| 56 |
+
elif url.startswith("postgresql://") and not url.startswith("postgresql+"):
|
| 57 |
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
| 58 |
+
# Strip sslmode from URL — handled via connect_args instead
|
| 59 |
+
if "asyncpg" in url and "sslmode=" in url:
|
| 60 |
+
parsed = urlparse(url)
|
| 61 |
+
params = parse_qs(parsed.query)
|
| 62 |
+
params.pop("sslmode", None)
|
| 63 |
+
new_query = urlencode(params, doseq=True)
|
| 64 |
+
url = urlunparse(parsed._replace(query=new_query))
|
| 65 |
return url
|
| 66 |
|
| 67 |
|
| 68 |
+
def _needs_ssl() -> bool:
|
| 69 |
+
"""Check if the configured database URL requires SSL."""
|
| 70 |
+
url = get_config()["database_url"]
|
| 71 |
+
return "sslmode=require" in url or "ssl=true" in url
|
| 72 |
+
|
| 73 |
+
|
| 74 |
async def init_db() -> None:
|
| 75 |
global _engine, _session_factory
|
| 76 |
url = _get_db_url()
|
| 77 |
+
is_sqlite = "sqlite" in url
|
| 78 |
+
connect_args: dict = {"check_same_thread": False} if is_sqlite else {}
|
| 79 |
+
if not is_sqlite and _needs_ssl():
|
| 80 |
+
connect_args["ssl"] = _ssl.create_default_context()
|
| 81 |
_engine = create_async_engine(url, echo=False, connect_args=connect_args)
|
| 82 |
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
| 83 |
async with _engine.begin() as conn:
|
outlook2api/routes.py
CHANGED
|
@@ -7,11 +7,13 @@ import time
|
|
| 7 |
|
| 8 |
from fastapi import APIRouter, HTTPException, Depends
|
| 9 |
from pydantic import BaseModel
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from outlook2api.auth import make_jwt, get_current_user
|
| 12 |
from outlook2api.config import get_config
|
|
|
|
| 13 |
from outlook2api.outlook_imap import fetch_messages_imap, validate_login
|
| 14 |
-
from outlook2api.store import AccountStore, get_store
|
| 15 |
|
| 16 |
router = APIRouter()
|
| 17 |
|
|
@@ -38,7 +40,7 @@ def get_domains():
|
|
| 38 |
|
| 39 |
|
| 40 |
@router.post("/accounts")
|
| 41 |
-
def create_account(body: AccountCreate,
|
| 42 |
address = body.address.strip().lower()
|
| 43 |
password = body.password
|
| 44 |
if not address or "@" not in address:
|
|
@@ -49,22 +51,29 @@ def create_account(body: AccountCreate, store: AccountStore = Depends(get_store)
|
|
| 49 |
raise HTTPException(status_code=400, detail=f"Domain {domain} not supported")
|
| 50 |
if not validate_login(address, password):
|
| 51 |
raise HTTPException(status_code=401, detail="Invalid credentials or IMAP disabled")
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
return {"id": f"/accounts/{secrets.token_hex(8)}", "address": address, "createdAt": time.time()}
|
| 54 |
|
| 55 |
|
| 56 |
@router.post("/token")
|
| 57 |
-
def get_token(body: TokenRequest,
|
| 58 |
address = body.address.strip().lower()
|
| 59 |
password = body.password
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 63 |
-
store.add(address, password)
|
| 64 |
else:
|
| 65 |
-
|
| 66 |
-
if
|
| 67 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
|
|
|
|
|
| 68 |
secret = get_config().get("jwt_secret", "change-me-in-production")
|
| 69 |
token = make_jwt(address, password, secret)
|
| 70 |
return {"token": token, "id": address}
|
|
@@ -118,8 +127,11 @@ async def get_message_code(
|
|
| 118 |
|
| 119 |
@router.delete("/accounts/me")
|
| 120 |
async def delete_account(
|
| 121 |
-
|
| 122 |
user: tuple[str, str] = Depends(get_current_user),
|
| 123 |
):
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
| 125 |
return {"status": "deleted"}
|
|
|
|
| 7 |
|
| 8 |
from fastapi import APIRouter, HTTPException, Depends
|
| 9 |
from pydantic import BaseModel
|
| 10 |
+
from sqlalchemy import select
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
|
| 13 |
from outlook2api.auth import make_jwt, get_current_user
|
| 14 |
from outlook2api.config import get_config
|
| 15 |
+
from outlook2api.database import Account, get_db
|
| 16 |
from outlook2api.outlook_imap import fetch_messages_imap, validate_login
|
|
|
|
| 17 |
|
| 18 |
router = APIRouter()
|
| 19 |
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
@router.post("/accounts")
|
| 43 |
+
async def create_account(body: AccountCreate, db: AsyncSession = Depends(get_db)):
|
| 44 |
address = body.address.strip().lower()
|
| 45 |
password = body.password
|
| 46 |
if not address or "@" not in address:
|
|
|
|
| 51 |
raise HTTPException(status_code=400, detail=f"Domain {domain} not supported")
|
| 52 |
if not validate_login(address, password):
|
| 53 |
raise HTTPException(status_code=401, detail="Invalid credentials or IMAP disabled")
|
| 54 |
+
# Add to database if not exists
|
| 55 |
+
existing = (await db.execute(select(Account).where(Account.email == address))).scalar_one_or_none()
|
| 56 |
+
if not existing:
|
| 57 |
+
db.add(Account(email=address, password=password, source="api"))
|
| 58 |
+
await db.commit()
|
| 59 |
return {"id": f"/accounts/{secrets.token_hex(8)}", "address": address, "createdAt": time.time()}
|
| 60 |
|
| 61 |
|
| 62 |
@router.post("/token")
|
| 63 |
+
async def get_token(body: TokenRequest, db: AsyncSession = Depends(get_db)):
|
| 64 |
address = body.address.strip().lower()
|
| 65 |
password = body.password
|
| 66 |
+
# Check database first
|
| 67 |
+
account = (await db.execute(select(Account).where(Account.email == address))).scalar_one_or_none()
|
| 68 |
+
if account:
|
| 69 |
+
if account.password != password:
|
| 70 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
|
|
| 71 |
else:
|
| 72 |
+
# Not in database — validate via IMAP and add
|
| 73 |
+
if not validate_login(address, password):
|
| 74 |
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 75 |
+
db.add(Account(email=address, password=password, source="api"))
|
| 76 |
+
await db.commit()
|
| 77 |
secret = get_config().get("jwt_secret", "change-me-in-production")
|
| 78 |
token = make_jwt(address, password, secret)
|
| 79 |
return {"token": token, "id": address}
|
|
|
|
| 127 |
|
| 128 |
@router.delete("/accounts/me")
|
| 129 |
async def delete_account(
|
| 130 |
+
db: AsyncSession = Depends(get_db),
|
| 131 |
user: tuple[str, str] = Depends(get_current_user),
|
| 132 |
):
|
| 133 |
+
account = (await db.execute(select(Account).where(Account.email == user[0]))).scalar_one_or_none()
|
| 134 |
+
if account:
|
| 135 |
+
await db.delete(account)
|
| 136 |
+
await db.commit()
|
| 137 |
return {"status": "deleted"}
|