diff --git a/outlook_auth/__init__.py b/outlook_auth/__init__.py new file mode 100644 index 000000000..f3e7b063f --- /dev/null +++ b/outlook_auth/__init__.py @@ -0,0 +1,32 @@ +""" +outlook_auth/__init__.py +""" + +from django.conf import settings +from outlook_auth import scheduler as _scheduler + + +settings.OUTLOOK_SCOPES = ["https://outlook.office.com/SMTP.Send"] + +# Add these in horilla/settings.py + +""" + +installed_apps = [ + ... + 'outlook_auth', + ... +] + +EMAIL_BACKEND = 'outlook_auth.backends.OutlookBackend' + +""" +# NOTE: Horilla should be run in https + +# Please add redircet url in Azure app authentication URi and AzureApi model redirect URi + +""" + +https:///outlook/callback + +""" diff --git a/outlook_auth/admin.py b/outlook_auth/admin.py new file mode 100644 index 000000000..49004f064 --- /dev/null +++ b/outlook_auth/admin.py @@ -0,0 +1,16 @@ +""" +outlook_auth/admin.py +""" + +from django.contrib import admin +from outlook_auth import models + + +# Register your models here. + + +admin.site.register( + [ + models.AzureApi, + ] +) diff --git a/outlook_auth/apps.py b/outlook_auth/apps.py new file mode 100644 index 000000000..1da7dc6a3 --- /dev/null +++ b/outlook_auth/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + + +class OutlookAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "outlook_auth" + + def ready(self): + from horilla.urls import urlpatterns,path,include + + urlpatterns.append( + path("outlook/", include("outlook_auth.urls")), + ) + return super().ready() diff --git a/outlook_auth/backends.py b/outlook_auth/backends.py new file mode 100644 index 000000000..705bea801 --- /dev/null +++ b/outlook_auth/backends.py @@ -0,0 +1,173 @@ +""" +outlook_auth/backeds.py +""" + +import base64 +import logging +from django.core.mail import EmailMessage +from django.core.mail.backends.smtp import EmailBackend +from base.models import EmailLog +from horilla.horilla_middlewares import _thread_locals +from outlook_auth import models +from outlook_auth.views import send_outlook_email + +logger = logging.getLogger(__name__) + + +class OutlookBackend(EmailBackend): + """ + OutlookBackend + """ + + api: models.AzureApi = None + + def __init__(self, *args, **kwargs): + request = getattr(_thread_locals, "request", None) + self.request = request + company = None + if request and not request.user.is_anonymous: + company = request.user.employee_get.get_company() + api = models.AzureApi.objects.filter(company=company).first() + if api is None: + api = models.AzureApi.objects.filter(is_primary=True).first() + self.api = api + + def send_messages(self, email_messages): + response = super().send_messages(email_messages) + for message in email_messages: + email_log = EmailLog( + subject=message.subject, + from_email=self.dynamic_from_email_with_display_name, + to=message.to, + body=message.body, + status="sent" if response else "failed", + ) + email_log.save() + return + + @property + def dynamic_from_email_with_display_name(self): + if not self.request.user: + return f"{self.api.outlook_display_name}<{self.api.outlook_email}>" + employee = self.request.user.employee_get + full_name = employee.get_full_name() + return f"{full_name} <{employee.get_email()}>" + + +actual_init = EmailMessage.__init__ + + +def __init__( + self: EmailMessage, + subject="", + body="", + from_email=None, + to=[], + bcc=[], + connection=None, + attachments=None, + headers=None, + cc=[], + reply_to=None, + *args, + **kwargs, +): + """ + custom __init_method to override + """ + request = getattr(_thread_locals, "request", None) + self.request = request + + if request: + try: + display_email_name = f"{request.user.employee_get.get_full_name()} <{request.user.employee_get.email}>" + from_email = display_email_name if not from_email else from_email + reply_to = [display_email_name] if not reply_to else reply_to + + except Exception as e: + logger.error(e) + self.subject = subject + self.body = body + self.from_email = from_email + self.to = to + self.cc = cc + self.bcc = bcc + self.attachments = attachments + self.headers = headers + self.reply_to = reply_to + + actual_init( + self, + subject=subject, + body=body, + from_email=from_email, + to=to, + bcc=bcc, + connection=connection, + attachments=attachments, + headers=headers, + cc=cc, + reply_to=reply_to, + ) + + # Prepare email data for Outlook API + + +def send_mail(self, *args, **kwargs): + """ + Sent mail + """ + + self.email_data = { + "message": { + "subject": self.subject, + "body": { + "contentType": "HTML" if self.content_subtype == "html" else "Text", + "content": self.body, + }, + "toRecipients": [ + {"emailAddress": {"address": recipient}} for recipient in self.to + ], + "ccRecipients": [ + {"emailAddress": {"address": recipient}} for recipient in self.cc + ], + "bccRecipients": [ + {"emailAddress": {"address": recipient}} for recipient in self.bcc + ], + }, + } + if self.request and not self.request.user.is_anonymous: + reply_to = self.request.user.employee_get.get_email() + self.email_data["message"]["replyTo"] = [] + self.email_data["message"]["replyTo"].append( + {"emailAddress": {"address": reply_to}} + ) + + if self.attachments: + outlook_attachments = [] + for attachment in self.attachments: + if isinstance(attachment, tuple): + filename, content, mimetype = attachment + if hasattr(content, "read"): + content = content.read() + + # Encode contentBytes using base64 + if isinstance(content, bytes): + content_bytes = base64.b64encode(content).decode("utf-8") + else: + content_bytes = content + + outlook_attachments.append( + { + "@odata.type": "#microsoft.graph.fileAttachment", + "name": filename, + "contentType": mimetype, + "contentBytes": content_bytes, + } + ) + self.email_data["message"]["attachments"] = outlook_attachments + send_outlook_email(self.request, self.email_data) + + +EmailMessage.__init__ = __init__ +EmailMessage.send = send_mail diff --git a/outlook_auth/cbv/views.py b/outlook_auth/cbv/views.py new file mode 100644 index 000000000..2f1cd641f --- /dev/null +++ b/outlook_auth/cbv/views.py @@ -0,0 +1,101 @@ +""" +outlook_auth/cbv.py + +""" + +from django.http import HttpResponse +from django.contrib import messages +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.utils.decorators import method_decorator +from horilla_views.generic.cbv import views +from horilla_views.cbv_methods import login_required, permission_required +from outlook_auth import models, filters, forms + + +@method_decorator(login_required, name="dispatch") +@method_decorator( + permission_required(perm="outlook_auth.view_azureapi"), name="dispatch" +) +class ServerNav(views.HorillaNavView): + """ + ServerList + """ + + model = models.AzureApi + search_url = reverse_lazy("outlook_server_list") + + def __init__(self, **kwargs): + self.create_attrs = f""" + onclick = "event.stopPropagation();" + data-toggle="oh-modal-toggle" + data-target="#genericModal" + hx-target="#genericModalBody" + hx-get="{reverse_lazy('outlook_server_create')}" + """ + super().__init__(**kwargs) + + nav_title = _("Mail Servers") + filter_instance = filters.AzureApiFilter() + search_swap_target = "#listContainer" + + +@method_decorator(login_required, name="dispatch") +@method_decorator( + permission_required(perm="outlook_auth.view_azureapi"), name="dispatch" +) +class ServerList(views.HorillaListView): + """ + ServerList + """ + + model = models.AzureApi + view_id = "listContainer" + columns = [ + (_("Name"), "outlook_display_name"), + (_("Email"), "outlook_email"), + (_("Company"), "company"), + (_("Token Expire"), "token_expire"), + (_("Primary"), "is_primary"), + ] + show_filter_tags = False + filter_class = filters.AzureApiFilter + search_url = reverse_lazy("outlook_server_list") + action_method = "actions" + selected_instances_key_id = "selectedRecords" + header_attrs = { + "action": """ + style = "width:298px !important" + """, + } + +@method_decorator(login_required, name="dispatch") +@method_decorator( + permission_required(perm="outlook_auth.add_azureapi"), name="dispatch" +) +class ServerForm(views.HorillaFormView): + """ + ServerForm + """ + + model = models.AzureApi + form_class = forms.OutlookServerForm + new_display_title = _("Create Mail Server") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.form.instance.pk: + self.form_class.verbose_name = _("Update Mail Server") + return context + + def form_valid(self, form: forms.OutlookServerForm) -> HttpResponse: + + if form.is_valid(): + if form.instance.pk: + message = _("Mail server updated successfully.") + else: + message = _("Mail server created successfully.") + form.save() + messages.success(self.request, _(message)) + return self.HttpResponse() + return super().form_valid(form) diff --git a/outlook_auth/filters.py b/outlook_auth/filters.py new file mode 100644 index 000000000..9a18ff3f4 --- /dev/null +++ b/outlook_auth/filters.py @@ -0,0 +1,25 @@ +""" +outlook_auth/filters.py +""" + +import django_filters +from outlook_auth import models + + +class AzureApiFilter(django_filters.FilterSet): + """ + AzureApiFilter + """ + + search = django_filters.CharFilter( + field_name="outlook_email", lookup_expr="icontains" + ) + + class Meta: + """ + Meta class for additional options""" + + fields = [ + "search", + ] + model = models.AzureApi diff --git a/outlook_auth/forms.py b/outlook_auth/forms.py new file mode 100644 index 000000000..e746dacd2 --- /dev/null +++ b/outlook_auth/forms.py @@ -0,0 +1,20 @@ +""" +outlook_auth/forms.py +""" + +from base.forms import ModelForm +from outlook_auth import models + + +class OutlookServerForm(ModelForm): + """ + OutlookServerForm + """ + + class Meta: + """ + Meta + """ + + model = models.AzureApi + fields = "__all__" diff --git a/outlook_auth/methods.py b/outlook_auth/methods.py new file mode 100644 index 000000000..461793b45 --- /dev/null +++ b/outlook_auth/methods.py @@ -0,0 +1,16 @@ +""" +outlook_auth/methods.py +""" + + +def sec_to_hm(seconds): + """ + this method is used to formate seconds to H:M and return it + args: + seconds : seconds + """ + + hour = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + seconds = int((seconds % 3600) % 60) + return f"{hour:02d}:{minutes:02d}" diff --git a/outlook_auth/migrations/__init__.py b/outlook_auth/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/outlook_auth/models.py b/outlook_auth/models.py new file mode 100644 index 000000000..7a8853643 --- /dev/null +++ b/outlook_auth/models.py @@ -0,0 +1,74 @@ +""" +outlook_auth/models.py +""" + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from base.models import Company + +from horilla_views.cbv_methods import render_template +from outlook_auth.methods import sec_to_hm + + +# Create your models here. + + +class AzureApi(models.Model): + """ + AzureApi + """ + + outlook_client_id = models.CharField(max_length=200, verbose_name=_("Client ID")) + outlook_client_secret = models.CharField( + max_length=200, verbose_name=_("Client Secret") + ) + outlook_tenant_id = models.CharField(max_length=200, verbose_name=_("Tenant ID")) + outlook_email = models.EmailField(verbose_name=_("Email")) + outlook_display_name = models.CharField( + max_length=25, verbose_name=_("Display Name") + ) + outlook_redirect_uri = models.URLField(verbose_name=("Redirect URi")) + outlook_authorization_url = models.URLField( + default="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + verbose_name="OAuth authorization endpoint", + ) + outlook_token_url = models.URLField( + default="https://login.microsoftonline.com/common/oauth2/v2.0/token", + verbose_name="OAuth token endpoint", + ) + outlook_api_endpoint = models.URLField( + default="https://graph.microsoft.com/v1.0", + verbose_name="Microsoft Graph API endpoint", + ) + company = models.ForeignKey(Company, on_delete=models.CASCADE) + is_primary = models.BooleanField(default=False) + token = models.JSONField(editable=False, default=dict) + oauth_state = models.CharField(editable=False, max_length=100, null=True) + last_refreshed = models.DateTimeField(null=True, editable=False) + + def save(self, *args, **kwargs): + if self.is_primary: + AzureApi.objects.filter(is_primary=True).update(is_primary=False) + elif not AzureApi.objects.filter(is_primary=True).first(): + self.is_primary = True + return super().save(*args, **kwargs) + + def __str__(self): + return f"{self.outlook_display_name} <{self.outlook_email}>" + + def actions(self): + return render_template("outlook/actions.html", {"instance": self}) + + def token_expire(self): + """ + last authenticated + """ + + if self.last_refreshed: + duration_seconds = (timezone.now() - self.last_refreshed).seconds + display = sec_to_hm(duration_seconds) + expires_in_seconds = self.token.get("expires_in") + if duration_seconds > expires_in_seconds: + return _("Expired⚠️") + return f"{display}/{sec_to_hm(expires_in_seconds)}" diff --git a/outlook_auth/scheduler.py b/outlook_auth/scheduler.py new file mode 100644 index 000000000..3dc299f05 --- /dev/null +++ b/outlook_auth/scheduler.py @@ -0,0 +1,41 @@ +""" +outlook_auth/scheduler.py + +""" + +import sys, logging +from apscheduler.schedulers.background import BackgroundScheduler + +logger = logging.getLogger(__name__) + + +def refresh_outlook_auth_token(): + """ + scheduler method to refresh token + """ + from outlook_auth.views import refresh_outlook_token + from outlook_auth.models import AzureApi + + apis = AzureApi.objects.filter(token__isnull=False) + for api in apis: + try: + refresh_outlook_token(api) + logger.info(f"Updated token for {api} outlook auth") + print(f"Updated token for {api} outlook auth") + except Exception as e: + logger.error(e) + + +if not any( + cmd in sys.argv + for cmd in ["makemigrations", "migrate", "compilemessages", "flush", "shell"] +): + scheduler = BackgroundScheduler() + scheduler.add_job( + refresh_outlook_auth_token, + "interval", + minutes=30, + id="refresh_outlook_auth_token", + ) + scheduler.start() + diff --git a/outlook_auth/templates/outlook/actions.html b/outlook_auth/templates/outlook/actions.html new file mode 100644 index 000000000..f09bc0e9e --- /dev/null +++ b/outlook_auth/templates/outlook/actions.html @@ -0,0 +1,16 @@ +{% load i18n %} +
+ {% if perms.outlook_auth.change_azureapi and instance.token %} + + {% endif %} + {% if perms.outlook_auth.change_azureapi %} + + {% endif %} + + {% if perms.outlook_auth.change_azureapi %} + + {% endif %} + {% if perms.outlook_auth.delete_azureapi %} + + {% endif %} +
diff --git a/outlook_auth/templates/outlook/view_records.html b/outlook_auth/templates/outlook/view_records.html new file mode 100644 index 000000000..adb83430b --- /dev/null +++ b/outlook_auth/templates/outlook/view_records.html @@ -0,0 +1,21 @@ +{% extends "settings.html" %} +{% block settings %} +{% load i18n %} +{% include "generic/components.html" %} + + +
+
+{% endblock settings %} \ No newline at end of file diff --git a/outlook_auth/tests.py b/outlook_auth/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/outlook_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/outlook_auth/urls.py b/outlook_auth/urls.py new file mode 100644 index 000000000..83d8ec447 --- /dev/null +++ b/outlook_auth/urls.py @@ -0,0 +1,28 @@ +""" +outlook_auth/urls.py +""" + +from django.urls import path +from outlook_auth import views +from outlook_auth.cbv import views as cbv + + +urlpatterns = [ + path("login/", views.outlook_login, name="outlook_login"), + path("refresh//", views.refresh_token, name="refresh_outlook_token"), + path("callback/", views.outlook_callback, name="outlook_callback"), + path("send_email/", views.send_outlook_email, name="send_email"), + path( + "view-outlook-servers/", views.view_outlook_records, name="outlook_view_records" + ), + path("outlook-server-nav/", cbv.ServerNav.as_view(), name="outlook_server_nav"), + path("outlook-server-list/", cbv.ServerList.as_view(), name="outlook_server_list"), + path( + "outlook-server-form/", cbv.ServerForm.as_view(), name="outlook_server_create" + ), + path( + "outlook-server-form//", + cbv.ServerForm.as_view(), + name="outlook_server_change", + ), +] diff --git a/outlook_auth/views.py b/outlook_auth/views.py new file mode 100644 index 000000000..370381c38 --- /dev/null +++ b/outlook_auth/views.py @@ -0,0 +1,170 @@ +""" +outlook_auth/views.py +""" + +from requests_oauthlib import OAuth2Session +from datetime import datetime +from django.http import HttpResponseRedirect +from django.utils.translation import gettext_lazy as _ +from django.contrib import messages +from django.shortcuts import redirect, render +from django.core.cache import cache +from horilla.decorators import ( + login_required, + permission_required, +) +from outlook_auth import models + + +@login_required +@permission_required("outlook_auth.add_azureapi") +def outlook_login(request): + """ + outlook login + """ + selected_company = request.session.get("selected_company") + if not selected_company or selected_company == "all": + api = models.AzureApi.objects.filter(is_primary=True).first() + else: + api = models.AzureApi.objects.filter(company=selected_company).first() + + if not api: + messages.info(request, "Not configured outlook") + oauth = OAuth2Session( + api.outlook_client_id, + redirect_uri=api.outlook_redirect_uri, + scope=["Mail.Send", "offline_access"], + ) + authorization_url, state = oauth.authorization_url(api.outlook_authorization_url) + + api.oauth_state = state + api.save() + cache.set("oauth_state", state) + return redirect(authorization_url) + + +def refresh_outlook_token(api: models.AzureApi): + """ + Refresh Outlook token + """ + oauth = OAuth2Session( + api.outlook_client_id, + token=api.token, + auto_refresh_kwargs={ + "client_id": api.outlook_client_id, + "client_secret": api.outlook_client_secret, + }, + auto_refresh_url=api.outlook_token_url, + ) + new_token = oauth.refresh_token( + api.outlook_token_url, + refresh_token=api.token["refresh_token"], + client_id=api.outlook_client_id, + client_secret=api.outlook_client_secret, + ) + api.token = new_token + api.last_refreshed = datetime.now() + api.save() + return api + + +@login_required +@permission_required("outlook_auth.change_azureapi") +def refresh_token(request, pk, *args, **kwargs): + """ + outlook_freshe + """ + api = models.AzureApi.objects.get(pk=pk) + old_token = api.token.get("access_token") + api = refresh_outlook_token(api) + if api.token.get("access_token") == old_token: + messages.info(request, _("Token not refreshed, Login required")) + else: + messages.success(request, _("Token refreshed successfully")) + + return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) + + +@login_required +@permission_required("outlook_auth.change_azureapi") +def outlook_callback(request): + """ + outlook callback + """ + selected_company = request.session.get("selected_company") + if not selected_company or selected_company == "all": + api = models.AzureApi.objects.filter(is_primary=True).first() + else: + api = models.AzureApi.objects.filter(company=selected_company).first() + + state = api.oauth_state + + oauth = OAuth2Session( + api.outlook_client_id, + state=state, + redirect_uri=api.outlook_redirect_uri, + ) + + authorization_response_uri = request.build_absolute_uri() + authorization_response_uri = authorization_response_uri.replace( + "http://", "https://" + ) + api.last_refreshed = datetime.now() + token = oauth.fetch_token( + api.outlook_token_url, + client_secret=api.outlook_client_secret, + authorization_response=authorization_response_uri, # Use the modified URI + ) + api.token = token + api.save() + + return redirect("/") + + +def send_outlook_email(request, email_data=None): + """ + send mail + """ + selected_company = None + if request: + selected_company = request.session.get("selected_company") + if not selected_company or selected_company == "all": + api = models.AzureApi.objects.filter(is_primary=True).first() + else: + api = models.AzureApi.objects.filter(company=selected_company).first() + token = api.token + + refresh_outlook_token(api) + if not token and request: + messages.info(request, "Mail not sent") + return redirect("outlook_login") + + oauth = OAuth2Session( + api.outlook_client_id, + token=token, + auto_refresh_kwargs={ + "client_id": api.outlook_client_id, + "client_secret": api.outlook_client_secret, + }, + auto_refresh_url=api.outlook_token_url, + ) + response = oauth.post(f"{api.outlook_api_endpoint}/me/sendMail", json=email_data) + + try: + response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx) + messages.success(request, _("Mail sent")) + # Email sent successfully! + return email_data + except Exception as e: + messages.error(_("Something went wrong")) + messages.info(_("Outlook authentication required/expired")) + return email_data + + +@login_required +@permission_required("outlook_auth.view_azureapi") +def view_outlook_records(request): + """ + View server records + """ + return render(request, "outlook/view_records.html") diff --git a/requirements.txt b/requirements.txt index 5812d1034..fb676b40d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ django-haystack django-import-export django-jsonfield django-mathfilters +django-microsoft-auth django-model-utils django-simple-history django-widget-tweaks @@ -34,6 +35,7 @@ Jinja2 idna lxml numpy +oauthlib openpyxl oscrypto pandas diff --git a/templates/settings.html b/templates/settings.html index ed15325b4..70e686599 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -110,16 +110,25 @@ > {% endif %} + {% if perms.base.view_dynamicemailconfiguration and not "outlook_auth"|app_installed %}
- {% if perms.base.view_dynamicemailconfiguration %} - {% trans "Mail Server" %} - {% endif %} + {% trans "Mail Server" %}
+ {% else %} + + {% endif %}
{% if perms.horilla_backup.view_googledrivebackup %}