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
|
### 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
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user