diff --git a/app/imap_client.py b/app/imap_client.py index a2f181e..ba8ef13 100644 --- a/app/imap_client.py +++ b/app/imap_client.py @@ -3,9 +3,11 @@ from __future__ import annotations import email import imaplib import logging +import socket from dataclasses import dataclass from datetime import date from email.message import Message +from typing import Any from app.config import InboxConfig @@ -24,6 +26,48 @@ class IMAPMessage: message: Message +def _resolved_addresses(host: str, port: int) -> list[tuple[Any, tuple]]: + return [(family, sockaddr) for family, _, _, _, sockaddr in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)] + + +class _ResolvedIMAP4(imaplib.IMAP4): + def __init__(self, host: str, port: int, addresses: list[tuple[Any, tuple]]): + self._resolved_addresses = addresses + super().__init__(host, port) + + def _create_socket(self, timeout): + last_error: Exception | None = None + for family, sockaddr in self._resolved_addresses: + try: + if timeout is not None: + return socket.create_connection(sockaddr, timeout) + return socket.create_connection(sockaddr) + except OSError as exc: + last_error = exc + logger.warning("IMAP connect failed for %s via %s: %s", self.host, sockaddr[0], exc) + raise IMAPError(f"Could not connect to IMAP host {self.host}: {last_error}") + + +class _ResolvedIMAP4SSL(imaplib.IMAP4_SSL): + def __init__(self, host: str, port: int, addresses: list[tuple[Any, tuple]]): + self._resolved_addresses = addresses + super().__init__(host, port) + + def _create_socket(self, timeout): + last_error: Exception | None = None + for family, sockaddr in self._resolved_addresses: + try: + if timeout is not None: + sock = socket.create_connection(sockaddr, timeout) + else: + sock = socket.create_connection(sockaddr) + return self.ssl_context.wrap_socket(sock, server_hostname=self.host) + except OSError as exc: + last_error = exc + logger.warning("IMAP SSL connect failed for %s via %s: %s", self.host, sockaddr[0], exc) + raise IMAPError(f"Could not connect to IMAP host {self.host}: {last_error}") + + class IMAPClient: def __init__(self, inbox: InboxConfig): self.inbox = inbox @@ -32,8 +76,13 @@ class IMAPClient: def __enter__(self) -> "IMAPClient": if not self.inbox.username or not self.inbox.password: raise IMAPError(f"Missing IMAP credentials for inbox {self.inbox.id}") - cls = imaplib.IMAP4_SSL if self.inbox.imap_ssl else imaplib.IMAP4 - self.conn = cls(self.inbox.imap_host, self.inbox.imap_port) + try: + addresses = _resolved_addresses(self.inbox.imap_host, self.inbox.imap_port) + except socket.gaierror as exc: + raise IMAPError(f"Could not resolve IMAP host {self.inbox.imap_host!r} for inbox {self.inbox.id}: {exc}") from exc + logger.info("IMAP host resolved for inbox %s: %r -> %s", self.inbox.id, self.inbox.imap_host, [item[1][0] for item in addresses]) + cls = _ResolvedIMAP4SSL if self.inbox.imap_ssl else _ResolvedIMAP4 + self.conn = cls(self.inbox.imap_host, self.inbox.imap_port, addresses) typ, _ = self.conn.login(self.inbox.username, self.inbox.password) if typ != "OK": raise IMAPError(f"IMAP login failed for {self.inbox.id}") diff --git a/app/templates/inboxes.html b/app/templates/inboxes.html index 701adbc..4d77b0b 100644 --- a/app/templates/inboxes.html +++ b/app/templates/inboxes.html @@ -274,16 +274,40 @@ result.className = "inbox-action-result is-error"; result.textContent = job.error || "Processing failed."; renderDuplicateReports(inboxId, []); + progress.classList.remove("is-active", "is-indeterminate"); + fill.style.width = "0%"; } return running; }; const pollJob = async (jobId, inboxId) => { - const response = await fetch(`/api/admin/import-jobs/${jobId}`, { credentials: "same-origin" }); - const job = await response.json(); - if (renderJob(job, inboxId)) { - window.setTimeout(() => pollJob(jobId, inboxId), 2000); - } else { + try { + const response = await fetch(`/api/admin/import-jobs/${jobId}`, { credentials: "same-origin" }); + if (!response.ok) { + throw new Error("Import job status is unavailable."); + } + const job = await response.json(); + if (renderJob(job, inboxId)) { + window.setTimeout(() => pollJob(jobId, inboxId), 2000); + } else { + renderInboxStatus(inboxId); + } + } catch (error) { + const result = document.getElementById(`inbox-action-result-${inboxId}`); + const progress = document.getElementById(`inbox-progress-${inboxId}`); + const fill = progress?.querySelector("span"); + const buttons = document.querySelectorAll(`.js-inbox-action[data-inbox-id="${inboxId}"]`); + buttons.forEach((button) => { button.disabled = false; }); + if (progress) { + progress.classList.remove("is-active", "is-indeterminate"); + } + if (fill) { + fill.style.width = "0%"; + } + if (result) { + result.className = "inbox-action-result is-error"; + result.textContent = error.message || "Import job status is unavailable."; + } renderInboxStatus(inboxId); } };