ohmyapi Claude Opus 4.6 (1M context) commited on
Commit
6a8bc20
·
1 Parent(s): f2310ba

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>

Files changed (3) hide show
  1. Dockerfile +1 -0
  2. outlook2api/database.py +20 -2
  3. 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
- connect_args = {"check_same_thread": False} if "sqlite" in url else {}
 
 
 
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, store: AccountStore = Depends(get_store)):
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
- store.add(address, password)
 
 
 
 
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, store: AccountStore = Depends(get_store)):
58
  address = body.address.strip().lower()
59
  password = body.password
60
- if not store.has(address):
61
- if not validate_login(address, password):
 
 
62
  raise HTTPException(status_code=401, detail="Invalid credentials")
63
- store.add(address, password)
64
  else:
65
- pwd = store.get_password(address)
66
- if pwd != password:
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
- store: AccountStore = Depends(get_store),
122
  user: tuple[str, str] = Depends(get_current_user),
123
  ):
124
- store.remove(user[0])
 
 
 
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"}