Merge pull request #224 from christianlouis/copilot/add-test-mail-button

Add "Send Test Email" button for SMTP Fallback configuration
This commit is contained in:
Christian Krakau-Louis
2026-05-03 23:01:44 +02:00
committed by GitHub
5 changed files with 154 additions and 1 deletions
+6
View File
@@ -41,6 +41,12 @@ 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.
- **IPv4 preference for all POP3 and IMAP connections**: The app now resolves - **IPv4 preference for all POP3 and IMAP connections**: The app now resolves
every mail-server hostname to an IPv4 address before connecting. This every mail-server hostname to an IPv4 address before connecting. This
prevents `ENETUNREACH` / `Network is unreachable` errors and IMAP/POP3 prevents `ENETUNREACH` / `Network is unreachable` errors and IMAP/POP3
+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 ─────────────────────────────────────────────────────────