diff --git a/.env.example b/.env.example
index d14be81..68fc356 100644
--- a/.env.example
+++ b/.env.example
@@ -2,5 +2,7 @@ VERKADA_API_KEY=This should be your API Key==
ELASTIC_PASSWORD=At least six characters long
KIBANA_SYSTEM_PASSWORD=At least six characters long and you will need to set in Kibana refer to README
KIBANA_ENCRYPTION_KEY=Create Key using command "openssl rand -base64 32" in linux or wsl==
-EMAIL_PASSWORD = Password for the email account you are sending the dashboard from
+AZURE_TENANT_ID=Directory (tenant) ID from your Microsoft Entra app registration
+AZURE_CLIENT_ID=Application (client) ID from your Microsoft Entra app registration
+AZURE_CERT_PATH=Path to the PEM file holding the private key + certificate uploaded to the app registration
WINDOWS_IP=This is the IP for your device
\ No newline at end of file
diff --git a/README.md b/README.md
index c779a82..0843001 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,119 @@ Follow this [guide](https://docs.docker.com/desktop/features/wsl/) to set up Doc
Clone the VerityBot git repository into the WSL2 terminal
```git clone git@github.com:VerCalBot/VerityBot.git```
+### Microsoft 365 email setup 📧
+
+VerityBot emails the Kibana dashboard link on a schedule. Because Microsoft 365
+has **disabled Basic Authentication (username/password) for SMTP**, VerityBot
+authenticates to Microsoft 365 using **OAuth2 with an app registration**
+(app-only / client-credentials flow, using a **certificate**). No mailbox
+password is used or stored.
+
+You must complete the steps below **before** running `initial_setup.sh`, because
+the setup dialogue box asks for the **Tenant ID**, **Client ID**, and
+**Certificate Path** produced here. These steps require a **Microsoft 365 /
+Entra ID administrator**.
+
+> The exact wording of some portal buttons changes over time; the resource names
+> (`Office 365 Exchange Online`, permission `SMTP.SendAsApp`, scope
+> `https://outlook.office365.com/.default`) are the parts that matter.
+
+#### 1. Choose or create the sender account
+Pick the mailbox that reports will be sent *from* (this is the `Email Sender`
+value in the dialogue box). This can be a normal licensed user or, preferably, a
+dedicated **service account** / **shared mailbox** (e.g. `veritybot@yourdomain`).
+It must have an Exchange Online mailbox.
+
+#### 2. Register an application in Microsoft Entra ID
+1. Go to the [Microsoft Entra admin center](https://entra.microsoft.com) →
+ **Identity → Applications → App registrations → New registration**.
+2. Name it (e.g. `VerityBot Email Sender`), leave redirect URI empty, and
+ register. (No redirect URI / SSO is needed for app-only auth.)
+3. On the app **Overview** page, copy the **Directory (tenant) ID** and the
+ **Application (client) ID** — these become `AZURE_TENANT_ID` and
+ `AZURE_CLIENT_ID`.
+
+#### 3. Grant the SMTP send permission (admin consent required)
+1. In the app, go to **API permissions → Add a permission → APIs my
+ organization uses**, and search for **Office 365 Exchange Online**.
+2. Choose **Application permissions**, select **`SMTP.SendAsApp`**, and add it.
+3. Click **Grant admin consent for <tenant>** and confirm the permission
+ shows a green "Granted" status.
+
+#### 4. Create and upload a certificate
+Generate a self-signed certificate on the machine that will run VerityBot (in
+WSL). This produces two files: a **private key + certificate** PEM that stays on
+the VerityBot host, and a **public certificate** (`.crt`) that you upload to
+Entra.
+
+```bash
+# Creates veritybot.key (private key), veritybot.crt (public cert), valid 2 years.
+openssl req -x509 -newkey rsa:2048 -nodes \
+ -keyout veritybot.key -out veritybot.crt \
+ -days 730 -subj "/CN=VerityBot Email Sender"
+
+# Combine the private key + certificate into one PEM for VerityBot to load.
+cat veritybot.key veritybot.crt > veritybot.pem
+chmod 600 veritybot.key veritybot.pem # keep the private key readable only by you
+```
+
+1. In the app, go to **Certificates & secrets → Certificates → Upload
+ certificate**, and upload **`veritybot.crt`** (the public cert only — never
+ upload the `.key` or `.pem`).
+2. Note the absolute path to **`veritybot.pem`** on the VerityBot host — this is
+ the `Certificate Path` you enter in the dialogue box (`AZURE_CERT_PATH`).
+ > Certificates expire (2 years above). Before the expiry date, generate a new
+ > cert, upload the new `.crt`, and re-run the setup dialogue box (or just
+ > point `AZURE_CERT_PATH` at the new `.pem`), or the nightly email will start
+ > failing. Choose a longer `-days` value if you prefer fewer rotations.
+
+#### 5. Scope the app to the sender mailbox and enable SMTP AUTH
+This restricts the app so it can only send as your sender mailbox, and ensures
+SMTP client submission is allowed for that mailbox. Run these in
+[Exchange Online PowerShell](https://learn.microsoft.com/powershell/exchange/connect-to-exchange-online-powershell)
+as an Exchange administrator:
+
+> **Is there a web UI for this instead?** Mostly no. `New-ServicePrincipal`
+> (registering the app in Exchange) and granting that service principal
+> `FullAccess` to the mailbox are **PowerShell-only** — Microsoft provides no
+> admin-center UI for them, and the Exchange delegation picker only lists users
+> and groups, not app service principals. The **last** command, enabling
+> Authenticated SMTP, *does* have a UI: Microsoft 365 admin center →
+> **Users → Active users** → select the sender account → **Mail** →
+> **Manage email apps** → check **Authenticated SMTP** → **Save changes**.
+
+```powershell
+Connect-ExchangeOnline
+
+# Register the app's service principal in Exchange Online.
+# AppId = Application (client) ID from step 2.
+# ObjectId = the *Enterprise application* Object ID for this app
+# (Entra → Enterprise applications → your app → Object ID).
+New-ServicePrincipal -AppId -ObjectId -DisplayName "VerityBot Email Sender"
+
+# Allow the app to send only as the sender mailbox.
+Add-MailboxPermission -Identity "veritybot@yourdomain.com" -User -AccessRights FullAccess
+
+# Make sure SMTP AUTH is enabled for the sender mailbox.
+Set-CASMailbox -Identity "veritybot@yourdomain.com" -SmtpClientAuthenticationDisabled $false
+```
+
+> If your tenant disables SMTP AUTH org-wide (the default), the per-mailbox
+> `Set-CASMailbox` override above re-enables it for just this one mailbox.
+
+#### 6. Confirm the sender domain is mapped
+VerityBot connects to `smtp.office365.com` for Microsoft 365 domains. If your
+sender domain is not already listed in `domain_to_server_mapping` in
+`src/email_sender.py`, add it there mapping to `office365.com`.
+
+You now have the three values the dialogue box needs:
+
+| Dialogue box field | Value from above | `.env` key |
+| ------------------ | -------------------------------------- | ----------------- |
+| Tenant ID | Directory (tenant) ID (step 2) | `AZURE_TENANT_ID` |
+| Client ID | Application (client) ID (step 2) | `AZURE_CLIENT_ID` |
+| Certificate Path | Path to `veritybot.pem` (step 4) | `AZURE_CERT_PATH` |
+
### Initial Setup 🧑💻
Ensure that Docker Desktop is open and running on your host machine \
@@ -35,7 +148,8 @@ When prompted, enter your User password \
Enter your credentials into the popup dialogue box
**Note**: All fields must be filled, use the ```Generate Key``` button to create a Kibana Encryption Key.
-**Note**: All passwords must be at least 6 characters long. \
+**Note**: All passwords must be at least 6 characters long.
+**Note**: The ```Tenant ID```, ```Client ID```, and ```Certificate Path``` fields come from the [Microsoft 365 email setup](#microsoft-365-email-setup) steps above. \
Click ```Save``` and exit the popup box \
diff --git a/requirements.txt b/requirements.txt
index f10b6f0..aed2153 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,8 @@
certifi==2026.1.4
charset-normalizer==3.4.4
+cryptography==44.0.0
idna==3.11
+msal==1.31.1
python-dotenv==1.2.1
requests==2.32.5
urllib3==2.6.3
diff --git a/scripts/initial_setup.sh b/scripts/initial_setup.sh
index dd52451..ecc530e 100755
--- a/scripts/initial_setup.sh
+++ b/scripts/initial_setup.sh
@@ -29,7 +29,10 @@ if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
-pip install python-dotenv
+# email_sender.py runs from cron via this venv; it needs python-dotenv (config),
+# msal (Microsoft 365 OAuth2 token acquisition), and cryptography (loading the
+# certificate credential).
+pip install python-dotenv msal cryptography
echo
echo "Getting Windows IP"
diff --git a/src/dialogueBox.py b/src/dialogueBox.py
index 51df303..2d25c48 100644
--- a/src/dialogueBox.py
+++ b/src/dialogueBox.py
@@ -1,5 +1,6 @@
import os
import tkinter as tk
+from tkinter import filedialog
import configparser
import dotenv
import secrets
@@ -22,7 +23,9 @@ def Save_Button():
newSendTime = inpSendTime.get()
newFreq = freqVar.get()
newFreqUnit = timeUnit.get()
- newEmailPass = inpEmailPass.get()
+ newTenantId = inpTenantId.get().strip()
+ newClientId = inpClientId.get().strip()
+ newCertPath = inpCertPath.get().strip()
newVerkAPI = inpApiKey.get("1.0", "end").strip()
newElastPass = inpElastPass.get()
newKibPass =inpKibPass.get()
@@ -35,7 +38,9 @@ def Save_Button():
co["Email"]["EMAIL_BODY_PREFIX"] = newMessage
co["Email"]["EMAIL_SEND_TIME"] = newSendTime
co["Verkada"]["ELASTIC_UPDATE_INTERVAL"] = newFreq + newFreqUnit
- dotenv.set_key(dotenv_file, "EMAIL_PASSWORD", newEmailPass)
+ dotenv.set_key(dotenv_file, "AZURE_TENANT_ID", newTenantId)
+ dotenv.set_key(dotenv_file, "AZURE_CLIENT_ID", newClientId)
+ dotenv.set_key(dotenv_file, "AZURE_CERT_PATH", newCertPath)
dotenv.set_key(dotenv_file, "VERKADA_API_KEY", newVerkAPI)
dotenv.set_key(dotenv_file, "ELASTIC_PASSWORD", newElastPass)
dotenv.set_key(dotenv_file,"KIBANA_SYSTEM_PASSWORD", newKibPass)
@@ -57,6 +62,15 @@ def generate_kibana_key():
inpKibEnc.delete("1.0", "end")
inpKibEnc.insert("1.0", key or "")
+ def browse_cert():
+ path = filedialog.askopenfilename(
+ title="Select Microsoft 365 certificate (private key + cert PEM)",
+ filetypes=[("PEM files", "*.pem"), ("All files", "*.*")],
+ )
+ if path:
+ inpCertPath.delete(0, "end")
+ inpCertPath.insert(0, path)
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
co = configparser.ConfigParser()
@@ -93,9 +107,18 @@ def generate_kibana_key():
sendTimeLabel = tk.Label(root, text="Email Send Time (24HR)").grid(row=5, column=0)
inpSendTime = tk.Entry(root, width = 7)
- #Email Password
- emailPassLabel = tk.Label(root, text="Email Password").grid(row=6, column=0)
- inpEmailPass = tk.Entry(root, width=50)
+ #Microsoft 365 app registration (OAuth2) -- three fields grouped in one row.
+ #See the "Microsoft 365 email setup" section of the README for how to obtain
+ #these values.
+ azureLabel = tk.Label(root, text="Microsoft 365 App").grid(row=6, column=0)
+ azureFrame = tk.Frame(root)
+ tk.Label(azureFrame, text="Tenant ID").grid(row=0, column=0, sticky='e')
+ inpTenantId = tk.Entry(azureFrame, width=40)
+ tk.Label(azureFrame, text="Client ID").grid(row=1, column=0, sticky='e')
+ inpClientId = tk.Entry(azureFrame, width=40)
+ tk.Label(azureFrame, text="Certificate Path").grid(row=2, column=0, sticky='e')
+ inpCertPath = tk.Entry(azureFrame, width=40)
+ certBrowseButton = tk.Button(azureFrame, text="Browse", command=browse_cert)
#ELASTIC_UPDATE_INTERVAL
freqLabel1 = tk.Label(root, text="Update Elastic every")
@@ -136,7 +159,11 @@ def generate_kibana_key():
inpSubject.grid(row=3, column=1, padx=5, pady=5, sticky='w')
inpMessage.grid(row=4, column=1, padx=(5,15), pady=5, sticky='w')
inpSendTime.grid(row=5, column=1, padx=5, pady=5, sticky='w')
- inpEmailPass.grid(row=6, column=1, padx=5, pady=5, sticky='w')
+ azureFrame.grid(row=6, column=1, padx=5, pady=5, sticky='w')
+ inpTenantId.grid(row=0, column=1, padx=5, pady=2, sticky='w')
+ inpClientId.grid(row=1, column=1, padx=5, pady=2, sticky='w')
+ inpCertPath.grid(row=2, column=1, padx=5, pady=2, sticky='w')
+ certBrowseButton.grid(row=2, column=2, padx=5, pady=2, sticky='w')
#Verkada Entries
freqLabel1.grid(row=7, column=0, padx=5, pady=5)
@@ -160,7 +187,9 @@ def generate_kibana_key():
emailSubject = co.get("Email", "EMAIL_SUBJECT")
emailMessage = co.get("Email", "EMAIL_BODY_PREFIX")
emailSendTime = co.get("Email", "EMAIL_SEND_TIME")
- emailPass = os.getenv("EMAIL_PASSWORD")
+ tenantId = os.getenv("AZURE_TENANT_ID")
+ clientId = os.getenv("AZURE_CLIENT_ID")
+ certPath = os.getenv("AZURE_CERT_PATH")
verkAPIKey = os.getenv("VERKADA_API_KEY")
elasticPass = os.getenv("ELASTIC_PASSWORD")
kibanaPass = os.getenv("KIBANA_SYSTEM_PASSWORD")
@@ -172,7 +201,9 @@ def generate_kibana_key():
inpSubject.insert(0, emailSubject)
inpMessage.insert("1.0", emailMessage)
inpSendTime.insert(0, emailSendTime)
- inpEmailPass.insert(0, emailPass or "")
+ inpTenantId.insert(0, tenantId or "")
+ inpClientId.insert(0, clientId or "")
+ inpCertPath.insert(0, certPath or "")
inpApiKey.insert("1.0", verkAPIKey or "")
inpElastPass.insert(0, elasticPass or "")
inpKibPass.insert(0, kibanaPass or "")
diff --git a/src/email_sender.py b/src/email_sender.py
index 1579b16..c0b738a 100644
--- a/src/email_sender.py
+++ b/src/email_sender.py
@@ -1,3 +1,4 @@
+import base64
import logging
import os
import smtplib
@@ -5,6 +6,10 @@
import subprocess
import Utils
+import msal
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
from dotenv import load_dotenv
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@@ -33,21 +38,75 @@ def domain_to_server(email: str) -> str | None:
return smtp_server
return None
+# Microsoft 365 has disabled Basic Authentication (username/password) for SMTP
+# AUTH. Instead we authenticate with an OAuth2 access token using the SMTP
+# XOAUTH2 mechanism. The token is obtained app-only (client credentials) from
+# Microsoft Entra ID using an app registration authenticated with a certificate
+# -- see the "Microsoft 365 email setup" section of the README for how to create
+# the app, upload the certificate, and grant it the SMTP.SendAsApp permission
+# scoped to the sender mailbox.
+
+# Build the MSAL certificate credential from a PEM file that contains both the
+# private key and the certificate. MSAL needs the private key plus the cert's
+# SHA-1 thumbprint, which we compute here so the operator never has to copy it.
+def load_client_credential() -> dict:
+ with open(cert_path, "rb") as f:
+ pem_data = f.read()
+ cert = x509.load_pem_x509_certificate(pem_data)
+ return {
+ "private_key": pem_data.decode(),
+ "thumbprint": cert.fingerprint(hashes.SHA1()).hex(),
+ "public_certificate": cert.public_bytes(serialization.Encoding.PEM).decode(),
+ }
+
+def get_access_token() -> str:
+ if not (tenant_id and client_id and cert_path):
+ raise RuntimeError(
+ "Missing Microsoft 365 credentials. AZURE_TENANT_ID, AZURE_CLIENT_ID "
+ "and AZURE_CERT_PATH must all be set in the .env file "
+ "(run the setup dialogue box to configure them)."
+ )
+ if not os.path.isfile(cert_path):
+ raise RuntimeError(f"Certificate file not found: {cert_path}")
+
+ app = msal.ConfidentialClientApplication(
+ client_id,
+ authority=f"https://login.microsoftonline.com/{tenant_id}",
+ client_credential=load_client_credential(),
+ )
+ # ".default" requests the application permissions already consented to the
+ # app registration (i.e. SMTP.SendAsApp on Office 365 Exchange Online).
+ result = app.acquire_token_for_client(
+ scopes=["https://outlook.office365.com/.default"]
+ )
+ if "access_token" not in result:
+ raise RuntimeError(
+ "Unable to acquire Microsoft 365 access token: "
+ f"{result.get('error_description', result)}"
+ )
+ return result["access_token"]
+
def send_email():
smtp_server = domain_to_server(sender_email)
- if smtp_server:
- try:
- with smtplib.SMTP(f"smtp.{smtp_server}", 587) as s:
- s.set_debuglevel(1)
- s.starttls()
- s.login(sender_email, str(email_password))
- s.sendmail(sender_email, recipient_email, message.as_string())
- s.quit()
- except Exception as e:
- logging.error(f"Unable to send email: error code {e.args.index}")
- exit(1)
- else:
+ if not smtp_server:
logging.error("Unable to send email. Make sure the sender email is correct!")
+ return
+
+ try:
+ access_token = get_access_token()
+ # XOAUTH2 SASL initial-response string; smtplib base64-encodes it for us.
+ auth_string = f"user={sender_email}\1auth=Bearer {access_token}\1\1"
+
+ with smtplib.SMTP(f"smtp.{smtp_server}", 587) as s:
+ s.set_debuglevel(1)
+ s.ehlo()
+ s.starttls()
+ s.ehlo()
+ s.auth("XOAUTH2", lambda challenge=None: auth_string)
+ s.sendmail(sender_email, recipient_email, message.as_string())
+ except Exception as e:
+ logging.error(f"Unable to send email: {e}")
+ exit(1)
# for sending the link to Kibana through email
# IP address is likely to change due to proxies or DHCP lease termination
@@ -68,7 +127,9 @@ def get_ip():
script_path = Utils.get_project_root() / "scripts" / "get_ip.sh"
subprocess.run([script_path], check=True)
-load_dotenv()
+# Load .env from the project root explicitly so this works when cron runs the
+# script from an arbitrary working directory.
+load_dotenv(Utils.get_project_root() / ".env")
IP = os.getenv("WINDOWS_IP") or get_ip()
# read prefix only from config (no env fallback)
@@ -87,7 +148,10 @@ def get_ip():
message.attach(MIMEText(html, "html"))
-email_password = os.getenv("EMAIL_PASSWORD")
+# Microsoft 365 app-only (certificate credentials) OAuth2 credentials.
+tenant_id = os.getenv("AZURE_TENANT_ID")
+client_id = os.getenv("AZURE_CLIENT_ID")
+cert_path = os.getenv("AZURE_CERT_PATH")
send_email()