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>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-03 20:58:32 +00:00
committed by GitHub
parent 89ebdd2b79
commit 2a005d9103
5 changed files with 155 additions and 1 deletions
+7
View File
@@ -30,6 +30,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 - **Friendly error messages**: Introduced `_format_connection_error()` helper in
`mail_processor.py` that translates raw OS/socket/SSL/POP3/IMAP exceptions into `mail_processor.py` that translates raw OS/socket/SSL/POP3/IMAP exceptions into
human-readable sentences including the host:port and actionable guidance (DNS human-readable sentences including the host:port and actionable guidance (DNS
+91 -1
View File
@@ -1,18 +1,24 @@
"""User management endpoints""" """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 fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_current_active_user 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.database_models import User, UserSmtpConfig
from app.models.schemas import ( from app.models.schemas import (
UserDetailResponse, UserDetailResponse,
UserUpdate, UserUpdate,
UserSmtpConfigUpdate, UserSmtpConfigUpdate,
UserSmtpConfigResponse, UserSmtpConfigResponse,
SmtpTestResponse,
) )
router = APIRouter() router = APIRouter()
@@ -134,3 +140,87 @@ async def delete_smtp_config(
if config: if config:
await db.delete(config) await db.delete(config)
await db.commit() 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}",
)
+5
View File
@@ -498,6 +498,11 @@ class UserSmtpConfigResponse(UserSmtpConfigBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class SmtpTestResponse(BaseModel):
success: bool
message: str
# Gmail OAuth Schemas # Gmail OAuth Schemas
class GmailAuthorizeResponse(BaseModel): class GmailAuthorizeResponse(BaseModel):
authorization_url: str authorization_url: str
+45
View File
@@ -18,6 +18,7 @@ import {
Bug, Bug,
RotateCcw, RotateCcw,
Tags, Tags,
Send,
} from 'lucide-react'; } from 'lucide-react';
const DEFAULT_GMAIL_IMPORT_LABEL_TEMPLATES = ['{{source_email}}', 'imported']; 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<HTMLInputElement>) => { const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setProfileForm((prev) => ({ ...prev, [name]: value })); setProfileForm((prev) => ({ ...prev, [name]: value }));
@@ -625,6 +639,21 @@ function SettingsContent() {
'Save SMTP Settings' 'Save SMTP Settings'
)} )}
</button> </button>
{smtpConfig && (
<button
type="button"
onClick={() => testSmtpMutation.mutate()}
disabled={testSmtpMutation.isPending}
title="Send a test email to your account address using these SMTP settings"
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-amber-50 text-amber-700 rounded-md hover:bg-amber-100 disabled:opacity-50 transition-colors"
>
{testSmtpMutation.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Sending</>
) : (
<><Send className="h-4 w-4" />Send Test Email</>
)}
</button>
)}
{smtpConfig && ( {smtpConfig && (
<button <button
type="button" type="button"
@@ -642,6 +671,22 @@ function SettingsContent() {
</span> </span>
)} )}
</div> </div>
{smtpTestResult !== null && (
<div
className={`flex items-center gap-2 text-sm rounded-md px-3 py-2 border ${
smtpTestResult.success
? 'text-green-700 bg-green-50 border-green-200'
: 'text-red-700 bg-red-50 border-red-200'
}`}
>
{smtpTestResult.success ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : (
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
)}
{smtpTestResult.message}
</div>
)}
</form> </form>
)} )}
</div> </div>
+7
View File
@@ -495,6 +495,13 @@ export const smtpApi = {
async remove(): Promise<void> { async remove(): Promise<void> {
await api.delete('/users/smtp-config'); await api.delete('/users/smtp-config');
}, },
async test(): Promise<{ success: boolean; message: string }> {
const response = await api.post<{ success: boolean; message: string }>(
'/users/smtp-config/test'
);
return response.data;
},
}; };
// ── Admin Types ───────────────────────────────────────────────────────── // ── Admin Types ─────────────────────────────────────────────────────────