Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
0cad79c
feat: add GraphQL API for snapshot subscription management
HarshitVerma109 Jun 26, 2026
0f4f87b
feat: add ProjectSubscriptionPreference model and update GraphQL API
HarshitVerma109 Jul 3, 2026
9cb8cf3
update code
HarshitVerma109 Jul 4, 2026
f774a5f
update tests
HarshitVerma109 Jul 4, 2026
bfed2f9
fix issues
HarshitVerma109 Jul 4, 2026
7481c1d
Add support for multiple subscriptions
HarshitVerma109 Jul 21, 2026
9f55a62
update code
HarshitVerma109 Jul 22, 2026
5aab13d
resolve conflicts
HarshitVerma109 Jul 22, 2026
1367cfb
fix issues
HarshitVerma109 Jul 22, 2026
78be5c6
Merge remote-tracking branch 'upstream/feature/community-snapshots' i…
HarshitVerma109 Jul 22, 2026
9b7a018
fix sonar issue
HarshitVerma109 Jul 23, 2026
b3608f3
update code
HarshitVerma109 Jul 24, 2026
a23c2d6
fix comment
HarshitVerma109 Jul 24, 2026
65635ad
fix comments
HarshitVerma109 Jul 24, 2026
37db804
feat: add settings page for snapshot subscription preferences
HarshitVerma109 Jul 4, 2026
13597eb
update tests
HarshitVerma109 Jul 4, 2026
7de6d3c
fix sonar issues
HarshitVerma109 Jul 4, 2026
2f1eeb1
fix: issues
HarshitVerma109 Jul 4, 2026
3253186
fix: sonar issue
HarshitVerma109 Jul 4, 2026
af9d7d0
fix comments
HarshitVerma109 Jul 8, 2026
5d60e40
fix comment
HarshitVerma109 Jul 8, 2026
b7fd6f0
update code
HarshitVerma109 Jul 8, 2026
c52062f
fix buttons
HarshitVerma109 Jul 9, 2026
8d560e5
fix button
HarshitVerma109 Jul 9, 2026
8a2a8ed
update code
HarshitVerma109 Jul 25, 2026
4fad0f1
fix sonar issue
HarshitVerma109 Jul 25, 2026
9dd24a4
feat: add email service abstraction layer
HarshitVerma109 Jul 12, 2026
f9f3a3b
update code
HarshitVerma109 Aug 2, 2026
b53c355
fix code
HarshitVerma109 Aug 2, 2026
4dfc9b5
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 Aug 2, 2026
f525f0c
update code
HarshitVerma109 Aug 4, 2026
cc409ad
fix issues
HarshitVerma109 Aug 4, 2026
ad4aa04
update code
HarshitVerma109 Aug 5, 2026
dcf4412
update code
HarshitVerma109 Aug 6, 2026
1147724
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 Aug 6, 2026
35ece3f
update code
HarshitVerma109 Aug 6, 2026
d383fed
Merge branch 'feat/snapshot-subscription-frontend' into feat/email-se…
HarshitVerma109 Aug 6, 2026
43d9a81
update code
HarshitVerma109 Aug 10, 2026
8424625
fix code
HarshitVerma109 Aug 10, 2026
8dc91c3
update code
HarshitVerma109 Aug 10, 2026
82f392d
update code
HarshitVerma109 Aug 10, 2026
156df98
update code
HarshitVerma109 Aug 10, 2026
5f8549b
fix issues
HarshitVerma109 Aug 10, 2026
3ca5a52
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 Aug 10, 2026
f2cef1c
fix issues
HarshitVerma109 Aug 11, 2026
193b8d6
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 Aug 11, 2026
ca54835
update code
HarshitVerma109 Aug 11, 2026
4b41651
fix issues
HarshitVerma109 Aug 11, 2026
c2a4084
update code
HarshitVerma109 Aug 11, 2026
5d5c665
fix sonar issue
HarshitVerma109 Aug 11, 2026
1f91b6f
update code
HarshitVerma109 Aug 12, 2026
fb80c6e
update code
HarshitVerma109 Aug 12, 2026
2d012dd
fix issue
HarshitVerma109 Aug 12, 2026
c75fbae
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 Aug 12, 2026
ea25297
update code
HarshitVerma109 Aug 12, 2026
88b3721
fix issue
HarshitVerma109 Aug 12, 2026
d9703a1
Merge branch 'feat/snapshot-subscription-frontend' into feat/email-se…
HarshitVerma109 Aug 13, 2026
2bf8b4d
Snapshot subscription backend
HarshitVerma109 Aug 17, 2026
a867959
update code
HarshitVerma109 Aug 17, 2026
8e2855c
fix issues
HarshitVerma109 Aug 17, 2026
9ac7dc8
Snapshot subscription frontend
HarshitVerma109 Aug 18, 2026
84f3738
fix issue
HarshitVerma109 Aug 18, 2026
efbac85
update code
HarshitVerma109 Aug 18, 2026
1cfc3e3
Merge branch 'feat/snapshot-subscription-frontend' into feat/email-se…
HarshitVerma109 Aug 18, 2026
2827bd5
update code
HarshitVerma109 Aug 27, 2026
e1fdcef
fix issues
HarshitVerma109 Aug 27, 2026
08f308e
fix issue
HarshitVerma109 Aug 27, 2026
d9cb9f6
update code
HarshitVerma109 Aug 27, 2026
7df7921
feat: add EmailLog model
HarshitVerma109 Aug 30, 2026
7d971fd
update code
HarshitVerma109 Aug 30, 2026
b04940c
Merge branch 'feat/email-service-abstraction' into feat/email-log-model
HarshitVerma109 Aug 30, 2026
6e02bc2
update code
HarshitVerma109 Aug 30, 2026
4adf32f
update code
HarshitVerma109 Sep 1, 2026
3f8e3fb
Merge branch 'feat/email-service-abstraction' into feat/email-log-model
HarshitVerma109 Sep 1, 2026
173dd95
update code
HarshitVerma109 Sep 4, 2026
428007a
Merge branch 'feat/email-service-abstraction' into feat/email-log-model
HarshitVerma109 Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified backend/data/nest.dump
Binary file not shown.
1 change: 1 addition & 0 deletions backend/src/apps/owasp/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .board_of_directors import BoardOfDirectorsAdmin
from .chapter import ChapterAdmin
from .committee import CommitteeAdmin
from .email_log import EmailLogAdmin
from .entity_channel import EntityChannelAdmin
from .entity_member import EntityMemberAdmin
from .event import EventAdmin
Expand Down
32 changes: 32 additions & 0 deletions backend/src/apps/owasp/admin/email_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Admin registration for EmailLog model."""

from django.contrib import admin

from apps.owasp.models.email_log import EmailLog


class EmailLogAdmin(admin.ModelAdmin):
"""Admin for EmailLog model."""

list_display = ("get_user", "snapshot", "status", "created_at")
list_filter = ("status", "created_at")
search_fields = ("snapshot_subscription__user__email",)
readonly_fields = (
"snapshot_subscription",
"snapshot",
"status",
"error_message",
"created_at",
)

def has_add_permission(self, request):
"""Prevent manual creation of email logs."""
return False

@admin.display(description="User")
def get_user(self, obj):
"""Return the user from the subscription."""
return obj.snapshot_subscription.user if obj.snapshot_subscription else "—"


admin.site.register(EmailLog, EmailLogAdmin)
65 changes: 65 additions & 0 deletions backend/src/apps/owasp/migrations/0076_emaillog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Generated by Django 5.2 on 2026-08-13 18:42

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("owasp", "0075_snapshotsubscription"),
]

operations = [
migrations.CreateModel(
name="EmailLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"status",
models.CharField(
choices=[("sent", "Sent"), ("failed", "Failed")], max_length=10
),
),
("error_message", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"snapshot",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="email_logs",
to="owasp.snapshot",
),
),
(
"snapshot_subscription",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="email_logs",
to="owasp.snapshotsubscription",
),
),
],
options={
"verbose_name_plural": "Email Logs",
"db_table": "owasp_email_logs",
"indexes": [
models.Index(fields=["-created_at"], name="owasp_email_log_created_idx")
],
"constraints": [
models.UniqueConstraint(
fields=["snapshot_subscription", "snapshot"],
condition=models.Q(status="sent"),
name="unique_email_per_subscription_snapshot",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
},
),
]
1 change: 1 addition & 0 deletions backend/src/apps/owasp/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .board_of_directors import BoardOfDirectors
from .chapter import Chapter
from .committee import Committee
from .email_log import EmailLog
from .entity_channel import EntityChannel
from .entity_member import EntityMember
from .event import Event
Expand Down
113 changes: 113 additions & 0 deletions backend/src/apps/owasp/models/email_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""OWASP app email log model."""
Comment thread
HarshitVerma109 marked this conversation as resolved.

from django.db import models

from apps.owasp.models.snapshot import Snapshot
from apps.owasp.models.snapshot_subscription import SnapshotSubscription


class EmailLog(models.Model):
"""Model for tracking sent digest emails and preventing duplicates.

Each log entry links a SnapshotSubscription to the Snapshot it was sent for.
The unique constraint ensures no duplicate emails per subscription per snapshot.
"""

class Meta:
"""Model options."""

db_table = "owasp_email_logs"
verbose_name_plural = "Email Logs"
constraints = [
models.UniqueConstraint(
fields=["snapshot_subscription", "snapshot"],
condition=models.Q(status="sent"),
name="unique_email_per_subscription_snapshot",
),
]
indexes = [
models.Index(fields=["-created_at"], name="owasp_email_log_created_idx"),
]

class Status(models.TextChoices):
"""Email delivery status choices."""

SENT = "sent", "Sent"
FAILED = "failed", "Failed"

snapshot_subscription = models.ForeignKey(
SnapshotSubscription,
on_delete=models.CASCADE,
related_name="email_logs",
)
snapshot = models.ForeignKey(
Snapshot,
on_delete=models.CASCADE,
related_name="email_logs",
)
status = models.CharField(
max_length=10,
choices=Status.choices,
)
error_message = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
"""Return a string representation."""
return f"{self.snapshot_subscription.user} — {self.snapshot.key} ({self.status})"

@classmethod
def is_duplicate(cls, *, snapshot, snapshot_subscription):
"""Check if a digest email has already been sent.

Args:
snapshot: The snapshot instance.
snapshot_subscription: The snapshot subscription instance.

Returns:
True if a log entry already exists for this combination.

"""
return cls.objects.filter(
Comment thread
HarshitVerma109 marked this conversation as resolved.
Comment thread
HarshitVerma109 marked this conversation as resolved.
Comment thread
HarshitVerma109 marked this conversation as resolved.
snapshot=snapshot,
snapshot_subscription=snapshot_subscription,
status=cls.Status.SENT,
).exists()

@classmethod
def mark_sent(cls, *, snapshot, snapshot_subscription):
"""Record a successfully sent email.

Args:
snapshot: The snapshot instance.
snapshot_subscription: The snapshot subscription instance.

Returns:
The created EmailLog instance.

"""
return cls.objects.create(
snapshot=snapshot,
snapshot_subscription=snapshot_subscription,
status=cls.Status.SENT,
)

@classmethod
def mark_failed(cls, *, snapshot, snapshot_subscription, error_message=""):
"""Record a failed email delivery attempt.

Args:
snapshot: The snapshot instance.
snapshot_subscription: The snapshot subscription instance.
error_message: Description of the failure.

Returns:
The created EmailLog instance.

"""
return cls.objects.create(
snapshot=snapshot,
snapshot_subscription=snapshot_subscription,
status=cls.Status.FAILED,
error_message=error_message,
)
Empty file.
47 changes: 47 additions & 0 deletions backend/src/apps/owasp/services/email/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Abstract base class for email service providers."""

from abc import ABC, abstractmethod


class EmailService(ABC):
"""Abstract email service provider.

Provides an abstraction layer that makes it easy to switch between
email sending service providers (e.g., Django SMTP, AWS SES, Resend).
"""

@abstractmethod
def send(
self,
to: str,
subject: str,
html_body: str,
plain_body: str,
headers: dict[str, str] | None = None,
) -> bool:
"""Send a single email.

Args:
to: Recipient email address.
subject: Email subject line.
html_body: HTML version of the email body.
plain_body: Plain text version of the email body.
headers: Optional custom email headers.

Returns:
True if the email was sent successfully.

"""

@abstractmethod
def send_bulk(self, messages: list[dict]) -> dict:
"""Send multiple emails.

Args:
messages: List of dicts, each containing 'to', 'subject',
'html_body', and 'plain_body' keys.

Returns:
Dict with 'sent' and 'failed' counts.

"""
91 changes: 91 additions & 0 deletions backend/src/apps/owasp/services/email/django_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Django email service implementation."""

import logging

from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection

from apps.owasp.services.email.base import EmailService

logger = logging.getLogger(__name__)


class DjangoEmailService(EmailService):
"""Email service using Django's EMAIL_BACKEND.

Works with any Django email backend including:
- Console (local dev)
- SMTP
- AWS SES (via django-ses)
"""

def send(
self,
to: str,
subject: str,
html_body: str,
plain_body: str,
headers: dict[str, str] | None = None,
) -> bool:
"""Send a single email using Django's EmailMultiAlternatives."""
try:
msg = EmailMultiAlternatives(
subject=subject,
body=plain_body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to],
headers=headers,
)
msg.attach_alternative(html_body, "text/html")
sent_count = msg.send()
except Exception:
logger.exception("Failed to send email")
Comment thread
HarshitVerma109 marked this conversation as resolved.
raise
return sent_count > 0

def _close_connection_safely(self, connection) -> None:
"""Safely close the email connection, logging any exceptions."""
if connection:
try:
connection.close()
except Exception:
logger.exception("Failed to close email connection")

def send_bulk(self, messages: list[dict]) -> dict:
Comment thread
HarshitVerma109 marked this conversation as resolved.
"""Send multiple emails using a single shared connection."""
results = {"sent": 0, "failed": 0}
Comment thread
HarshitVerma109 marked this conversation as resolved.
if not messages:
return results

connection = None
try:
connection = get_connection()
connection.open()
Comment thread
HarshitVerma109 marked this conversation as resolved.
except Exception:
logger.exception("Failed to open email connection")
self._close_connection_safely(connection)
return {"sent": 0, "failed": len(messages)}

try:
for message in messages:
try:
msg = EmailMultiAlternatives(
subject=message["subject"],
body=message["plain_body"],
from_email=settings.DEFAULT_FROM_EMAIL,
to=[message["to"]],
headers=message.get("headers"),
connection=connection,
)
msg.attach_alternative(message["html_body"], "text/html")
if msg.send() > 0:
results["sent"] += 1
else:
results["failed"] += 1
except Exception:
logger.exception("Failed to send email")
results["failed"] += 1
finally:
self._close_connection_safely(connection)

return results
16 changes: 16 additions & 0 deletions backend/src/apps/owasp/services/email/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Email service factory."""

from apps.owasp.services.email.base import EmailService
from apps.owasp.services.email.django_email import DjangoEmailService


def get_email_service() -> EmailService:
Comment thread
HarshitVerma109 marked this conversation as resolved.
"""Return the configured email service.

Currently returns DjangoEmailService, which works with any
Django EMAIL_BACKEND (console, SMTP, AWS SES).

To add a new provider, create a new EmailService subclass
and add a settings-based switch here.
"""
return DjangoEmailService()
2 changes: 2 additions & 0 deletions backend/src/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class Base(Configuration):
SITE_NAME = "localhost"
SITE_URL = "http://localhost:8000"

DEFAULT_FROM_EMAIL = "OWASP Nest <noreply@owasp.org>"

DJANGO_APPS = (
"django.contrib.admin",
"django.contrib.auth",
Expand Down
Loading
Loading