Repository: bookwyrm-social/bookwyrm

Issue:
Surface email errors to the web UI
**Is your feature request related to a problem? Please describe.**
Currently, it is not immediately obvious when an automated email from BookWyrm fails to send; it requires you to either verify through checking your mail provider or checking through Docker container logs.

**Describe the solution you'd like**
Ideally, any error involving failed emails would be caught and then somehow surfaced to the web admin panel, perhaps through a modal or notification.

**Additional context**
I discovered this by having not set EMAIL_SENDER_NAME or EMAIL_SENDER_DOMAIN to the correct values. It took me digging through Docker container logs until I finally found the container with the error message that it failed to send an email to `admin@`.



Discussion:

Resolving patch:
diff --git a/bookwyrm/views/admin/dashboard.py b/bookwyrm/views/admin/dashboard.py
--- a/bookwyrm/views/admin/dashboard.py
+++ b/bookwyrm/views/admin/dashboard.py
@@ -1,5 +1,7 @@
 """ instance overview """
 from datetime import timedelta
+import re
+
 from dateutil.parser import parse
 from packaging import version
 
@@ -13,6 +15,7 @@
 from bookwyrm import models, settings
 from bookwyrm.connectors.abstract_connector import get_data
 from bookwyrm.connectors.connector_manager import ConnectorException
+from bookwyrm.utils import regex
 
 
 # pylint: disable= no-self-use
@@ -26,90 +29,18 @@ class Dashboard(View):
 
     def get(self, request):
         """list of users"""
-        interval = int(request.GET.get("days", 1))
-        now = timezone.now()
-        start = request.GET.get("start")
-        if start:
-            start = timezone.make_aware(parse(start))
-        else:
-            start = now - timedelta(days=6 * interval)
-
-        end = request.GET.get("end")
-        end = timezone.make_aware(parse(end)) if end else now
-        start = start.replace(hour=0, minute=0, second=0)
-
-        user_queryset = models.User.objects.filter(local=True)
-        user_chart = Chart(
-            queryset=user_queryset,
-            queries={
-                "total": lambda q, s, e: q.filter(
-                    Q(is_active=True) | Q(deactivation_date__gt=e),
-                    created_date__lte=e,
-                ).count(),
-                "active": lambda q, s, e: q.filter(
-                    Q(is_active=True) | Q(deactivation_date__gt=e),
-                    created_date__lte=e,
-                )
-                .filter(
-                    last_active_date__gt=e - timedelta(days=31),
-                )
-                .count(),
-            },
-        )
-
-        status_queryset = models.Status.objects.filter(user__local=True, deleted=False)
-        status_chart = Chart(
-            queryset=status_queryset,
-            queries={
-                "total": lambda q, s, e: q.filter(
-                    created_date__gt=s,
-                    created_date__lte=e,
-                ).count()
-            },
-        )
-
-        register_chart = Chart(
-            queryset=user_queryset,
-            queries={
-                "total": lambda q, s, e: q.filter(
-                    created_date__gt=s,
-                    created_date__lte=e,
-                ).count()
-            },
-        )
-
-        works_chart = Chart(
-            queryset=models.Work.objects,
-            queries={
-                "total": lambda q, s, e: q.filter(
-                    created_date__gt=s,
-                    created_date__lte=e,
-                ).count()
-            },
-        )
-
-        data = {
-            "start": start.strftime("%Y-%m-%d"),
-            "end": end.strftime("%Y-%m-%d"),
-            "interval": interval,
-            "users": user_queryset.filter(is_active=True).count(),
-            "active_users": user_queryset.filter(
-                is_active=True, last_active_date__gte=now - timedelta(days=31)
-            ).count(),
-            "statuses": status_queryset.count(),
-            "works": models.Work.objects.count(),
-            "reports": models.Report.objects.filter(resolved=False).count(),
-            "pending_domains": models.LinkDomain.objects.filter(
-                status="pending"
-            ).count(),
-            "invite_requests": models.InviteRequest.objects.filter(
-                ignored=False, invite__isnull=True
-            ).count(),
-            "user_stats": user_chart.get_chart(start, end, interval),
-            "status_stats": status_chart.get_chart(start, end, interval),
-            "register_stats": register_chart.get_chart(start, end, interval),
-            "works_stats": works_chart.get_chart(start, end, interval),
-        }
+        data = get_charts_and_stats(request)
+
+        # Make sure email looks properly configured
+        email_config_error = re.findall(
+            r"[\s\@]", settings.EMAIL_SENDER_DOMAIN
+        ) or not re.match(regex.DOMAIN, settings.EMAIL_SENDER_DOMAIN)
+
+        data["email_config_error"] = email_config_error
+        # pylint: disable=line-too-long
+        data[
+            "email_sender"
+        ] = f"{settings.EMAIL_SENDER_NAME}@{settings.EMAIL_SENDER_DOMAIN}"
 
         # check version
         try:
@@ -126,6 +57,91 @@ def get(self, request):
         return TemplateResponse(request, "settings/dashboard/dashboard.html", data)
 
 
+def get_charts_and_stats(request):
+    """Defines the dashbaord charts"""
+    interval = int(request.GET.get("days", 1))
+    now = timezone.now()
+    start = request.GET.get("start")
+    if start:
+        start = timezone.make_aware(parse(start))
+    else:
+        start = now - timedelta(days=6 * interval)
+
+    end = request.GET.get("end")
+    end = timezone.make_aware(parse(end)) if end else now
+    start = start.replace(hour=0, minute=0, second=0)
+
+    user_queryset = models.User.objects.filter(local=True)
+    user_chart = Chart(
+        queryset=user_queryset,
+        queries={
+            "total": lambda q, s, e: q.filter(
+                Q(is_active=True) | Q(deactivation_date__gt=e),
+                created_date__lte=e,
+            ).count(),
+            "active": lambda q, s, e: q.filter(
+                Q(is_active=True) | Q(deactivation_date__gt=e),
+                created_date__lte=e,
+            )
+            .filter(
+                last_active_date__gt=e - timedelta(days=31),
+            )
+            .count(),
+        },
+    )
+
+    status_queryset = models.Status.objects.filter(user__local=True, deleted=False)
+    status_chart = Chart(
+        queryset=status_queryset,
+        queries={
+            "total": lambda q, s, e: q.filter(
+                created_date__gt=s,
+                created_date__lte=e,
+            ).count()
+        },
+    )
+
+    register_chart = Chart(
+        queryset=user_queryset,
+        queries={
+            "total": lambda q, s, e: q.filter(
+                created_date__gt=s,
+                created_date__lte=e,
+            ).count()
+        },
+    )
+
+    works_chart = Chart(
+        queryset=models.Work.objects,
+        queries={
+            "total": lambda q, s, e: q.filter(
+                created_date__gt=s,
+                created_date__lte=e,
+            ).count()
+        },
+    )
+    return {
+        "start": start.strftime("%Y-%m-%d"),
+        "end": end.strftime("%Y-%m-%d"),
+        "interval": interval,
+        "users": user_queryset.filter(is_active=True).count(),
+        "active_users": user_queryset.filter(
+            is_active=True, last_active_date__gte=now - timedelta(days=31)
+        ).count(),
+        "statuses": status_queryset.count(),
+        "works": models.Work.objects.count(),
+        "reports": models.Report.objects.filter(resolved=False).count(),
+        "pending_domains": models.LinkDomain.objects.filter(status="pending").count(),
+        "invite_requests": models.InviteRequest.objects.filter(
+            ignored=False, invite__isnull=True
+        ).count(),
+        "user_stats": user_chart.get_chart(start, end, interval),
+        "status_stats": status_chart.get_chart(start, end, interval),
+        "register_stats": register_chart.get_chart(start, end, interval),
+        "works_stats": works_chart.get_chart(start, end, interval),
+    }
+
+
 class Chart:
     """Data for a chart"""
 


Test patch: