Skip to content

Commit 616de55

Browse files
committed
Improve IMAP scaling and refresh README
1 parent 627697a commit 616de55

11 files changed

Lines changed: 1031 additions & 326 deletions

File tree

README.md

Lines changed: 200 additions & 291 deletions
Large diffs are not rendered by default.

inboxanchor/api/main.py

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@
1010
from pydantic import BaseModel, Field
1111

1212
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
1915
from inboxanchor.api.v1.routers.oauth import router as oauth_router
2016
from inboxanchor.api.v1.routers.webhooks import router as webhook_router
2117
from inboxanchor.bootstrap import InboxAnchorService, list_provider_profiles
18+
from inboxanchor.connectors.imap_transport import (
19+
IMAPAuthenticationError,
20+
IMAPFolderError,
21+
ImaplibTransport,
22+
IMAPTransportError,
23+
)
2224
from inboxanchor.infra.auth import AuthService
2325
from inboxanchor.infra.database import session_scope
2426
from inboxanchor.infra.repository import InboxRepository
@@ -138,6 +140,79 @@ def _service_for_request(provider_name: Optional[str] = None) -> InboxAnchorServ
138140
return InboxAnchorService(provider_name=provider_name)
139141

140142

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+
141216
def _cors_origins() -> list[str]:
142217
configured = os.getenv("INBOXANCHOR_CORS_ORIGINS", "").strip()
143218
if configured:
@@ -272,16 +347,46 @@ def save_provider_connection(
272347
if provider in {"imap", "yahoo", "outlook"} and payload.imap is not None:
273348
with session_scope() as session:
274349
repository = InboxRepository(session)
350+
existing_state = repository.get_provider_connection(provider)
275351
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+
)
276363
next_password = str(existing_secret.get("password") or "")
277364
if payload.imap.clear_password:
278365
next_password = ""
279366
elif payload.imap.password.strip():
280367
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+
)
281383
if next_password:
282384
repository.save_provider_secret(provider, {"password": next_password})
283385
else:
284386
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)
285390
saved = service.save_provider_connection(state)
286391
mark_frontend_provider_dirty(provider)
287392
return saved.model_dump(mode="json")

inboxanchor/api/v1/routers/frontend.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,11 +222,24 @@ def _mailbox_progress_target(
222222

223223

224224
def _use_industrial_unread_sync(provider: object, *, time_range: Optional[str] = None) -> bool:
225-
if normalize_time_range(time_range) != ALL_TIME_RANGE:
226-
return False
225+
del time_range
227226
return callable(getattr(provider, "iter_all_unread_batches", None))
228227

229228

229+
def _unread_scan_batch_size(
230+
provider: object | None,
231+
*,
232+
default_batch_size: int,
233+
industrial_unread_mode: bool,
234+
) -> int:
235+
if not industrial_unread_mode:
236+
return min(default_batch_size, 250)
237+
provider_name = str(getattr(provider, "provider_name", "") or "").lower()
238+
if provider_name in {"imap", "yahoo", "outlook"}:
239+
return min(default_batch_size, 250)
240+
return min(default_batch_size, 100)
241+
242+
230243
def _extract_bearer_token(authorization: Optional[str]) -> Optional[str]:
231244
if not authorization:
232245
return None
@@ -445,6 +458,16 @@ def _provider_runtime_error_message(provider_name: str, exc: Exception) -> str:
445458
if provider_name == "gmail":
446459
return f"Gmail connected, but InboxAnchor could not fetch unread mail: {message}"
447460
if provider_name in {"imap", "yahoo", "outlook"}:
461+
if "imap login failed" in lowered or "authenticationfailed" in lowered:
462+
if provider_name == "yahoo":
463+
return (
464+
"Yahoo rejected the IMAP login. Reconnect Yahoo with an app password from "
465+
"Yahoo Account Security; the normal Yahoo password usually will not work here."
466+
)
467+
return (
468+
f"{provider_name.upper()} rejected the IMAP login. Reconnect the mailbox with "
469+
"the correct username and IMAP or app password."
470+
)
448471
return (
449472
f"{provider_name.upper()} connected, but InboxAnchor could not fetch unread "
450473
f"mail: {message}"
@@ -1356,9 +1379,10 @@ def _sync_unread_working_set(
13561379
time_range=normalized_time_range,
13571380
)
13581381
scan_limit = None if industrial_unread_mode else (limit_override or settings.default_scan_limit)
1359-
scan_batch_size = min(
1360-
batch_size_override or settings.default_batch_size,
1361-
100 if industrial_unread_mode else 250,
1382+
scan_batch_size = _unread_scan_batch_size(
1383+
provider,
1384+
default_batch_size=batch_size_override or settings.default_batch_size,
1385+
industrial_unread_mode=industrial_unread_mode,
13621386
)
13631387
wait_job: Optional[FrontendRunJob] = None
13641388
job = pre_registered_job
@@ -2646,6 +2670,10 @@ def _build_ops_overview(
26462670
connection = load_provider_connection(provider_name)
26472671
else:
26482672
connection = ProviderConnectionState(provider=provider_name)
2673+
industrial_unread_mode = _use_industrial_unread_sync(
2674+
getattr(service, "provider", None),
2675+
time_range=normalized_time_range,
2676+
)
26492677
cache_stats = _load_mailbox_cache_stats(provider_name, time_range=normalized_time_range)
26502678
sync_state = _load_mailbox_sync_state(provider_name, time_range=normalized_time_range)
26512679
processed_total = max(
@@ -2743,7 +2771,7 @@ def _build_ops_overview(
27432771
f"Scans the full unread working set in "
27442772
f"{time_range_label(normalized_time_range).lower()}."
27452773
)
2746-
if normalized_time_range == ALL_TIME_RANGE and provider_name == "gmail"
2774+
if industrial_unread_mode
27472775
else (
27482776
f"Scans up to {settings.default_scan_limit} unread emails in "
27492777
f"{time_range_label(normalized_time_range).lower()}."

inboxanchor/bootstrap.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,13 @@ def build_provider(provider_name: Optional[str] = None, *, owner_email: Optional
425425
provider_name,
426426
error,
427427
)
428+
preview_seed_messages = (
429+
[]
430+
if state.status in {"configured", "connected"} or state.sync_enabled
431+
else demo_emails
432+
)
428433
return IMAPEmailClient(
429-
seed_messages=demo_emails,
434+
seed_messages=preview_seed_messages,
430435
provider_name=provider_name,
431436
)
432437
return FakeEmailProvider(demo_emails, provider_name=provider_name)

inboxanchor/connectors/imap_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ def iter_unread_batches(
6161
for start in range(0, len(emails), batch_size):
6262
yield emails[start : start + batch_size]
6363

64+
def iter_all_unread_batches(
65+
self,
66+
*,
67+
batch_size: int = 100,
68+
include_body: bool = True,
69+
time_range: Optional[str] = None,
70+
):
71+
emails = self.list_unread(
72+
limit=0,
73+
include_body=include_body,
74+
time_range=time_range,
75+
)
76+
for start in range(0, len(emails), batch_size):
77+
yield emails[start : start + batch_size]
78+
6479
def iter_mailbox_batches(
6580
self,
6681
*,

0 commit comments

Comments
 (0)