Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Every email sent via send_email() now includes a BCC to the portal administrator (MAIL_BCC env var, defaults to maciej.pienczyn@inpi.pl). Recipients who are already in TO are automatically excluded from BCC. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
532 lines
18 KiB
Python
532 lines
18 KiB
Python
"""
|
|
Norda Biznes - Email Service
|
|
=============================
|
|
|
|
Sends emails via Microsoft Graph API using Application permissions.
|
|
Based on mtbtracker implementation.
|
|
|
|
Author: Norda Biznes Development Team
|
|
Created: 2025-12-25
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from typing import Optional, List
|
|
from dotenv import load_dotenv
|
|
|
|
# Load .env file for environment variables
|
|
load_dotenv()
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Check if msal is available
|
|
try:
|
|
import msal
|
|
import requests
|
|
MSAL_AVAILABLE = True
|
|
except ImportError:
|
|
MSAL_AVAILABLE = False
|
|
logger.warning("msal package not installed. Email service will be disabled.")
|
|
|
|
|
|
class EmailService:
|
|
"""Service for sending emails via Microsoft Graph API"""
|
|
|
|
def __init__(self, tenant_id: str, client_id: str, client_secret: str, mail_from: str):
|
|
"""
|
|
Initialize Email Service
|
|
|
|
Args:
|
|
tenant_id: Azure AD Tenant ID
|
|
client_id: Application (client) ID
|
|
client_secret: Client secret value
|
|
mail_from: Default sender email address (e.g., noreply@inpi.pl)
|
|
"""
|
|
self.tenant_id = tenant_id
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
self.mail_from = mail_from
|
|
self.authority = f"https://login.microsoftonline.com/{tenant_id}"
|
|
self.scope = ["https://graph.microsoft.com/.default"]
|
|
self.graph_endpoint = "https://graph.microsoft.com/v1.0"
|
|
|
|
def _get_access_token(self) -> Optional[str]:
|
|
"""
|
|
Acquire access token using client credentials flow
|
|
|
|
Returns:
|
|
Access token string or None if failed
|
|
"""
|
|
if not MSAL_AVAILABLE:
|
|
logger.error("msal package not available")
|
|
return None
|
|
|
|
try:
|
|
app = msal.ConfidentialClientApplication(
|
|
self.client_id,
|
|
authority=self.authority,
|
|
client_credential=self.client_secret,
|
|
)
|
|
|
|
result = app.acquire_token_silent(self.scope, account=None)
|
|
|
|
if not result:
|
|
logger.info("No token in cache, acquiring new token")
|
|
result = app.acquire_token_for_client(scopes=self.scope)
|
|
|
|
if "access_token" in result:
|
|
return result["access_token"]
|
|
else:
|
|
logger.error(f"Failed to acquire token: {result.get('error')}: {result.get('error_description')}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Exception during token acquisition: {e}", exc_info=True)
|
|
return None
|
|
|
|
def send_mail(
|
|
self,
|
|
to: List[str],
|
|
subject: str,
|
|
body_text: str,
|
|
body_html: Optional[str] = None,
|
|
from_address: Optional[str] = None,
|
|
bcc: Optional[List[str]] = None
|
|
) -> bool:
|
|
"""
|
|
Send email via Microsoft Graph API
|
|
|
|
Args:
|
|
to: List of recipient email addresses
|
|
subject: Email subject
|
|
body_text: Plain text email body
|
|
body_html: HTML email body (optional)
|
|
from_address: Sender email (optional, defaults to configured mail_from)
|
|
bcc: List of BCC recipient email addresses (optional)
|
|
|
|
Returns:
|
|
True if sent successfully, False otherwise
|
|
"""
|
|
if not MSAL_AVAILABLE:
|
|
logger.error("msal package not available - cannot send email")
|
|
return False
|
|
|
|
try:
|
|
# Get access token
|
|
token = self._get_access_token()
|
|
if not token:
|
|
logger.error("Failed to acquire access token")
|
|
return False
|
|
|
|
# Use default sender if not specified
|
|
sender = from_address or self.mail_from
|
|
|
|
# Prepare recipients
|
|
recipients = [{"emailAddress": {"address": email}} for email in to]
|
|
|
|
# Prepare message body
|
|
if body_html:
|
|
content_type = "HTML"
|
|
content = body_html
|
|
else:
|
|
content_type = "Text"
|
|
content = body_text
|
|
|
|
# Build email message
|
|
# Display name for sender - shown in email clients
|
|
sender_display_name = "Norda Biznes Partner"
|
|
|
|
email_msg = {
|
|
"message": {
|
|
"subject": subject,
|
|
"body": {
|
|
"contentType": content_type,
|
|
"content": content
|
|
},
|
|
"toRecipients": recipients,
|
|
"from": {
|
|
"emailAddress": {
|
|
"address": sender,
|
|
"name": sender_display_name
|
|
}
|
|
}
|
|
},
|
|
"saveToSentItems": "false"
|
|
}
|
|
|
|
# Add BCC recipients if provided
|
|
if bcc:
|
|
bcc_recipients = [{"emailAddress": {"address": email}} for email in bcc]
|
|
email_msg["message"]["bccRecipients"] = bcc_recipients
|
|
|
|
# Send email via Graph API
|
|
url = f"{self.graph_endpoint}/users/{sender}/sendMail"
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
response = requests.post(url, headers=headers, json=email_msg, timeout=30)
|
|
|
|
if response.status_code == 202:
|
|
logger.info(f"Email sent successfully to {to}")
|
|
return True
|
|
else:
|
|
logger.error(f"Failed to send email. Status: {response.status_code}, Response: {response.text}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Exception during email sending: {e}", exc_info=True)
|
|
return False
|
|
|
|
|
|
# Global service instance
|
|
_email_service: Optional[EmailService] = None
|
|
|
|
|
|
def init_email_service():
|
|
"""
|
|
Initialize the global Email Service instance from environment variables
|
|
|
|
Required env vars:
|
|
AZURE_TENANT_ID: Azure AD Tenant ID
|
|
AZURE_CLIENT_ID: Application (client) ID
|
|
AZURE_CLIENT_SECRET: Client secret value
|
|
MAIL_FROM: Default sender email address
|
|
"""
|
|
global _email_service
|
|
|
|
tenant_id = os.getenv('AZURE_TENANT_ID')
|
|
client_id = os.getenv('AZURE_CLIENT_ID')
|
|
client_secret = os.getenv('AZURE_CLIENT_SECRET')
|
|
mail_from = os.getenv('MAIL_FROM', 'noreply@inpi.pl')
|
|
|
|
if tenant_id and client_id and client_secret:
|
|
_email_service = EmailService(tenant_id, client_id, client_secret, mail_from)
|
|
logger.info(f"Email Service initialized with sender: {mail_from}")
|
|
return True
|
|
else:
|
|
logger.warning("Email Service not configured - missing Azure credentials")
|
|
return False
|
|
|
|
|
|
def send_email(
|
|
to: List[str],
|
|
subject: str,
|
|
body_text: str,
|
|
body_html: Optional[str] = None,
|
|
from_address: Optional[str] = None,
|
|
email_type: str = 'notification',
|
|
user_id: Optional[int] = None,
|
|
recipient_name: Optional[str] = None,
|
|
bcc: Optional[List[str]] = None
|
|
) -> bool:
|
|
"""
|
|
Send email using the global Email Service instance
|
|
|
|
Args:
|
|
to: List of recipient email addresses (can be single email as list)
|
|
subject: Email subject
|
|
body_text: Plain text email body
|
|
body_html: HTML email body (optional)
|
|
from_address: Sender email (optional)
|
|
email_type: Type of email for logging (welcome, password_reset, notification)
|
|
user_id: User ID for logging (optional)
|
|
recipient_name: Recipient name for logging (optional)
|
|
bcc: List of BCC emails (optional, defaults to MAIL_BCC env var)
|
|
|
|
Returns:
|
|
True if sent successfully, False otherwise
|
|
"""
|
|
if not _email_service:
|
|
logger.error("Email Service not initialized. Call init_email_service() first.")
|
|
return False
|
|
|
|
# Convert single email to list if needed
|
|
if isinstance(to, str):
|
|
to = [to]
|
|
|
|
# Default BCC from env var — admin gets a copy of every email
|
|
if bcc is None:
|
|
default_bcc = os.getenv('MAIL_BCC', 'maciej.pienczyn@inpi.pl')
|
|
if default_bcc:
|
|
bcc = [addr.strip() for addr in default_bcc.split(',') if addr.strip()]
|
|
# Don't BCC someone who is already a direct recipient
|
|
bcc = [addr for addr in bcc if addr not in to]
|
|
|
|
result = _email_service.send_mail(to, subject, body_text, body_html, from_address, bcc=bcc)
|
|
|
|
# Log email to database
|
|
_log_email(
|
|
email_type=email_type,
|
|
recipient_emails=to,
|
|
recipient_name=recipient_name,
|
|
subject=subject,
|
|
user_id=user_id,
|
|
sender_email=from_address or _email_service.mail_from if _email_service else None,
|
|
success=result
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def _log_email(
|
|
email_type: str,
|
|
recipient_emails: List[str],
|
|
subject: str,
|
|
success: bool,
|
|
recipient_name: Optional[str] = None,
|
|
user_id: Optional[int] = None,
|
|
sender_email: Optional[str] = None,
|
|
error_message: Optional[str] = None
|
|
) -> None:
|
|
"""
|
|
Log email to database for monitoring.
|
|
|
|
Args:
|
|
email_type: Type of email (welcome, password_reset, notification)
|
|
recipient_emails: List of recipient email addresses
|
|
subject: Email subject
|
|
success: Whether email was sent successfully
|
|
recipient_name: Recipient name (optional)
|
|
user_id: User ID (optional)
|
|
sender_email: Sender email address (optional)
|
|
error_message: Error message if failed (optional)
|
|
"""
|
|
try:
|
|
from database import SessionLocal, EmailLog
|
|
from datetime import datetime
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
for email in recipient_emails:
|
|
log_entry = EmailLog(
|
|
email_type=email_type,
|
|
recipient_email=email,
|
|
recipient_name=recipient_name,
|
|
subject=subject,
|
|
user_id=user_id,
|
|
sender_email=sender_email,
|
|
status='sent' if success else 'failed',
|
|
sent_at=datetime.utcnow() if success else None,
|
|
error_message=error_message if not success else None
|
|
)
|
|
db.add(log_entry)
|
|
db.commit()
|
|
logger.info(f"Email logged: {email_type} -> {recipient_emails} (status: {'sent' if success else 'failed'})")
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Failed to log email to database: {e}")
|
|
finally:
|
|
db.close()
|
|
except ImportError:
|
|
# Database not available (e.g., during testing)
|
|
logger.warning("Could not log email - database module not available")
|
|
|
|
|
|
def is_configured() -> bool:
|
|
"""
|
|
Check if Email Service is configured and ready
|
|
|
|
Returns:
|
|
True if service is initialized, False otherwise
|
|
"""
|
|
return _email_service is not None
|
|
|
|
|
|
# Auto-initialize on module load
|
|
init_email_service()
|
|
|
|
|
|
# ============================================================
|
|
# EMAIL TEMPLATES
|
|
# ============================================================
|
|
|
|
def send_password_reset_email(email: str, reset_url: str) -> bool:
|
|
"""
|
|
Send password reset email
|
|
|
|
Args:
|
|
email: Recipient email address
|
|
reset_url: Password reset URL with token
|
|
|
|
Returns:
|
|
True if sent successfully, False otherwise
|
|
"""
|
|
subject = "Reset hasła - Norda Biznes Partner"
|
|
|
|
body_text = f"""Otrzymałeś ten email, ponieważ zażądano resetowania hasła dla Twojego konta Norda Biznes Partner.
|
|
|
|
Aby zresetować hasło, kliknij w poniższy link:
|
|
{reset_url}
|
|
|
|
Link będzie ważny przez 1 godzinę.
|
|
|
|
Jeśli nie zażądałeś resetowania hasła, zignoruj ten email.
|
|
|
|
---
|
|
Norda Biznes Partner - Platforma Networkingu Regionalnej Izby Przedsiębiorców
|
|
https://nordabiznes.pl
|
|
"""
|
|
|
|
body_html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
body {{ font-family: 'Inter', Arial, sans-serif; line-height: 1.6; color: #1e293b; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; background: #f8fafc; }}
|
|
.header {{ background-color: #1e3a8a; background: linear-gradient(135deg, #1e40af, #1e3a8a); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
|
|
.header h1 {{ margin: 0; font-size: 28px; font-weight: 700; text-shadow: 0 2px 4px rgba(0,0,0,0.3); }}
|
|
.content {{ background: white; padding: 30px; border-radius: 0 0 8px 8px; }}
|
|
.button {{ display: inline-block; padding: 14px 32px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; margin: 20px 0; font-weight: 600; }}
|
|
.button:hover {{ background: #1e40af; }}
|
|
.footer {{ text-align: center; padding: 20px; color: #64748b; font-size: 0.9em; }}
|
|
.warning {{ background: #fef3c7; border-left: 4px solid #f59e0b; padding: 15px; margin: 20px 0; border-radius: 0 8px 8px 0; color: #92400e; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Reset hasła</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>Otrzymałeś ten email, ponieważ zażądano resetowania hasła dla Twojego konta Norda Biznes Partner.</p>
|
|
|
|
<p>Aby zresetować hasło, kliknij w poniższy przycisk:</p>
|
|
|
|
<center>
|
|
<a href="{reset_url}" class="button">Zresetuj hasło</a>
|
|
</center>
|
|
|
|
<div class="warning">
|
|
<strong>Ważność linku:</strong> 1 godzina
|
|
</div>
|
|
|
|
<p>Jeśli przycisk nie działa, skopiuj i wklej poniższy link do przeglądarki:</p>
|
|
<p style="word-break: break-all; color: #2563eb;">{reset_url}</p>
|
|
|
|
<p style="margin-top: 30px; color: #64748b; font-size: 0.9em;">
|
|
<strong>Nie zażądałeś resetowania hasła?</strong><br>
|
|
Zignoruj ten email. Twoje hasło pozostanie bez zmian.
|
|
</p>
|
|
</div>
|
|
<div class="footer">
|
|
<p><strong>Norda Biznes Partner</strong> - Platforma Networkingu Regionalnej Izby Przedsiębiorców</p>
|
|
<p><a href="https://nordabiznes.pl">nordabiznes.pl</a></p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return send_email(
|
|
to=[email],
|
|
subject=subject,
|
|
body_text=body_text,
|
|
body_html=body_html,
|
|
email_type='password_reset'
|
|
)
|
|
|
|
|
|
def send_welcome_email(email: str, name: str, verification_url: str) -> bool:
|
|
"""
|
|
Send welcome/verification email after registration
|
|
|
|
Args:
|
|
email: Recipient email address
|
|
name: User's name
|
|
verification_url: Email verification URL with token
|
|
|
|
Returns:
|
|
True if sent successfully, False otherwise
|
|
"""
|
|
subject = "Witamy w Norda Biznes Partner - Potwierdź email"
|
|
|
|
body_text = f"""Witaj {name}!
|
|
|
|
Dziękujemy za rejestrację w Norda Biznes Partner - platformie networkingu Regionalnej Izby Przedsiębiorców Norda Biznes.
|
|
|
|
Aby aktywować swoje konto, potwierdź adres email klikając w poniższy link:
|
|
{verification_url}
|
|
|
|
Link będzie ważny przez 24 godziny.
|
|
|
|
Po potwierdzeniu email będziesz mógł:
|
|
- Przeglądać profile firm członkowskich Izby
|
|
- Korzystać z asystenta AI do wyszukiwania usług
|
|
- Nawiązywać kontakty biznesowe
|
|
|
|
Pozdrawiamy,
|
|
Zespół Norda Biznes Partner
|
|
|
|
---
|
|
Norda Biznes Partner - Platforma Networkingu Regionalnej Izby Przedsiębiorców
|
|
https://nordabiznes.pl
|
|
"""
|
|
|
|
body_html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
body {{ font-family: 'Inter', Arial, sans-serif; line-height: 1.6; color: #1e293b; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; background: #f8fafc; }}
|
|
.header {{ background-color: #1e3a8a; background: linear-gradient(135deg, #1e40af, #1e3a8a); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
|
|
.header h1 {{ margin: 0; font-size: 28px; font-weight: 700; text-shadow: 0 2px 4px rgba(0,0,0,0.3); }}
|
|
.content {{ background: white; padding: 30px; border-radius: 0 0 8px 8px; }}
|
|
.button {{ display: inline-block; padding: 14px 32px; background: #10b981; color: white; text-decoration: none; border-radius: 8px; margin: 20px 0; font-weight: 600; }}
|
|
.footer {{ text-align: center; padding: 20px; color: #64748b; font-size: 0.9em; }}
|
|
.features {{ background: #f0fdf4; padding: 20px; border-radius: 8px; margin: 20px 0; color: #166534; }}
|
|
.features ul {{ margin: 10px 0; padding-left: 20px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>Witamy w Norda Biznes Partner!</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>Witaj <strong>{name}</strong>!</p>
|
|
|
|
<p>Dziękujemy za rejestrację w Norda Biznes Partner - platformie networkingu Regionalnej Izby Przedsiębiorców Norda Biznes.</p>
|
|
|
|
<p>Aby aktywować swoje konto, potwierdź adres email:</p>
|
|
|
|
<center>
|
|
<a href="{verification_url}" class="button">Potwierdź email</a>
|
|
</center>
|
|
|
|
<div class="features">
|
|
<strong>Po potwierdzeniu email będziesz mógł:</strong>
|
|
<ul>
|
|
<li>Przeglądać profile firm członkowskich Izby</li>
|
|
<li>Korzystać z asystenta AI do wyszukiwania usług</li>
|
|
<li>Nawiązywać kontakty biznesowe</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<p style="color: #64748b; font-size: 0.9em;">
|
|
Link będzie ważny przez 24 godziny.
|
|
</p>
|
|
</div>
|
|
<div class="footer">
|
|
<p><strong>Norda Biznes Partner</strong> - Platforma Networkingu Regionalnej Izby Przedsiębiorców</p>
|
|
<p><a href="https://nordabiznes.pl">nordabiznes.pl</a></p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return send_email(
|
|
to=[email],
|
|
subject=subject,
|
|
body_text=body_text,
|
|
body_html=body_html,
|
|
email_type='welcome',
|
|
recipient_name=name
|
|
)
|