Add support email functionality via Mailgun

Backend:
- Add email_service.py with Mailgun API integration
- Add SupportEmailRequest schema for email endpoint
- Add Mailgun config settings (API URL, key, from address, support email)
- Update .env.example with Mailgun configuration variables

Frontend:
- Update Login.tsx SupportModal to send emails via /api/support/email
- Update Profile.tsx question form to send emails via apiService
- Add loading states, success/error feedback, and auto-close on success

The support forms on both the login page and profile page now actually
send emails to the support team instead of just showing alerts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael 2026-01-24 07:03:11 -06:00
parent b119951f93
commit e2fd9549f7
6 changed files with 168 additions and 27 deletions

View file

@ -29,3 +29,9 @@ DATABASE_URL=postgresql+asyncpg://modcomms:modcomms_dev@localhost:5432/modcomms
# File Storage Path (for uploaded proofs)
# Defaults to ../storage relative to backend/
# FILE_STORAGE_PATH=/path/to/storage
# Mailgun Configuration (for support emails)
MAILGUN_API_URL=https://api.mailgun.net/v3/your-domain/messages
MAILGUN_API_KEY=your_mailgun_api_key_here
MAILGUN_FROM=noreply@your-domain.com
SUPPORT_EMAIL=support@your-domain.com

View file

@ -173,3 +173,11 @@ class UserResponse(BaseModel):
class Config:
from_attributes = True
# Support email schemas
class SupportEmailRequest(BaseModel):
message: str
subject: str
user_name: Optional[str] = None
user_email: Optional[str] = None

View file

@ -35,6 +35,12 @@ class Settings:
_default_storage = Path(__file__).parent.parent.parent / "storage"
FILE_STORAGE_PATH: str = os.getenv("FILE_STORAGE_PATH", str(_default_storage))
# Mailgun Configuration for support emails
MAILGUN_API_URL: str = os.getenv("MAILGUN_API_URL", "")
MAILGUN_API_KEY: str = os.getenv("MAILGUN_API_KEY", "")
MAILGUN_FROM: str = os.getenv("MAILGUN_FROM", "")
SUPPORT_EMAIL: str = os.getenv("SUPPORT_EMAIL", "BAICsupport@oliver.agency")
def validate(self) -> None:
"""Validate required settings are present."""
if not self.GEMINI_API_KEY:

View file

@ -0,0 +1,43 @@
"""Email service for sending support emails via Mailgun."""
import httpx
from app.config import settings
class EmailService:
"""Service for sending emails via Mailgun API."""
async def send_support_email(
self,
message: str,
subject: str,
user_name: str | None = None,
user_email: str | None = None,
) -> bool:
"""Send a support email via Mailgun API.
Args:
message: The message body
subject: Email subject line
user_name: Optional name of the user submitting
user_email: Optional email of the user submitting
Returns:
True if email was sent successfully, False otherwise
"""
body = f"From: {user_name or 'Anonymous'}\nEmail: {user_email or 'Not provided'}\n\n{message}"
async with httpx.AsyncClient() as client:
response = await client.post(
settings.MAILGUN_API_URL,
auth=("api", settings.MAILGUN_API_KEY),
data={
"from": settings.MAILGUN_FROM,
"to": settings.SUPPORT_EMAIL,
"subject": subject,
"text": body,
},
)
return response.status_code == 200
email_service = EmailService()

View file

@ -6,19 +6,49 @@ import { XIcon } from './icons/XIcon';
import { MicrosoftLogo } from './icons/MicrosoftLogo';
import { loginRequest } from '../services/authConfig';
const API_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
const SupportModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSubmit: (query: string) => void;
}> = ({ isOpen, onClose, onSubmit }) => {
}> = ({ isOpen, onClose }) => {
const [query, setQuery] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
if (!isOpen) return null;
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
onSubmit(query);
if (!query.trim()) return;
setIsSubmitting(true);
setSubmitStatus(null);
try {
const response = await fetch(`${API_URL}/api/support/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: query,
subject: 'Support Request from Mod Comms Login',
}),
});
if (!response.ok) {
throw new Error('Failed to send');
}
setSubmitStatus({ type: 'success', message: 'Thank you for your query. A member of the support team will be in touch with you shortly.' });
setQuery('');
setTimeout(() => {
onClose();
setSubmitStatus(null);
}, 3000);
} catch {
setSubmitStatus({ type: 'error', message: 'Failed to send your message. Please try again later.' });
} finally {
setIsSubmitting(false);
}
};
@ -40,6 +70,11 @@ const SupportModal: React.FC<{
<form onSubmit={handleSubmit}>
<p className="text-gray-600 mb-4">Please describe your issue or query below. A member of our team will be in touch shortly.</p>
{submitStatus && (
<div className={`mb-4 p-3 rounded-md ${submitStatus.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
{submitStatus.message}
</div>
)}
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
@ -47,21 +82,33 @@ const SupportModal: React.FC<{
rows={5}
placeholder="Type your message here..."
required
disabled={isSubmitting}
/>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={!query.trim()}
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
disabled={!query.trim() || isSubmitting}
>
Submit Query
{isSubmitting ? (
<>
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending...
</>
) : (
'Submit Query'
)}
</button>
</div>
</form>
@ -76,12 +123,6 @@ export const Login: React.FC = () => {
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const handleSupportSubmit = (query: string) => {
console.log("Support query submitted:", query);
alert("Thank you for your query. A member of the support team will be in touch with you shortly.");
setIsSupportModalOpen(false);
};
const handleMicrosoftLogin = async () => {
console.log('[MSAL Login] Starting Microsoft login popup...');
console.log('[MSAL Login] Login request scopes:', loginRequest.scopes);
@ -117,7 +158,6 @@ export const Login: React.FC = () => {
<SupportModal
isOpen={isSupportModalOpen}
onClose={() => setIsSupportModalOpen(false)}
onSubmit={handleSupportSubmit}
/>
<div className="fixed inset-0 overflow-y-auto bg-[#0f172a] flex items-center justify-center font-sans p-4">
{/* Modern Glassy Background */}

View file

@ -3,6 +3,7 @@ import { IPublicClientApplication } from '@azure/msal-browser';
import { LogoutIcon } from './icons/LogoutIcon';
import { QuestionMarkIcon } from './icons/QuestionMarkIcon';
import { getUserInfo, UserInfo } from '../services/authService';
import { apiService } from '../services/apiService';
interface ProfileProps {
onLogout: () => void;
@ -13,6 +14,8 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, msalInstance }) => {
const [isQuestionFormVisible, setIsQuestionFormVisible] = useState(false);
const [question, setQuestion] = useState('');
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
useEffect(() => {
const info = getUserInfo(msalInstance);
@ -39,15 +42,34 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, msalInstance }) => {
setIsQuestionFormVisible(prev => !prev);
};
const handleSubmitQuestion = (e: React.FormEvent) => {
const handleSubmitQuestion = async (e: React.FormEvent) => {
e.preventDefault();
if (!question.trim()) {
alert('Please enter a question before submitting.');
setSubmitStatus({ type: 'error', message: 'Please enter a question before submitting.' });
return;
}
alert(`Your question has been submitted:\n\n"${question}"\n\nWe'll get back to you shortly.`);
setQuestion('');
setIsQuestionFormVisible(false);
setIsSubmitting(true);
setSubmitStatus(null);
try {
await apiService.sendSupportEmail({
message: question,
subject: 'Question from Mod Comms User',
user_name: userInfo ? `${userInfo.firstName} ${userInfo.lastName}`.trim() : undefined,
user_email: userInfo?.email,
});
setSubmitStatus({ type: 'success', message: 'Your question has been submitted. We\'ll get back to you shortly.' });
setQuestion('');
setTimeout(() => {
setIsQuestionFormVisible(false);
setSubmitStatus(null);
}, 3000);
} catch (error) {
setSubmitStatus({ type: 'error', message: 'Failed to submit question. Please try again later.' });
} finally {
setIsSubmitting(false);
}
};
return (
@ -92,6 +114,11 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, msalInstance }) => {
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">Ask a Question</h2>
<form onSubmit={handleSubmitQuestion}>
<p className="text-gray-600 mb-4">Your question will be sent to the OLIVER Agency support team.</p>
{submitStatus && (
<div className={`mb-4 p-3 rounded-md ${submitStatus.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
{submitStatus.message}
</div>
)}
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
@ -99,14 +126,25 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, msalInstance }) => {
placeholder="Type your question here..."
rows={5}
required
disabled={isSubmitting}
/>
<div className="mt-4 flex justify-end">
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-5 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled={!question.trim()}
<button
type="submit"
className="bg-brand-accent text-white font-semibold py-2 px-5 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
disabled={!question.trim() || isSubmitting}
>
Submit Question
{isSubmitting ? (
<>
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Submitting...
</>
) : (
'Submit Question'
)}
</button>
</div>
</form>