# SPDX-License-Identifier: GPL-3.0-or-later """ Email service for Golf game authentication. Provides email sending via Resend for verification, password reset, and notifications. """ import logging from typing import Optional from config import config logger = logging.getLogger(__name__) class EmailService: """ Email service using Resend API. Handles all transactional emails for authentication: - Email verification - Password reset - Password changed notification """ def __init__(self, api_key: str, from_address: str, base_url: str): """ Initialize email service. Args: api_key: Resend API key. from_address: Sender email address. base_url: Base URL for verification/reset links. """ self.api_key = api_key self.from_address = from_address self.base_url = base_url.rstrip("/") self._client = None @classmethod def create(cls) -> "EmailService": """Create EmailService from config.""" return cls( api_key=config.RESEND_API_KEY, from_address=config.EMAIL_FROM, base_url=config.BASE_URL, ) @property def client(self): """Lazy-load Resend client.""" if self._client is None: try: import resend resend.api_key = self.api_key self._client = resend except ImportError: logger.warning("resend package not installed, emails will be logged only") self._client = None return self._client def is_configured(self) -> bool: """Check if email service is properly configured.""" return bool(self.api_key) async def send_verification_email( self, to: str, token: str, username: str, ) -> Optional[str]: """ Send email verification email. Args: to: Recipient email address. token: Verification token. username: User's display name. Returns: Resend message ID if sent, None if not configured. """ if not self.is_configured(): logger.info(f"Email not configured. Would send verification to {to}") return None verify_url = f"{self.base_url}/verify-email?token={token}" subject = "Verify your Golf Game account" html = f"""

Welcome to Golf Game, {username}!

Please verify your email address by clicking the link below:

Verify Email Address

Or copy and paste this URL into your browser:

{verify_url}

This link will expire in 24 hours.

If you didn't create this account, you can safely ignore this email.

""" return await self._send_email(to, subject, html) async def send_password_reset_email( self, to: str, token: str, username: str, ) -> Optional[str]: """ Send password reset email. Args: to: Recipient email address. token: Reset token. username: User's display name. Returns: Resend message ID if sent, None if not configured. """ if not self.is_configured(): logger.info(f"Email not configured. Would send password reset to {to}") return None reset_url = f"{self.base_url}/reset-password?token={token}" subject = "Reset your Golf Game password" html = f"""

Password Reset Request

Hi {username},

We received a request to reset your password. Click the link below to set a new password:

Reset Password

Or copy and paste this URL into your browser:

{reset_url}

This link will expire in 1 hour.

If you didn't request this, you can safely ignore this email. Your password will remain unchanged.

""" return await self._send_email(to, subject, html) async def send_password_changed_notification( self, to: str, username: str, ) -> Optional[str]: """ Send password changed notification email. Args: to: Recipient email address. username: User's display name. Returns: Resend message ID if sent, None if not configured. """ if not self.is_configured(): logger.info(f"Email not configured. Would send password change notification to {to}") return None subject = "Your Golf Game password was changed" html = f"""

Password Changed

Hi {username},

Your password was successfully changed.

If you did not make this change, please contact support immediately.

""" return await self._send_email(to, subject, html) async def send_invite_request_admin_notification( self, to: str, requester_name: str, requester_email: str, message: str, ) -> Optional[str]: """Notify admin of a new invite request.""" if not self.is_configured(): logger.info(f"Email not configured. Would send invite request notification to {to}") return None admin_url = f"{self.base_url}/admin.html" message_html = f"

Message: {message}

" if message else "" subject = f"Golf Game invite request from {requester_name}" html = f"""

New Invite Request

Name: {requester_name}

Email: {requester_email}

{message_html}

Review in Admin Panel

""" return await self._send_email(to, subject, html) async def send_invite_approved_email( self, to: str, name: str, invite_code: str, ) -> Optional[str]: """Notify requester that their invite was approved.""" if not self.is_configured(): logger.info(f"Email not configured. Would send invite approval to {to}") return None signup_url = f"{self.base_url}/?invite={invite_code}" subject = "Your Golf Game invite is ready!" html = f"""

You're In, {name}!

Your request to join Golf Game has been approved.

Use this link to create your account:

{signup_url}

Or sign up manually with invite code: {invite_code}

This invite is single-use and expires in 7 days.

""" return await self._send_email(to, subject, html) async def send_invite_denied_email( self, to: str, name: str, ) -> Optional[str]: """Notify requester that their invite was denied.""" if not self.is_configured(): logger.info(f"Email not configured. Would send invite denial to {to}") return None subject = "Golf Game invite request update" html = f"""

Hi {name},

Thanks for your interest in Golf Game. Unfortunately, we're not able to approve your invite request at this time.

We may open up registrations in the future — stay tuned!

""" return await self._send_email(to, subject, html) async def _send_email( self, to: str, subject: str, html: str, ) -> Optional[str]: """ Send an email via Resend. Args: to: Recipient email address. subject: Email subject. html: HTML email body. Returns: Resend message ID if sent, None on error. """ if not self.client: logger.warning(f"Resend not available. Email to {to}: {subject}") return None try: params = { "from": self.from_address, "to": [to], "subject": subject, "html": html, } response = self.client.Emails.send(params) message_id = response.get("id") if isinstance(response, dict) else getattr(response, "id", None) logger.info(f"Email sent to {to}: {message_id}") return message_id except Exception as e: logger.error(f"Failed to send email to {to}: {e}") return None # Global email service instance _email_service: Optional[EmailService] = None def get_email_service() -> EmailService: """Get or create the global email service instance.""" global _email_service if _email_service is None: _email_service = EmailService.create() return _email_service