From 2a005d9103adf5cd0fc9311d05375eff16ce9c6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 20:58:32 +0000 Subject: [PATCH] feat: add Send Test Email button for SMTP Fallback config Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/9485ffb9-ce3d-4c6b-9d2c-9144dd4287ff Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 7 ++ backend/app/api/v1/endpoints/users.py | 92 ++++++++++++++++++++++++++- backend/app/models/schemas.py | 5 ++ frontend/src/app/settings/page.tsx | 45 +++++++++++++ frontend/src/lib/api.ts | 7 ++ 5 files changed, 155 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcff43d..b2f08b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Send Test Email button for SMTP Fallback**: a new "Send Test Email" button + appears on the Settings page next to the Save/Remove SMTP controls once a + configuration is saved. It calls a new `POST /users/smtp-config/test` + endpoint that sends a test message to the user's registered email address + using the stored SMTP credentials, and displays an inline success or error + banner with the result. + - **Friendly error messages**: Introduced `_format_connection_error()` helper in `mail_processor.py` that translates raw OS/socket/SSL/POP3/IMAP exceptions into human-readable sentences including the host:port and actionable guidance (DNS diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 60c05be..98ccaf8 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -1,18 +1,24 @@ """User management endpoints""" +import asyncio +import smtplib +from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.database import get_db from app.core.deps import get_current_active_user -from app.core.security import encrypt_credential +from app.core.security import encrypt_credential, decrypt_credential from app.models.database_models import User, UserSmtpConfig from app.models.schemas import ( UserDetailResponse, UserUpdate, UserSmtpConfigUpdate, UserSmtpConfigResponse, + SmtpTestResponse, ) router = APIRouter() @@ -134,3 +140,87 @@ async def delete_smtp_config( if config: await db.delete(config) await db.commit() + + +@router.post("/smtp-config/test", response_model=SmtpTestResponse) +async def test_smtp_config( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): + """Send a test email using the current user's saved SMTP configuration""" + result = await db.execute( + select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id) + ) + config = result.scalar_one_or_none() + + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No SMTP configuration found. Save one first.", + ) + + if not config.encrypted_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No SMTP password stored. Save the configuration with a password first.", + ) + + password = decrypt_credential(config.encrypted_password) # type: ignore[arg-type] + recipient = current_user.email # type: ignore[arg-type] + + def _send_test() -> None: + msg = MIMEText( + "This is a test email sent by InboxConverge to verify your SMTP settings.", + "plain", + "utf-8", + ) + msg["From"] = config.username # type: ignore[index] + msg["To"] = recipient + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = make_msgid() + msg["Subject"] = "InboxConverge – SMTP Test" + + if config.use_tls: # type: ignore[union-attr] + server: smtplib.SMTP = smtplib.SMTP( + config.host, config.port, timeout=30 # type: ignore[arg-type] + ) + server.starttls() + else: + server = smtplib.SMTP_SSL( + config.host, config.port, timeout=30 # type: ignore[arg-type] + ) + + try: + server.login(config.username, password) # type: ignore[arg-type] + server.send_message(msg) + finally: + try: + server.quit() + except Exception: + pass + + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _send_test) + return SmtpTestResponse( + success=True, + message=f"Test email sent successfully to {recipient}.", + ) + except smtplib.SMTPAuthenticationError as exc: + smtp_err = exc.smtp_error + detail = ( + smtp_err.decode(errors="replace") + if isinstance(smtp_err, bytes) + else str(exc) + ) + return SmtpTestResponse( + success=False, + message=f"Authentication failed: {detail}", + ) + except smtplib.SMTPException as exc: + return SmtpTestResponse(success=False, message=f"SMTP error: {exc}") + except OSError as exc: + return SmtpTestResponse( + success=False, + message=f"Connection error: {exc}", + ) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index da5673b..cb772f0 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -498,6 +498,11 @@ class UserSmtpConfigResponse(UserSmtpConfigBase): model_config = ConfigDict(from_attributes=True) +class SmtpTestResponse(BaseModel): + success: bool + message: str + + # Gmail OAuth Schemas class GmailAuthorizeResponse(BaseModel): authorization_url: str diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 6253e9f..db9c4a5 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -18,6 +18,7 @@ import { Bug, RotateCcw, Tags, + Send, } from 'lucide-react'; const DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES = ['{{source_email}}', 'imported']; @@ -249,6 +250,19 @@ function SettingsContent() { }, }); + const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null); + const testSmtpMutation = useMutation({ + mutationFn: smtpApi.test, + onSuccess: (result) => { + setSmtpTestResult(result); + setTimeout(() => setSmtpTestResult(null), 6000); + }, + onError: () => { + setSmtpTestResult({ success: false, message: 'Request failed. Check your network connection.' }); + setTimeout(() => setSmtpTestResult(null), 6000); + }, + }); + const handleProfileChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setProfileForm((prev) => ({ ...prev, [name]: value })); @@ -625,6 +639,21 @@ function SettingsContent() { 'Save SMTP Settings' )} + {smtpConfig && ( + + )} {smtpConfig && (