nordabiz/email_service.py
Maciej Pienczyn 902ff58d54
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
fix: correct Facebook URL + side-by-side events layout + FB in footer
- Updated Facebook link from /nordabiznes to /profile.php?id=100057396041901
  across all 4 locations (email templates, JSON-LD schema)
- Added Facebook link to site footer (Contact section)
- Added "Follow us on Facebook" to landing page CTA
- Redesigned upcoming events: side-by-side layout instead of stacked

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:14:09 +01:00

507 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 (v3 design)
# ============================================================
def _email_v3_wrap(title: str, subtitle: str, content_html: str) -> str:
"""Wrap email content in v3 branded shell (header + footer)."""
return f'''<!DOCTYPE html>
<html lang="pl">
<head><meta charset="UTF-8"></head>
<body style="margin:0; padding:0; background:#f1f5f9; font-family: 'Inter', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9; padding: 32px 0;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.08);">
<tr><td style="background: linear-gradient(135deg, #1e40af, #172554); padding: 36px 32px; text-align: center;">
<img src="https://nordabiznes.pl/static/img/logo-email.png" width="64" height="64" alt="NB" style="border-radius:50%; margin-bottom:16px; border: 2px solid rgba(255,255,255,0.3);">
<h1 style="margin:0; color:#ffffff; font-size:24px; font-weight:700;">{title}</h1>
<p style="margin:8px 0 0; color:#93c5fd; font-size:14px;">{subtitle}</p>
</td></tr>
<tr><td style="padding: 32px;">
{content_html}
</td></tr>
<tr><td style="background:#f8fafc; padding: 24px; text-align:center; border-top: 1px solid #e2e8f0;">
<p style="margin:0 0 4px; color:#1e293b; font-size:14px; font-weight:600;">Norda Biznes Partner</p>
<p style="margin:0 0 2px; color:#94a3b8; font-size:12px;">Stowarzyszenie Norda Biznes</p>
<p style="margin:0 0 12px; color:#94a3b8; font-size:12px;">ul. 12 Marca 238/5, 84-200 Wejherowo</p>
<p style="margin:0 0 12px; color:#94a3b8; font-size:13px;">
<a href="https://nordabiznes.pl" style="color:#2563eb; text-decoration:none;">nordabiznes.pl</a>
&nbsp;|&nbsp;
<a href="https://www.facebook.com/profile.php?id=100057396041901" style="color:#2563eb; text-decoration:none;">Facebook</a>
</p>
<p style="margin:0; color:#cbd5e1; font-size:11px;">To powiadomienie zostało wysłane automatycznie.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>'''
def send_password_reset_email(email: str, reset_url: str) -> bool:
"""Send password reset email."""
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
https://nordabiznes.pl
"""
content = f'''
<p style="margin:0 0 20px; color:#1e293b; font-size:16px;">Otrzymałeś ten email, ponieważ zażądano resetowania hasła dla Twojego konta.</p>
<p style="margin:0 0 24px; color:#475569; font-size:15px;">Kliknij poniższy przycisk, aby ustawić nowe hasło:</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr><td align="center" style="padding: 8px 0;">
<a href="{reset_url}" style="display:inline-block; padding:16px 40px; background: linear-gradient(135deg, #dc2626, #991b1b); color:#ffffff; text-decoration:none; border-radius:8px; font-size:15px; font-weight:600;">Zresetuj hasło</a>
</td></tr>
</table>
<!-- Warning -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:#fef3c7; border-radius:8px; border: 1px solid #fcd34d; margin-bottom:24px;">
<tr><td style="padding: 16px;">
<p style="margin:0 0 4px; color:#92400e; font-size:14px; font-weight:600;">Ważność linku: 1 godzina</p>
<p style="margin:0; color:#92400e; font-size:13px;">Po tym czasie konieczne będzie ponowne żądanie resetu hasła.</p>
</td></tr>
</table>
<p style="margin:0 0 8px; color:#64748b; font-size:13px;">Jeśli przycisk nie działa, skopiuj i wklej ten link:</p>
<p style="margin:0 0 24px; color:#2563eb; font-size:13px; word-break:break-all;">{reset_url}</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:8px; border: 1px solid #e2e8f0;">
<tr><td style="padding: 16px;">
<p style="margin:0; color:#64748b; font-size:14px;">
<strong>Nie zażądałeś resetowania hasła?</strong><br>
Zignoruj ten email — Twoje hasło pozostanie bez zmian.
</p>
</td></tr>
</table>'''
body_html = _email_v3_wrap('Reset hasła', 'Norda Biznes Partner', content)
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."""
subject = "Witamy w Norda Biznes Partner — Potwierdź email"
body_text = f"""Witaj {name}!
Dziękujemy za rejestrację w Norda Biznes Partner.
Aby aktywować konto, kliknij: {verification_url}
Link ważny 24 godziny.
Pozdrawiamy,
Zespół Norda Biznes Partner
https://nordabiznes.pl
"""
check = (
'<div style="width:28px; height:28px; background:#16a34a; border-radius:50%; '
'text-align:center; line-height:28px; display:inline-block; margin-right:10px;">'
'<span style="color:#fff; font-size:16px; font-weight:bold;">&#10003;</span></div>'
)
content = f'''
<p style="margin:0 0 16px; color:#1e293b; font-size:16px;">Witaj <strong>{name}</strong>!</p>
<p style="margin:0 0 28px; color:#475569; font-size:15px; line-height:1.5;">Dziękujemy za rejestrację w Norda Biznes Partner — platformie networkingu Regionalnej Izby Przedsiębiorców.</p>
<p style="margin:0 0 16px; color:#1e293b; font-size:15px;">Aby aktywować konto, potwierdź swój adres email:</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:28px;">
<tr><td align="center" style="padding: 8px 0;">
<a href="{verification_url}" style="display:inline-block; padding:16px 40px; background: linear-gradient(135deg, #16a34a, #15803d); color:#ffffff; text-decoration:none; border-radius:8px; font-size:15px; font-weight:600;">Potwierdź email</a>
</td></tr>
</table>
<p style="margin:0 0 14px; color:#1e293b; font-size:16px; font-weight:600;">Co zyskujesz:</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fdf8; border-radius:10px; border: 1px solid #d1fae5; margin-bottom:28px;">
<tr><td style="padding: 14px 16px; border-bottom: 1px solid #e2e8f0;">
{check}<span style="color:#1e293b; font-size:15px; font-weight:500;">Katalog firm członkowskich Izby</span>
</td></tr>
<tr><td style="padding: 14px 16px; border-bottom: 1px solid #e2e8f0;">
{check}<span style="color:#1e293b; font-size:15px; font-weight:500;">Asystent AI do wyszukiwania usług</span>
</td></tr>
<tr><td style="padding: 14px 16px;">
{check}<span style="color:#1e293b; font-size:15px; font-weight:500;">Networking i kontakty biznesowe</span>
</td></tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#fef3c7; border-radius:8px; border: 1px solid #fcd34d;">
<tr><td style="padding: 14px 16px;">
<p style="margin:0; color:#92400e; font-size:13px;">Link aktywacyjny jest ważny przez <strong>24 godziny</strong>.</p>
</td></tr>
</table>'''
body_html = _email_v3_wrap('Witamy!', 'Norda Biznes Partner', content)
return send_email(
to=[email],
subject=subject,
body_text=body_text,
body_html=body_html,
email_type='welcome',
recipient_name=name
)