diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..a7acdbd Binary files /dev/null and b/.coverage differ diff --git a/.env.example b/.env.example index 599e264..857e06d 100644 --- a/.env.example +++ b/.env.example @@ -12,12 +12,12 @@ ADMIN_PASSWORD=change_me CORS_ORIGINS=http://localhost:3000,http://localhost:5173,https://eventradar.dev VITE_API_URL=http://localhost:8000 -# Email (SMTP) -SMTP_HOST=smtp.gmail.com +# Email (SMTP) — bkz. README "E-posta ve Tarayıcı Bildirimleri" (Resend önerilir) +SMTP_HOST=smtp.resend.com SMTP_PORT=587 -SMTP_USER= +SMTP_USER=resend SMTP_PASS= -SMTP_FROM=noreply@eventradar.dev +SMTP_FROM=TechEventRadar # Telegram TELEGRAM_BOT_TOKEN= diff --git a/README.md b/README.md index e45c9c6..db01d47 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,19 @@ docker compose run --rm scraper python scripts/run_daily_scrape.py ## E-posta ve Tarayıcı Bildirimleri -E-posta aboneliği double opt-in ile çalışır: kullanıcı onay bağlantısını açtıktan sonra haftalık özete dahil edilir. Gmail SMTP kullanıyorsan normal hesap şifresi yerine Google Uygulama Şifresi kullan: +E-posta aboneliği double opt-in ile çalışır: kullanıcı onay bağlantısını açtıktan sonra haftalık özete dahil edilir. + +**Önerilen: Resend (kendi domain'inden gönderim).** `eventradar.dev`'i [resend.com](https://resend.com) üzerinde doğrulayıp (SPF/DKIM/DMARC kayıtları) API key aldıktan sonra: + +```env +SMTP_HOST=smtp.resend.com +SMTP_PORT=587 +SMTP_USER=resend +SMTP_PASS= +SMTP_FROM=TechEventRadar +``` + +**Alternatif: Gmail SMTP** (domain doğrulaması yapmak istemiyorsan, normal hesap şifresi yerine Google Uygulama Şifresi kullan — bu durumda `SMTP_FROM` mutlaka kimlik doğrulanan Gmail adresiyle aynı olmalı, aksi halde SPF/DKIM eşleşmez ve mailler spam'e düşer): ```env SMTP_HOST=smtp.gmail.com diff --git a/app/services/email_service.py b/app/services/email_service.py index 4fd11cf..651fcfa 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -3,6 +3,7 @@ from html import escape from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid, parseaddr from markdown import markdown from ..core.config import settings @@ -16,42 +17,147 @@ def _is_configured() -> bool: return bool(settings.smtp_host and settings.smtp_user and settings.smtp_pass) -def _send(to: str, subject: str, html_body: str) -> bool: - """SMTP üzerinden HTML e-posta gönderir. Başarılıysa True döner.""" +def _send( + to: str, + subject: str, + html_body: str, + *, + text_body: str = "", + unsubscribe_url: str | None = None, +) -> bool: + """SMTP üzerinden e-posta gönderir (düz metin + HTML). Başarılıysa True döner.""" if not _is_configured(): logger.info( "[DEV] SMTP yapılandırılmamış, e-posta gönderilmedi: %s -> %s", to, subject ) return False + from_addr = settings.smtp_from + _, from_email = parseaddr(from_addr) + domain = from_email.split("@")[-1] if "@" in from_email else "eventradar.dev" + msg = MIMEMultipart("alternative") msg["Subject"] = subject - msg["From"] = settings.smtp_from + msg["From"] = from_addr msg["To"] = to + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = make_msgid(domain=domain) + if unsubscribe_url: + # RFC 2369 — spam filtreleri ve mail sağlayıcıları (Gmail/Outlook) bu + # başlığı görünce gönderen adına "Abonelikten çık" kısayolu gösterir, + # bu da bulk mail'de kullanıcıların "spam" tuşlaması yerine düzgün + # çıkış yapmasını sağlar ve deliverability'yi iyileştirir. + msg["List-Unsubscribe"] = f"<{unsubscribe_url}>" + # Düz metin parçası önce eklenmeli: multipart/alternative'de son parça + # tercih edilen (HTML) olarak render edilir, ama düz metin alternatifinin + # varlığı da spam filtreleri için önemli bir sinyaldir. + msg.attach(MIMEText(text_body or _html_to_text(html_body), "plain", "utf-8")) msg.attach(MIMEText(html_body, "html", "utf-8")) try: with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as server: server.starttls() server.login(settings.smtp_user, settings.smtp_pass) - server.sendmail(settings.smtp_from, to, msg.as_string()) + server.sendmail(from_email, to, msg.as_string()) return True except Exception as exc: logger.error("E-posta gönderilemedi (%s): %s", to, exc) return False +def _html_to_text(html_body: str) -> str: + """Kaba bir HTML→düz metin dönüşümü (yalnızca fallback için).""" + import re + + text = re.sub(r"<(script|style)[^>]*>.*?", "", html_body, flags=re.S) + text = re.sub(r"<(br|/p|/div|/tr|/h[1-6])\s*/?>", "\n", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n\s*\n+", "\n\n", text) + return text.strip() + + +# Marka renkleri (frontend/src/index.css ile aynı palet) +ACCENT = "#38BDF8" +ACCENT_DARK = "#0EA5E9" +INK = "#0B1120" +BODY_TEXT = "#334155" +MUTED_TEXT = "#94A3B8" +CARD_BG = "#FFFFFF" +PAGE_BG = "#F1F5F9" +BORDER = "#E2E8F0" +FONT_STACK = ( + "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif" +) + + +def _button(url: str, label: str) -> str: + return ( + f'{label}' + ) + + +def _wrapper(inner_html: str, unsubscribe_url: str | None = None) -> str: + """Tüm e-postalar için ortak, marka uyumlu HTML iskeleti.""" + footer_unsub = ( + f' · Abonelikten çık' + if unsubscribe_url + else "" + ) + return f"""\ + + + + + + + +
+ + + + + + + + + + +
+ + + TechEventRadar + +
+ {inner_html} +
+ Türkiye'deki tech etkinliklerini tek yerde topluyoruz.
+ eventradar.dev{footer_unsub} +
+
+ +""" + + def send_confirmation_email(email: str, confirm_token: str) -> bool: confirm_url = f"{SITE_URL}/abone-onay?token={confirm_token}" - html = f""" -
-

TechEventRadar aboneliğini onayla

-

Haftalık etkinlik özetlerini almak için aboneliğini onaylaman gerekiyor.

-

Aboneliği Onayla

-

Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.

-
+ inner = f""" +

Aboneliğini onayla

+

Haftalık etkinlik özetlerini almak için aboneliğini onaylaman gerekiyor.

+

{_button(confirm_url, "Aboneliği Onayla")}

+

Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.

""" - return _send(email, "TechEventRadar aboneliğini onayla", html) + text = ( + "Aboneliğini onayla\n\n" + "Haftalık etkinlik özetlerini almak için aboneliğini onaylaman gerekiyor.\n\n" + f"Onayla: {confirm_url}\n\n" + "Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin." + ) + return _send( + email, "TechEventRadar aboneliğini onayla", _wrapper(inner), text_body=text + ) def send_weekly_digest_email( @@ -60,24 +166,42 @@ def send_weekly_digest_email( unsubscribe_url = f"{SITE_URL}/abone-iptal?token={unsubscribe_token}" if events: items = "".join( - f'
  • {e["title"]}' - f' ({e.get("source", "")})
  • ' + f'' + f'{e["title"]}' + f'
    {e.get("source", "")}
    ' + "" for e in events[:15] ) - body = f"

    Bu hafta eklenen etkinlikler:

      {items}
    " + body = f'{items}
    ' else: - body = "

    Bu hafta yeni etkinlik eklenmedi.

    " + body = f'

    Bu hafta yeni etkinlik eklenmedi.

    ' - html = f""" -
    -

    Haftalık Etkinlik Özeti

    + inner = f""" +

    Haftalık Etkinlik Özeti

    +

    Bu hafta eklenen etkinlikler

    {body} -

    Tüm etkinlikleri gör →

    -
    -

    Abonelikten çık

    -
    +

    {_button(SITE_URL, "Tüm Etkinlikleri Gör")}

    """ - return _send(email, "TechEventRadar — Haftalık Etkinlik Özeti", html) + if events: + text_items = "\n".join( + f'- {e["title"]} ({e.get("source", "")}): {SITE_URL}/etkinlik/{e["id"]}' + for e in events[:15] + ) + text_body = f"Bu hafta eklenen etkinlikler:\n\n{text_items}" + else: + text_body = "Bu hafta yeni etkinlik eklenmedi." + text = ( + f"Haftalık Etkinlik Özeti\n\n{text_body}\n\n" + f"Tüm etkinlikleri gör: {SITE_URL}\n\n" + f"Abonelikten çık: {unsubscribe_url}" + ) + return _send( + email, + "TechEventRadar — Haftalık Etkinlik Özeti", + _wrapper(inner, unsubscribe_url), + text_body=text, + unsubscribe_url=unsubscribe_url, + ) def send_weekly_blog_email( @@ -94,15 +218,22 @@ def send_weekly_blog_email( rendered_content = markdown(escape(content)) safe_title = escape(title) safe_summary = escape(summary) - html = f""" -
    -

    {safe_title}

    -

    {safe_summary}

    -
    - {rendered_content} -

    Yazıyı EventRadar'da aç →

    -
    -

    Abonelikten çık

    -
    + inner = f""" +

    {safe_title}

    +

    {safe_summary}

    +
    +
    {rendered_content}
    +

    {_button(post_url, "Yazıyı EventRadar'da Aç")}

    """ - return _send(email, title, html) + text = ( + f"{title}\n\n{summary}\n\n{content}\n\n" + f"Yazıyı EventRadar'da aç: {post_url}\n\n" + f"Abonelikten çık: {unsubscribe_url}" + ) + return _send( + email, + title, + _wrapper(inner, unsubscribe_url), + text_body=text, + unsubscribe_url=unsubscribe_url, + ) diff --git a/app/services/notification_service.py b/app/services/notification_service.py index e511d67..2fd2fad 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -2,6 +2,7 @@ import logging from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from email.utils import formatdate, make_msgid, parseaddr from sqlalchemy.orm import Session from ..models.subscriber import Subscriber from ..schemas.subscriber import BroadcastRequest @@ -49,7 +50,14 @@ def broadcast_message(self, request: BroadcastRequest) -> dict: try: if sub.channel == "email": self._send_email( - str(sub.contact_info), "EventRadar Bildirimi", request.message + str(sub.contact_info), + "EventRadar Bildirimi", + request.message, + unsubscribe_token=( + str(sub.unsubscribe_token) + if sub.unsubscribe_token is not None + else None + ), ) elif sub.channel == "telegram": self._send_telegram(str(sub.contact_info), request.message) @@ -65,21 +73,35 @@ def broadcast_message(self, request: BroadcastRequest) -> dict: "failed_count": failed, } - def _send_email(self, to: str, subject: str, body: str) -> None: + def _send_email( + self, to: str, subject: str, body: str, unsubscribe_token: str | None = None + ) -> None: if settings.debug or not settings.smtp_host: logger.info("[DEV] Email to %s: %s", to, body) return + from_addr = settings.smtp_from + _, from_email = parseaddr(from_addr) + domain = from_email.split("@")[-1] if "@" in from_email else "eventradar.dev" + msg = MIMEMultipart("alternative") msg["Subject"] = subject - msg["From"] = settings.smtp_from + msg["From"] = from_addr msg["To"] = to + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = make_msgid(domain=domain) + if unsubscribe_token: + unsubscribe_url = ( + f"https://eventradar.dev/abone-iptal?token={unsubscribe_token}" + ) + msg["List-Unsubscribe"] = f"<{unsubscribe_url}>" + body = f"{body}\n\nAbonelikten çık: {unsubscribe_url}" msg.attach(MIMEText(body, "plain", "utf-8")) with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server: server.starttls() server.login(settings.smtp_user, settings.smtp_pass) - server.sendmail(settings.smtp_from, to, msg.as_string()) + server.sendmail(from_email, to, msg.as_string()) def _send_telegram(self, chat_id: str, message: str) -> None: if settings.debug or not settings.telegram_bot_token: diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index b91a646..851470a 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -58,7 +58,10 @@ def test_broadcast_email_calls_send_email(test_db): result = service.broadcast_message(req) mock_send.assert_called_once_with( - "test@example.com", "EventRadar Bildirimi", "Hello there!" + "test@example.com", + "EventRadar Bildirimi", + "Hello there!", + unsubscribe_token=None, ) assert result["recipient_count"] == 1 assert result["failed_count"] == 0