-
-
Notifications
You must be signed in to change notification settings - Fork 696
Feat/email log model #5489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
HarshitVerma109
wants to merge
76
commits into
OWASP:feature/community-snapshots
Choose a base branch
from
HarshitVerma109:feat/email-log-model
base: feature/community-snapshots
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Feat/email log model #5489
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 0f4f87b
feat: add ProjectSubscriptionPreference model and update GraphQL API
HarshitVerma109 9cb8cf3
update code
HarshitVerma109 f774a5f
update tests
HarshitVerma109 bfed2f9
fix issues
HarshitVerma109 7481c1d
Add support for multiple subscriptions
HarshitVerma109 9f55a62
update code
HarshitVerma109 5aab13d
resolve conflicts
HarshitVerma109 1367cfb
fix issues
HarshitVerma109 78be5c6
Merge remote-tracking branch 'upstream/feature/community-snapshots' i…
HarshitVerma109 9b7a018
fix sonar issue
HarshitVerma109 b3608f3
update code
HarshitVerma109 a23c2d6
fix comment
HarshitVerma109 65635ad
fix comments
HarshitVerma109 37db804
feat: add settings page for snapshot subscription preferences
HarshitVerma109 13597eb
update tests
HarshitVerma109 7de6d3c
fix sonar issues
HarshitVerma109 2f1eeb1
fix: issues
HarshitVerma109 3253186
fix: sonar issue
HarshitVerma109 af9d7d0
fix comments
HarshitVerma109 5d60e40
fix comment
HarshitVerma109 b7fd6f0
update code
HarshitVerma109 c52062f
fix buttons
HarshitVerma109 8d560e5
fix button
HarshitVerma109 8a2a8ed
update code
HarshitVerma109 4fad0f1
fix sonar issue
HarshitVerma109 9dd24a4
feat: add email service abstraction layer
HarshitVerma109 f9f3a3b
update code
HarshitVerma109 b53c355
fix code
HarshitVerma109 4dfc9b5
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 f525f0c
update code
HarshitVerma109 cc409ad
fix issues
HarshitVerma109 ad4aa04
update code
HarshitVerma109 dcf4412
update code
HarshitVerma109 1147724
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 35ece3f
update code
HarshitVerma109 d383fed
Merge branch 'feat/snapshot-subscription-frontend' into feat/email-se…
HarshitVerma109 43d9a81
update code
HarshitVerma109 8424625
fix code
HarshitVerma109 8dc91c3
update code
HarshitVerma109 82f392d
update code
HarshitVerma109 156df98
update code
HarshitVerma109 5f8549b
fix issues
HarshitVerma109 3ca5a52
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 f2cef1c
fix issues
HarshitVerma109 193b8d6
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 ca54835
update code
HarshitVerma109 4b41651
fix issues
HarshitVerma109 c2a4084
update code
HarshitVerma109 5d5c665
fix sonar issue
HarshitVerma109 1f91b6f
update code
HarshitVerma109 fb80c6e
update code
HarshitVerma109 2d012dd
fix issue
HarshitVerma109 c75fbae
Merge branch 'feature/snapshot-subscription-graphql' into feat/snapsh…
HarshitVerma109 ea25297
update code
HarshitVerma109 88b3721
fix issue
HarshitVerma109 d9703a1
Merge branch 'feat/snapshot-subscription-frontend' into feat/email-se…
HarshitVerma109 2bf8b4d
Snapshot subscription backend
HarshitVerma109 a867959
update code
HarshitVerma109 8e2855c
fix issues
HarshitVerma109 9ac7dc8
Snapshot subscription frontend
HarshitVerma109 84f3738
fix issue
HarshitVerma109 efbac85
update code
HarshitVerma109 1cfc3e3
Merge branch 'feat/snapshot-subscription-frontend' into feat/email-se…
HarshitVerma109 2827bd5
update code
HarshitVerma109 e1fdcef
fix issues
HarshitVerma109 08f308e
fix issue
HarshitVerma109 d9cb9f6
update code
HarshitVerma109 7df7921
feat: add EmailLog model
HarshitVerma109 7d971fd
update code
HarshitVerma109 b04940c
Merge branch 'feat/email-service-abstraction' into feat/email-log-model
HarshitVerma109 6e02bc2
update code
HarshitVerma109 4adf32f
update code
HarshitVerma109 3f8e3fb
Merge branch 'feat/email-service-abstraction' into feat/email-log-model
HarshitVerma109 173dd95
update code
HarshitVerma109 428007a
Merge branch 'feat/email-service-abstraction' into feat/email-log-model
HarshitVerma109 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ) | ||
| ], | ||
| }, | ||
| ), | ||
| ] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| """OWASP app email log model.""" | ||
|
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( | ||
|
HarshitVerma109 marked this conversation as resolved.
HarshitVerma109 marked this conversation as resolved.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| """ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
|
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: | ||
|
HarshitVerma109 marked this conversation as resolved.
|
||
| """Send multiple emails using a single shared connection.""" | ||
| results = {"sent": 0, "failed": 0} | ||
|
HarshitVerma109 marked this conversation as resolved.
|
||
| if not messages: | ||
| return results | ||
|
|
||
| connection = None | ||
| try: | ||
| connection = get_connection() | ||
| connection.open() | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: | ||
|
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() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.