Skip to main content

office365 mail in openforis arena

· 4 min read
Rok Damjanić
IT, sports, nature

Fixing Office365 Mail in OpenForis Arena with STARTTLS

The OpenForis Arena platform comes with built-in email support, but the default configuration does not correctly support STARTTLS when using Office365 SMTP servers. This causes the entire mailing service to fail — which means new users cannot register, and existing admins are unable to invite users to join the system.

This guide explains how to patch the email integration to work with Office365 using NodeMailer, configure the necessary environment variables, and deploy Arena using Docker Compose — complete with working email functionality and GitHub package authentication for building the image.

Using default mailer.js component produces errors like this below, even if the config is correctly filled out.

Error screenshot 1

Error screenshot 2


Why this matters

Office365 requires STARTTLS on port 587 with the correct authentication and TLS configuration. Arena's default mail configuration isn't compatible, leading to failed email deliveries or TLS handshake errors.

We’ll fix that by:

  • Modifying Arena’s mailer.js file
  • Enabling Office365 as a mail provider
  • Setting up appropriate environment variables
  • Building and running Arena via Docker

Step 1: Clone the Arena Repository

git clone https://github.com/openforis/arena.git
cd arena

Step 2: Patch the Mailer Utility

Edit the file server/utils/mailer.js and replace its contents with the following:

mailer.js
import * as nodemailer from 'nodemailer'
import * as ProcessUtils from '@core/processUtils'
import * as i18nFactory from '@core/i18n/i18nFactory'
import * as Log from '@server/log/log'

const logger = Log.getLogger('Mailer')

const emailServices = {
office365: 'office365',
}

const emailService = ProcessUtils.ENV.emailService
const authUser = ProcessUtils.ENV.emailAuthUser
const authPass = ProcessUtils.ENV.emailAuthPassword
const from = ProcessUtils.ENV.adminEmail || authUser

logger.debug('Email configuration:', {
emailService,
authUser: authUser ? '[set]' : '[not set]',
authPass: authPass ? '[set]' : '[not set]',
from,
})

const transporter = nodemailer.createTransport({
host: 'smtp.office365.com',
port: 587,
secure: false,
auth: {
user: authUser,
pass: authPass,
},
requireTLS: true,
tls: {
minVersion: 'TLSv1.3',
ciphers: 'TLS_AES_256_GCM_SHA384',
rejectUnauthorized: false,
},
debug: true,
logger: true,
})

transporter.verify((error, success) => {
if (error) {
logger.error('❌ Transporter verification failed on startup:', error)
} else {
logger.debug('✅ Transporter verified on startup')
}
})

const sendEmailMSOffice365 = async ({ to, subject, html, text = null }) => {
try {
await transporter.verify()
logger.debug('✅ Email transporter connection verified before send')

const info = await transporter.sendMail({
from,
to,
replyTo: from,
subject,
html,
text,
})

logger.debug('📩 Email sent:', info.messageId)
return info
} catch (error) {
logger.error('❌ Email sending error:', error)
throw error
}
}

export const sendEmail = async ({ to, msgKey, msgParams = {}, i18n: i18nParam = null, lang = 'en' }) => {
if (!authUser || !authPass) {
const error = new Error('Email auth credentials not configured in environment')
logger.error(error.message)
throw error
}

const i18n = i18nParam ? i18nParam : await i18nFactory.createI18nAsync(lang)

const subject = i18n.t(`${msgKey}.subject`, msgParams)
const html = i18n.t(`${msgKey}.body`, msgParams)

if (emailService === emailServices.office365) {
return await sendEmailMSOffice365({ to, subject, html })
} else {
throw new Error('Invalid email service specified: ' + emailService)
}
}

Step 3: Get Your GitHub NPM Token

To build Arena with Arena’s GitHub Packages, you need a personal access token.

How to get your token:

  1. Go to GitHub > Settings > Developer settings > Personal access tokens
  2. Click "Generate new token (classic)"
  3. Select the following scope:
    • read:packages
  4. Generate and copy the token. It will look like:
ghp_ze4O1W2hfRTZ878d1Fvnndz4eZUDc

Step 4: Configure Environment Variables

Create a .env file in the root of the Arena project:

NPM_TOKEN=ghp_ze4O1W2hfRTZ878d1Fvnndz4eZUDc

Then modify the arena.env file for Arena’s runtime environment:

EMAIL_SERVICE=office365
[email protected] (same as in arena.env)
EMAIL_AUTH_PASSWORD=your_office365_password

Step 5: Build with Docker Compose

Create a docker-compose.yml in the root of your project:

services:
arena:
build:
context: .
dockerfile: Dockerfile
args:
- NPM_TOKEN=${NPM_TOKEN}
container_name: openforis-arena
restart: always
env_file:
- arena.env
network_mode: "host"

Then run:

docker compose build
docker compose up -d

All Set!

You now have a working OpenForis Arena deployment that can send emails through Office365 using STARTTLS — complete with logs, error handling, and secure configuration.

No more failed handshakes or broken notifications. 🎉


ldap3 in paperless-ngx

· 6 min read
Rok Damjanić
IT, sports, nature

In this post, I will explain how I integrated LDAP authentication into Paperless-ngx using the ldap3 library. By leveraging LDAP, we can centrally manage user authentication, ensuring secure and streamlined access to Paperless-ngx.

Custom LDAP Authentication Backend

The first step is creating a custom authentication backend. This backend uses ldap3 to authenticate users against the LDAP directory. Below is the complete implementation also transfering groups from LDAP.

Key Features of the Backend

  • Centralized Authentication: User credentials are verified against the LDAP server.
  • Dynamic User Management: Users are automatically created or updated in the local Django database upon successful authentication.
  • Dynamic Group Management: Groups are automatically generated from those that the user belongs to.
  • Admin: When a user is in the admin group, he also gets a staff member status.
  • Logging: Authentication attempts are logged for debugging and auditing purposes.

The backend including LDAP groups

ldap_auth_backend.py

import logging
from django.contrib.auth.models import User, Group
from django.contrib.auth.backends import BaseBackend
from ldap3 import Server, Connection, ALL, SUBTREE

# LDAP Server Configuration
LDAP_SERVER = "ldap://example.com:3268"
LDAP_AUTH_SEARCH_BASE = "ou=it,ou=departments,dc=example,dc=com"
LDAP_AUTH_CONNECTION_USERNAME = "cn=ldapadmin,ou=users,dc=example,dc=com"
LDAP_AUTH_CONNECTION_PASSWORD = "pass of ldapadmin"

logger = logging.getLogger(__name__)

class LDAPBackend(BaseBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
server = Server(LDAP_SERVER, get_info=ALL)
conn = Connection(server, user=LDAP_AUTH_CONNECTION_USERNAME, password=LDAP_AUTH_CONNECTION_PASSWORD, auto_bind=True)

# Define the LDAP search filter to find the user by sAMAccountName
search_filter = f"(sAMAccountName={username})"

try:
# Search for the user in LDAP
conn.search(
search_base=LDAP_AUTH_SEARCH_BASE,
search_filter=search_filter,
search_scope=SUBTREE,
attributes=['sAMAccountName', 'mail', 'givenName', 'sn', 'memberOf']
)

if conn.entries:
# If the user exists, attempt to bind with user's credentials to authenticate
user_dn = conn.entries[0].entry_dn
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)

if user_conn.bind():
# Extract user details from LDAP entry
ldap_entry = conn.entries[0]
first_name = ldap_entry.givenName.value if hasattr(ldap_entry, 'givenName') else ""
last_name = ldap_entry.sn.value if hasattr(ldap_entry, 'sn') else ""
email = ldap_entry.mail.value if hasattr(ldap_entry, 'mail') else ""
groups = ldap_entry.memberOf.values if hasattr(ldap_entry, 'memberOf') else []

# Get or create the Django user
user, created = User.objects.get_or_create(username=username)
if created:
user.set_unusable_password()
user.first_name = first_name
user.last_name = last_name
user.email = email

# Create groups and assign the user to them
for group_dn in groups:
group_name = group_dn.split(',')[0][3:] # Extract group name from DN (e.g., "CN=admin" -> "admin")
group, _ = Group.objects.get_or_create(name=group_name) # Ensure the group exists
if not user.groups.filter(name=group_name).exists():
user.groups.add(group)

# Check if the user is part of the 'admin' group to set is_staff
if group_name.lower() == 'admin':
user.is_staff = True

user.save()

logger.info(f"User {username} authenticated and {'created' if created else 'updated'} in Django. Assigned to groups {', '.join([g.split(',')[0][3:] for g in groups])}.")
return user
except Exception as e:
logger.warning(f"LDAP authentication failed for user {username}: {e}")
return None

def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None

Backend without groups

example with just user creation without any groups
docker-compose.env
import logging
from django.contrib.auth.models import User
from django.contrib.auth.backends import BaseBackend
from ldap3 import Server, Connection, ALL, SUBTREE

# LDAP Server Configuration
LDAP_SERVER = "ldap://example.com:3268"
LDAP_AUTH_SEARCH_BASE = "ou=it,ou=departments,dc=example,dc=com"
LDAP_AUTH_CONNECTION_USERNAME = "cn=ldapadmin,ou=users,dc=example,dc=com"
LDAP_AUTH_CONNECTION_PASSWORD = "pass of ldapadmin"

logger = logging.getLogger(__name__)

class LDAPBackend(BaseBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
server = Server(LDAP_SERVER, get_info=ALL)
conn = Connection(server, user=LDAP_AUTH_CONNECTION_USERNAME, password=LDAP_AUTH_CONNECTION_PASSWORD, auto_bind=True)

# Define the LDAP search filter to find the user by sAMAccountName
search_filter = f"(sAMAccountName={username})"

try:
# Search for the user in LDAP
conn.search(
search_base=LDAP_AUTH_SEARCH_BASE,
search_filter=search_filter,
search_scope=SUBTREE,
attributes=['sAMAccountName', 'mail', 'givenName', 'sn']
)

if conn.entries:
# If the user exists, attempt to bind with user's credentials to authenticate
user_dn = conn.entries[0].entry_dn
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)

if user_conn.bind():
# Extract user details from LDAP entry
ldap_entry = conn.entries[0]
first_name = ldap_entry.givenName.value if hasattr(ldap_entry, 'givenName') else ""
last_name = ldap_entry.sn.value if hasattr(ldap_entry, 'sn') else ""
email = ldap_entry.mail.value if hasattr(ldap_entry, 'mail') else ""

# Get or create the Django user
user, created = User.objects.get_or_create(username=username)
if created:
user.set_unusable_password()

# Update user's first name, last name, and email
user.first_name = first_name
user.last_name = last_name
user.email = email

# Explicitly set is_staff to False
user.is_staff = False
user.save()

logger.info(f"User {username} authenticated and {'created' if created else 'updated'} in Django with name '{first_name} {last_name}' and email '{email}'.")
return user
except Exception as e:
logger.warning(f"LDAP authentication failed for user {username}: {e}")
return None

def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None

Adding LDAP Support to Paperless-ngx

To integrate this backend into Paperless-ngx, I created a custom Docker image and updated the necessary configuration files.

Custom Docker Image

I extended the official Paperless-ngx Docker image to include the ldap3 library and the custom authentication backend. Here's the Dockerfile:

# Start from the official paperless-ngx image
FROM ghcr.io/paperless-ngx/paperless-ngx:latest

# Install LDAP library
RUN pip install ldap3

# Copy the custom LDAP backend code
COPY ldap_auth_backend.py /usr/src/paperless/src/paperless/ldap_auth_backend.py

# Update settings.py to include the LDAP backend using a sed command
RUN sed -i "/^AUTHENTICATION_BACKENDS = /a 'paperless.ldap_auth_backend.LDAPBackend'," /usr/src/paperless/src/paperless/settings.py

Build and Run the Docker Image

Use the following commands to build and run the custom Docker image:

# Build the Docker image
docker build -t paperless-ngx-ldap .

# Run the Docker container
docker run -d \
--name paperless-ngx-ldap \
-e PAPERLESS_DBHOST=your_db_host \
-e PAPERLESS_DBPASS=your_db_password \
-e PAPERLESS_TIMEZONE=your_timezone \
-p 8000:8000 \
paperless-ngx-ldap

Docker compose example with existing postgresql

docker-compose.yml example

This is an example docker compose file

docker-compose.yml
services:
broker:
image: docker.io/library/redis:7
restart: unless-stopped
volumes:
- redisdata:/data

webserver:
build: .
restart: unless-stopped
depends_on:
- broker
- gotenberg
- tika
ports:
- "8000:8000"
volumes:
- /home/user/docker-compose/paperless-ngx/data/:/usr/src/paperless/data
- /home/user/docker-compose/paperless-ngx/media/:/usr/src/paperless/media
- /home/user/docker-compose/paperless-ngx/export:/usr/src/paperless/export
- /home/user/docker-compose/paperless-ngx/consume/:/usr/src/paperless/consume

env_file: docker-compose.env
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_TIKA_ENABLED: 1
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
PAPERLESS_LDAP_PIP_INSTALL: "true"

gotenberg:
image: docker.io/gotenberg/gotenberg:7.8
restart: unless-stopped

command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"

tika:
image: ghcr.io/paperless-ngx/tika:latest
restart: unless-stopped

volumes:
data:
media:
redisdata:

environment example
docker-compose.env
   PAPERLESS_TIME_ZONE=Europe/Ljubljana
PAPERLESS_OCR_LANGUAGE=eng+slv+deu
PAPERLESS_SECRET_KEY=OO5345345349urerexuRj1nkKoUmKzJJcq2vBik4Cwre0luVS9iapnLP5
PAPERLESS_OCR_LANGUAGES=eng slv deu
PAPERLESS_DBENGINE=postgresql
PAPERLESS_DBHOST=192.168.1.2
PAPERLESS_DBPORT=5432
PAPERLESS_DBUSER: paperless
PAPERLESS_DBPASS: strongpass
PAPERLESS_URL=https://paperless.example.com
PAPERLESS_CSRF_TRUSTED_ORIGINS=https://paperless.example.com,https://example.com
PAPERLESS_ALLOWED_HOSTS=paperless.paperless.example.com,192.168.1.4,example.com
PAPERLESS_CORS_ALLOWED_HOSTS=https://paperless.example.com,https://example.com # can be set using PAPERLESS_URL

Final Thoughts

By integrating LDAP authentication into Paperless-ngx, I was able to streamline user management and centralize authentication using our existing LDAP infrastructure. The flexibility of Paperless-ngx and the power of ldap3 made this integration straightforward and effective. I hope this guide helps others looking to implement similar functionality. There are also others who managed to create usefull integrations on (didn't work for me though): https://github.com/paperless-ngx/paperless-ngx/discussions/3228.