Skip to content

Commit 35a7353

Browse files
committed
Add email reports & Playwright PNG export
Introduce automated email reporting with SMTP support and server-side PNG exports. - Add email configuration and scheduling (simple_org_chart/email_config.py) and sender logic (simple_org_chart/email_sender.py). - Add Playwright-based screenshot exporter (simple_org_chart/screenshot.py) and wire PNG generation into report flows. - Trigger scheduled/`Send Now` emails after successful syncs (data_update.py) and add API endpoints to manage/test email config (app_main.py). - Update scheduler to mark runs as 'scheduled' so reports are only sent for scheduled syncs. - Include Playwright dependency and adjust Dockerfile to use Ubuntu 22.04, install Python 3.10, and install Playwright browsers; add playwright to requirements.txt. - Make runtime/config tweaks: expose APP_BASE_URL and GUNICORN_TIMEOUT in .env.template and use env timeout in gunicorn config. - Frontend: add email report UI/README docs, add search highlight duration setting, and implement timed fading highlight behavior in static JS/CSS and configure UI. This change enables configurable, scheduled email reports (XLSX and optional PNG attachments) and ensures server-side PNG generation works reliably in Docker.
1 parent a96a7bb commit 35a7353

18 files changed

Lines changed: 1754 additions & 24 deletions

.env.template

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,20 @@ AZURE_CLIENT_SECRET=your-client-secret-here
1414
ADMIN_PASSWORD=your-admin-password-here
1515
SECRET_KEY=generate-a-64-character-random-string
1616

17+
# Application Configuration
1718
# Optional application port (defaults to 5000)
1819
APP_PORT=5000
1920

21+
# Gunicorn worker timeout in seconds (default: 600 = 10 minutes)
22+
# Increase if PNG screenshot generation times out on large org charts
23+
GUNICORN_TIMEOUT=600
24+
25+
# Base URL for the application (required for PNG email attachments)
26+
# Docker: Use http://localhost (port 80 inside container) or your external URL
27+
# Non-Docker: Use http://localhost:5000 or http://127.0.0.1:5000
28+
# IMPORTANT: This is the URL accessible FROM INSIDE the container, not external port mapping
29+
APP_BASE_URL=http://localhost
30+
2031
# Optional comma-separated list of origins allowed to call the API (e.g. intranet portals)
2132
CORS_ALLOWED_ORIGINS=https://intranet.yourcompany.com,https://another-app.yourcompany.com
2233

@@ -94,4 +105,26 @@ CORS_ALLOWED_ORIGINS=https://intranet.yourcompany.com,https://another-app.yourco
94105
# GRAPH_API_ENDPOINT=https://graph.microsoft.com/v1.0
95106

96107
# Graph API beta endpoint
97-
# GRAPH_API_BETA_ENDPOINT=https://graph.microsoft.com/beta
108+
# GRAPH_API_BETA_ENDPOINT=https://graph.microsoft.com/beta
109+
110+
# ─────────────────────────────────────────────────────────────────────────────
111+
# SMTP Email Configuration (for automated email reports)
112+
# ─────────────────────────────────────────────────────────────────────────────
113+
114+
# SMTP server hostname (e.g., smtp.gmail.com, smtp.office365.com)
115+
# SMTP_SERVER=smtp.gmail.com
116+
117+
# SMTP server port (common: 587 for STARTTLS, 465 for SSL/TLS, 25 for plain)
118+
# SMTP_PORT=587
119+
120+
# SMTP username (often your email address)
121+
# SMTP_USERNAME=your-email@example.com
122+
123+
# SMTP password or app-specific password
124+
# SMTP_PASSWORD=your-smtp-password
125+
126+
# From address for sent emails (must be authorized by SMTP server)
127+
# SMTP_FROM_ADDRESS=noreply@yourcompany.com
128+
129+
# Encryption protocol: TLS (STARTTLS for port 587), SSL (SSL/TLS for port 465), or None
130+
# SMTP_ENCRYPTION=TLS

Dockerfile

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
# Use Python 3.10 slim image as base
2-
FROM python:3.10-slim
1+
# Use Ubuntu 22.04 with Python 3.10 for better Playwright support
2+
FROM ubuntu:22.04
33

44
# Set working directory
55
WORKDIR /app
@@ -10,22 +10,35 @@ ENV PYTHONUNBUFFERED=1
1010
ENV FLASK_APP=app.py
1111
ENV FLASK_ENV=production
1212
ENV APP_PORT=5000
13+
ENV DEBIAN_FRONTEND=noninteractive
14+
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
1315

14-
# Install system dependencies and create application user
16+
# Install Python 3.10 and system dependencies
1517
RUN apt-get update && apt-get install -y \
18+
python3.10 \
19+
python3.10-venv \
20+
python3-pip \
1621
gcc \
1722
curl \
1823
&& rm -rf /var/lib/apt/lists/* \
1924
&& groupadd --system app \
2025
&& useradd --system --gid app --create-home app
2126

27+
# Create symlinks for python/pip
28+
RUN ln -sf /usr/bin/python3.10 /usr/bin/python && \
29+
ln -sf /usr/bin/python3.10 /usr/bin/python3
30+
2231
# Copy requirements first for better Docker layer caching
2332
COPY requirements.txt .
2433

2534
# Install Python dependencies
2635
RUN pip install --no-cache-dir --upgrade pip && \
2736
pip install --no-cache-dir -r requirements.txt
2837

38+
# Install Playwright browsers to system location and set ownership
39+
RUN playwright install --with-deps chromium && \
40+
chown -R app:app /ms-playwright
41+
2942
# Copy application code
3043
COPY . .
3144

README.md

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ python -c "import secrets; print(secrets.token_hex(32))"
9999
| `GRAPH_API_ENDPOINT` | `https://graph.microsoft.com/v1.0` | Microsoft Graph API v1.0 endpoint. |
100100
| `GRAPH_API_BETA_ENDPOINT` | `https://graph.microsoft.com/beta` | Microsoft Graph API beta endpoint. |
101101

102+
**SMTP Email Configuration (optional, for automated reports)**
103+
104+
| Variable | Default | Description |
105+
| --- | --- | --- |
106+
| `SMTP_SERVER` | *(none)* | SMTP server hostname (e.g., `smtp.gmail.com`, `smtp.office365.com`). |
107+
| `SMTP_PORT` | `587` | SMTP server port (587 for STARTTLS, 465 for SSL/TLS, 25 for plain). |
108+
| `SMTP_USERNAME` | *(none)* | SMTP username (often your email address). |
109+
| `SMTP_PASSWORD` | *(none)* | SMTP password or app-specific password. |
110+
| `SMTP_FROM_ADDRESS` | *(none)* | From address for sent emails (must be authorized by SMTP server). |
111+
| `SMTP_ENCRYPTION` | `TLS` | Encryption protocol: `TLS` (STARTTLS for port 587), `SSL` (SSL/TLS for port 465), or `None`. |
112+
| `APP_BASE_URL` | `http://localhost:5000` | Base URL for generating PNG screenshots (required for PNG email attachments). |
113+
102114
## Running the Application
103115

104116
### Docker (recommended)
@@ -116,17 +128,86 @@ docker compose up -d
116128

117129
- **Interactive D3 Org Chart**: Pan, zoom, and expand/collapse hierarchies with persistent hidden subtrees.
118130
- **Search & Discovery**: Real-time directory search, quick navigation helpers, and configurable filters for guests/disabled users.
119-
- **Configuration UI** (`/configure`): Adjust styling, filtering, export columns, and scheduling without editing files.
131+
- **Configuration UI** (`/configure`): Adjust styling, filtering, export columns, scheduling, and email reports without editing files.
120132
- **Admin Reports** (`/reports`):
121133
- Missing managers
122-
- Users by last sign-in activity
123-
- Employees hired in the last 365 days
124-
- Users hidden by filters
134+
- Users by last sign-in activity
135+
- Employees hired in the last 365 days
136+
- Users hidden by filters
137+
- **Automated Email Reports**: Schedule daily, weekly, or monthly reports sent via SMTP after data synchronization.
125138
- **Export Options**: SVG/PNG/PDF snapshots and XLSX exports for reports and chart data.
126139
- **MicroSIP Directory Feed**: Serve a MicroSIP contacts JSON at /contacts.json using cached employee data.
127140
- **Desk Phone Directory**: Yealink-compatible XML phonebook at /contacts.xml for T31P, T33G, T46U, and similar models.
128141
- **Caching & Scheduling**: JSON caches regenerate nightly; manual refresh endpoints keep data current on demand.
129142

143+
## Automated Email Reports
144+
145+
SimpleOrgChart can send scheduled email reports with organization chart data as attachments after each successful data synchronization. **Disabled by default.**
146+
147+
### Prerequisites
148+
149+
1. Configure SMTP environment variables in `.env` (see SMTP Email Configuration section above)
150+
2. Ensure all required SMTP settings are provided: server, port, username, password, and from address
151+
3. Set `APP_BASE_URL` in `.env` to the **internal** URL where the app listens:
152+
- **Docker**: `http://localhost` (port 80 inside container, regardless of external port mapping)
153+
- **Non-Docker**: `http://localhost:5000` (or whatever port you configured)
154+
4. **(Docker users)** PNG screenshots are automatically supported - Playwright is included in the Docker image
155+
5. **(Non-Docker users)** For PNG chart screenshots, install Playwright:
156+
```bash
157+
pip install playwright
158+
playwright install --with-deps chromium
159+
```
160+
161+
### Configuration
162+
163+
1. Navigate to `/configure` and locate the **📧 Email Reports** section
164+
2. Enable **Automated Email Reports** toggle
165+
3. Configure the following options:
166+
- **Recipient Email**: Email address(es) to receive reports (comma-separated for multiple recipients)
167+
- **Report Frequency**: Choose daily, weekly, or monthly
168+
- **Day of Week**: For weekly schedules, select which day to send
169+
- **Day of Month**: For monthly schedules, select first or last day
170+
- **Attachment Types**: Select which file formats to include:
171+
- **XLSX (Excel)**: Employee data spreadsheet (always available)
172+
- **PNG (Chart Image)**: Visual org chart diagram (requires Playwright)
173+
4. Click **Send Test Email** to verify SMTP configuration
174+
5. Save settings
175+
176+
### How It Works
177+
178+
- Email reports are triggered automatically after successful data synchronization
179+
- The scheduler checks if an email should be sent based on the configured frequency
180+
- For **daily** reports: Emails are sent on the specified day of the week if at least 24 hours have passed since the last email
181+
- For **weekly** reports: Emails are sent on the specified day of the week if at least 7 days have passed
182+
- For **monthly** reports: Emails are sent on the first or last day of the month if at least 28 days have passed
183+
184+
Email reports support two attachment types:
185+
186+
1. **XLSX (Excel)** - Always available
187+
- Complete employee directory with name, title, department, email, phone, manager, location details
188+
- Server-side generation, no additional dependencies
189+
190+
2. **PNG (Chart Image)** - Included in Docker, optional for manual installs
191+
- Visual organization chart diagram
192+
- Full chart view with all employees
193+
- Generated via headless browser screenshot
194+
- **Docker**: Automatically available (Playwright included in image)
195+
- **Manual installs**: Requires `playwright install --with-deps chromium`
196+
197+
> **Note**: SVG and PDF exports require client-side rendering and are not available for automated email reports. These formats can still be generated manually from the web interface.
198+
199+
### Test Email
200+
201+
Use the **Send Test Email** button to verify your SMTP configuration before enabling automated reports. Use **Manual Send Now** to immediately send a report with XLSX and/or PNG attachments based on your configuration.
202+
203+
### SMTP Status
204+
205+
The email reports section displays the current SMTP configuration status:
206+
-**SMTP configured**: Shows the server, port, and from address
207+
-**SMTP not configured**: Indicates missing SMTP environment variables
208+
209+
If SMTP is not configured, ensure all required variables are set in your `.env` file and restart the application.
210+
130211
## MicroSIP Directory
131212

132213
SimpleOrgChart can publish a MicroSIP-compatible directory export. **Disabled by default.**

deploy/gunicorn.conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
workers = multiprocessing.cpu_count() * 2 + 1
1313
worker_class = 'sync'
1414
worker_connections = 1000
15-
timeout = 30
15+
timeout = int(os.getenv("GUNICORN_TIMEOUT", "600")) # Default 10 minutes for PNG screenshot generation
1616
keepalive = 2
1717

1818
# Restart workers after this many requests, to help prevent memory leaks

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ gunicorn==25.1.0
1111
marshmallow==4.2.2
1212
openpyxl==3.1.5
1313
Pillow==12.1.1
14+
playwright==1.49.1
1415
idna==3.11
1516
itsdangerous==2.2.0
1617
Jinja2==3.1.6

simple_org_chart/app_main.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@
3434

3535
import simple_org_chart.config as app_config
3636
from simple_org_chart.auth import login_required, require_auth, sanitize_next_path
37+
from simple_org_chart.email_config import (
38+
get_smtp_config,
39+
is_smtp_configured,
40+
load_email_config,
41+
save_email_config,
42+
get_report_types,
43+
)
3744
from simple_org_chart.settings import (
3845
DEFAULT_SETTINGS,
3946
department_is_ignored,
@@ -1109,6 +1116,142 @@ def reset_all_settings():
11091116
logger.error(f"Error resetting all settings: {e}")
11101117
return jsonify({'error': 'Reset failed'}), 500
11111118

1119+
1120+
@app.route('/api/email-config', methods=['GET', 'POST'])
1121+
@require_auth
1122+
@limiter.limit(RATE_LIMIT_SETTINGS)
1123+
def handle_email_config():
1124+
"""Get or update email report configuration."""
1125+
if request.method == 'GET':
1126+
try:
1127+
email_config = load_email_config()
1128+
smtp_config = get_smtp_config()
1129+
1130+
# Return configuration with SMTP status (but not credentials)
1131+
return jsonify({
1132+
'success': True,
1133+
'config': email_config,
1134+
'smtpConfigured': is_smtp_configured(),
1135+
'smtpServer': smtp_config.get('server', ''),
1136+
'smtpPort': smtp_config.get('port', 587),
1137+
'smtpFromAddress': smtp_config.get('fromAddress', ''),
1138+
'availableReports': get_report_types(),
1139+
})
1140+
except Exception as e:
1141+
logger.error(f"Error loading email config: {e}")
1142+
return jsonify({'error': 'Failed to load email configuration'}), 500
1143+
1144+
elif request.method == 'POST':
1145+
try:
1146+
new_config = request.json
1147+
if not new_config:
1148+
return jsonify({'error': 'No configuration provided'}), 400
1149+
1150+
# Validate recipient email if enabled
1151+
if new_config.get('enabled') and not new_config.get('recipientEmail'):
1152+
return jsonify({'error': 'Recipient email is required when enabled'}), 400
1153+
1154+
if save_email_config(new_config):
1155+
return jsonify({'success': True})
1156+
else:
1157+
return jsonify({'error': 'Failed to save email configuration'}), 500
1158+
except Exception as e:
1159+
logger.error(f"Error saving email config: {e}")
1160+
return jsonify({'error': 'Internal server error'}), 500
1161+
1162+
1163+
@app.route('/api/email-config/test', methods=['POST'])
1164+
@require_auth
1165+
@limiter.limit('2 per minute')
1166+
def test_email():
1167+
"""Send a test email to verify SMTP configuration."""
1168+
try:
1169+
if not is_smtp_configured():
1170+
return jsonify({
1171+
'error': 'SMTP not configured. Please set SMTP environment variables in .env file.'
1172+
}), 400
1173+
1174+
email_config = load_email_config()
1175+
recipient = email_config.get('recipientEmail')
1176+
1177+
if not recipient:
1178+
return jsonify({'error': 'No recipient email configured'}), 400
1179+
1180+
# Import email sender module (we'll create this next)
1181+
from simple_org_chart.email_sender import send_test_email
1182+
1183+
success, message = send_test_email(recipient)
1184+
1185+
if success:
1186+
return jsonify({'success': True, 'message': message})
1187+
else:
1188+
return jsonify({'error': message}), 500
1189+
1190+
except ImportError:
1191+
return jsonify({'error': 'Email sender module not available'}), 500
1192+
except Exception as e:
1193+
logger.error(f"Error sending test email: {e}")
1194+
return jsonify({'error': str(e)}), 500
1195+
1196+
1197+
@app.route('/api/email-config/test-with-attachments', methods=['POST'])
1198+
@require_auth
1199+
@limiter.limit('1 per 2 minutes')
1200+
def test_email_with_attachments():
1201+
"""Send an email report with attachments immediately."""
1202+
try:
1203+
if not is_smtp_configured():
1204+
return jsonify({
1205+
'error': 'SMTP not configured. Please set SMTP environment variables in .env file.'
1206+
}), 400
1207+
1208+
email_config = load_email_config()
1209+
recipient = email_config.get('recipientEmail')
1210+
1211+
if not recipient:
1212+
return jsonify({'error': 'No recipient email configured'}), 400
1213+
1214+
file_types = email_config.get('fileTypes', [])
1215+
if not file_types:
1216+
return jsonify({'error': 'No file types selected for attachments'}), 400
1217+
1218+
# Generate XLSX if requested
1219+
xlsx_content = None
1220+
if 'xlsx' in file_types:
1221+
try:
1222+
from simple_org_chart.data_update import _generate_xlsx_bytes
1223+
xlsx_content = _generate_xlsx_bytes()
1224+
except Exception as e:
1225+
logger.error(f"Failed to generate XLSX for email: {e}")
1226+
return jsonify({'error': f'Failed to generate XLSX: {str(e)}'}), 500
1227+
1228+
# Get base URL for PNG generation if requested
1229+
base_url = None
1230+
if 'png' in file_types:
1231+
base_url = os.environ.get('APP_BASE_URL', 'http://localhost:5000')
1232+
1233+
# Send email with attachments
1234+
from simple_org_chart.email_sender import send_test_email_with_attachments
1235+
1236+
success, message = send_test_email_with_attachments(
1237+
recipient=recipient,
1238+
xlsx_content=xlsx_content,
1239+
base_url=base_url
1240+
)
1241+
1242+
if success:
1243+
return jsonify({'success': True, 'message': message})
1244+
else:
1245+
return jsonify({'error': message}), 500
1246+
1247+
except ImportError as e:
1248+
logger.error(f"Import error: {e}")
1249+
return jsonify({'error': 'Required module not available'}), 500
1250+
except Exception as e:
1251+
logger.error(f"Error sending email with attachments: {e}")
1252+
return jsonify({'error': str(e)}), 500
1253+
1254+
11121255
@app.route('/api/export-xlsx')
11131256
def export_xlsx():
11141257
"""Export organizational data to XLSX format"""

0 commit comments

Comments
 (0)