feat: add mailbox test diagnostics
This commit is contained in:
@@ -143,6 +143,161 @@ def _redact_sensitive_text(value: object) -> str:
|
|||||||
return redact_sensitive_text(value)
|
return redact_sensitive_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
DIAGNOSTIC_COPY: Dict[str, Dict[str, Any]] = {
|
||||||
|
"ok": {
|
||||||
|
"summary": "Connection test completed successfully.",
|
||||||
|
"recovery_steps": [],
|
||||||
|
},
|
||||||
|
"auth_required": {
|
||||||
|
"summary": "The mailbox has not been connected yet.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Use the Connect Gmail action to complete authorization.",
|
||||||
|
"Confirm the authorized mailbox is the one that receives DMARC aggregate reports.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"auth_expired": {
|
||||||
|
"summary": "The saved authorization is expired, revoked, or no longer accepted.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Reconnect the mailbox from Mail Sources.",
|
||||||
|
"If your provider shows a consent screen, approve Gmail read-only access again.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"authentication": {
|
||||||
|
"summary": "The server rejected the username, password, app password, or OAuth token.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Verify the username and use an app-specific password when the provider requires one.",
|
||||||
|
"Reconnect OAuth sources if the provider recently changed account security settings.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"summary": "The account is connected but does not have enough mailbox access.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Grant read access for the mailbox that receives DMARC reports.",
|
||||||
|
"For Gmail, reconnect and approve the requested Gmail read-only scope.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"connectivity": {
|
||||||
|
"summary": "DMARQ could not reach the mail server reliably.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Check the server hostname, port, TLS setting, and any firewall allowlists.",
|
||||||
|
"Use port 993 with SSL for most IMAP providers.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"mailbox_not_found": {
|
||||||
|
"summary": "The configured mailbox folder could not be opened.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Choose one of the available mailbox names returned by the test.",
|
||||||
|
"Check capitalization and nested folder separators such as Archive/DMARC.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"throttling": {
|
||||||
|
"summary": "The mail provider is rate limiting or temporarily refusing requests.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Wait a few minutes and retry the test.",
|
||||||
|
"Increase the polling interval if repeated imports trigger provider limits.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"missing_config": {
|
||||||
|
"summary": "Required connection settings are missing.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Fill in the server, username, and password or complete Gmail authorization.",
|
||||||
|
"Save the source before running stored-source tests.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"not_implemented": {
|
||||||
|
"summary": "This connection method cannot be tested from this screen yet.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Use IMAP or Gmail API for mailbox ingestion.",
|
||||||
|
"Keep unsupported sources disabled until a test path is implemented.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"unknown": {
|
||||||
|
"summary": "The connection failed, but DMARQ could not classify the provider response.",
|
||||||
|
"recovery_steps": [
|
||||||
|
"Retry the test once to rule out a transient provider issue.",
|
||||||
|
"Check the latest import history and server logs for the sanitized provider response.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic_category(message: str, details: Optional[object] = None) -> str:
|
||||||
|
"""Map provider-specific failures to operator-friendly categories."""
|
||||||
|
text = f"{message} {_redact_sensitive_text(details or '')}".lower()
|
||||||
|
if any(term in text for term in ("not yet authorised", "not yet authorized", "complete oauth")):
|
||||||
|
return "auth_required"
|
||||||
|
if any(
|
||||||
|
term in text for term in ("expired", "revoked", "invalid_grant", "refresh token", "oauth")
|
||||||
|
):
|
||||||
|
return "auth_expired"
|
||||||
|
if any(term in text for term in ("rate", "quota", "throttl", "too many", "429")):
|
||||||
|
return "throttling"
|
||||||
|
if any(term in text for term in ("scope", "permission", "access denied", "insufficient")):
|
||||||
|
return "permissions"
|
||||||
|
if any(term in text for term in ("mailbox", "folder", "select failed", "does not exist")):
|
||||||
|
return "mailbox_not_found"
|
||||||
|
if any(term in text for term in ("credential", "password", "auth", "login", "invalid token")):
|
||||||
|
return "authentication"
|
||||||
|
if any(
|
||||||
|
term in text
|
||||||
|
for term in (
|
||||||
|
"timeout",
|
||||||
|
"timed out",
|
||||||
|
"dns",
|
||||||
|
"resolve",
|
||||||
|
"refused",
|
||||||
|
"network",
|
||||||
|
"ssl",
|
||||||
|
"certificate",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return "connectivity"
|
||||||
|
if "not yet implemented" in text:
|
||||||
|
return "not_implemented"
|
||||||
|
if any(term in text for term in ("not fully configured", "missing", "required")):
|
||||||
|
return "missing_config"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _connection_diagnostic(
|
||||||
|
success: bool, message: str, details: Optional[object] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Build sanitized connection diagnostics for API responses and UI recovery copy."""
|
||||||
|
category = "ok" if success else _diagnostic_category(message, details)
|
||||||
|
diagnostic_copy = DIAGNOSTIC_COPY[category]
|
||||||
|
diagnostic: Dict[str, Any] = {
|
||||||
|
"category": category,
|
||||||
|
"summary": diagnostic_copy["summary"],
|
||||||
|
"recovery_steps": diagnostic_copy["recovery_steps"],
|
||||||
|
}
|
||||||
|
if details and not success:
|
||||||
|
diagnostic["details"] = _redact_sensitive_text(details)
|
||||||
|
return diagnostic
|
||||||
|
|
||||||
|
|
||||||
|
def _connection_test_response(
|
||||||
|
success: bool,
|
||||||
|
message: str,
|
||||||
|
stats: Optional[Dict[str, Any]] = None,
|
||||||
|
details: Optional[object] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Normalize stored and ad-hoc mailbox test responses."""
|
||||||
|
stats = stats or {}
|
||||||
|
diagnostic = _connection_diagnostic(success, message, details or stats.get("diagnostic_detail"))
|
||||||
|
return {
|
||||||
|
"success": success,
|
||||||
|
"message": _redact_sensitive_text(message),
|
||||||
|
"message_count": stats.get("message_count", 0),
|
||||||
|
"unread_count": stats.get("unread_count", 0),
|
||||||
|
"dmarc_count": stats.get("dmarc_count", 0),
|
||||||
|
"available_mailboxes": stats.get("available_mailboxes", []),
|
||||||
|
"diagnostic": diagnostic,
|
||||||
|
"diagnostic_category": diagnostic["category"],
|
||||||
|
"recovery_steps": diagnostic["recovery_steps"],
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_source_or_404(source_id: int, db: Session) -> MailSource:
|
def _get_source_or_404(source_id: int, db: Session) -> MailSource:
|
||||||
source = db.query(MailSource).filter(MailSource.id == source_id).first()
|
source = db.query(MailSource).filter(MailSource.id == source_id).first()
|
||||||
if source is None:
|
if source is None:
|
||||||
@@ -479,12 +634,11 @@ async def test_stored_mail_source(
|
|||||||
|
|
||||||
if source.method == "GMAIL_API":
|
if source.method == "GMAIL_API":
|
||||||
if not source.gmail_access_token:
|
if not source.gmail_access_token:
|
||||||
return {
|
return _connection_test_response(
|
||||||
"success": False,
|
False,
|
||||||
"message": "Gmail API source is not yet authorised. "
|
"Gmail API source is not yet authorised. "
|
||||||
"Use the 'Connect Gmail' button to complete OAuth2 authorisation.",
|
"Use the Connect Gmail button to complete OAuth2 authorisation.",
|
||||||
"timestamp": datetime.now().isoformat(),
|
)
|
||||||
}
|
|
||||||
try:
|
try:
|
||||||
gmail_client = GmailClient(
|
gmail_client = GmailClient(
|
||||||
client_id=source.gmail_client_id or "",
|
client_id=source.gmail_client_id or "",
|
||||||
@@ -497,29 +651,27 @@ async def test_stored_mail_source(
|
|||||||
service.users().getProfile(userId="me").execute()
|
service.users().getProfile(userId="me").execute()
|
||||||
source.last_checked = datetime.utcnow()
|
source.last_checked = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return _connection_test_response(
|
||||||
"success": True,
|
True,
|
||||||
"message": f"Gmail API credentials are valid (account: {source.gmail_email or 'unknown'}).",
|
f"Gmail API credentials are valid (account: {source.gmail_email or 'unknown'}).",
|
||||||
"timestamp": datetime.now().isoformat(),
|
)
|
||||||
}
|
|
||||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
logger.error(
|
logger.error(
|
||||||
"Gmail API test failed for source id=%d: %s",
|
"Gmail API test failed for source id=%d: %s",
|
||||||
int(source_id),
|
int(source_id),
|
||||||
_redact_sensitive_text(exc),
|
_redact_sensitive_text(exc),
|
||||||
)
|
)
|
||||||
return {
|
return _connection_test_response(
|
||||||
"success": False,
|
False,
|
||||||
"message": "Gmail API test failed. Check server logs for details.",
|
"Gmail API test failed. The saved authorization may need attention.",
|
||||||
"timestamp": datetime.now().isoformat(),
|
details=exc,
|
||||||
}
|
)
|
||||||
|
|
||||||
if source.method != "IMAP":
|
if source.method != "IMAP":
|
||||||
return {
|
return _connection_test_response(
|
||||||
"success": False,
|
False,
|
||||||
"message": f"Connection testing for method '{source.method}' is not yet implemented.",
|
f"Connection testing for method '{source.method}' is not yet implemented.",
|
||||||
"timestamp": datetime.now().isoformat(),
|
)
|
||||||
}
|
|
||||||
|
|
||||||
imap_client = IMAPClient(
|
imap_client = IMAPClient(
|
||||||
server=source.server,
|
server=source.server,
|
||||||
@@ -534,15 +686,7 @@ async def test_stored_mail_source(
|
|||||||
source.last_checked = datetime.utcnow()
|
source.last_checked = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {
|
return _connection_test_response(success, message, stats)
|
||||||
"success": success,
|
|
||||||
"message": message,
|
|
||||||
"message_count": stats.get("message_count", 0),
|
|
||||||
"unread_count": stats.get("unread_count", 0),
|
|
||||||
"dmarc_count": stats.get("dmarc_count", 0),
|
|
||||||
"available_mailboxes": stats.get("available_mailboxes", []),
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-connection", response_model=Dict[str, Any])
|
@router.post("/test-connection", response_model=Dict[str, Any])
|
||||||
@@ -558,11 +702,10 @@ async def test_connection_adhoc(
|
|||||||
method = request.method.upper()
|
method = request.method.upper()
|
||||||
|
|
||||||
if method != "IMAP":
|
if method != "IMAP":
|
||||||
return {
|
return _connection_test_response(
|
||||||
"success": False,
|
False,
|
||||||
"message": f"Connection testing for method '{method}' is not yet implemented.",
|
f"Connection testing for method '{method}' is not yet implemented.",
|
||||||
"timestamp": datetime.now().isoformat(),
|
)
|
||||||
}
|
|
||||||
|
|
||||||
imap_client = IMAPClient(
|
imap_client = IMAPClient(
|
||||||
server=request.server,
|
server=request.server,
|
||||||
@@ -572,15 +715,7 @@ async def test_connection_adhoc(
|
|||||||
)
|
)
|
||||||
success, message, stats = imap_client.test_connection()
|
success, message, stats = imap_client.test_connection()
|
||||||
|
|
||||||
return {
|
return _connection_test_response(success, message, stats)
|
||||||
"success": success,
|
|
||||||
"message": message,
|
|
||||||
"message_count": stats.get("message_count", 0),
|
|
||||||
"unread_count": stats.get("unread_count", 0),
|
|
||||||
"dmarc_count": stats.get("dmarc_count", 0),
|
|
||||||
"available_mailboxes": stats.get("available_mailboxes", []),
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -102,7 +102,11 @@ class IMAPClient:
|
|||||||
- stats: Dictionary with mailbox statistics (if successful)
|
- stats: Dictionary with mailbox statistics (if successful)
|
||||||
"""
|
"""
|
||||||
if not all([self.server, self.username, self.password]):
|
if not all([self.server, self.username, self.password]):
|
||||||
return False, "IMAP credentials not fully configured", {}
|
return (
|
||||||
|
False,
|
||||||
|
"IMAP credentials not fully configured.",
|
||||||
|
{"diagnostic_detail": "missing server, username, or password"},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create IMAP4 connection
|
# Create IMAP4 connection
|
||||||
@@ -119,7 +123,17 @@ class IMAPClient:
|
|||||||
message_count = 0
|
message_count = 0
|
||||||
unread_count = 0
|
unread_count = 0
|
||||||
|
|
||||||
if status == "OK":
|
if status != "OK":
|
||||||
|
mail.logout()
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"Configured mailbox folder could not be opened.",
|
||||||
|
{
|
||||||
|
"available_mailboxes": available_mailboxes,
|
||||||
|
"diagnostic_detail": f"select failed for folder {self.folder}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
message_count = int(data[0])
|
message_count = int(data[0])
|
||||||
|
|
||||||
# Count unread messages
|
# Count unread messages
|
||||||
@@ -148,9 +162,27 @@ class IMAPClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return True, "Connection successful", stats
|
return True, "Connection successful", stats
|
||||||
|
except imaplib.IMAP4.error as e:
|
||||||
|
logger.error("IMAP connection test failed: %s", str(e))
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"IMAP authentication failed or the mailbox server rejected the request.",
|
||||||
|
{"diagnostic_detail": str(e)},
|
||||||
|
)
|
||||||
|
except (TimeoutError, OSError) as e:
|
||||||
|
logger.error("IMAP connection test failed: %s", str(e))
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"Could not reach the IMAP server.",
|
||||||
|
{"diagnostic_detail": str(e)},
|
||||||
|
)
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.error("IMAP connection test failed: %s", str(e))
|
logger.error("IMAP connection test failed: %s", str(e))
|
||||||
return False, "Connection failed. Check server address and credentials.", {}
|
return (
|
||||||
|
False,
|
||||||
|
"Connection failed. Check mailbox settings and try again.",
|
||||||
|
{"diagnostic_detail": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
def _process_single_email(self, mail, email_id: bytes, stats: dict) -> None:
|
def _process_single_email(self, mail, email_id: bytes, stats: dict) -> None:
|
||||||
"""Fetch, parse, and store DMARC attachments from one email message."""
|
"""Fetch, parse, and store DMARC attachments from one email message."""
|
||||||
|
|||||||
@@ -33,12 +33,23 @@
|
|||||||
<!-- Feedback alerts -->
|
<!-- Feedback alerts -->
|
||||||
<template x-if="feedback.message && feedback.type === 'success'">
|
<template x-if="feedback.message && feedback.type === 'success'">
|
||||||
{% call alert(variant="success") %}
|
{% call alert(variant="success") %}
|
||||||
{% call alert_description() %}<span x-text="feedback.message"></span>{% endcall %}
|
{% call alert_description() %}
|
||||||
|
<span x-text="feedback.message"></span>
|
||||||
|
<div class="mt-2 text-xs" x-show="feedback.diagnostic_summary" x-text="feedback.diagnostic_summary"></div>
|
||||||
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
</template>
|
</template>
|
||||||
<template x-if="feedback.message && feedback.type === 'error'">
|
<template x-if="feedback.message && feedback.type === 'error'">
|
||||||
{% call alert(variant="error") %}
|
{% call alert(variant="error") %}
|
||||||
{% call alert_description() %}<span x-text="feedback.message"></span>{% endcall %}
|
{% call alert_description() %}
|
||||||
|
<span x-text="feedback.message"></span>
|
||||||
|
<div class="mt-2 text-xs font-medium" x-show="feedback.diagnostic_summary" x-text="feedback.diagnostic_summary"></div>
|
||||||
|
<ul class="mt-2 list-disc pl-5 text-xs space-y-1" x-show="feedback.recovery_steps && feedback.recovery_steps.length">
|
||||||
|
<template x-for="step in feedback.recovery_steps" :key="step">
|
||||||
|
<li x-text="step"></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -440,7 +451,15 @@
|
|||||||
<!-- Test connection result -->
|
<!-- Test connection result -->
|
||||||
<template x-if="testResult.message">
|
<template x-if="testResult.message">
|
||||||
<div :class="testResult.success ? 'alert alert-success' : 'alert alert-error'" class="text-sm p-3 rounded">
|
<div :class="testResult.success ? 'alert alert-success' : 'alert alert-error'" class="text-sm p-3 rounded">
|
||||||
|
<div>
|
||||||
<span x-text="testResult.message"></span>
|
<span x-text="testResult.message"></span>
|
||||||
|
<div class="mt-2 text-xs font-medium" x-show="testResult.diagnostic_summary" x-text="testResult.diagnostic_summary"></div>
|
||||||
|
<ul class="mt-2 list-disc pl-5 text-xs space-y-1" x-show="testResult.recovery_steps && testResult.recovery_steps.length">
|
||||||
|
<template x-for="step in testResult.recovery_steps" :key="step">
|
||||||
|
<li x-text="step"></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -554,8 +573,8 @@ function mailSourcesApp() {
|
|||||||
historySource: null,
|
historySource: null,
|
||||||
importHistory: [],
|
importHistory: [],
|
||||||
historyLoading: false,
|
historyLoading: false,
|
||||||
feedback: { message: '', type: '' },
|
feedback: { message: '', type: '', diagnostic_summary: '', recovery_steps: [] },
|
||||||
testResult: { message: '', success: false },
|
testResult: { message: '', success: false, diagnostic_summary: '', recovery_steps: [] },
|
||||||
gmailConnected: false,
|
gmailConnected: false,
|
||||||
gmailEmail: '',
|
gmailEmail: '',
|
||||||
|
|
||||||
@@ -699,6 +718,22 @@ function mailSourcesApp() {
|
|||||||
return 'badge-outline';
|
return 'badge-outline';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
emptyTestResult() {
|
||||||
|
return { message: '', success: false, diagnostic_summary: '', recovery_steps: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
emptyFeedback() {
|
||||||
|
return { message: '', type: '', diagnostic_summary: '', recovery_steps: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
diagnosticFromResult(result) {
|
||||||
|
const diagnostic = result.diagnostic || {};
|
||||||
|
return {
|
||||||
|
diagnostic_summary: diagnostic.summary || '',
|
||||||
|
recovery_steps: result.recovery_steps || diagnostic.recovery_steps || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
openAddForm() {
|
openAddForm() {
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
this.gmailConnected = false;
|
this.gmailConnected = false;
|
||||||
@@ -717,7 +752,7 @@ function mailSourcesApp() {
|
|||||||
gmail_client_id: '',
|
gmail_client_id: '',
|
||||||
gmail_client_secret: '',
|
gmail_client_secret: '',
|
||||||
};
|
};
|
||||||
this.testResult = { message: '', success: false };
|
this.testResult = this.emptyTestResult();
|
||||||
this.showForm = true;
|
this.showForm = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -739,14 +774,14 @@ function mailSourcesApp() {
|
|||||||
gmail_client_id: source.gmail_client_id || '',
|
gmail_client_id: source.gmail_client_id || '',
|
||||||
gmail_client_secret: '', // never pre-fill client secret
|
gmail_client_secret: '', // never pre-fill client secret
|
||||||
};
|
};
|
||||||
this.testResult = { message: '', success: false };
|
this.testResult = this.emptyTestResult();
|
||||||
this.showForm = true;
|
this.showForm = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
closeForm() {
|
closeForm() {
|
||||||
this.showForm = false;
|
this.showForm = false;
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
this.testResult = { message: '', success: false };
|
this.testResult = this.emptyTestResult();
|
||||||
},
|
},
|
||||||
|
|
||||||
async connectGmail() {
|
async connectGmail() {
|
||||||
@@ -869,20 +904,23 @@ function mailSourcesApp() {
|
|||||||
|
|
||||||
async testSource(id) {
|
async testSource(id) {
|
||||||
this.testing[id] = true;
|
this.testing[id] = true;
|
||||||
this.feedback = { message: '', type: '' };
|
this.feedback = this.emptyFeedback();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/v1/mail-sources/${id}/test`, { method: 'POST' });
|
const resp = await fetch(`/api/v1/mail-sources/${id}/test`, { method: 'POST' });
|
||||||
const result = await resp.json();
|
const result = await resp.json();
|
||||||
|
const diagnostic = this.diagnosticFromResult(result);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
this.feedback = {
|
this.feedback = {
|
||||||
message: `Connection test successful for source #${id}: ${result.message}`,
|
message: `Connection test successful for source #${id}: ${result.message}`,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
...diagnostic,
|
||||||
};
|
};
|
||||||
await this.loadSources();
|
await this.loadSources();
|
||||||
} else {
|
} else {
|
||||||
this.feedback = {
|
this.feedback = {
|
||||||
message: `Connection test failed for source #${id}: ${result.message}`,
|
message: `Connection test failed for source #${id}: ${result.message}`,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
...diagnostic,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -894,7 +932,7 @@ function mailSourcesApp() {
|
|||||||
|
|
||||||
async testAdHoc() {
|
async testAdHoc() {
|
||||||
this.isTesting = true;
|
this.isTesting = true;
|
||||||
this.testResult = { message: '', success: false };
|
this.testResult = this.emptyTestResult();
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
server: this.form.server,
|
server: this.form.server,
|
||||||
@@ -910,11 +948,13 @@ function mailSourcesApp() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const result = await resp.json();
|
const result = await resp.json();
|
||||||
|
const diagnostic = this.diagnosticFromResult(result);
|
||||||
this.testResult = {
|
this.testResult = {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
message: result.success
|
message: result.success
|
||||||
? `✓ Connected. ${result.message_count || 0} messages, ${result.dmarc_count || 0} potential DMARC reports.`
|
? `✓ Connected. ${result.message_count || 0} messages, ${result.dmarc_count || 0} potential DMARC reports.`
|
||||||
: `✗ ${result.message}`,
|
: `✗ ${result.message}`,
|
||||||
|
...diagnostic,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.testResult = { success: false, message: `Error: ${e.message}` };
|
this.testResult = { success: false, message: `Error: ${e.message}` };
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ class TestTestConnection:
|
|||||||
success, message, stats = client.test_connection()
|
success, message, stats = client.test_connection()
|
||||||
assert success is False
|
assert success is False
|
||||||
assert "not fully configured" in message
|
assert "not fully configured" in message
|
||||||
assert stats == {}
|
assert stats["diagnostic_detail"] == "missing server, username, or password"
|
||||||
|
|
||||||
def test_successful_connection(self):
|
def test_successful_connection(self):
|
||||||
client = self._make_client()
|
client = self._make_client()
|
||||||
@@ -296,7 +296,8 @@ class TestTestConnection:
|
|||||||
with patch("imaplib.IMAP4_SSL", side_effect=ConnectionRefusedError("refused")):
|
with patch("imaplib.IMAP4_SSL", side_effect=ConnectionRefusedError("refused")):
|
||||||
success, message, stats = client.test_connection()
|
success, message, stats = client.test_connection()
|
||||||
assert success is False
|
assert success is False
|
||||||
assert stats == {}
|
assert "IMAP server" in message
|
||||||
|
assert stats["diagnostic_detail"] == "refused"
|
||||||
|
|
||||||
def test_list_status_not_ok_returns_empty_mailboxes(self):
|
def test_list_status_not_ok_returns_empty_mailboxes(self):
|
||||||
client = self._make_client()
|
client = self._make_client()
|
||||||
@@ -312,19 +313,21 @@ class TestTestConnection:
|
|||||||
assert success is True
|
assert success is True
|
||||||
assert stats["available_mailboxes"] == []
|
assert stats["available_mailboxes"] == []
|
||||||
|
|
||||||
def test_select_not_ok_skips_message_count(self):
|
def test_select_not_ok_returns_mailbox_diagnostic(self):
|
||||||
client = self._make_client()
|
client = self._make_client()
|
||||||
mock_mail = MagicMock()
|
mock_mail = MagicMock()
|
||||||
mock_mail.login.return_value = None
|
mock_mail.login.return_value = None
|
||||||
mock_mail.list.return_value = ("OK", [])
|
mock_mail.list.return_value = ("OK", [b'(\\HasNoChildren) "/" INBOX'])
|
||||||
mock_mail.select.return_value = ("NO", [])
|
mock_mail.select.return_value = ("NO", [])
|
||||||
mock_mail.search.return_value = ("OK", [b""])
|
mock_mail.search.return_value = ("OK", [b""])
|
||||||
|
|
||||||
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
||||||
success, message, stats = client.test_connection()
|
success, message, stats = client.test_connection()
|
||||||
|
|
||||||
assert success is True
|
assert success is False
|
||||||
assert stats["message_count"] == 0
|
assert "folder" in message
|
||||||
|
assert stats["available_mailboxes"] == ["INBOX"]
|
||||||
|
assert "select failed" in stats["diagnostic_detail"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from fastapi.testclient import TestClient
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.api_v1.endpoints import mail_sources as mail_sources_endpoint
|
||||||
from app.core.credential_encryption import is_encrypted_secret
|
from app.core.credential_encryption import is_encrypted_secret
|
||||||
from app.models.mail_source import MailSource
|
from app.models.mail_source import MailSource
|
||||||
from app.models.mail_source_import import MailSourceImport
|
from app.models.mail_source_import import MailSourceImport
|
||||||
@@ -640,6 +641,32 @@ class TestMailSourcesAPIAuthed:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
|
assert data["diagnostic_category"] == "authentication"
|
||||||
|
assert data["diagnostic"]["summary"]
|
||||||
|
assert data["recovery_steps"]
|
||||||
|
|
||||||
|
def test_test_stored_imap_source_sanitizes_diagnostics(self, authed_client: TestClient):
|
||||||
|
create_resp = authed_client.post(
|
||||||
|
"/api/v1/mail-sources",
|
||||||
|
json={"name": "IMAP Secret Fail", "method": "IMAP", "server": "bad.host"},
|
||||||
|
)
|
||||||
|
source_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.test_connection.return_value = (
|
||||||
|
False,
|
||||||
|
"Connection failed",
|
||||||
|
{"diagnostic_detail": "password=secret-token refused"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.api.api_v1.endpoints.mail_sources.IMAPClient", return_value=mock_client):
|
||||||
|
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/test")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert data["diagnostic_category"] == "authentication"
|
||||||
|
assert "secret-token" not in json.dumps(data)
|
||||||
|
assert "password=**redacted**" in data["diagnostic"]["details"]
|
||||||
|
|
||||||
def test_test_stored_non_imap_source(self, authed_client: TestClient):
|
def test_test_stored_non_imap_source(self, authed_client: TestClient):
|
||||||
create_resp = authed_client.post(
|
create_resp = authed_client.post(
|
||||||
@@ -700,6 +727,7 @@ class TestMailSourcesAPIAuthed:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
|
assert data["diagnostic_category"] == "not_implemented"
|
||||||
assert "not yet implemented" in data["message"]
|
assert "not yet implemented" in data["message"]
|
||||||
|
|
||||||
def test_adhoc_gmail_api_returns_not_implemented(self, authed_client: TestClient):
|
def test_adhoc_gmail_api_returns_not_implemented(self, authed_client: TestClient):
|
||||||
@@ -707,8 +735,23 @@ class TestMailSourcesAPIAuthed:
|
|||||||
resp = authed_client.post("/api/v1/mail-sources/test-connection", json=payload)
|
resp = authed_client.post("/api/v1/mail-sources/test-connection", json=payload)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["success"] is False
|
assert resp.json()["success"] is False
|
||||||
|
assert resp.json()["diagnostic_category"] == "not_implemented"
|
||||||
assert "not yet implemented" in resp.json()["message"]
|
assert "not yet implemented" in resp.json()["message"]
|
||||||
|
|
||||||
|
def test_diagnostic_category_common_failure_modes(self):
|
||||||
|
cases = {
|
||||||
|
"invalid_grant refresh token expired": "auth_expired",
|
||||||
|
"insufficient permission for gmail scope": "permissions",
|
||||||
|
"rate limit 429 too many requests": "throttling",
|
||||||
|
"select failed for folder DMARC": "mailbox_not_found",
|
||||||
|
"login failed invalid password": "authentication",
|
||||||
|
"dns timeout refused": "connectivity",
|
||||||
|
"server is required": "missing_config",
|
||||||
|
}
|
||||||
|
|
||||||
|
for message, expected in cases.items():
|
||||||
|
assert mail_sources_endpoint._diagnostic_category(message) == expected
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Gmail API-specific tests
|
# Gmail API-specific tests
|
||||||
@@ -750,6 +793,8 @@ class TestGmailAPIMailSource:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
assert "not yet authorised" in data["message"].lower() or "oauth" in data["message"].lower()
|
assert "not yet authorised" in data["message"].lower() or "oauth" in data["message"].lower()
|
||||||
|
assert data["diagnostic_category"] == "auth_required"
|
||||||
|
assert any("Connect Gmail" in step for step in data["recovery_steps"])
|
||||||
|
|
||||||
def test_gmail_source_test_with_valid_token(self, authed_client: TestClient):
|
def test_gmail_source_test_with_valid_token(self, authed_client: TestClient):
|
||||||
"""Test a GMAIL_API source that has valid OAuth tokens (mocked)."""
|
"""Test a GMAIL_API source that has valid OAuth tokens (mocked)."""
|
||||||
@@ -1703,7 +1748,8 @@ class TestGmailTestConnectionFailure:
|
|||||||
# Stack-trace / raw exception text must NOT be exposed to callers
|
# Stack-trace / raw exception text must NOT be exposed to callers
|
||||||
assert "internal oauth error" not in data["message"]
|
assert "internal oauth error" not in data["message"]
|
||||||
assert "token expired" not in data["message"]
|
assert "token expired" not in data["message"]
|
||||||
assert "check server logs" in data["message"].lower()
|
assert "authorization may need attention" in data["message"].lower()
|
||||||
|
assert data["diagnostic_category"] == "auth_expired"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user