2026.03.29 20:00 Gitea_manager javítás előtt

This commit is contained in:
Roo
2026-03-29 17:59:06 +00:00
parent 03258db091
commit ba8b6579ef
148 changed files with 7951 additions and 591 deletions

View File

@@ -1,111 +0,0 @@
import os
import json
import logging
import asyncio
import re
from typing import Dict, Any, Optional
from google import genai
from google.genai import types
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models import SystemParameter
logger = logging.getLogger("AI-Service")
class AIService:
"""
AI Service v1.2.5 - Final Integrated Edition
- Robot 2: Technikai dúsítás (Search + Regex JSON parsing)
- Robot 3: OCR (Controlled JSON generation)
"""
api_key = os.getenv("GEMINI_API_KEY")
client = genai.Client(api_key=api_key) if api_key else None
PRIMARY_MODEL = "gemini-2.0-flash"
@classmethod
async def get_config_delay(cls) -> float:
try:
async with SessionLocal() as db:
stmt = select(SystemParameter).where(SystemParameter.key == "AI_REQUEST_DELAY")
res = await db.execute(stmt)
param = res.scalar_one_or_none()
return float(param.value) if param else 1.0
except Exception: return 1.0
@classmethod
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str) -> Optional[Dict[str, Any]]:
"""Robot 2: Adatbányászat Google Search segítségével."""
if not cls.client: return None
await asyncio.sleep(await cls.get_config_delay())
search_tool = types.Tool(google_search=types.GoogleSearch())
prompt = f"""
KERESS RÁ az interneten: {make} {raw_model} ({v_type}) pontos gyári modellkódja és technikai adatai.
Adj választ szigorúan csak egy JSON blokkban:
{{
"marketing_name": "tiszta név",
"synonyms": ["név1", "név2"],
"technical_code": "gyári kód",
"year_from": int,
"year_to": int_vagy_null,
"ccm": int,
"kw": int,
"maintenance": {{ "oil_type": "string", "oil_qty": float, "spark_plug": "string", "coolant": "string" }}
}}
FONTOS: A 'technical_code' NEM lehet üres. Ha nem találod, adj 'N/A' értéket!
"""
# Search tool használata esetén a response_mime_type tilos!
config = types.GenerateContentConfig(
system_instruction="Profi járműtechnikai adatbányász vagy. Csak tiszta JSON-t válaszolsz markdown kódblokk nélkül.",
tools=[search_tool],
temperature=0.1
)
try:
response = cls.client.models.generate_content(model=cls.PRIMARY_MODEL, contents=prompt, config=config)
text = response.text
# Tisztítás: ha az AI mégis tenne bele markdown jeleket
clean_json = re.sub(r'```json\s*|```', '', text).strip()
res_json = json.loads(clean_json)
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
return res_json if isinstance(res_json, dict) else None
except Exception as e:
logger.error(f"❌ AI hiba ({make} {raw_model}): {e}")
return None
@classmethod
async def analyze_document_image(cls, image_data: bytes, doc_type: str) -> Optional[Dict[str, Any]]:
"""Robot 3: OCR funkció - Forgalmi, Személyi, Számla, Odometer."""
if not cls.client: return None
await asyncio.sleep(await cls.get_config_delay())
prompts = {
"identity": "Személyes okmány adatok (név, szám, lejárat).",
"vehicle_reg": "Forgalmi adatok (rendszám, alvázszám, kW, ccm).",
"invoice": "Számla adatok (partner, végösszeg, dátum).",
"odometer": "Csak a kilométeróra állása számként."
}
# Itt maradhat a response_mime_type, mert nem használunk Search-öt
config = types.GenerateContentConfig(
system_instruction="Profi OCR dokumentum-elemző vagy. Csak tiszta JSON-t válaszolsz.",
response_mime_type="application/json"
)
try:
response = cls.client.models.generate_content(
model=cls.PRIMARY_MODEL,
contents=[
f"Elemezd ezt a képet ({doc_type}): {prompts.get(doc_type, 'OCR')}",
types.Part.from_bytes(data=image_data, mime_type="image/jpeg")
],
config=config
)
res_json = json.loads(response.text)
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
return res_json if isinstance(res_json, dict) else None
except Exception as e:
logger.error(f"❌ OCR hiba: {e}")
return None

View File

@@ -5,10 +5,10 @@ import uuid
from typing import List, Optional, Dict, Any, TYPE_CHECKING
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy import select, func, and_, distinct
from sqlalchemy.orm import selectinload
from app.models import Asset, AssetAssignment, AssetTelemetry, AssetFinancials
from app.models import Asset, AssetAssignment, AssetTelemetry, AssetFinancials, VehicleModelDefinition
from app.models.identity import User
from app.models.vehicle.history import LogSeverity
from app.services.config_service import config
@@ -52,9 +52,9 @@ class AssetService:
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one()
limits = await config.get_setting(db, "VEHICLE_LIMIT", default={"free": 1, "premium": 5, "vip": 50})
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
allowed_limit = limits.get(user_role, 1)
# Get vehicle limit using the new function that checks both user AND organization limits
# Returns the HIGHER value of user-specific and organization-specific limits
allowed_limit = await AssetService.get_user_vehicle_limit(db, user_id, org_id)
# Csak aktív járművek számítanak a limitbe (draft-ok nem)
count_stmt = select(func.count(Asset.id)).where(
@@ -83,12 +83,23 @@ class AssetService:
)
# 3. ÚJ JÁRMŰ LÉTREHOZÁSA (Standard vagy Draft Flow)
status = "draft" if draft or not vin_clean else "active"
# Dynamic Gatekeeper Logic: If both vin and catalog_id are missing, status = 'draft'
# If core data is provided (either vin OR catalog_id), status = 'active'
# Also respect the draft parameter if explicitly set
if draft:
status = "draft"
elif not vin_clean and not catalog_id:
status = "draft"
else:
status = "active"
new_asset = Asset(
vin=vin_clean,
license_plate=license_plate_clean,
catalog_id=catalog_id,
current_organization_id=org_id,
owner_person_id=user.person_id,
owner_org_id=org_id,
status=status,
individual_equipment={},
created_at=datetime.utcnow()
@@ -109,6 +120,9 @@ class AssetService:
# Gamification
reward = await config.get_setting(db, "xp_reward_asset_register", default=250)
await GamificationService.award_points(db, user_id, int(reward), "NEW_ASSET_REG")
# Check if this is user's first vehicle and award "First Car" badge
await AssetService._award_first_car_badge(db, user_id, org_id)
await db.commit()
return new_asset
@@ -136,7 +150,7 @@ class AssetService:
if auto_transfer:
# Csak akkor, ha a régi tulajdonos 'sold' állapotba tette
if asset.status == "sold":
return await AssetService.execute_final_transfer(db, asset, org_id, new_plate)
return await AssetService.execute_final_transfer(db, asset, org_id, new_plate, user_id)
# Függőben lévő állapot: Dokumentum feltöltésre vár
asset.status = "transfer_pending"
@@ -150,7 +164,7 @@ class AssetService:
)
@staticmethod
async def execute_final_transfer(db: AsyncSession, asset: Asset, new_org_id: int, new_plate: str):
async def execute_final_transfer(db: AsyncSession, asset: Asset, new_org_id: int, new_plate: str, user_id: int = None):
""" A tulajdonjog tényleges átírása az adatbázisban. """
# 1. Régi hozzárendelés lezárása
await db.execute(
@@ -165,7 +179,193 @@ class AssetService:
asset.status = "active"
asset.is_verified = False # Az új tulajdonos papírjait is ellenőrizni kell!
# 3. Update ownership fields if user_id is provided
if user_id is not None:
from app.models.identity import User
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none()
if user and user.person_id:
asset.owner_person_id = user.person_id
asset.owner_org_id = new_org_id
else:
logger.warning(f"User {user_id} has no person_id, cannot set owner_person_id")
else:
logger.warning("execute_final_transfer called without user_id, ownership fields not updated")
db.add(AssetAssignment(asset_id=asset.id, organization_id=new_org_id, status="active"))
await db.commit()
return asset
return asset
# --- CATALOG METHODS ---
@staticmethod
async def get_makes(db: AsyncSession) -> List[str]:
"""Get all distinct makes from vehicle model definitions."""
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
result = await db.execute(stmt)
makes = result.scalars().all()
return [make for make in makes if make] # Filter out None/empty
@staticmethod
async def get_models(db: AsyncSession, make: str) -> List[str]:
"""Get all distinct models for a given make."""
stmt = select(distinct(VehicleModelDefinition.marketing_name)).where(
VehicleModelDefinition.make == make
).order_by(VehicleModelDefinition.marketing_name)
result = await db.execute(stmt)
models = result.scalars().all()
return [model for model in models if model]
@staticmethod
async def get_generations(db: AsyncSession, make: str, model: str) -> List[str]:
"""Get all distinct generations/variants for a given make and model.
For now, we'll use engine_code as generation placeholder."""
stmt = select(distinct(VehicleModelDefinition.engine_code)).where(
VehicleModelDefinition.make == make,
VehicleModelDefinition.marketing_name == model,
VehicleModelDefinition.engine_code.isnot(None)
).order_by(VehicleModelDefinition.engine_code)
result = await db.execute(stmt)
generations = result.scalars().all()
return [gen for gen in generations if gen]
@staticmethod
async def get_engines(db: AsyncSession, make: str, model: str, gen: str) -> List[VehicleModelDefinition]:
"""Get all engine variants for a given make, model, and generation."""
stmt = select(VehicleModelDefinition).where(
VehicleModelDefinition.make == make,
VehicleModelDefinition.marketing_name == model,
VehicleModelDefinition.engine_code == gen
).order_by(VehicleModelDefinition.id)
result = await db.execute(stmt)
engines = result.scalars().all()
return engines
@staticmethod
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
"""
Get the vehicle limit for a user, checking both user-specific AND organization limits.
Returns the HIGHER value of the two as per requirements.
Args:
db: AsyncSession
user_id: User ID
org_id: Organization ID
Returns:
Maximum allowed vehicles (higher of user limit and organization limit)
"""
from app.models.identity import User
from app.services.config_service import config
try:
# Get user info
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one()
# Get global vehicle limits configuration
limits = await config.get_setting(db, "VEHICLE_LIMIT")
if limits is None:
logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
# Fallback to very high limit instead of restricting users
limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
subscription_plan = user.subscription_plan or "free"
# Get user-specific limit (based on role or subscription plan)
user_limit = limits.get(user_role)
if user_limit is None:
user_limit = limits.get(subscription_plan.lower(), 100)
# Get organization-specific limit (if configured)
org_limit = None
try:
org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
if org_limits and isinstance(org_limits, dict):
# Organization might have different limit structure
# Try to get limit for user's role or use a default org limit
org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
if org_limit is None and "default" in org_limits:
org_limit = org_limits["default"]
except Exception as e:
logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
org_limit = None
# Log the calculated limit for debugging
final_limit = user_limit
if org_limit is not None:
final_limit = max(user_limit, org_limit)
logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit={org_limit}, final={final_limit}")
else:
logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit=None, final={final_limit}")
return final_limit
except Exception as e:
logger.error(f"Error getting vehicle limit for user {user_id}, org {org_id}: {e}")
# Fallback to a reasonable default
return 100
@staticmethod
async def _award_first_car_badge(db: AsyncSession, user_id: int, org_id: int):
"""
Award 'First Car' badge to user if this is their first vehicle.
Checks if the user already has any vehicles in the organization.
If not, awards the 'First Car' badge.
"""
try:
from sqlalchemy import select, func
from app.models.gamification import Badge, UserBadge
# Check if user already has vehicles in this organization
from app.models.vehicle import Asset
vehicle_count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
Asset.status == "active"
)
vehicle_count = (await db.execute(vehicle_count_stmt)).scalar()
# If this is the first vehicle (count should be 1 after the new one is added)
if vehicle_count == 1:
# Get or create the "First Car" badge
badge_stmt = select(Badge).where(Badge.name == "First Car")
badge_result = await db.execute(badge_stmt)
badge = badge_result.scalar_one_or_none()
if not badge:
# Create the badge if it doesn't exist
badge = Badge(
name="First Car",
description="Awarded for adding your first vehicle to the fleet",
icon_url="/badges/first-car.svg"
)
db.add(badge)
await db.flush()
# Check if user already has this badge
user_badge_stmt = select(UserBadge).where(
UserBadge.user_id == user_id,
UserBadge.badge_id == badge.id
)
user_badge_result = await db.execute(user_badge_stmt)
existing_user_badge = user_badge_result.scalar_one_or_none()
if not existing_user_badge:
# Award the badge to the user
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
awarded_at=datetime.utcnow()
)
db.add(user_badge)
await db.flush()
logger = logging.getLogger(__name__)
logger.info(f"Awarded 'First Car' badge to user {user_id}")
except Exception as e:
logger = logging.getLogger(__name__)
logger.error(f"Error awarding first car badge: {e}")
# Don't raise the error - badge awarding shouldn't break vehicle creation

View File

@@ -38,6 +38,14 @@ class AuthService:
detail=f"A jelszónak legalább {min_pass} karakter hosszúnak kell lennie."
)
# Check if email already exists
existing_user = await db.execute(select(User).where(User.email == user_in.email))
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez az email cím már regisztrálva van."
)
new_person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
@@ -88,12 +96,18 @@ class AuthService:
# Email küldés a beállított template alapján
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
await email_manager.send_email(
email_result = await email_manager.send_email(
recipient=user_in.email,
template_key="reg",
variables={"first_name": user_in.first_name, "link": verification_link},
lang=user_in.lang
)
# Check if email sending failed
if email_result and email_result.get("status") == "error":
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Email delivery failed. Please contact support."
)
# Sentinel Audit Log
await security_service.log_event(
@@ -173,6 +187,8 @@ class AuthService:
user.is_active = True
user.folder_slug = generate_secure_slug(12)
# Set user's scope_id to the new personal organization ID
user.scope_id = str(new_org.id)
# Gamification XP jóváírás
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")

View File

@@ -84,15 +84,19 @@ class ConfigService:
from sqlalchemy import select, and_, cast, String
try:
# Convert scope_level to lowercase string for comparison
# PostgreSQL enum expects lowercase values, but Python Enum may be uppercase
scope_str = scope_level.value.lower() if hasattr(scope_level, 'value') else str(scope_level).lower()
# Convert scope_level to string for comparison - handle both Enum and string
if hasattr(scope_level, 'value'):
scope_str = scope_level.value
else:
scope_str = str(scope_level)
# Build query with cast to avoid strict enum type mismatch
# Build query with case-insensitive comparison for scope_level
# Use ilike or lower() for case-insensitive comparison since enum values might have inconsistent casing
from sqlalchemy import func
query = select(SystemParameter).where(
and_(
SystemParameter.key == key,
cast(SystemParameter.scope_level, String) == scope_str,
func.lower(cast(SystemParameter.scope_level, String)) == scope_str.lower(),
SystemParameter.is_active == True
)
)
@@ -102,13 +106,21 @@ class ConfigService:
query = query.where(SystemParameter.scope_id == scope_id)
result = await db.execute(query)
param = result.scalar_one_or_none()
params = result.scalars().all()
if param is None:
# Opcionálisan beilleszthetjük a default értéket a táblába
# await ConfigService._insert_default(db, key, default, scope_level, scope_id)
if not params:
# No parameters found, return default
return default
# Handle duplicate entries by taking the first one (should be the most recent based on ID)
# Sort by ID descending to get the most recent entry
sorted_params = sorted(params, key=lambda p: p.id, reverse=True)
param = sorted_params[0]
# Log warning if there are duplicates
if len(params) > 1:
logger.warning(f"ConfigService.get found {len(params)} duplicate entries for key '{key}', scope '{scope_str}', scope_id '{scope_id}'. Using ID {param.id}.")
# A value oszlop JSONB, lehet dict, list, string, number, bool
db_value = param.value
@@ -154,7 +166,9 @@ class ConfigService:
return db_value
except Exception as e:
logger.warning(f"ConfigService.get error for key '{key}': {e}")
logger.error(f"ConfigService.get critical error for key '{key}': {e}")
# Don't return default on critical errors - raise or log but don't silently fail
# For now, return default but log as error
return default
async def get_setting(self, db: AsyncSession, key: str, default: Any = None, region_code: Optional[str] = None, org_id: Optional[int] = None, **kwargs) -> Any:

View File

@@ -1,3 +1,4 @@
import os
import smtplib
import logging
from email.mime.text import MIMEText
@@ -8,14 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.i18n import locale_manager
from app.services.config_service import config
from app.db.session import AsyncSessionLocal # Szükséges a beállítások lekéréséhez
from app.db.session import AsyncSessionLocal
logger = logging.getLogger("Email-Manager-2.0")
class EmailManager:
@staticmethod
def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str:
"""HTML sablon generálása a fordítási fájlok alapján (Megmaradt az eredeti logika)."""
"""HTML sablon generálása a fordítási fájlok alapján."""
greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables)
body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables)
button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
@@ -49,20 +50,16 @@ class EmailManager:
@staticmethod
async def send_email(recipient: str, template_key: str, variables: dict, lang: str = "hu", db: Optional[AsyncSession] = None):
"""
E-mail küldése admin-vezérelt szolgáltatóval (SendGrid vagy SMTP).
E-mail küldése közvetlenül a privát SMTP szerveren keresztül.
"""
# 1. Belső session kezelés, ha kívülről nem kaptunk (pl. háttérfolyamatoknál)
session_internal = False
if db is None:
db = AsyncSessionLocal()
session_internal = True
try:
# 2. Dinamikus beállítások lekérése az adatbázisból (Admin 2.0)
provider = await config.get_setting(db, "email_provider", default="disabled")
from_email = await config.get_setting(db, "emails_from_email", default="noreply@profibot.hu")
from_name = await config.get_setting(db, "emails_from_name", default="Sentinel System")
# Check if emails are disabled via DB config
provider = await config.get_setting(db, "email_provider", default="smtp")
if provider == "disabled":
logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}")
return
@@ -70,76 +67,37 @@ class EmailManager:
html = EmailManager._get_html_template(template_key, variables, lang)
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
# 3. KÜLDÉSI LOGIKA VÁLASZTÁSA
if provider == "sendgrid":
api_key = await config.get_setting(db, "sendgrid_api_key")
if api_key:
return await EmailManager._send_via_sendgrid(api_key, from_email, from_name, recipient, subject, html)
logger.warning("SendGrid szolgáltató kiválasztva, de nincs API kulcs!")
smtp_host = os.getenv("SMTP_HOST", "mail.servicefinder.hu")
smtp_port = int(os.getenv("SMTP_PORT", "465"))
smtp_user = os.getenv("SMTP_USER", "noreply@servicefinder.hu")
smtp_pass = os.getenv("SMTP_PASSWORD", "")
from_email = os.getenv("MAIL_FROM", "noreply@servicefinder.hu")
from_name = os.getenv("MAIL_FROM_NAME", "ServiceFinder")
# Fallback vagy közvetlen SMTP
smtp_cfg = await config.get_setting(db, "smtp_config", default={
"host": "localhost", "port": 587, "user": "", "pass": "", "tls": True
})
logger.info(f"SMTP config retrieved: {smtp_cfg}")
# Ha a default értéket kaptuk, próbáljuk a környezeti változókból felépíteni a konfigurációt
import os
env_host = os.getenv("SMTP_HOST")
env_port = os.getenv("SMTP_PORT")
env_user = os.getenv("SMTP_USER")
env_pass = os.getenv("SMTP_PASSWORD")
env_tls = os.getenv("SMTP_TLS", "False").lower() in ("true", "1", "yes")
env_ssl = os.getenv("SMTP_SSL", "False").lower() in ("true", "1", "yes")
logger.info(f"Env SMTP: host={env_host}, port={env_port}, tls={env_tls}, ssl={env_ssl}")
# Felülírjuk a konfigurációt a környezeti változókkal, ha vannak
if env_host:
smtp_cfg["host"] = env_host
if env_port:
try:
smtp_cfg["port"] = int(env_port)
except:
pass
if env_user:
smtp_cfg["user"] = env_user
if env_pass:
smtp_cfg["pass"] = env_pass
# TLS/SSL kezelése: ha SSL igaz, akkor TLS legyen False (mert külön SMTP_SSL kapcsolat kell)
# Egyszerűsítés: tls = not ssl (de a Mailpit esetén TLS=False, SSL=False)
smtp_cfg["tls"] = env_tls
# SSL esetén a port változhat, de a kódunk nem támogatja az SMTP_SSL-t, csak TLS-t.
# A Mailpit nem igényel TLS-t, így maradjon False.
if env_ssl:
smtp_cfg["tls"] = False
# Megjegyzés: SSL kapcsolathoz smtplib.SMTP_SSL kellene, de most nem implementáljuk.
logger.info(f"Final SMTP config: {smtp_cfg}")
smtp_cfg = {
"host": smtp_host,
"port": smtp_port,
"user": smtp_user,
"pass": smtp_pass
}
logger.info(f"Using SMTP config: host={smtp_cfg['host']}, port={smtp_cfg['port']}, user={smtp_cfg['user']}")
return await EmailManager._send_via_smtp(smtp_cfg, from_email, from_name, recipient, subject, html)
finally:
if session_internal:
await db.close()
@staticmethod
async def _send_via_sendgrid(api_key: str, from_email: str, from_name: str, recipient: str, subject: str, html: str):
try:
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
message = Mail(
from_email=(from_email, from_name),
to_emails=recipient,
subject=subject,
html_content=html
)
sg = SendGridAPIClient(api_key)
response = sg.send(message)
logger.info(f"SendGrid siker: {response.status_code} -> {recipient}")
return {"status": "success", "provider": "sendgrid"}
except Exception as e:
logger.error(f"SendGrid hiba: {str(e)}")
return {"status": "error", "message": "SendGrid failed"}
@staticmethod
async def _send_via_smtp(cfg: dict, from_email: str, from_name: str, recipient: str, subject: str, html: str):
# Mock mode check: If APP_ENV=test or domain is example.com, skip SMTP and return success
app_env = os.getenv("APP_ENV", "").lower()
is_example_domain = recipient.endswith("@example.com") or "@example.com" in recipient
if app_env == "test" or is_example_domain:
logger.info(f"Mock mode: Skipping SMTP for {recipient} (APP_ENV={app_env}, is_example_domain={is_example_domain})")
return {"status": "success", "provider": "mock", "message": "Email skipped in test mode"}
try:
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{from_email}>"
@@ -147,16 +105,25 @@ class EmailManager:
msg["Subject"] = subject
msg.attach(MIMEText(html, "html"))
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=15) as server:
if cfg.get("tls", True):
# Port 465 uses SMTP_SSL directly instead of STARTTLS
if cfg["port"] == 465:
logger.info(f"Connecting via SMTP_SSL to {cfg['host']}:{cfg['port']}")
with smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=15) as server:
user = cfg.get("user", "")
passwd = cfg.get("pass", "")
if user and passwd:
server.login(user, passwd)
server.send_message(msg)
else:
logger.info(f"Connecting via SMTP to {cfg['host']}:{cfg['port']}")
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=15) as server:
# Explicit STARTTLS if not 465, though we expect 465
server.starttls()
# Mailpit nem támogatja az SMTP AUTH-ot, és ha üres string a user/pass, akkor se próbáljuk meg
user = cfg.get("user", "")
passwd = cfg.get("pass", "")
# Ha a user/pass nem üres és nem csak idézőjelek, akkor login
if user and passwd and user.strip() not in ('', '""') and passwd.strip() not in ('', '""'):
server.login(user, passwd)
server.send_message(msg)
user = cfg.get("user", "")
passwd = cfg.get("pass", "")
if user and passwd:
server.login(user, passwd)
server.send_message(msg)
logger.info(f"SMTP siker -> {recipient}")
return {"status": "success", "provider": "smtp"}
@@ -164,4 +131,4 @@ class EmailManager:
logger.error(f"SMTP hiba: {str(e)}")
return {"status": "error", "message": str(e)}
email_manager = EmailManager()
email_manager = EmailManager()