feat: Implement email sending utilities and templates for job notifications
Some checks failed
CI/CD Pipeline / test (push) Failing after 4m9s
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:
130
web/email_service.py
Normal file
130
web/email_service.py
Normal 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
|
||||
Reference in New Issue
Block a user