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:
parent
b119951f93
commit
e2fd9549f7
6 changed files with 168 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
43
backend/app/services/email_service.py
Normal file
43
backend/app/services/email_service.py
Normal 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()
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue