Skip to content
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

✨(domains) send email when domain status changes #668

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ and this project adheres to

### Added

- ✨(domains) add notification when domain status changes
- ✨(domains) add periodic tasks to fetch domain status
- 🧑‍💻(docker) add celery beat to manage periodic tasks
- ✨(dimail) management command to fetch domain status

### Changed
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d celery-beat-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
Expand Down
16 changes: 16 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ services:
depends_on:
- app-dev

celery-beat-dev:
user: ${DOCKER_USER:-1000}
image: people:backend-development
command: ["celery", "-A", "people.celery_app", "beat", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev

app:
build:
context: .
Expand Down
11 changes: 11 additions & 0 deletions src/backend/mailbox_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ def get_abilities(self, user):
"manage_accesses": is_owner_or_admin,
}

def send_notification(self, subject, message):
"""
Notify owners and admins of the domain
"""
for access in self.accesses.filter(
role__in=[MailDomainRoleChoices.OWNER, MailDomainRoleChoices.ADMIN]
).all():
access.user.email_user(
subject=subject, message=message, from_email=settings.DEFAULT_FROM_EMAIL
)


class MailDomainAccess(BaseModel):
"""Allow to manage users' accesses to mail domains."""
Expand Down
57 changes: 57 additions & 0 deletions src/backend/mailbox_manager/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Mailbox manager tasks."""

import logging

import requests
from celery.schedules import crontab

from mailbox_manager.models import MailDomain, MailDomainStatusChoices
from mailbox_manager.utils.dimail import DimailAPIClient
from people.celery_app import app as celery_app

logger = logging.getLogger(__name__)


@celery_app.on_after_finalize.connect
def setup_periodic_tasks(sender, **kwargs):
"""
Setup periodic tasks.
"""
sender.add_periodic_task(
crontab(minute="*/5"), fetch_domains_status.s(), name="Fetch domains status"
)


@celery_app.task
def fetch_domains_status():
"""
Call dimail to check and update domains status.
"""
client = DimailAPIClient()
# do not fetch status of disabled domains
domains = MailDomain.objects.exclude(status=MailDomainStatusChoices.DISABLED)
update_count, check_count = 0, 0
for domain in domains:
old_status = domain.status
try:
client.fetch_domain_status(domain)
except requests.exceptions.HTTPError as err:
logger.error("Failed to fetch status for domain %s: %s", domain.name, err)
else:
if old_status != domain.status:
update_count += 1
# Send notification to owners and admins of the domain
# when its status changes to failed or enabled
if domain.status == MailDomainStatusChoices.FAILED:
domain.send_notification(
subject="Domain status changed",
message=f"Domain {domain.name} is down",
)
elif domain.status == MailDomainStatusChoices.ENABLED:
domain.send_notification(
subject="Domain status changed",
message=f"Domain {domain.name} is up",
)
else:
check_count += 1
return f"Domains processed: {update_count} updated, {check_count} checked"
136 changes: 136 additions & 0 deletions src/backend/mailbox_manager/tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""
Unit tests for mailbox manager tasks.
"""

import json
import re
from unittest import mock

from django.conf import settings

import pytest
import responses

from mailbox_manager import enums, factories, tasks

from .fixtures.dimail import CHECK_DOMAIN_BROKEN, CHECK_DOMAIN_OK

pytestmark = pytest.mark.django_db


@responses.activate
def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals
"""Test fetch domain status from dimail task"""

domain_enabled1 = factories.MailDomainEnabledFactory()
domain_enabled2 = factories.MailDomainEnabledFactory()
owner_domain_enabled2 = factories.MailDomainAccessFactory(
domain=domain_enabled2, role=enums.MailDomainRoleChoices.OWNER
).user
admin_domain_enabled2 = factories.MailDomainAccessFactory(
domain=domain_enabled2, role=enums.MailDomainRoleChoices.ADMIN
).user
domain_disabled = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.DISABLED
)
domain_failed = factories.MailDomainFactory(
status=enums.MailDomainStatusChoices.FAILED
)
owner_domain_failed = factories.MailDomainAccessFactory(
domain=domain_failed, role=enums.MailDomainRoleChoices.OWNER
).user
admin_domain_failed = factories.MailDomainAccessFactory(
domain=domain_failed, role=enums.MailDomainRoleChoices.ADMIN
).user
body_content_ok1 = CHECK_DOMAIN_OK.copy()
body_content_ok1["name"] = domain_enabled1.name

body_content_broken = CHECK_DOMAIN_BROKEN.copy()
body_content_broken["name"] = domain_enabled2.name

body_content_ok2 = CHECK_DOMAIN_OK.copy()
body_content_ok2["name"] = domain_disabled.name

body_content_ok3 = CHECK_DOMAIN_OK.copy()
body_content_ok3["name"] = domain_failed.name
for domain, body_content in [
(domain_enabled1, body_content_ok1),
(domain_enabled2, body_content_broken),
(domain_failed, body_content_ok3),
]:
# Mock dimail API with success response
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=200,
content_type="application/json",
)
with mock.patch("django.core.mail.send_mail") as mock_send:
tasks.fetch_domains_status()
domain_enabled1.refresh_from_db()
domain_enabled2.refresh_from_db()
domain_disabled.refresh_from_db()
domain_failed.refresh_from_db()
# Nothing change for the first domain enable
assert domain_enabled1.status == enums.MailDomainStatusChoices.ENABLED
# Status of the second activated domain has changed to failure
assert domain_enabled2.status == enums.MailDomainStatusChoices.FAILED
# Status of the failed domain has changed to enabled
assert domain_failed.status == enums.MailDomainStatusChoices.ENABLED
# Check notification was sent to owners and admins
assert mock_send.call_count == 4
calls = [
mock.call(
"Domain status changed",
f"Domain {domain_enabled2.name} is down",
settings.DEFAULT_FROM_EMAIL,
[owner_domain_enabled2.email],
),
mock.call(
"Domain status changed",
f"Domain {domain_enabled2.name} is down",
settings.DEFAULT_FROM_EMAIL,
[admin_domain_enabled2.email],
),
mock.call(
"Domain status changed",
f"Domain {domain_failed.name} is up",
settings.DEFAULT_FROM_EMAIL,
[owner_domain_failed.email],
),
mock.call(
"Domain status changed",
f"Domain {domain_failed.name} is up",
settings.DEFAULT_FROM_EMAIL,
[admin_domain_failed.email],
),
]
mock_send.assert_has_calls(calls, any_order=True)
# Disabled domain was excluded
assert domain_disabled.status == enums.MailDomainStatusChoices.DISABLED


@responses.activate
def test_fetch_domains_status_error_handling(caplog):
"""Test fetch domain status from dimail task with error"""
caplog.set_level("ERROR")

domain = factories.MailDomainEnabledFactory()

# Mock dimail API with error response
responses.add(
responses.GET,
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps({"error": "Internal Server Error"}),
status=500,
content_type="application/json",
)

tasks.fetch_domains_status()
domain.refresh_from_db()

# Domain status should remain unchanged
assert domain.status == enums.MailDomainStatusChoices.ENABLED
# Check that error was logged
assert f"Failed to fetch status for domain {domain.name}" in caplog.text
4 changes: 4 additions & 0 deletions src/backend/people/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""People module."""

from .celery_app import app as celery_app

__all__ = ("celery_app",)
Loading