Per-month quota for email-sending

Fix #34
This commit is contained in:
Klaas van Schelven
2025-07-28 14:41:32 +02:00
parent 13dbc4dd29
commit e363917e9c
8 changed files with 113 additions and 3 deletions

View File

@@ -59,6 +59,8 @@ DEFAULTS = {
"MAX_EVENTS_PER_PROJECT_PER_5_MINUTES": 1_000,
"MAX_EVENTS_PER_PROJECT_PER_HOUR": 5_000,
"MAX_EMAILS_PER_MONTH": None, # None means "no limit"; for non-None values, the quota is per calendar month
# I don't think Sentry specifies this one, but we do: given the spec 8KiB should be enough by an order of magnitude.
"MAX_HEADER_SIZE": 8 * _KIBIBYTE,

View File

@@ -1,8 +1,9 @@
import json
from datetime import timedelta
from collections import namedtuple
from django.conf import settings
from django.utils import timezone
from django.conf import settings
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.contrib.auth.models import AnonymousUser
@@ -119,6 +120,14 @@ def useful_settings_processor(request):
system_warnings.append(SystemWarning(EMAIL_BACKEND_WARNING, ignore_url))
if get_settings().MAX_EMAILS_PER_MONTH is not None:
email_quota_usage = json.loads(installation.email_quota_usage)
this_month_usage = email_quota_usage.get("per_month", {}).get(timezone.now().strftime("%Y-%m"), 0)
if this_month_usage >= get_settings().MAX_EMAILS_PER_MONTH:
system_warnings.append(SystemWarning(
f"Bugsink has sent {this_month_usage} emails this month, which is the maximum. "
"No more emails will be sent until the 1st of next month.", None))
return system_warnings + get_snappea_warnings()
return {

View File

@@ -98,6 +98,8 @@ BUGSINK = {
"MAX_EVENTS_PER_PROJECT_PER_5_MINUTES": 1_000_000,
"MAX_EVENTS_PER_PROJECT_PER_HOUR": 50_000_000,
"MAX_EMAILS_PER_MONTH": 10, # for development: a thing to tune if you want to the the quota system
"KEEP_ARTIFACT_BUNDLES": True, # in development: useful to preserve sourcemap uploads
}

View File

@@ -7,13 +7,20 @@ from unittest import TestCase as RegularTestCase
from django.test import TestCase as DjangoTestCase
from django.test import override_settings
from django.core.exceptions import SuspiciousOperation
from django.contrib.auth import get_user_model
from django.test.utils import CaptureQueriesContext
from django.db import connection
from .wsgi import allowed_hosts_error_message
from .test_utils import TransactionTestCase25251 as TransactionTestCase
from .transaction import immediate_atomic
from .volume_based_condition import VolumeBasedCondition
from .streams import (
compress_with_zlib, GeneratorReader, WBITS_PARAM_FOR_GZIP, WBITS_PARAM_FOR_DEFLATE, MaxDataReader,
MaxDataWriter, zlib_generator, brotli_generator)
User = get_user_model()
def apply_n(f, n, v):
for i in range(n):
@@ -421,3 +428,25 @@ class AllowedHostsMsgTestCase(DjangoTestCase):
"'Host: teeestserver' as sent by browser/proxy not in ALLOWED_HOSTS=['testserver']. "
"Add 'teeestserver' to ALLOWED_HOSTS or configure proxy to use 'Host: testserver'.",
allowed_hosts_error_message("teeestserver", ["testserver"]))
class TestAtomicTransactions(TransactionTestCase):
def test_only_if_needed(self):
with CaptureQueriesContext(connection) as queries_context:
with immediate_atomic(only_if_needed=True):
User.objects.create(username="testuser", password="testpass")
self.assertTrue(User.objects.filter(username="testuser").exists())
self.assertEquals([1], [1 for q in queries_context.captured_queries if q['sql'].startswith("BEGIN")])
self.assertEquals([1], [1 for q in queries_context.captured_queries if q['sql'].startswith("COMMIT")])
with CaptureQueriesContext(connection) as queries_context:
with immediate_atomic(only_if_needed=True):
with immediate_atomic(only_if_needed=True):
with immediate_atomic(only_if_needed=True):
User.objects.create(username="testuser2", password="testpass2")
self.assertTrue(User.objects.filter(username="testuser2").exists())
self.assertEquals([1], [1 for q in queries_context.captured_queries if q['sql'].startswith("BEGIN")])
self.assertEquals([1], [1 for q in queries_context.captured_queries if q['sql'].startswith("COMMIT")])

View File

@@ -192,8 +192,10 @@ class ImmediateAtomic(SuperDurableAtomic):
@contextmanager
def immediate_atomic(using=None, savepoint=True, durable=True):
def immediate_atomic(using=None, savepoint=True, only_if_needed=False):
# this is the Django 4.2 db.transaction.atomic, but using ImmediateAtomic, and with durable=True by default
# only_if_needed is useful if you want to wrap some code in an as tight-as-possible transaction to avoid hogging the
# global write lock, while not worrying about whether the code is already in a transaction or not.
# the following assertion is because "BEGIN IMMEDIATE" supposes a "BEGIN" (of a transaction), i.e. has no meaning
# when this wrapper is not the outermost one.
@@ -201,8 +203,15 @@ def immediate_atomic(using=None, savepoint=True, durable=True):
# Side-note: the parameter `savepoint` is a bit of a misnomer, it is not about "is the current thing a savepoint",
# but rather, "are savepoints allowed inside the current context". (The former would imply that it could never be
# combined with durable=True, which is not the case.)
assert durable, "immediate_atomic should always be used with durable=True"
durable = True # for the cases where only_if_needed is False or not applicable (i.e. 'needed') durable=True.
using = DEFAULT_DB_ALIAS if using is None else using # harmonize to "default" at the top for downstream lookups
connection = django_db_transaction.get_connection(using)
if only_if_needed and connection.in_atomic_block:
yield
return
if callable(using):
immediate_atomic = ImmediateAtomic(DEFAULT_DB_ALIAS, savepoint, durable)(using)

View File

@@ -1,6 +1,8 @@
import logging
from collections import defaultdict
from urllib.parse import urlparse
from django.utils import timezone
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from django.apps import apps
@@ -8,8 +10,20 @@ from django.db.models import ForeignKey, F
from .version import version
logger = logging.getLogger("bugsink.email")
def send_rendered_email(subject, base_template_name, recipient_list, context=None):
from phonehome.models import Installation
if not Installation.check_and_inc_email_quota(timezone.now()):
logger.warning(
"Email quota exceeded; not sending email with subject '%s' to %s",
subject,
recipient_list,
)
return
if context is None:
context = {}

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.21 on 2025-07-28 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("phonehome", "0001_b_squashed_initial"),
]
operations = [
migrations.AddField(
model_name="installation",
name="email_quota_usage",
field=models.TextField(default='{"per_month": {}}'),
),
]

View File

@@ -1,6 +1,10 @@
import json
import uuid
from django.db import models
from bugsink.transaction import immediate_atomic
from bugsink.app_settings import get_settings
class Installation(models.Model):
@@ -12,6 +16,29 @@ class Installation(models.Model):
silence_email_system_warning = models.BooleanField(default=False)
email_quota_usage = models.TextField(null=False, default='{"per_month": {}}')
@classmethod
@immediate_atomic(only_if_needed=True) # minimalize write-lock-hogging (while being callable within atomic blocks)
def check_and_inc_email_quota(cls, date):
obj = cls.objects.first()
email_quota_usage = json.loads(obj.email_quota_usage)
key = date.strftime('%Y-%m')
if key not in email_quota_usage["per_month"]:
email_quota_usage['per_month'] = {key: 0} # full overwrite: no need to keep old info around.
if (get_settings().MAX_EMAILS_PER_MONTH is not None
and email_quota_usage['per_month'][key] >= get_settings().MAX_EMAILS_PER_MONTH):
return False
email_quota_usage['per_month'][key] += 1
obj.email_quota_usage = json.dumps(email_quota_usage)
obj.save()
return True
class OutboundMessage(models.Model):
attempted_at = models.DateTimeField(auto_now_add=True)