Update README.md

This commit is contained in:
DualStackAdmin 2025-10-09 02:16:12 +04:00 committed by GitHub
parent c0cc2cab15
commit 3093eafc05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

461
README.md
View file

@ -1,420 +1,75 @@
#!/bin/bash # M365 Mailbox Backup Tool (self-hosted-m365-backup)
#================================================================================= This is an open-source, self-hosted web application designed to back up Microsoft 365 mailboxes to a local server in the standard `.eml` format. Built with a security-first approach, it leverages the Microsoft Identity Platform for authentication, ensuring that access is protected by your organization's existing security policies.
# M365 Mailbox Backup Tool - Automatic Installation Script (v2.1 - Interactive IP)
#=================================================================================
# --- Color Codes --- ## Features
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m' # No Color
if [ "$EUID" -ne 0 ]; then - **Web-Based Management Panel:** A modern and convenient web interface for all operations.
echo -e "${RED}Please run this script with 'sudo': sudo ./install.sh${NC}" - **Secure, Enterprise-Grade Authentication:** Instead of managing local passwords, the application integrates with the Microsoft Identity Platform ("Login with Microsoft"). This ensures that your organization's existing security policies, including **Multi-Factor Authentication (2FA/MFA)**, are automatically enforced, providing a highly secure login experience for administrators.
exit 1 - **Selective Backup:** Choose one or more users to back up from a dynamically loaded list of all tenant users.
fi - **Date Filtering:** Back up items from specific time ranges (presets or custom dates).
- **Asynchronous Background Tasks:** Large backup jobs run in the background using Celery and Redis, ensuring the UI is always responsive.
- **Task Status Dashboard:** Monitor the status of all initiated backup tasks (Pending, In Progress, Success, Failure).
- **Interactive Restore Portal:** An Outlook-like three-pane interface to browse backed-up data:
- Navigation through the original mailbox folder structure (Inbox, Sent Items, etc.).
- A list of emails within the selected folder.
- In-browser preview of emails without downloading.
- **Privacy-by-Design Filter:** The email preview pane is blurred by default to respect user privacy, with a hover-to-reveal feature for administrators.
- **Full-Text Search:** Search within the subject and body of all backed-up emails for a specific user.
- **Automated Installation:** A single `install.sh` script to deploy the entire application and its dependencies on a fresh Ubuntu server.
echo "=== M365 Backup Tool Installation Started ===" ## Architecture
# --- NEW: INTERACTIVE IP PROMPT --- - **Backend:** Python, Flask
DETECTED_IP=$(hostname -I | awk '{print $1}') - **Web Server:** Gunicorn, Nginx (as a Reverse Proxy)
read -p "Enter the server IP address or press Enter to use the detected IP [$DETECTED_IP]: " USER_IP - **Background Tasks:** Celery
IP_ADDR=${USER_IP:-$DETECTED_IP} - **Queue Manager / Broker:** Redis
# ------------------------------------ - **Frontend:** HTML, Pico.css, HTMX
- **Authentication:** Microsoft Identity Platform (OAuth 2.0)
REDIRECT_URI="https://$IP_ADDR/get_token" ## Installation Guide
echo "---------------------------------------------------------------------"
echo "IMPORTANT: Please ensure you have set the following Redirect URI"
echo "in your Azure AD App Registration under the 'Web' platform section:"
echo ""
echo "$REDIRECT_URI"
echo ""
echo "---------------------------------------------------------------------"
read -p "Press Enter after you have added the Redirect URI in the Azure Portal..."
echo -n "Enter your Azure Tenant ID: " The installation process consists of two main parts: preparing the prerequisites in the Azure Portal and then running the installation script on your server.
read TENANT_ID
echo -n "Enter your Application (Client) ID: "
read CLIENT_ID
echo -n "Enter your Client Secret Value (will not be visible): "
read -s CLIENT_SECRET
echo ""
echo -n "Enter comma-separated emails of allowed admins: "
read ALLOWED_ADMINS
echo ""
echo "Credentials received."
# --- System Dependency Installation --- ### I. Azure Portal Configuration (Prerequisites)
echo "--> Updating system and installing required packages..."
apt-get update > /dev/null 2>&1
apt-get install -y nginx redis-server python3-pip python3-venv > /dev/null 2>&1
# --- Project Directory and Virtual Environment --- Before running the installation script, you must complete the following steps in the Azure Portal.
PROJECT_DIR="/opt/m365-backup-tool"
USERNAME=${SUDO_USER:-$(whoami)}
echo "--> Creating project directory: $PROJECT_DIR" **1. App Registration:**
mkdir -p "$PROJECT_DIR" - Log in to `portal.azure.com`.
cd "$PROJECT_DIR" - Navigate to **Azure Active Directory** > **App registrations** > **+ New registration**.
- Give your application a name (e.g., `M365-Backup-Tool`) and click **Register**.
echo "--> Creating Python virtual environment..." **2. API Permissions:**
python3 -m venv venv - In your newly created app, navigate to **API permissions** from the left menu.
- Click **+ Add a permission** > **Microsoft Graph**.
- You will need to add two types of permissions:
- **Application permissions:**
- `User.Read.All` (To get the list of all users for backup selection)
- `Mail.Read.All` (To read all mailboxes for the backup process)
- **Delegated permissions:**
- `User.Read` (To allow users to log in to the application with their Microsoft account)
- After adding these permissions, you **must** click the **"Grant admin consent for [Your Organization's Name]"** button to approve them. The status for all permissions should show a green checkmark and "Granted".
echo "--> Installing Python libraries..." **3. Add a Redirect URI:**
source venv/bin/activate - From the left menu, select **Authentication**.
pip install Flask gunicorn "celery[redis]" msal requests python-decouple > /dev/null 2>&1 - Click **+ Add a platform** > **Web**.
deactivate - In the **Redirect URIs** field, you will enter a URI in the format `https://<your_server_ip_address>/get_token`. The `install.sh` script will show you the exact URI to use when you run it.
- Click **Save**.
# --- Application File Creation --- **4. Create a Client Secret:**
echo "--> Creating .env file for credentials..." - From the left menu, select **Certificates & secrets** > **+ New client secret**.
cat <<EOF > .env - Add a description and choose an expiration period (e.g., 24 months).
TENANT_ID=${TENANT_ID} - Click **Add**.
CLIENT_ID=${CLIENT_ID} - **VERY IMPORTANT:** Immediately copy the **"Value"** of the newly created secret and save it somewhere safe. You will not be able to see this value again after you leave the page.
CLIENT_SECRET=${CLIENT_SECRET}
FLASK_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(24))')
ALLOWED_ADMINS=${ALLOWED_ADMINS}
REDIRECT_URI=${REDIRECT_URI}
EOF
echo "--> Creating application files and templates..." **Summary:** At the end of this stage, you must have these 3 pieces of information: **Tenant ID**, **Client ID**, and **Client Secret Value**.
# app.py file
cat <<'EOF' > app.py
import os, re, msal, requests, json, secrets
from flask import Flask, request, redirect, url_for, render_template, send_from_directory, session
from celery import Celery
from email import policy
from email.parser import BytesParser
from datetime import datetime, timedelta
from decouple import config
from functools import wraps
TENANT_ID = config('TENANT_ID', default='') ### II. Server Installation
CLIENT_ID = config('CLIENT_ID', default='')
CLIENT_SECRET = config('CLIENT_SECRET', default='')
FLASK_SECRET_KEY = config('FLASK_SECRET_KEY')
ALLOWED_ADMINS = [admin.strip().lower() for admin in config('ALLOWED_ADMINS', default='').split(',')]
REDIRECT_URI = config('REDIRECT_URI')
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPE = ["User.Read"]
BACKUP_DIR = os.path.join(os.getcwd(), "backup")
app = Flask(__name__) Log into your fresh **Ubuntu 22.04 LTS** server. The following commands will download the installation script, make it executable, and then run it with the necessary privileges.
app.secret_key = FLASK_SECRET_KEY
app.config.update(broker_url='redis://localhost:6379/0', result_backend='redis://localhost:6379/0')
celery = Celery(app.name, broker=app.config['broker_url'])
celery.conf.update(app.config)
msal_app = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET) ```bash
wget [https://raw.githubusercontent.com/DualStackAdmin/self-hosted-m365-backup/main/install.sh](https://raw.githubusercontent.com/DualStackAdmin/self-hosted-m365-backup/main/install.sh)
def login_required(f): chmod +x install.sh
@wraps(f) sudo ./install.sh
def decorated_function(*args, **kwargs):
if 'user' not in session: return redirect(url_for('show_login_page'))
if session['user'].get('email', '').lower() not in ALLOWED_ADMINS:
session.clear(); return "Access Denied. You are not an authorized administrator.", 403
return f(*args, **kwargs)
return decorated_function
@app.route('/login_page')
def show_login_page(): return render_template('login.html')
@app.route('/login')
def login():
session["flow"] = msal_app.initiate_auth_code_flow(SCOPE, redirect_uri=REDIRECT_URI)
return redirect(session["flow"]["auth_uri"])
@app.route('/get_token')
def get_token():
try:
result = msal_app.acquire_token_by_auth_code_flow(session.get("flow", {}), request.args)
if "error" in result: return f"Login Error: {result.get('error_description')}", 400
claims = result.get("id_token_claims", {})
user_email = claims.get("preferred_username")
if not user_email or user_email.lower() not in ALLOWED_ADMINS:
session.clear(); return "Access Denied. Your account is not in the list of authorized administrators.", 403
session["user"] = {"name": claims.get("name"), "email": user_email}
except ValueError as e: return f"Error processing token: {str(e)}", 400
return redirect(url_for('index'))
@app.route('/logout')
def logout():
session.clear()
return redirect(f"https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri={url_for('show_login_page', _external=True, _scheme='https')}")
def sanitize_filename(filename): return re.sub(r'[\\/*?:"<>|]', "", filename)
@celery.task
def trigger_backup(user_email, date_option='all_time', start_date_str=None, end_date_str=None):
app_for_backup = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET)
result = app_for_backup.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
if "access_token" not in result: return f"Auth Error for background task: {result.get('error_description')}"
access_token = result['access_token']; headers = {'Authorization': 'Bearer ' + access_token}
user_backup_path = os.path.join(BACKUP_DIR, user_email)
if not os.path.exists(user_backup_path): os.makedirs(user_backup_path)
all_folders, folders_url = [], f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders?$top=100"
while folders_url:
response = requests.get(folders_url, headers=headers)
if response.status_code != 200: return f"Error fetching mail folders: {response.json()}"
data = response.json(); all_folders.extend(data.get('value', [])); folders_url = data.get('@odata.nextLink')
total_message_count = 0
for folder in all_folders:
folder_name, folder_id = folder.get('displayName'), folder.get('id')
if not folder_name or not folder_id: continue
local_folder_path = os.path.join(user_backup_path, sanitize_filename(folder_name))
if not os.path.exists(local_folder_path): os.makedirs(local_folder_path)
filter_query = ""
now = datetime.utcnow()
if date_option == 'last_3_months': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=90)).isoformat()}Z"
elif date_option == 'last_6_months': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=180)).isoformat()}Z"
elif date_option == 'last_1_year': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=365)).isoformat()}Z"
elif date_option == 'last_2_years': filter_query = f"$filter=receivedDateTime ge {(now - timedelta(days=730)).isoformat()}Z"
elif date_option == 'custom_range' and start_date_str and end_date_str:
start_iso = datetime.fromisoformat(start_date_str).isoformat(); end_iso = (datetime.fromisoformat(end_date_str) + timedelta(days=1)).isoformat()
filter_query = f"$filter=receivedDateTime ge {start_iso}Z and receivedDateTime lt {end_iso}Z"
base_url = f"https://graph.microsoft.com/v1.0/users/{user_email}/mailFolders/{folder_id}/messages"
messages_url = f"{base_url}?$top=1000"
if filter_query: messages_url = f"{base_url}?{filter_query}&$top=1000"
message_count_in_folder = 0
while messages_url:
msg_response = requests.get(messages_url, headers=headers)
if msg_response.status_code != 200: break
messages_data = msg_response.json(); messages = messages_data.get('value', [])
for message in messages:
message_id = message.get('id')
if not message_id: continue
mime_url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages/{message_id}/$value"
mime_response = requests.get(mime_url, headers=headers)
if mime_response.status_code == 200:
with open(os.path.join(local_folder_path, f"{sanitize_filename(message_id)}.eml"), 'wb') as f: f.write(mime_response.content)
message_count_in_folder += 1
messages_url = messages_data.get('@odata.nextLink')
total_message_count += message_count_in_folder
return f"Total {total_message_count} emails downloaded."
@app.route('/')
@login_required
def index(): return render_template('index.html')
@app.route('/get_users')
@login_required
def get_users():
app_for_users = msal.ConfidentialClientApplication(client_id=CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET)
result = app_for_users.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
if "access_token" not in result: return render_template('partials/user_list_error.html', error_message="App Authentication Error.")
headers = {'Authorization': 'Bearer ' + result['access_token']}
users, users_url = [], "https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName"
while users_url:
response = requests.get(users_url, headers=headers)
if response.status_code != 200: return render_template('partials/user_list_error.html', error_message="Error fetching users.")
data = response.json(); users.extend(data.get('value', [])); users_url = data.get('@odata.nextLink')
return render_template('partials/user_list.html', users=sorted(users, key=lambda u: u.get('displayName') or ''))
@app.route('/backup', methods=['POST'])
@login_required
def backup():
emails = request.form.getlist('emails'); date_option = request.form.get('date_option'); start_date = request.form.get('start_date'); end_date = request.form.get('end_date')
if 'tasks' not in session: session['tasks'] = []
for email in emails:
task = trigger_backup.delay(email, date_option, start_date, end_date)
session['tasks'].append({'id': task.id, 'email': email})
session.modified = True
return redirect(url_for('list_tasks'))
@app.route('/tasks')
@login_required
def list_tasks():
task_info = []
for task_meta in session.get('tasks', []):
task = trigger_backup.AsyncResult(task_meta.get('id'))
task_info.append({'id': task_meta.get('id'), 'email': task_meta.get('email'), 'status': task.state, 'result': task.result if task.state in ['SUCCESS', 'FAILURE'] else str(task.info)})
return render_template('tasks.html', tasks=reversed(task_info))
@app.route('/backups')
@login_required
def list_backups():
if not os.path.exists(BACKUP_DIR): os.makedirs(BACKUP_DIR)
return render_template('backups.html', users=os.listdir(BACKUP_DIR))
@app.route('/backups/<path:user_email>')
@login_required
def restore_portal(user_email):
user_dir = os.path.join(BACKUP_DIR, user_email)
if not os.path.exists(user_dir): return "No backup found for this user.", 404
all_folders = [d for d in os.listdir(user_dir) if os.path.isdir(os.path.join(user_dir, d))]
active_folder_name = request.args.get('folder')
if not active_folder_name: active_folder_name = next((f for f in ['Inbox'] if f in all_folders), all_folders[0] if all_folders else None)
search_query = request.args.get('q', '').lower()
emails = []
if active_folder_name:
selected_folder_path = os.path.join(user_dir, active_folder_name)
if os.path.exists(selected_folder_path):
parser = BytesParser(policy=policy.default)
for filename in os.listdir(selected_folder_path):
if filename.endswith(".eml"):
with open(os.path.join(selected_folder_path, filename), 'rb') as f: msg = parser.parse(f)
if search_query:
content_to_search = str(msg.get_payload(decode=True))
if msg['subject']: content_to_search += msg['subject']
if search_query not in content_to_search.lower(): continue
emails.append({'filename': filename, 'subject': msg['subject'], 'from': msg['from'], 'date': msg['date']})
return render_template('portal.html', user_email=user_email, folders=sorted(all_folders), emails=emails, active_folder=active_folder_name, search_query=search_query)
@app.route('/download/<path:user_email>/<path:folder_name>/<path:filename>')
@login_required
def download_file(user_email, folder_name, filename): return send_from_directory(directory=os.path.join(BACKUP_DIR, user_email, folder_name), path=filename, as_attachment=True)
@app.route('/preview/<path:user_email>/<path:folder_name>/<path:filename>')
@login_required
def preview_file(user_email, folder_name, filename):
file_path = os.path.join(BACKUP_DIR, user_email, folder_name, filename)
if not os.path.exists(file_path): return {"error": "File not found"}, 404
with open(file_path, 'rb') as f: msg = BytesParser(policy=policy.default).parse(f)
html_body, plain_body = None, None
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == 'text/html' and not html_body: html_body = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='ignore')
elif ct == 'text/plain' and not plain_body: plain_body = part.get_payload(decode=True).decode(part.get_content_charset() or 'utf-8', errors='ignore')
else: plain_body = msg.get_payload(decode=True).decode(msg.get_content_charset() or 'utf-8', errors='ignore')
if html_body: return {"body": html_body}
elif plain_body: return {"body": f"<pre>{plain_body}</pre>"}
else: return {"body": "Could not display content."}
EOF
mkdir -p templates/partials
cat <<'EOF' > templates/login.html
<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login - M365 Backup Tool</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css"></head><body><main class="container"><article style="margin-top: 5rem;"><h1 style="text-align: center;">M365 Backup Tool</h1><p style="text-align: center;">Please log in with your Microsoft account to continue.</p><div style="text-align: center; margin-top: 2rem;"><a href="/login" role="button">Login with Microsoft</a></div></article></main></body></html>
EOF
cat <<'EOF' > templates/layout.html
<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{% block title %}{% endblock %} - M365 Backup Tool</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css"><style>:root { --pico-font-size: 100%; } .htmx-indicator{opacity:0;transition:opacity 200ms ease-in;text-align:center;margin-top:1rem;} .htmx-request .htmx-indicator,.htmx-request.htmx-indicator{opacity:1}</style></head><body><main class="container"><nav><ul><li><strong>M365 Backup Tool</strong></li></ul><ul><li><a href="/">New Backup</a></li><li><a href="/backups">Existing Backups</a></li><li><a href="/tasks">Tasks</a></li>{% if session.user %}<li><a href="/logout" role="button" class="secondary outline">Logout ({{ session.user.name }})</a></li>{% endif %}</ul></nav><hr>{% block content %}{% endblock %}</main><script src="https://unpkg.com/htmx.org@1.9.10"></script></body></html>
EOF
cat <<'EOF' > templates/index.html
{% extends 'layout.html' %}{% block title %}New Backup{% endblock %}{% block content %}<h2>Start New Backup</h2><form action="/backup" method="post"><div style="position: sticky; top: 0; background-color: var(--background-color); padding-top: 1rem; padding-bottom: 1rem; z-index: 10;"><div class="grid"><label for="date_option">Select Date Range<select id="date_option" name="date_option" onchange="toggleCustomDate()"><option value="all_time" selected>All Time</option><option value="last_3_months">Last 3 Months</option><option value="last_6_months">Last 6 Months</option><option value="last_1_year">Last 1 Year</option><option value="last_2_years">Last 2 Years</option><option value="custom_range">Custom Range</option></select></label><div></div></div><div id="custom_date_fields" style="display: none;" class="grid"><label for="start_date">Start Date<input type="date" id="start_date" name="start_date"></label><label for="end_date">End Date<input type="date" id="end_date" name="end_date"></label></div><button type="submit" style="margin-top: 1rem;">Backup Selected Users</button><hr></div><p>Select users to back up:</p><div hx-get="/get_users" hx-trigger="load" hx-indicator="#user-list-indicator"><p id="user-list-indicator" class="htmx-indicator"><progress></progress><br>Loading user list from Azure...</p></div></form><script>
function toggleCustomDate() {
const dateOption = document.getElementById('date_option'), customDateFields = document.getElementById('custom_date_fields');
if (dateOption.value === 'custom_range') { customDateFields.style.display = 'grid'; } else { customDateFields.style.display = 'none'; }
}
</script>{% endblock %}
EOF
cat <<'EOF' > templates/partials/user_list.html
<figure><table><thead><tr><th scope="col" style="width:50px;">Select</th><th scope="col">Display Name</th><th scope="col">Email</th></tr></thead><tbody>
{% for user in users %}<tr><td><input type="checkbox" name="emails" value="{{ user.userPrincipalName }}"></td><td>{{ user.displayName }}</td><td>{{ user.userPrincipalName }}</td></tr>
{% else %}<tr><td colspan="3">No users found or error loading users.</td></tr>{% endfor %}
</tbody></table></figure>
EOF
cat <<'EOF' > templates/partials/user_list_error.html
<article><h5 style="color:var(--pico-color-red);">Error Loading Users</h5><p>Could not load the user list.</p><p><small><strong>Details:</strong> {{ error_message }}</small></p></article>
EOF
cat <<'EOF' > templates/backups.html
{% extends 'layout.html' %}{% block title %}Existing Backups{% endblock %}{% block content %}<h2>Backed Up Users</h2><ul>{% for user in users %}<li><a href="/backups/{{ user }}">{{ user }}</a></li>{% else %}<li>No backups found yet.</li>{% endfor %}</ul>{% endblock %}
EOF
cat <<'EOF' > templates/portal.html
{% extends 'layout.html' %}{% block title %}{{ user_email }} - Restore Portal{% endblock %}{% block content %}
<style>.portal-grid{display:grid;grid-template-columns:250px 1fr 1fr;gap:1rem;height:80vh;}.folder-list,.email-list,.preview-pane{overflow-y:auto;height:100%;}.folder-list ul{list-style-type:none;padding:0;margin:0;}.folder-list a{display:block;padding:0.5rem 1rem;border-radius:0.25rem;text-decoration:none;color:var(--pico-contrast);}.folder-list a:hover{background-color:var(--pico-muted-background-color);}.folder-list a.active{background-color:var(--pico-primary);color:var(--pico-primary-inverse);}.email-list table tbody tr{cursor:pointer;}.preview-pane iframe{width:100%;height:100%;border:none;filter:blur(8px);transition:filter 0.3s ease-in-out;}.preview-pane:hover iframe{filter:blur(0);}</style>
<h2>Restore Portal for: {{ user_email }}</h2><hr><div class="portal-grid"><article class="folder-list"><ul>
{% for folder in folders %}<li><a href="{{ url_for('restore_portal', user_email=user_email, folder=folder) }}" class="{{ 'active' if folder == active_folder else '' }}">{{ folder }}</a></li>{% endfor %}
</ul></article><article class="email-list"><form method="get" action="{{ url_for('restore_portal', user_email=user_email) }}"><input type="search" name="q" placeholder="Search in {{ active_folder }}..." value="{{ search_query or '' }}" style="margin-bottom:0;"><input type="hidden" name="folder" value="{{ active_folder }}"></form><figure><table><tbody>
{% for email in emails %}<tr onclick="fetchEmail('{{ user_email }}', '{{ active_folder }}', '{{ email.filename }}')"><td><strong>{{ email.from }}</strong><br>{{ email.subject }}<br><small>{{ email.date }}</small></td></tr>
{% else %}<tr><td>No emails found in this folder.</td></tr>{% endfor %}
</tbody></table></figure></article><article class="preview-pane" id="preview-pane"><p>Select an email to preview...</p></article></div>
<script>
function fetchEmail(userEmail, folderName, fileName) {
const previewPane = document.getElementById('preview-pane');
previewPane.innerHTML = '<p>Loading...</p>';
fetch(`/preview/${userEmail}/${folderName}/${fileName}`).then(response=>response.json()).then(data=>{
if(data.error){previewPane.innerHTML=`<p>Error: ${data.error}</p>`;} else {
const iframe=document.createElement('iframe');previewPane.innerHTML='';previewPane.appendChild(iframe);
iframe.contentWindow.document.open();iframe.contentWindow.document.write(data.body);iframe.contentWindow.document.close();
}}).catch(error=>{previewPane.innerHTML=`<p>An error occurred: ${error}</p>`;});
}
</script>{% endblock %}
EOF
cat <<'EOF' > templates/tasks.html
{% extends 'layout.html' %}{% block title %}Task Status{% endblock %}{% block content %}<h2>Task Status</h2><figure><table><thead><tr><th>Task ID</th><th>User</th><th>Status</th><th>Result</th></tr></thead><tbody>
{% for task in tasks %}<tr><td><small>{{ task.id }}</small></td><td>{{ task.email }}</td><td>{{ task.status }}</td><td><small>{{ task.result }}</small></td></tr>
{% else %}<tr><td colspan="4">No tasks have been started yet.</td></tr>{% endfor %}
</tbody></table></figure><a href="{{ url_for('list_tasks') }}" role="button" class="secondary outline">Refresh Page</a>{% endblock %}
EOF
echo "--> Creating self-signed SSL certificate..."
mkdir -p /etc/nginx/ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/nginx/ssl/m365-backup.key \
-out /etc/nginx/ssl/m365-backup.crt \
-subj "/C=XX/ST=Global/L=Global/O=Global/OU=IT/CN=$IP_ADDR"
echo "--> Creating systemd service files..."
cat <<EOF > /etc/systemd/system/m365-web.service
[Unit]
Description=Gunicorn for M365 Backup App
After=network.target
[Service]
User=$USERNAME
Group=www-data
WorkingDirectory=$PROJECT_DIR
ExecStart=$PROJECT_DIR/venv/bin/gunicorn --workers 3 --bind unix:$PROJECT_DIR/m365-backup.sock -m 007 app:app
Restart=always
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF > /etc/systemd/system/m365-worker.service
[Unit]
Description=Celery Worker for M365 Backup App
After=network.target
[Service]
User=$USERNAME
Group=www-data
WorkingDirectory=$PROJECT_DIR
ExecStart=$PROJECT_DIR/venv/bin/celery -A app.celery worker --loglevel=info
Restart=always
[Install]
WantedBy=multi-user.target
EOF
echo "--> Creating Nginx proxy configuration..."
cat <<EOF > /etc/nginx/sites-available/m365-backup
server {
listen 80;
server_name $IP_ADDR;
return 301 https://\$server_name\$request_uri;
}
server {
listen 443 ssl;
server_name $IP_ADDR;
ssl_certificate /etc/nginx/ssl/m365-backup.crt;
ssl_certificate_key /etc/nginx/ssl/m365-backup.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
include proxy_params;
proxy_pass http://unix:$PROJECT_DIR/m365-backup.sock;
}
}
EOF
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/m365-backup /etc/nginx/sites-enabled/
chown -R $USERNAME:www-data "$PROJECT_DIR"
chmod -R 775 "$PROJECT_DIR"
echo "--> Starting and enabling all services..."
systemctl daemon-reload
systemctl restart redis-server nginx
systemctl start m365-web m365-worker
systemctl enable redis-server nginx m365-web m365-worker
echo "--> Verifying service status..."
sleep 5
FINAL_STATUS=0
if systemctl is-active --quiet m365-web.service; then
echo -e "${GREEN}✅ Web Server (Gunicorn) is running.${NC}"
else
echo -e "${RED}❌ Web Server (Gunicorn) failed to start!${NC}"; FINAL_STATUS=1; fi
if systemctl is-active --quiet m365-worker.service; then
echo -e "${GREEN}✅ Background Worker (Celery) is running.${NC}"
else
echo -e "${RED}❌ Background Worker (Celery) failed to start!${NC}"; FINAL_STATUS=1; fi
echo ""
echo "============================================================"
if [ $FINAL_STATUS -eq 0 ]; then
echo -e "${GREEN}✅ INSTALLATION COMPLETED SUCCESSFULLY!${NC}"
echo ""
echo "You can access the application at: https://$IP_ADDR"
else
echo -e "${RED}❌ INSTALLATION FAILED!${NC}"
echo -e "${RED}Please check the error messages and service logs.${NC}"
fi
echo "============================================================"