|
10 | 10 | from pydantic import BaseModel, Field |
11 | 11 |
|
12 | 12 | from inboxanchor.api.v1.routers.auth import router as auth_router |
13 | | -from inboxanchor.api.v1.routers.frontend import ( |
14 | | - mark_frontend_provider_dirty, |
15 | | -) |
16 | | -from inboxanchor.api.v1.routers.frontend import ( |
17 | | - router as frontend_router, |
18 | | -) |
| 13 | +from inboxanchor.api.v1.routers.frontend import mark_frontend_provider_dirty |
| 14 | +from inboxanchor.api.v1.routers.frontend import router as frontend_router |
19 | 15 | from inboxanchor.api.v1.routers.oauth import router as oauth_router |
20 | 16 | from inboxanchor.api.v1.routers.webhooks import router as webhook_router |
21 | 17 | from inboxanchor.bootstrap import InboxAnchorService, list_provider_profiles |
| 18 | +from inboxanchor.connectors.imap_transport import ( |
| 19 | + IMAPAuthenticationError, |
| 20 | + IMAPFolderError, |
| 21 | + ImaplibTransport, |
| 22 | + IMAPTransportError, |
| 23 | +) |
22 | 24 | from inboxanchor.infra.auth import AuthService |
23 | 25 | from inboxanchor.infra.database import session_scope |
24 | 26 | from inboxanchor.infra.repository import InboxRepository |
@@ -138,6 +140,79 @@ def _service_for_request(provider_name: Optional[str] = None) -> InboxAnchorServ |
138 | 140 | return InboxAnchorService(provider_name=provider_name) |
139 | 141 |
|
140 | 142 |
|
| 143 | +def _imap_auth_failure_message(provider: str) -> str: |
| 144 | + if provider == "yahoo": |
| 145 | + return ( |
| 146 | + "Yahoo rejected the IMAP login. Reconnect with a Yahoo app password from " |
| 147 | + "Yahoo Account Security; normal Yahoo passwords usually do not work for IMAP." |
| 148 | + ) |
| 149 | + if provider == "outlook": |
| 150 | + return ( |
| 151 | + "Outlook rejected the IMAP login. Reconnect with the mailbox username and an " |
| 152 | + "Outlook app password or provider-specific IMAP credential." |
| 153 | + ) |
| 154 | + return ( |
| 155 | + "InboxAnchor could not log into the IMAP mailbox. Reconnect with the mailbox " |
| 156 | + "username and the correct IMAP or app password." |
| 157 | + ) |
| 158 | + |
| 159 | + |
| 160 | +def _validate_imap_connection( |
| 161 | + provider: str, |
| 162 | + *, |
| 163 | + state: IMAPConnectionState, |
| 164 | + password: str, |
| 165 | +) -> None: |
| 166 | + if not state.host.strip(): |
| 167 | + raise HTTPException(status_code=400, detail="Enter the IMAP host before connecting.") |
| 168 | + if not state.username.strip(): |
| 169 | + raise HTTPException( |
| 170 | + status_code=400, |
| 171 | + detail="Enter the mailbox username before connecting the IMAP provider.", |
| 172 | + ) |
| 173 | + if not password.strip(): |
| 174 | + raise HTTPException( |
| 175 | + status_code=400, |
| 176 | + detail=( |
| 177 | + "Enter the mailbox password or app password before connecting the IMAP provider." |
| 178 | + ), |
| 179 | + ) |
| 180 | + |
| 181 | + transport = ImaplibTransport( |
| 182 | + state.host, |
| 183 | + state.port, |
| 184 | + state.username, |
| 185 | + password, |
| 186 | + use_ssl=state.use_ssl, |
| 187 | + mailbox=state.mailbox, |
| 188 | + provider_name=provider, |
| 189 | + archive_mailbox=state.archive_mailbox or None, |
| 190 | + trash_mailbox=state.trash_mailbox or None, |
| 191 | + ) |
| 192 | + try: |
| 193 | + transport._connect() |
| 194 | + except IMAPAuthenticationError as exc: |
| 195 | + raise HTTPException(status_code=400, detail=_imap_auth_failure_message(provider)) from exc |
| 196 | + except IMAPFolderError as exc: |
| 197 | + raise HTTPException( |
| 198 | + status_code=400, |
| 199 | + detail=( |
| 200 | + f"InboxAnchor logged into the mailbox, but could not open '{state.mailbox}'. " |
| 201 | + "Check the mailbox name and try again." |
| 202 | + ), |
| 203 | + ) from exc |
| 204 | + except IMAPTransportError as exc: |
| 205 | + raise HTTPException( |
| 206 | + status_code=502, |
| 207 | + detail=( |
| 208 | + f"InboxAnchor could not reach the live {provider.upper()} IMAP server. " |
| 209 | + "Check the host, port, and backend network access, then try again." |
| 210 | + ), |
| 211 | + ) from exc |
| 212 | + finally: |
| 213 | + transport.close() |
| 214 | + |
| 215 | + |
141 | 216 | def _cors_origins() -> list[str]: |
142 | 217 | configured = os.getenv("INBOXANCHOR_CORS_ORIGINS", "").strip() |
143 | 218 | if configured: |
@@ -272,16 +347,46 @@ def save_provider_connection( |
272 | 347 | if provider in {"imap", "yahoo", "outlook"} and payload.imap is not None: |
273 | 348 | with session_scope() as session: |
274 | 349 | repository = InboxRepository(session) |
| 350 | + existing_state = repository.get_provider_connection(provider) |
275 | 351 | existing_secret = repository.get_provider_secret(provider) |
| 352 | + previous_username = ( |
| 353 | + existing_state.imap.username.strip().lower() |
| 354 | + if existing_state.imap is not None |
| 355 | + else "" |
| 356 | + ) |
| 357 | + next_username = payload.imap.username.strip().lower() |
| 358 | + switching_mailbox = bool( |
| 359 | + previous_username |
| 360 | + and next_username |
| 361 | + and previous_username != next_username |
| 362 | + ) |
276 | 363 | next_password = str(existing_secret.get("password") or "") |
277 | 364 | if payload.imap.clear_password: |
278 | 365 | next_password = "" |
279 | 366 | elif payload.imap.password.strip(): |
280 | 367 | next_password = payload.imap.password.strip() |
| 368 | + elif switching_mailbox: |
| 369 | + raise HTTPException( |
| 370 | + status_code=400, |
| 371 | + detail=( |
| 372 | + "You changed the IMAP mailbox username. Enter the new Yahoo or IMAP " |
| 373 | + "app password as well so InboxAnchor does not reuse the previous " |
| 374 | + "account's secret." |
| 375 | + ), |
| 376 | + ) |
| 377 | + if payload.sync_enabled: |
| 378 | + _validate_imap_connection( |
| 379 | + provider, |
| 380 | + state=imap_state, |
| 381 | + password=next_password, |
| 382 | + ) |
281 | 383 | if next_password: |
282 | 384 | repository.save_provider_secret(provider, {"password": next_password}) |
283 | 385 | else: |
284 | 386 | repository.clear_provider_secret(provider) |
| 387 | + disconnecting_mailbox = payload.imap.clear_password and not payload.sync_enabled |
| 388 | + if switching_mailbox or disconnecting_mailbox: |
| 389 | + repository.reset_provider_runtime_state(provider) |
285 | 390 | saved = service.save_provider_connection(state) |
286 | 391 | mark_frontend_provider_dirty(provider) |
287 | 392 | return saved.model_dump(mode="json") |
|
0 commit comments