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:
committed by
GitHub
parent
89ebdd2b79
commit
2a005d9103
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setProfileForm((prev) => ({ ...prev, [name]: value }));
|
||||
@@ -625,6 +639,21 @@ function SettingsContent() {
|
||||
'Save SMTP Settings'
|
||||
)}
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -642,6 +671,22 @@ function SettingsContent() {
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -495,6 +495,13 @@ export const smtpApi = {
|
||||
async remove(): Promise<void> {
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user