feat: Implement email sending utilities and templates for job notifications
Some checks failed
CI/CD Pipeline / test (push) Failing after 4m9s

- Added email_service.py for sending emails with SMTP configuration.
- Introduced email_templates.py to render job alert email subjects and bodies.
- Enhanced scraper.py to extract contact information from job listings.
- Updated settings.js to handle negative keyword input validation.
- Created email.html and email_templates.html for managing email subscriptions and templates in the admin interface.
- Modified base.html to include links for email alerts and templates.
- Expanded user settings.html to allow management of negative keywords.
- Updated utils.py to include functions for retrieving negative keywords and email settings.
- Enhanced job filtering logic to exclude jobs containing negative keywords.
This commit is contained in:
2025-11-28 18:15:08 +01:00
parent 8afb208985
commit 2185a07ff0
23 changed files with 2660 additions and 63 deletions

130
web/email_service.py Normal file
View File

@@ -0,0 +1,130 @@
"""Email sending utilities for the jobs scraper."""
from __future__ import annotations
from email.message import EmailMessage
from typing import Iterable, Sequence
import smtplib
from web.utils import get_email_settings
class EmailConfigurationError(RuntimeError):
"""Raised when email settings are missing or invalid."""
class EmailDeliveryError(RuntimeError):
"""Raised when an email fails to send."""
def _normalize_addresses(addresses: Sequence[str] | str | None) -> list[str]:
if not addresses:
return []
if isinstance(addresses, str):
items = [addresses]
else:
items = list(addresses)
cleaned: list[str] = []
seen: set[str] = set()
for raw in items:
if not isinstance(raw, str):
continue
addr = raw.strip()
if not addr:
continue
lower = addr.lower()
if lower in seen:
continue
seen.add(lower)
cleaned.append(addr)
return cleaned
def _ensure_recipients(*recipient_groups: Iterable[str]) -> list[str]:
merged: list[str] = []
seen: set[str] = set()
for group in recipient_groups:
for addr in group:
lower = addr.lower()
if lower in seen:
continue
seen.add(lower)
merged.append(addr)
if not merged:
raise EmailConfigurationError(
"At least one recipient address is required")
return merged
def send_email(
*,
subject: str,
body: str,
to: Sequence[str] | str,
cc: Sequence[str] | str | None = None,
bcc: Sequence[str] | str | None = None,
reply_to: Sequence[str] | str | None = None,
settings: dict | None = None,
) -> bool:
"""Send an email using configured SMTP settings.
Returns True when a message is sent, False when email is disabled.
Raises EmailConfigurationError for invalid config and EmailDeliveryError for SMTP failures.
"""
config = settings or get_email_settings()
if not config.get("enabled"):
return False
smtp_cfg = config.get("smtp", {})
host = (smtp_cfg.get("host") or "").strip()
if not host:
raise EmailConfigurationError("SMTP host is not configured")
port = int(smtp_cfg.get("port", 587) or 587)
timeout = int(smtp_cfg.get("timeout", 30) or 30)
use_ssl = bool(smtp_cfg.get("use_ssl", False))
use_tls = bool(smtp_cfg.get("use_tls", True))
from_address = (config.get("from_address")
or smtp_cfg.get("username") or "").strip()
if not from_address:
raise EmailConfigurationError("From address is not configured")
to_list = _normalize_addresses(to)
cc_list = _normalize_addresses(cc)
bcc_list = _normalize_addresses(bcc)
reply_to_list = _normalize_addresses(reply_to)
all_recipients = _ensure_recipients(to_list, cc_list, bcc_list)
message = EmailMessage()
message["Subject"] = subject
message["From"] = from_address
message["To"] = ", ".join(to_list)
if cc_list:
message["Cc"] = ", ".join(cc_list)
if reply_to_list:
message["Reply-To"] = ", ".join(reply_to_list)
message.set_content(body)
username = (smtp_cfg.get("username") or "").strip()
password = smtp_cfg.get("password") or ""
client_cls = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP
try:
with client_cls(host=host, port=port, timeout=timeout) as client:
client.ehlo()
if use_tls and not use_ssl:
client.starttls()
client.ehlo()
if username:
client.login(username, password)
client.send_message(message, from_addr=from_address,
to_addrs=all_recipients)
except EmailConfigurationError:
raise
except Exception as exc: # pragma: no cover - network errors depend on env
raise EmailDeliveryError(str(exc)) from exc
return True