[ADD] OUTLOOK: Add outlook mail integration to Horilla

This commit is contained in:
Horilla
2025-02-25 17:53:28 +05:30
parent b630caa940
commit 1f306792ea
18 changed files with 769 additions and 8 deletions

32
outlook_auth/__init__.py Normal file
View File

@@ -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://<your_domain.com>/outlook/callback
"""

16
outlook_auth/admin.py Normal file
View File

@@ -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,
]
)

14
outlook_auth/apps.py Normal file
View File

@@ -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()

173
outlook_auth/backends.py Normal file
View File

@@ -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

101
outlook_auth/cbv/views.py Normal file
View File

@@ -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)

25
outlook_auth/filters.py Normal file
View File

@@ -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

20
outlook_auth/forms.py Normal file
View File

@@ -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__"

16
outlook_auth/methods.py Normal file
View File

@@ -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}"

View File

74
outlook_auth/models.py Normal file
View File

@@ -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)}"

41
outlook_auth/scheduler.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,16 @@
{% load i18n %}
<div class="oh-btn-group">
{% if perms.outlook_auth.change_azureapi and instance.token %}
<a href="{% url 'refresh_outlook_token' instance.pk %}" class="oh-btn oh-btn--light-bkg w-100" title="{% trans 'Refresh Token' %}"><ion-icon name="refresh"></ion-icon></a>
{% endif %}
{% if perms.outlook_auth.change_azureapi %}
<a target="_blank" href="/outlook/login/" class="oh-btn oh-btn--light-bkg w-100" title="{% trans 'Login' %}"><ion-icon name="log-in-outline"></ion-icon></a>
{% endif %}
<a hx-get="{% url 'mail-server-test-email' %}?instance_id={{ instance.id }}" onclick="event.stopPropagation()" hx-target="#mailServerModalBody" data-toggle="oh-modal-toggle" data-target="#mailServerModal" class="oh-btn oh-btn--light-bkg w-100" title="Test email"><ion-icon name="mail-unread-outline" role="img" class="md hydrated" aria-label="at-circle-outline"></ion-icon></a>
{% if perms.outlook_auth.change_azureapi %}
<a hx-get="{% url 'outlook_server_change' instance.id %}?instance_ids={{ instance.ordered_ids }}" onclick="event.stopPropagation()" hx-target="#genericModalBody" data-toggle="oh-modal-toggle" data-target="#genericModal" class="oh-btn oh-btn--light-bkg w-100" title="Edit"><ion-icon name="create-outline" role="img" class="md hydrated" aria-label="create outline"></ion-icon></a>
{% endif %}
{% if perms.outlook_auth.delete_azureapi %}
<a onclick="event.stopPropagation()" hx-get="{% url 'generic-delete' %}?pk={{instance.pk}}&model=outlook_auth.AzureApi" data-toggle="oh-modal-toggle" data-target="#deleteConfirmation" hx-target="#deleteConfirmationBody" type="submit" class="oh-btn oh-btn--danger-outline oh-btn--light-bkg w-100" title="Remove"><ion-icon name="trash-outline" role="img" class="md hydrated" aria-label="trash outline"></ion-icon></a>
{% endif %}
</div>

View File

@@ -0,0 +1,21 @@
{% extends "settings.html" %}
{% block settings %}
{% load i18n %}
{% include "generic/components.html" %}
<div class="oh-modal" id="mailServerModal" role="dialog" aria-labelledby="mailServerModal" aria-hidden="true">
<div class="oh-modal__dialog">
<div class="oh-modal__dialog-header">
<h2 class="oh-modal__dialog-title" id="editModal1ModalLabel">
{% trans "Mail Server" %}
</h2>
<button class="oh-modal__close" aria-label="Close">
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
<div class="oh-modal__dialog-body" id="mailServerModalBody"></div>
</div>
</div>
<div style="display: none;" data-ids="[]" id="selectedRecords"></div>
<div hx-get="{% url "outlook_server_nav" %}" hx-trigger="load"></div>
<div class="oh-wrapper" hx-get="{% url "outlook_server_list" %}" hx-trigger="load"></div>
{% endblock settings %}

3
outlook_auth/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

28
outlook_auth/urls.py Normal file
View File

@@ -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/<int:pk>/", 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/<int:pk>/",
cbv.ServerForm.as_view(),
name="outlook_server_change",
),
]

170
outlook_auth/views.py Normal file
View File

@@ -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")

View File

@@ -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

View File

@@ -110,16 +110,25 @@
>
{% endif %}
</div>
{% if perms.base.view_dynamicemailconfiguration and not "outlook_auth"|app_installed %}
<div class="oh-input-group">
{% if perms.base.view_dynamicemailconfiguration %}
<a
id="date"
href="{% url 'mail-server-conf' %}"
class="oh-inner-sidebar__link oh-dropdown__link"
>{% trans "Mail Server" %}</a
>
{% endif %}
<a
id="date"
href="{% url 'mail-server-conf' %}"
class="oh-inner-sidebar__link oh-dropdown__link"
>{% trans "Mail Server" %}</a
>
</div>
{% else %}
<div class="oh-input-group">
<a
id="date"
href="{% url 'outlook_view_records' %}"
class="oh-inner-sidebar__link oh-dropdown__link"
>{% trans "Outlook Mail" %}</a
>
</div>
{% endif %}
<div class="oh-input-group">
{% if perms.horilla_backup.view_googledrivebackup %}
<a