Merge branch 'v2.4.5' into stable

This commit is contained in:
usmannasir
2026-03-06 19:19:16 +05:00
54 changed files with 9938 additions and 48 deletions

View File

@@ -75,6 +75,8 @@ INSTALLED_APPS = [
'CLManager',
'IncBackups',
'aiScanner',
'webmail',
'emailDelivery',
# 'WebTerminal'
]

View File

@@ -45,5 +45,7 @@ urlpatterns = [
path('CloudLinux/', include('CLManager.urls')),
path('IncrementalBackups/', include('IncBackups.urls')),
path('aiscanner/', include('aiScanner.urls')),
path('webmail/', include('webmail.urls')),
path('emailDelivery/', include('emailDelivery.urls')),
# path('Terminal/', include('WebTerminal.urls')),
]

View File

@@ -181,6 +181,29 @@ sh <(curl https://raw.githubusercontent.com/usmannasir/cyberpanel/stable/preUpgr
---
## 🧪 Testing
CyberPanel includes an OLS feature test suite with 128 tests covering all custom OpenLiteSpeed features.
### Running Tests
```bash
# On the target server, set up test data (once):
bash tests/ols_test_setup.sh
# Run the full 128-test suite:
bash tests/ols_feature_tests.sh
```
### Test Coverage
| Phase | Tests | Coverage |
|-------|-------|----------|
| Phase 1: Live Environment | 56 | Binary integrity, CyberPanel module, Auto-SSL, LE certificates, SSL listener auto-mapping, cert serving, HTTPS/HTTP, .htaccess processing, VHost config, origin headers, PHP config |
| Phase 2: ReadApacheConf | 72 | Include/IncludeOptional, global tuning, listener creation, ProxyPass, IfModule, VHost creation, SSL dedup, Directory/Location blocks, PHP version detection, ScriptAlias, HTTP/HTTPS, process health, graceful restart |
---
## 🔧 Troubleshooting
### **Common Issues & Solutions**

View File

@@ -1,6 +1,6 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
{% with CP_VERSION="2.4.4.1" %}
{% with CP_VERSION="2.4.5" %}
<!DOCTYPE html>
<html lang="en" ng-app="CyberCP">
<head>
@@ -1599,7 +1599,7 @@
</a>
{% endif %}
{% if admin or createEmail %}
<a href="/snappymail/index.php" class="menu-item" target="_blank" rel="noopener">
<a href="/webmail/" class="menu-item">
<span>Access Webmail</span>
</a>
{% endif %}
@@ -1937,8 +1937,12 @@
<a href="{% url 'Rspamd' %}" class="menu-item">
<span>RSPAMD</span>
</a>
<a href="{% url 'emailDeliveryHome' %}" class="menu-item">
<span>Email Delivery</span>
<span class="badge">NEW</span>
</a>
</div>
<a href="#" class="menu-item" onclick="toggleSubmenu('manage-services-submenu', this); return false;">
<div class="icon-wrapper">
<i class="fas fa-folder-open"></i>

View File

@@ -29,7 +29,7 @@ import pwd
# Create your views here.
VERSION = '2.4'
BUILD = 4
BUILD = 5
@ensure_csrf_cookie

View File

@@ -0,0 +1,266 @@
# CyberMail Email Delivery — Setup & Administration Guide
**Feature**: CyberMail Email Delivery Integration
**CyberPanel Version**: 2.4.5+
**Platform**: https://platform.cyberpersons.com
**Last Updated**: 2026-03-06
---
## Overview
CyberMail Email Delivery is a built-in CyberPanel feature that routes outgoing emails through CyberMail's optimized delivery infrastructure. It solves common email deliverability problems — emails landing in spam, IP blacklisting, missing DNS records — by providing dedicated sending servers, automatic DNS configuration, and real-time delivery analytics.
### Key Benefits
- **15,000 emails/month free** on the Free plan
- **Automatic DNS setup** — SPF, DKIM, and DMARC records configured in one click
- **SMTP relay** — route all server email through CyberMail with one toggle
- **Real-time analytics** — delivery logs, bounce tracking, reputation monitoring
- **Multi-region delivery** — 4 delivery nodes with 99.9% uptime SLA
- **98%+ inbox rate** across major providers (Gmail, Outlook, Yahoo)
---
## Prerequisites
1. CyberPanel 2.4.5 or later installed
2. Active internet connection from the server
3. PowerDNS running (for automatic DNS configuration)
4. Postfix installed (for SMTP relay feature)
---
## Installation
The CyberMail module is included in CyberPanel 2.4.5+. No separate installation needed.
### Verify the Module
```bash
# Check the app exists
ls /usr/local/CyberCP/emailDelivery/
# Check it's in INSTALLED_APPS
grep -n "emailDelivery" /usr/local/CyberCP/CyberCP/settings.py
# Run migrations if needed
cd /usr/local/CyberCP
python manage.py migrate emailDelivery
```
### Database Tables
The module creates two tables:
| Table | Purpose |
|-------|---------|
| `cybermail_accounts` | Stores per-admin CyberMail account connections |
| `cybermail_domains` | Tracks sending domains and their verification status |
---
## Getting Started
### Step 1: Access CyberMail
Navigate to: **https://your-server:8090/emailDelivery/**
You'll see the CyberMail marketing page with plan information and a "Get Started Free" button.
### Step 2: Connect Your Account
1. Click **"Get Started Free"**
2. Enter your email address (defaults to your CyberPanel admin email)
3. Create a password for your CyberMail account
4. Click **Connect**
This registers your account on the CyberMail platform and obtains an API key that's stored locally for future API calls.
> **Note**: If you already have a CyberMail account on the platform, use the same email and password. The system will link your existing account.
### Step 3: Add Sending Domains
1. After connecting, you'll see the dashboard
2. Go to the **Domains** tab
3. Click **"Add Domain"**
4. Enter your domain name (e.g., `example.com`)
5. Click **Add**
The system will:
- Register the domain on the CyberMail platform
- Automatically create SPF, DKIM, and DMARC DNS records in PowerDNS
- Report how many DNS records were configured
### Step 4: Verify Domain
1. Click **"Verify"** next to your domain
2. The system checks SPF, DKIM, and DMARC records
3. Green checkmarks appear for each verified record
4. Status changes to "Verified" when all records pass
> **DNS Propagation**: If verification fails immediately after adding, wait 5-10 minutes for DNS propagation and try again.
### Step 5: Enable SMTP Relay (Optional)
The SMTP relay routes ALL outgoing email from your server through CyberMail:
1. Go to the **Relay** tab
2. Click **"Enable Relay"**
3. The system will:
- Create (or rotate) SMTP credentials on the platform
- Configure Postfix with the relay host (`mail.cyberpersons.com:587`)
- Set up SASL authentication
- Enable TLS encryption
**What gets configured in Postfix** (`/etc/postfix/main.cf`):
```
relayhost = [mail.cyberpersons.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
```
---
## Dashboard Overview
After connecting, the dashboard provides five tabs:
### Domains Tab
- Lists all sending domains with verification status
- SPF, DKIM, DMARC status badges (green = verified)
- Actions: Verify, Auto-Configure DNS, Remove
- DNS auto-configuration works when the domain exists in PowerDNS
### SMTP Tab
- Manage SMTP credentials for sending
- Create new credentials with descriptions
- Rotate passwords (one-time display)
- Delete unused credentials
### Relay Tab
- Shows current relay status (Enabled/Disabled)
- Displays relay host and port
- Enable/Disable toggle
- Relay info: `mail.cyberpersons.com:587` with STARTTLS
### Logs Tab
- Paginated delivery logs
- Filter by: Status (delivered/bounced/failed), Days (1-30)
- Shows: Date, From, To, Subject, Status
- Color-coded status badges
### Stats Tab
- Aggregate stats: Total Sent, Delivered, Bounced, Failed, Delivery Rate
- Per-domain breakdown table
- Useful for identifying domains with deliverability issues
---
## Plans & Pricing
| Plan | Price | Emails/Month | Features |
|------|-------|-------------|----------|
| **Free** | $0 | 15,000 | Shared infrastructure, basic analytics |
| **Starter** | $15/mo | 100,000 | Priority support, advanced analytics |
| **Professional** | $90/mo | 500,000 | Dedicated IPs, custom DKIM, webhooks |
| **Enterprise** | $299/mo | 2,000,000 | SLA guarantee, account manager, custom limits |
Upgrade at: https://platform.cyberpersons.com
---
## Promotional Banners
CyberMail banners appear on email-related pages to inform users about the delivery service:
- Mail Functions (`/mailServer/`)
- Create Email Account
- DKIM Manager
- Webmail
- Email Premium
- Email Marketing
Banners are dismissible with a 7-day cookie-based suppression. They show:
- "Stop Landing in Spam" headline
- Brief feature description
- "Get Started Free" CTA linking to `/emailDelivery/`
---
## Disconnecting
1. Go to **https://your-server:8090/emailDelivery/**
2. Click the **"Disconnect"** button
3. Confirm the action
Disconnecting will:
- Disable SMTP relay if active (remove Postfix relay config)
- Clear the stored API key
- Remove local domain records
- Reset SMTP credential references
> **Note**: Your CyberMail platform account is NOT deleted. You can reconnect later with the same credentials.
---
## Troubleshooting
### "Account not connected" error
The admin session doesn't have an active CyberMail connection. Click "Get Started Free" to connect.
### DNS records not auto-configured
- The domain must exist in PowerDNS on this server
- Check if the domain was created via CyberPanel's DNS management
- If using external DNS, add records manually using the DNS records shown on the platform
### Domain verification failing
- Wait 5-10 minutes after DNS changes for propagation
- Verify records exist: `dig TXT example.com +short`
- Check for conflicting SPF records (only one SPF record allowed per domain)
### SMTP relay not working
- Check Postfix status: `systemctl status postfix`
- Verify relay config: `grep relayhost /etc/postfix/main.cf`
- Check SASL credentials: `cat /etc/postfix/sasl_passwd`
- Test connectivity: `telnet mail.cyberpersons.com 587`
- Check mail queue: `mailq`
- View Postfix logs: `tail -f /var/log/mail.log`
### Relay shows "Failed to configure"
- Ensure `/usr/local/CyberCP/plogical/mailUtilities.py` has the `configureRelayHost` method
- Check file permissions on `/etc/postfix/sasl_passwd`
- Verify Postfix is installed and running
### Emails still going to spam
1. Verify all DNS records (SPF, DKIM, DMARC) are green
2. Check your domain's reputation at https://www.mail-tester.com
3. Ensure you're not sending to purchased/scraped lists
4. Consider upgrading to a plan with dedicated IPs
---
## File Reference
| File | Purpose |
|------|---------|
| `emailDelivery/emailDeliveryManager.py` | Core business logic, platform API calls |
| `emailDelivery/models.py` | Database models (CyberMailAccount, CyberMailDomain) |
| `emailDelivery/views.py` | Django view functions (thin wrappers) |
| `emailDelivery/urls.py` | URL routing (18 endpoints) |
| `emailDelivery/static/emailDelivery/emailDelivery.js` | AngularJS controller |
| `emailDelivery/templates/emailDelivery/index.html` | Single-page template (marketing + dashboard) |
| `plogical/mailUtilities.py` | Postfix relay configuration (configureRelayHost/removeRelayHost) |
---
## Security
- API keys are stored per-admin in the local database, never in config files
- All platform API calls use HTTPS with Bearer token authentication
- SMTP credentials use SASL over TLS (STARTTLS on port 587)
- SASL password file is chmod 600 (root-only readable)
- Session-based authentication with CSRF protection on all endpoints
- Passwords are never stored locally — only on the platform

View File

@@ -0,0 +1,393 @@
# CyberMail Email Delivery — Technical Reference
**Module**: `emailDelivery`
**Platform API Base**: `https://platform.cyberpersons.com/email/cp/`
**Last Updated**: 2026-03-06
---
## Architecture
```
CyberPanel UI (AngularJS)
|
v
Django Views (emailDelivery/views.py)
|
v
EmailDeliveryManager (emailDelivery/emailDeliveryManager.py)
|
├──> CyberMail Platform API (HTTPS POST, Bearer auth)
├──> PowerDNS (via dnsUtilities.DNS.createDNSRecord)
└──> Postfix (via mailUtilities.py subprocess as root)
```
### Design Patterns
- **Manager Class Pattern**: All business logic in `EmailDeliveryManager`, views are thin wrappers
- **Per-User API Keys**: Each CyberPanel admin gets their own platform API key (no server-level key)
- **AngularJS SPA**: Single template with conditional rendering based on `isConnected` state
- **Subprocess for Root Operations**: Postfix relay config runs via `ProcessUtilities.outputExecutioner()` which executes as root
---
## Database Models
### CyberMailAccount (`cybermail_accounts`)
```python
class CyberMailAccount(models.Model):
admin = OneToOneField(Administrator, CASCADE) # 1:1 with admin
platform_account_id = IntegerField(null=True) # Platform's account ID
api_key = CharField(max_length=255) # Per-user Bearer token
email = CharField(max_length=255) # Platform email
plan_name = CharField(default='Free') # Display name
plan_slug = CharField(default='free') # free/starter/professional/enterprise
emails_per_month = IntegerField(default=15000) # Plan limit
is_connected = BooleanField(default=False) # Active connection
relay_enabled = BooleanField(default=False) # Postfix relay active
smtp_credential_id = IntegerField(null=True) # Active relay credential
smtp_username = CharField(max_length=255) # Relay SMTP username
smtp_host = CharField(default='mail.cyberpersons.com')
smtp_port = IntegerField(default=587)
created_at = DateTimeField(auto_now_add=True)
updated_at = DateTimeField(auto_now=True)
```
### CyberMailDomain (`cybermail_domains`)
```python
class CyberMailDomain(models.Model):
account = ForeignKey(CyberMailAccount, CASCADE)
domain = CharField(max_length=255)
platform_domain_id = IntegerField(null=True)
status = CharField(default='pending') # pending/verified
spf_verified = BooleanField(default=False)
dkim_verified = BooleanField(default=False)
dmarc_verified = BooleanField(default=False)
dns_configured = BooleanField(default=False) # Auto-configured in PowerDNS
created_at = DateTimeField(auto_now_add=True)
```
---
## API Endpoints (CyberPanel Internal)
All endpoints are POST, require authenticated CyberPanel session, and return JSON.
### Account Management
| Endpoint | Method | Purpose | Request Body |
|----------|--------|---------|-------------|
| `/emailDelivery/` | GET | Render page | — |
| `/emailDelivery/connect/` | POST | Register/connect account | `{email, password}` |
| `/emailDelivery/status/` | POST | Get account status + sync domains | — |
| `/emailDelivery/disconnect/` | POST | Disconnect account, cleanup | — |
### Domain Management
| Endpoint | Method | Purpose | Request Body |
|----------|--------|---------|-------------|
| `/emailDelivery/domains/add/` | POST | Add sending domain | `{domain}` |
| `/emailDelivery/domains/list/` | POST | List domains with status | — |
| `/emailDelivery/domains/verify/` | POST | Verify DNS records | `{domain}` |
| `/emailDelivery/domains/dns-records/` | POST | Get required DNS records | `{domain}` |
| `/emailDelivery/domains/auto-configure-dns/` | POST | Auto-add DNS to PowerDNS | `{domain}` |
| `/emailDelivery/domains/remove/` | POST | Remove sending domain | `{domain}` |
### SMTP Credentials
| Endpoint | Method | Purpose | Request Body |
|----------|--------|---------|-------------|
| `/emailDelivery/smtp/create/` | POST | Create SMTP credential | `{description}` |
| `/emailDelivery/smtp/list/` | POST | List all credentials | — |
| `/emailDelivery/smtp/rotate/` | POST | Rotate credential password | `{credential_id}` |
| `/emailDelivery/smtp/delete/` | POST | Delete credential | `{credential_id}` |
### Relay
| Endpoint | Method | Purpose | Request Body |
|----------|--------|---------|-------------|
| `/emailDelivery/relay/enable/` | POST | Enable Postfix SMTP relay | — |
| `/emailDelivery/relay/disable/` | POST | Disable Postfix SMTP relay | — |
### Analytics
| Endpoint | Method | Purpose | Request Body |
|----------|--------|---------|-------------|
| `/emailDelivery/stats/` | POST | Aggregate sending stats | — |
| `/emailDelivery/stats/domains/` | POST | Per-domain stats | — |
| `/emailDelivery/logs/` | POST | Paginated delivery logs | `{page, per_page, status, from_domain, days}` |
### Health
| Endpoint | Method | Purpose | Request Body |
|----------|--------|---------|-------------|
| `/emailDelivery/health/` | POST | Platform health check | — |
---
## Platform API Mapping
Each CyberPanel endpoint maps to a platform API call:
| CyberPanel Method | Platform Endpoint | Auth | Notes |
|-------------------|-------------------|------|-------|
| `connect()` | `api/register/` | None (public) | Returns `api_key` for future calls |
| `getStatus()` | `api/account/` + `api/domains/list/` | Bearer | Syncs plan + domains |
| `addDomain()` | `api/domains/add/` + `api/domains/dns-records/` | Bearer | Auto-configures DNS |
| `listDomains()` | `api/domains/list/` | Bearer | Syncs verification status |
| `verifyDomain()` | `api/domains/verify/` | Bearer | Returns spf/dkim/dmarc booleans |
| `getDnsRecords()` | `api/domains/dns-records/` | Bearer | Returns required records |
| `removeDomain()` | `api/domains/remove/` | Bearer | — |
| `createSmtpCredential()` | `api/smtp/create/` | Bearer | Returns one-time password |
| `listSmtpCredentials()` | `api/smtp/list/` | Bearer | Normalizes `id``credential_id` |
| `rotateSmtpPassword()` | `api/smtp/rotate/` | Bearer | Normalizes `new_password``password` |
| `deleteSmtpCredential()` | `api/smtp/delete/` | Bearer | Clears relay if active credential |
| `enableRelay()` | `api/smtp/create/` or `api/smtp/rotate/` | Bearer | Then configures Postfix |
| `getStats()` | `api/stats/` | Bearer | — |
| `getDomainStats()` | `api/stats/domains/` | Bearer | Converts dict → array |
| `getLogs()` | `api/logs/` | Bearer | Maps field names for JS |
| `checkStatus()` | `api/health/` | None | — |
### API Response Normalization
The manager normalizes platform responses for frontend compatibility:
| Platform Field | CyberPanel Field | Method |
|---------------|-----------------|--------|
| `id` (credential) | `credential_id` | `listSmtpCredentials()` |
| `new_password` | `password` | `rotateSmtpPassword()` |
| `queued_at` | `date` | `getLogs()` |
| `from_email` | `from` | `getLogs()` |
| `to_email` | `to` | `getLogs()` |
| `domains` (dict) | `domains` (array) | `getDomainStats()` |
---
## Connection Flow
```
1. User clicks "Get Started Free"
2. JS sends POST /emailDelivery/connect/ {email, password}
3. Manager calls platform POST api/register/ (no auth)
4. Platform returns {success, data: {api_key, account_id, plan_name, ...}}
5. Manager creates/updates CyberMailAccount with api_key
6. All subsequent calls use Authorization: Bearer <api_key>
```
### Reconnection Behavior
When connecting with an existing `CyberMailAccount` record:
- Updates email, api_key, platform_account_id, plan info
- Resets: `smtp_credential_id=None`, `smtp_username=''`, `relay_enabled=False`
- Deletes all local `CyberMailDomain` records (stale data)
### Disconnection Behavior
- Disables Postfix relay if active
- Clears: `is_connected`, `relay_enabled`, `api_key`, `smtp_credential_id`, `smtp_username`, `platform_account_id`
- Deletes all local domain records
- Does NOT delete the platform account
---
## DNS Auto-Configuration
### Flow
```
1. User adds domain via addDomain()
2. Manager calls platform api/domains/add/
3. Manager calls _autoConfigureDnsForDomain()
4. → Finds domain zone in PowerDNS (dns.models.Domains)
5. → Calls platform api/domains/dns-records/ to get required records
6. → For each record: DNS.createDNSRecord(zone, host, type, value, priority, ttl)
7. → Marks CyberMailDomain.dns_configured = True
8. User clicks "Verify" → calls platform api/domains/verify/
9. Platform checks DNS → returns spf/dkim/dmarc booleans
```
### DNS Record Types Created
| Record | Type | Example Value |
|--------|------|---------------|
| SPF | TXT | `v=spf1 include:spf.cyberpersons.com ~all` |
| DKIM | TXT | `v=DKIM1; k=rsa; p=MIIBIjANBg...` |
| DMARC | TXT | `v=DMARC1; p=quarantine; rua=mailto:dmarc@...` |
### When Auto-Configuration Fails
- Domain not in PowerDNS → returns message to add records manually
- Platform API unreachable → returns connection error
- Individual record creation fails → logged, continues with remaining records
---
## SMTP Relay Configuration
### Enable Relay Flow
```
1. Manager checks account.smtp_credential_id
2. If no credential:
→ POST api/smtp/create/ {email, description: "CyberPanel Relay"}
→ Stores credential_id, username, gets one-time password
3. If credential exists:
→ POST api/smtp/rotate/ {email, credential_id}
→ Gets new_password
4. Calls subprocess: python mailUtilities.py configureRelayHost
--smtpHost mail.cyberpersons.com --smtpPort 587
--smtpUser <username> --smtpPassword <password>
5. mailUtilities.py (runs as root):
→ Writes /etc/postfix/main.cf relay lines
→ Writes /etc/postfix/sasl_passwd
→ chmod 600, postmap, systemctl reload postfix
6. Sets account.relay_enabled = True
```
### Postfix Configuration Applied
```ini
# Added to /etc/postfix/main.cf
relayhost = [mail.cyberpersons.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
```
```
# /etc/postfix/sasl_passwd
[mail.cyberpersons.com]:587 username:password
```
### Disable Relay
```
1. Calls subprocess: python mailUtilities.py removeRelayHost
2. mailUtilities.py removes relay lines from main.cf
3. Restores smtp_tls_security_level = may
4. Deletes sasl_passwd and .db files
5. Reloads Postfix
6. Sets account.relay_enabled = False
```
### Output Parsing
The subprocess prints `1,None` on success. The manager checks for `'1,None' in output` (not `startswith`) because Python SyntaxWarnings may appear before the success output.
---
## Frontend Architecture
### Template: `emailDelivery/templates/emailDelivery/index.html`
- Extends `baseTemplate/index.html`
- Uses AngularJS 1.6.5 with `{$ $}` interpolation (not `{{ }}`)
- CSS classes use `ed-` prefix
- Two views controlled by Django `{% if isConnected %}`:
- Marketing landing page (not connected)
- Dashboard with tabs (connected)
### Controller: `emailDeliveryCtrl`
Located in `emailDelivery/static/emailDelivery/emailDelivery.js`
Key state variables:
```javascript
$scope.isConnected // Boolean — dashboard vs marketing view
$scope.activeTab // 'domains' | 'smtp' | 'relay' | 'logs' | 'stats'
$scope.account // Account object from getStatus
$scope.domains // Array of domain objects
$scope.smtpCredentials // Array of SMTP credential objects
$scope.stats // Aggregate stats object
$scope.domainStats // Array of per-domain stats
$scope.logs // Array of log entries
$scope.logFilters // {status, from_domain, days}
$scope.logsPage // Current log page number
$scope.logsTotalPages // Total log pages
```
### Modal Handling
Bootstrap 3 modals are placed OUTSIDE the `ng-controller` div (AngularJS scope limitation). Modal forms use jQuery + `onclick` handlers that call standalone functions (`cmConnect()`, `cmAddDomain()`, etc.) which make AJAX calls with `$.ajax()` and CSRF tokens.
### CSRF Token
All AJAX calls include the CSRF token from cookies:
```javascript
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length == 2) return parts.pop().split(";").shift();
}
```
---
## Error Handling
### API Errors
- Connection timeout: 30-second timeout on all platform API calls
- Connection errors: caught and returned as `{success: false, error: "Could not connect..."}`
- Missing API key: `{success: false, error: "No API key found. Please reconnect your account."}`
### Logging
All errors logged via `CyberCPLogFileWriter.writeToFile()` with format:
```
[EmailDeliveryManager.<method>] Error: <message>
```
Log file: `/home/cyberpanel/error-logs.txt`
---
## Banner System
Promotional banners appear on 6 email-related pages:
| Page | Template Path |
|------|--------------|
| Mail Functions | `mailServer/templates/mailServer/index.html` |
| Create Email | `mailServer/templates/mailServer/createEmailAccount.html` |
| DKIM Manager | `mailServer/templates/mailServer/dkimManager.html` |
| Webmail | `webmail/templates/webmail/index.html` |
| Email Premium | `emailPremium/templates/emailPremium/emailPage.html` |
| Email Marketing | `emailMarketing/templates/emailMarketing/emailMarketing.html` |
### Banner Behavior
- Hidden by default (`display:none`)
- Shown via JS if `cybermail_dismiss=1` cookie is NOT present
- Dismiss button sets cookie with 7-day expiry (`max-age=604800`)
- Links to `/emailDelivery/`
---
## File Structure
```
emailDelivery/
├── __init__.py
├── apps.py # Django app config
├── models.py # CyberMailAccount, CyberMailDomain
├── views.py # Thin view wrappers (18 endpoints)
├── urls.py # URL patterns
├── emailDeliveryManager.py # Core business logic (~743 lines)
├── migrations/
│ └── __init__.py
├── static/
│ └── emailDelivery/
│ └── emailDelivery.js # AngularJS controller
└── templates/
└── emailDelivery/
└── index.html # SPA template (marketing + dashboard)
```
### Related Files
| File | Modifications |
|------|--------------|
| `CyberCP/settings.py` | Added `'emailDelivery'` to `INSTALLED_APPS` |
| `CyberCP/urls.py` | Added `path('emailDelivery/', include('emailDelivery.urls'))` |
| `plogical/mailUtilities.py` | Added `configureRelayHost()` and `removeRelayHost()` static methods + argparse args |

View File

@@ -0,0 +1,188 @@
# CyberMail Email Delivery — User Guide
**For**: CyberPanel users and resellers
**Last Updated**: 2026-03-06
---
## What is CyberMail?
CyberMail is CyberPanel's built-in email delivery service. It routes your outgoing emails through optimized servers so they land in the inbox instead of spam. Every CyberPanel installation includes CyberMail with a free tier of 15,000 emails per month.
---
## Quick Start
### 1. Open CyberMail
Log into CyberPanel and navigate to: **Email > Email Delivery** or go directly to `https://your-server:8090/emailDelivery/`
### 2. Create Your Account
- Click **"Get Started Free"**
- Enter your email address
- Choose a password
- Click **Connect**
You'll immediately get access to the dashboard with the Free plan (15,000 emails/month).
### 3. Add Your Domain
- Go to the **Domains** tab
- Click **"Add Domain"**
- Type your domain name (e.g., `mydomain.com`)
- Click **Add**
CyberMail will automatically set up the DNS records needed for email delivery (SPF, DKIM, DMARC). If your domain's DNS is managed by CyberPanel's PowerDNS, this happens instantly.
### 4. Verify Your Domain
- Click **"Verify"** next to your domain
- Check that SPF, DKIM, and DMARC all show green checkmarks
- If any are red, wait a few minutes for DNS propagation and verify again
### 5. Start Sending
Once your domain is verified, emails sent from that domain will benefit from CyberMail's delivery optimization. For maximum deliverability, enable the SMTP relay.
---
## Features
### Domain Management
Add multiple sending domains to your CyberMail account. Each domain gets:
- **SPF record** — tells receivers your emails are authorized
- **DKIM signing** — cryptographically signs your emails to prevent tampering
- **DMARC policy** — instructs receivers how to handle unauthenticated emails
**Status indicators:**
- Gray badge = Not verified
- Green badge = Verified and active
**Actions:**
- **Verify** — recheck DNS records
- **Auto DNS** — reconfigure DNS records in PowerDNS (if records were deleted)
- **Remove** — remove the domain from CyberMail
### SMTP Credentials
Create credentials for sending emails through CyberMail's SMTP servers:
- **Create** — generates a username and one-time password
- **Rotate** — generates a new password (old one stops working)
- **Delete** — permanently removes the credential
> **Important**: The password is shown only once when created or rotated. Copy it immediately.
**SMTP Settings for manual configuration:**
| Setting | Value |
|---------|-------|
| Host | `mail.cyberpersons.com` |
| Port | `587` |
| Security | STARTTLS |
| Authentication | Login (SASL) |
| Username | Shown after creation |
| Password | Shown once after creation |
### SMTP Relay
The easiest way to use CyberMail — route ALL outgoing email from your server automatically:
- **Enable** — one click to configure everything
- **Disable** — one click to revert to direct sending
When enabled, every email your server sends (from all websites, all email accounts) goes through CyberMail. No application-level changes needed.
### Delivery Logs
Monitor every email sent through CyberMail:
- **Filter by status**: All, Delivered, Bounced, Failed, Deferred
- **Filter by time**: Last 1, 3, 7, 14, or 30 days
- **View details**: Date, sender, recipient, subject, delivery status
### Statistics
Track your email performance:
- **Total Sent** — emails sent this billing period
- **Delivered** — successfully delivered to recipient
- **Bounced** — rejected by recipient server
- **Failed** — permanent delivery failures
- **Delivery Rate** — percentage of successful deliveries
- **Per-Domain Breakdown** — stats for each sending domain
### Usage Tracking
The dashboard shows a progress bar of your monthly email usage:
- Green = under 80% usage
- Yellow/warning = approaching limit
- An upgrade banner appears when you're near your plan limit
---
## Plans
| | Free | Starter | Professional | Enterprise |
|---|---|---|---|---|
| **Price** | $0/mo | $15/mo | $90/mo | $299/mo |
| **Emails** | 15,000 | 100,000 | 500,000 | 2,000,000 |
| **Infrastructure** | Shared | Shared | Dedicated IPs | Dedicated IPs |
| **Analytics** | Basic | Advanced | Advanced | Advanced |
| **Support** | Community | Priority | Priority | Dedicated Manager |
| **Custom DKIM** | No | No | Yes | Yes |
| **Webhooks** | No | No | Yes | Yes |
| **SLA** | — | — | — | 99.9% |
To upgrade, visit the CyberMail platform at https://platform.cyberpersons.com
---
## Disconnecting Your Account
If you need to disconnect CyberMail:
1. Go to the CyberMail dashboard
2. Click the **"Disconnect"** button
3. Confirm the action
**What happens:**
- SMTP relay is disabled (if it was enabled)
- Postfix returns to direct sending
- Local data (domains, credentials) is cleared
- Your platform account is preserved — you can reconnect anytime
---
## FAQ
**Q: Will enabling relay affect my existing email accounts?**
A: Yes, ALL outgoing email from the server will route through CyberMail. This includes emails from websites (contact forms, notifications) and email accounts (Postfix). Incoming email is not affected.
**Q: Can I use CyberMail without the relay?**
A: Yes. You can use SMTP credentials directly in your applications (WordPress SMTP plugins, custom scripts, etc.) without enabling the server-wide relay.
**Q: What happens if I exceed my plan limit?**
A: Check with the platform for current overage policies. The dashboard shows your usage so you can monitor and upgrade before hitting limits.
**Q: Can I use CyberMail for bulk marketing emails?**
A: CyberMail is designed for transactional and legitimate business email. Bulk marketing to purchased lists is not permitted. Use it for newsletters to opted-in subscribers, transactional emails, and business communications.
**Q: My domain DNS is not managed by CyberPanel. Can I still use CyberMail?**
A: Yes. After adding the domain, click "DNS Records" to see the required records. Add them manually at your DNS provider (Cloudflare, Route53, etc.).
**Q: I disconnected and reconnected. Why are my old domains gone?**
A: Reconnecting clears stale local data. Simply add your domains again — if they're still registered on the platform, they'll link back.
**Q: Is there a banner on other pages?**
A: Yes, a promotional banner appears on email-related pages (Webmail, Mail Functions, etc.). It can be dismissed by clicking the X button and won't reappear for 7 days.
---
## Support
- **CyberPanel Issues**: https://github.com/usmannasir/cyberpanel/issues
- **CyberMail Platform**: https://platform.cyberpersons.com
- **Email Deliverability Help**: Check your domain at https://www.mail-tester.com

View File

@@ -21,7 +21,7 @@ CyberPanel now supports comprehensive resource limits for shared hosting environ
- Ubuntu 20.04+, Debian 11+, CentOS Stream 9+, AlmaLinux 9+, Rocky Linux 9+
- RHEL 8 family (RHEL 8, AlmaLinux 8, Rocky 8, CloudLinux 8) with cgroups v2 manually enabled
2. **CyberPanel Version**: v2.4.4-dev or later
2. **CyberPanel Version**: v2.4.5 or later
3. **OpenLiteSpeed**: Installed and running

View File

6
emailDelivery/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EmaildeliveryConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'emailDelivery'

View File

@@ -0,0 +1,747 @@
import json
import requests
from django.http import JsonResponse
from loginSystem.models import Administrator
from plogical.acl import ACLManager
from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging
from plogical.httpProc import httpProc
from plogical.processUtilities import ProcessUtilities
from .models import CyberMailAccount, CyberMailDomain
class EmailDeliveryManager:
PLATFORM_URL = 'https://platform.cyberpersons.com/email/cp/'
def __init__(self):
self.logger = logging
def _apiCall(self, endpoint, data=None, apiKey=None):
"""POST to platform API. If apiKey provided, sends Bearer auth."""
headers = {'Content-Type': 'application/json'}
if apiKey:
headers['Authorization'] = 'Bearer %s' % apiKey
url = self.PLATFORM_URL + endpoint
try:
resp = requests.post(url, json=data or {}, headers=headers, timeout=30)
return resp.json()
except requests.exceptions.Timeout:
return {'success': False, 'error': 'Platform API request timed out.'}
except requests.exceptions.ConnectionError:
return {'success': False, 'error': 'Could not connect to CyberMail platform.'}
except Exception as e:
return {'success': False, 'error': str(e)}
def _accountApiCall(self, account, endpoint, data=None):
"""API call using a CyberMailAccount's stored per-user key."""
if not account.api_key:
return {'success': False, 'error': 'No API key found. Please reconnect your account.'}
return self._apiCall(endpoint, data, apiKey=account.api_key)
def home(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
isConnected = False
try:
account = CyberMailAccount.objects.get(admin=admin)
isConnected = account.is_connected
except CyberMailAccount.DoesNotExist:
pass
data = {
'isConnected': isConnected,
'adminEmail': admin.email,
'adminName': admin.firstName if hasattr(admin, 'firstName') else admin.userName,
}
proc = httpProc(request, 'emailDelivery/index.html', data, 'admin')
return proc.render()
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.home] Error: %s' % str(e))
proc = httpProc(request, 'emailDelivery/index.html', {
'isConnected': False,
'adminEmail': '',
'adminName': '',
})
return proc.render()
def getStatus(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
result = self._accountApiCall(account, 'api/account/', {'email': account.email})
if not result.get('success', False):
return JsonResponse({'success': False, 'error': result.get('error', 'Failed to get account status')})
accountData = result.get('data', {})
# Platform returns plan info nested under data.plan
planInfo = accountData.get('plan', {})
if planInfo.get('name'):
account.plan_name = planInfo['name']
account.plan_slug = planInfo.get('slug', account.plan_slug)
account.emails_per_month = planInfo.get('emails_per_month', account.emails_per_month)
account.save()
# Sync domains from platform to local DB
try:
domainResult = self._accountApiCall(account, 'api/domains/list/', {'email': account.email})
if domainResult.get('success', False):
platformDomains = domainResult.get('data', {}).get('domains', [])
for pd in platformDomains:
try:
cmDomain = CyberMailDomain.objects.get(account=account, domain=pd['domain'])
cmDomain.status = pd.get('status', cmDomain.status)
cmDomain.spf_verified = pd.get('spf_verified', False)
cmDomain.dkim_verified = pd.get('dkim_verified', False)
cmDomain.dmarc_verified = pd.get('dmarc_verified', False)
cmDomain.save()
except CyberMailDomain.DoesNotExist:
CyberMailDomain.objects.create(
account=account,
domain=pd['domain'],
platform_domain_id=pd.get('id'),
status=pd.get('status', 'pending'),
spf_verified=pd.get('spf_verified', False),
dkim_verified=pd.get('dkim_verified', False),
dmarc_verified=pd.get('dmarc_verified', False),
)
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.getStatus] Domain sync error: %s' % str(e))
domains = list(CyberMailDomain.objects.filter(account=account).values(
'id', 'domain', 'platform_domain_id', 'status',
'spf_verified', 'dkim_verified', 'dmarc_verified', 'dns_configured'
))
return JsonResponse({
'success': True,
'account': {
'email': account.email,
'plan_name': account.plan_name,
'plan_slug': account.plan_slug,
'emails_per_month': account.emails_per_month,
'relay_enabled': account.relay_enabled,
'smtp_host': account.smtp_host,
'smtp_port': account.smtp_port,
},
'domains': domains,
'stats': {
'emails_sent': accountData.get('emails_sent_this_month', 0),
'reputation_score': accountData.get('reputation_score', 0),
},
})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.getStatus] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def connect(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
data = json.loads(request.body)
password = data.get('password', '')
email = data.get('email', admin.email)
if not password:
return JsonResponse({'success': False, 'error': 'Password is required'})
fullName = admin.firstName if hasattr(admin, 'firstName') and admin.firstName else admin.userName
# Public endpoint — no API key needed for registration
result = self._apiCall('api/register/', {
'email': email,
'password': password,
'full_name': fullName,
})
if not result.get('success', False):
return JsonResponse({'success': False, 'error': result.get('error', 'Registration failed')})
accountData = result.get('data', {})
apiKey = accountData.get('api_key', '')
account, created = CyberMailAccount.objects.get_or_create(
admin=admin,
defaults={
'email': email,
'api_key': apiKey,
'platform_account_id': accountData.get('account_id'),
'plan_name': accountData.get('plan_name', 'Free'),
'plan_slug': accountData.get('plan_slug', 'free'),
'emails_per_month': accountData.get('emails_per_month', 15000),
'is_connected': True,
}
)
if not created:
account.email = email
account.api_key = apiKey
account.platform_account_id = accountData.get('account_id')
account.plan_name = accountData.get('plan_name', 'Free')
account.plan_slug = accountData.get('plan_slug', 'free')
account.emails_per_month = accountData.get('emails_per_month', 15000)
account.is_connected = True
# Reset relay fields from previous account
account.smtp_credential_id = None
account.smtp_username = ''
account.relay_enabled = False
account.save()
# Clear stale domains from previous account
CyberMailDomain.objects.filter(account=account).delete()
return JsonResponse({'success': True, 'message': 'Connected to CyberMail successfully'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.connect] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def addDomain(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
domainName = data.get('domain', '')
if not domainName:
return JsonResponse({'success': False, 'error': 'Domain name is required'})
result = self._accountApiCall(account, 'api/domains/add/', {
'email': account.email,
'domain': domainName,
})
if not result.get('success', False):
return JsonResponse({'success': False, 'error': result.get('error', 'Failed to add domain')})
domainData = result.get('data', {})
cmDomain, created = CyberMailDomain.objects.get_or_create(
account=account,
domain=domainName,
defaults={
'platform_domain_id': domainData.get('id') or domainData.get('domain_id'),
'status': domainData.get('status', 'pending'),
}
)
if not created:
cmDomain.platform_domain_id = domainData.get('id') or domainData.get('domain_id')
cmDomain.status = domainData.get('status', 'pending')
cmDomain.save()
# Auto-configure DNS if domain exists in PowerDNS
dnsResult = self._autoConfigureDnsForDomain(account, domainName)
return JsonResponse({
'success': True,
'message': 'Domain added successfully',
'dns_configured': dnsResult.get('success', False),
'dns_message': dnsResult.get('message', ''),
})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.addDomain] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def _autoConfigureDnsForDomain(self, account, domainName):
try:
from dns.models import Domains as dnsDomains
from plogical.dnsUtilities import DNS
try:
zone = dnsDomains.objects.get(name=domainName)
except dnsDomains.DoesNotExist:
return {'success': False, 'message': 'Domain not found in PowerDNS. Please add DNS records manually.'}
recordsResult = self._accountApiCall(account, 'api/domains/dns-records/', {
'email': account.email,
'domain': domainName,
})
if not recordsResult.get('success', False):
return {'success': False, 'message': 'Could not fetch DNS records from platform.'}
records = recordsResult.get('data', {}).get('records', [])
added = 0
for rec in records:
try:
# Platform returns 'host' for the DNS hostname, 'type' for record type, 'value' for content
recordHost = rec.get('host', '')
recordType = rec.get('type', '')
recordValue = rec.get('value', '')
if not recordHost or not recordType or not recordValue:
continue
DNS.createDNSRecord(
zone,
recordHost,
recordType,
recordValue,
rec.get('priority', 0),
rec.get('ttl', 3600)
)
added += 1
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager._autoConfigureDnsForDomain] Record error: %s' % str(e))
try:
cmDomain = CyberMailDomain.objects.get(account=account, domain=domainName)
cmDomain.dns_configured = True
cmDomain.save()
except CyberMailDomain.DoesNotExist:
pass
return {'success': True, 'message': '%d DNS records configured automatically.' % added}
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager._autoConfigureDnsForDomain] Error: %s' % str(e))
return {'success': False, 'message': str(e)}
def verifyDomain(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
domainName = data.get('domain', '')
result = self._accountApiCall(account, 'api/domains/verify/', {
'email': account.email,
'domain': domainName,
})
if not result.get('success', False):
return JsonResponse({'success': False, 'error': result.get('error', 'Verification failed')})
verifyData = result.get('data', {})
# Platform returns: spf, dkim, dmarc, all_verified, verification_token
allVerified = verifyData.get('all_verified', False)
try:
cmDomain = CyberMailDomain.objects.get(account=account, domain=domainName)
cmDomain.status = 'verified' if allVerified else 'pending'
cmDomain.spf_verified = verifyData.get('spf', False)
cmDomain.dkim_verified = verifyData.get('dkim', False)
cmDomain.dmarc_verified = verifyData.get('dmarc', False)
cmDomain.save()
except CyberMailDomain.DoesNotExist:
pass
return JsonResponse({'success': True, 'data': {
'status': 'verified' if allVerified else 'pending',
'spf_verified': verifyData.get('spf', False),
'dkim_verified': verifyData.get('dkim', False),
'dmarc_verified': verifyData.get('dmarc', False),
'all_verified': allVerified,
}})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.verifyDomain] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def listDomains(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
result = self._accountApiCall(account, 'api/domains/list/', {'email': account.email})
if not result.get('success', False):
return JsonResponse({'success': False, 'error': result.get('error', 'Failed to list domains')})
platformDomains = result.get('data', {}).get('domains', [])
for pd in platformDomains:
try:
cmDomain = CyberMailDomain.objects.get(account=account, domain=pd['domain'])
cmDomain.status = pd.get('status', cmDomain.status)
cmDomain.spf_verified = pd.get('spf_verified', False)
cmDomain.dkim_verified = pd.get('dkim_verified', False)
cmDomain.dmarc_verified = pd.get('dmarc_verified', False)
cmDomain.save()
except CyberMailDomain.DoesNotExist:
CyberMailDomain.objects.create(
account=account,
domain=pd['domain'],
platform_domain_id=pd.get('id'),
status=pd.get('status', 'pending'),
spf_verified=pd.get('spf_verified', False),
dkim_verified=pd.get('dkim_verified', False),
dmarc_verified=pd.get('dmarc_verified', False),
)
domains = list(CyberMailDomain.objects.filter(account=account).values(
'id', 'domain', 'platform_domain_id', 'status',
'spf_verified', 'dkim_verified', 'dmarc_verified', 'dns_configured'
))
return JsonResponse({'success': True, 'domains': domains})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.listDomains] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def getDnsRecords(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
domainName = data.get('domain', '')
result = self._accountApiCall(account, 'api/domains/dns-records/', {
'email': account.email,
'domain': domainName,
})
return JsonResponse(result)
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.getDnsRecords] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def removeDomain(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
domainName = data.get('domain', '')
result = self._accountApiCall(account, 'api/domains/remove/', {
'email': account.email,
'domain': domainName,
})
if not result.get('success', False):
return JsonResponse({'success': False, 'error': result.get('error', 'Failed to remove domain')})
CyberMailDomain.objects.filter(account=account, domain=domainName).delete()
return JsonResponse({'success': True, 'message': 'Domain removed successfully'})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.removeDomain] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def autoConfigureDns(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
domainName = data.get('domain', '')
result = self._autoConfigureDnsForDomain(account, domainName)
return JsonResponse({
'success': result.get('success', False),
'message': result.get('message', ''),
})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.autoConfigureDns] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def createSmtpCredential(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
result = self._accountApiCall(account, 'api/smtp/create/', {
'email': account.email,
'description': data.get('description', ''),
})
return JsonResponse(result)
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.createSmtpCredential] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def listSmtpCredentials(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
result = self._accountApiCall(account, 'api/smtp/list/', {'email': account.email})
# Normalize: platform returns 'id' per credential, JS expects 'credential_id'
if result.get('success') and result.get('data', {}).get('credentials'):
for cred in result['data']['credentials']:
if 'id' in cred and 'credential_id' not in cred:
cred['credential_id'] = cred['id']
return JsonResponse(result)
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.listSmtpCredentials] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def rotateSmtpPassword(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
result = self._accountApiCall(account, 'api/smtp/rotate/', {
'email': account.email,
'credential_id': data.get('credential_id'),
})
# Normalize: platform returns 'new_password', JS expects 'password'
if result.get('success') and result.get('data', {}).get('new_password'):
result['data']['password'] = result['data'].pop('new_password')
return JsonResponse(result)
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.rotateSmtpPassword] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def deleteSmtpCredential(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
result = self._accountApiCall(account, 'api/smtp/delete/', {
'email': account.email,
'credential_id': data.get('credential_id'),
})
if result.get('success', False):
if account.smtp_credential_id == data.get('credential_id'):
account.smtp_credential_id = None
account.smtp_username = ''
account.save()
return JsonResponse(result)
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.deleteSmtpCredential] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def enableRelay(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
# Create SMTP credential if none exists
if not account.smtp_credential_id:
result = self._accountApiCall(account, 'api/smtp/create/', {
'email': account.email,
'description': 'CyberPanel Relay',
})
if not result.get('success', False):
return JsonResponse({'success': False, 'error': result.get('error', 'Failed to create SMTP credential')})
credData = result.get('data', {})
account.smtp_credential_id = credData.get('credential_id')
account.smtp_username = credData.get('username', '')
account.save()
smtpPassword = credData.get('password', '')
else:
# Rotate to get a fresh password
result = self._accountApiCall(account, 'api/smtp/rotate/', {
'email': account.email,
'credential_id': account.smtp_credential_id,
})
if not result.get('success', False):
return JsonResponse({'success': False, 'error': result.get('error', 'Failed to get SMTP password')})
smtpPassword = result.get('data', {}).get('new_password', '')
# Configure Postfix relay via mailUtilities subprocess
import shlex
execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py" \
" configureRelayHost --smtpHost %s --smtpPort %s --smtpUser %s --smtpPassword %s" % (
shlex.quote(str(account.smtp_host)),
shlex.quote(str(account.smtp_port)),
shlex.quote(str(account.smtp_username)),
shlex.quote(str(smtpPassword))
)
output = ProcessUtilities.outputExecutioner(execPath)
if output and '1,None' in output:
account.relay_enabled = True
account.save()
return JsonResponse({'success': True, 'message': 'SMTP relay enabled successfully'})
else:
return JsonResponse({'success': False, 'error': 'Failed to configure Postfix relay: %s' % str(output)})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.enableRelay] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def disableRelay(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py removeRelayHost"
output = ProcessUtilities.outputExecutioner(execPath)
if output and '1,None' in output:
account.relay_enabled = False
account.save()
return JsonResponse({'success': True, 'message': 'SMTP relay disabled successfully'})
else:
return JsonResponse({'success': False, 'error': 'Failed to remove Postfix relay: %s' % str(output)})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.disableRelay] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def getStats(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
result = self._accountApiCall(account, 'api/stats/', {'email': account.email})
# Normalize for JS: platform returns data.total_sent etc at top level
if result.get('success') and result.get('data'):
d = result['data']
result['data'] = {
'total_sent': d.get('total_sent', 0),
'delivered': d.get('delivered', 0),
'bounced': d.get('bounced', 0),
'failed': d.get('failed', 0),
'delivery_rate': d.get('delivery_rate', 0),
}
return JsonResponse(result)
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.getStats] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def getDomainStats(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
result = self._accountApiCall(account, 'api/stats/domains/', {'email': account.email})
# Normalize: platform returns data.domains as dict, JS expects array
if result.get('success') and result.get('data'):
domainsData = result['data'].get('domains', {})
if isinstance(domainsData, dict):
domainsList = []
for domainName, stats in domainsData.items():
entry = {'domain': domainName}
if isinstance(stats, dict):
entry.update(stats)
domainsList.append(entry)
result['data']['domains'] = domainsList
return JsonResponse(result)
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.getDomainStats] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def getLogs(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
data = json.loads(request.body)
result = self._accountApiCall(account, 'api/logs/', {
'email': account.email,
'page': data.get('page', 1),
'per_page': data.get('per_page', 50),
'status': data.get('status', ''),
'from_domain': data.get('from_domain', ''),
'days': data.get('days', 7),
})
# Normalize field names and pagination
if result.get('success') and result.get('data'):
pagination = result['data'].get('pagination', {})
result['data']['total_pages'] = pagination.get('total_pages', 1)
result['data']['page'] = pagination.get('page', 1)
# Map platform field names to what JS/template expects
logs = result['data'].get('logs', [])
for log in logs:
log['date'] = log.get('queued_at', '')
log['from'] = log.get('from_email', '')
log['to'] = log.get('to_email', '')
return JsonResponse(result)
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.getLogs] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def disconnect(self, request, userID):
try:
admin = Administrator.objects.get(pk=userID)
account = CyberMailAccount.objects.get(admin=admin, is_connected=True)
# Disable relay first if enabled
if account.relay_enabled:
execPath = "/usr/local/CyberCP/bin/python /usr/local/CyberCP/plogical/mailUtilities.py removeRelayHost"
ProcessUtilities.outputExecutioner(execPath)
account.is_connected = False
account.relay_enabled = False
account.api_key = ''
account.smtp_credential_id = None
account.smtp_username = ''
account.platform_account_id = None
account.save()
# Remove local domain records
CyberMailDomain.objects.filter(account=account).delete()
return JsonResponse({'success': True, 'message': 'Disconnected from CyberMail'})
except CyberMailAccount.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Account not connected'})
except Exception as e:
self.logger.writeToFile('[EmailDeliveryManager.disconnect] Error: %s' % str(e))
return JsonResponse({'success': False, 'error': str(e)})
def checkStatus(self, request, userID):
try:
result = self._apiCall('api/health/', {})
return JsonResponse(result)
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})

View File

44
emailDelivery/models.py Normal file
View File

@@ -0,0 +1,44 @@
from django.db import models
from loginSystem.models import Administrator
class CyberMailAccount(models.Model):
admin = models.OneToOneField(Administrator, on_delete=models.CASCADE, related_name='cybermail_account')
platform_account_id = models.IntegerField(null=True)
api_key = models.CharField(max_length=255, blank=True)
email = models.CharField(max_length=255)
plan_name = models.CharField(max_length=100, default='Free')
plan_slug = models.CharField(max_length=50, default='free')
emails_per_month = models.IntegerField(default=15000)
is_connected = models.BooleanField(default=False)
relay_enabled = models.BooleanField(default=False)
smtp_credential_id = models.IntegerField(null=True)
smtp_username = models.CharField(max_length=255, blank=True)
smtp_host = models.CharField(max_length=255, default='mail.cyberpersons.com')
smtp_port = models.IntegerField(default=587)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'cybermail_accounts'
def __str__(self):
return f"CyberMail Account for {self.admin.userName}"
class CyberMailDomain(models.Model):
account = models.ForeignKey(CyberMailAccount, on_delete=models.CASCADE, related_name='domains')
domain = models.CharField(max_length=255)
platform_domain_id = models.IntegerField(null=True)
status = models.CharField(max_length=50, default='pending')
spf_verified = models.BooleanField(default=False)
dkim_verified = models.BooleanField(default=False)
dmarc_verified = models.BooleanField(default=False)
dns_configured = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'cybermail_domains'
def __str__(self):
return f"CyberMail Domain: {self.domain}"

View File

@@ -0,0 +1,326 @@
app.controller('emailDeliveryCtrl', ['$scope', '$http', '$timeout', function($scope, $http, $timeout) {
function apiCall(url, data, callback, errback) {
var config = {headers: {'X-CSRFToken': getCookie('csrftoken')}};
$http.post(url, data || {}, config).then(function(resp) {
if (callback) callback(resp.data);
}, function(err) {
console.error('API error:', url, err);
if (errback) errback(err);
});
}
function notify(msg, type) {
new PNotify({title: type === 'error' ? 'Error' : 'Email Delivery', text: msg, type: type || 'success'});
}
$scope.loading = true;
$scope.activeTab = 'domains';
// Account data
$scope.account = {};
$scope.stats = {};
$scope.domains = [];
$scope.smtpCredentials = [];
$scope.logs = [];
$scope.detailedStats = {};
$scope.domainStats = [];
// Form data
$scope.connectEmail = '';
$scope.connectPassword = '';
$scope.connectLoading = false;
$scope.newDomainName = '';
$scope.addDomainLoading = false;
$scope.newSmtpDescription = '';
$scope.createSmtpLoading = false;
$scope.oneTimePassword = null;
// Relay
$scope.relayLoading = false;
// Logs
$scope.logFilters = {status: '', from_domain: '', days: 7};
$scope.logsPage = 1;
$scope.logsTotalPages = 1;
$scope.logsLoading = false;
// Domain stats
$scope.domainStatsLoading = false;
// Disconnect
$scope.disconnectLoading = false;
$scope.init = function(isConnected, adminEmail) {
$scope.isConnected = isConnected;
$scope.connectEmail = adminEmail || '';
if (isConnected) {
$scope.refreshDashboard();
} else {
$scope.loading = false;
}
};
$scope.refreshDashboard = function() {
$scope.loading = true;
apiCall('/emailDelivery/status/', {}, function(data) {
if (data.success) {
$scope.account = data.account;
$scope.stats = data.stats || {};
$scope.domains = data.domains || [];
}
$scope.loading = false;
}, function() {
$scope.loading = false;
notify('Failed to load dashboard data', 'error');
});
};
$scope.getUsagePercent = function() {
if (!$scope.account.emails_per_month) return 0;
return Math.min(100, Math.round(($scope.stats.emails_sent || 0) / $scope.account.emails_per_month * 100));
};
// ============ Connect ============
$scope.connectAccount = function() {
if (!$scope.connectEmail || !$scope.connectPassword) {
notify('Please fill in all fields.', 'error');
return;
}
$scope.connectLoading = true;
apiCall('/emailDelivery/connect/', {
email: $scope.connectEmail,
password: $scope.connectPassword
}, function(data) {
$scope.connectLoading = false;
if (data.success) {
$('#connectModal').modal('hide');
notify('Connected to CyberMail successfully!');
$timeout(function() { window.location.reload(); }, 1000);
} else {
notify(data.error || 'Connection failed.', 'error');
}
}, function() {
$scope.connectLoading = false;
notify('Connection failed. Please try again.', 'error');
});
};
// ============ Domains ============
$scope.addDomain = function() {
if (!$scope.newDomainName) {
notify('Please enter a domain name.', 'error');
return;
}
$scope.addDomainLoading = true;
apiCall('/emailDelivery/domains/add/', {
domain: $scope.newDomainName
}, function(data) {
$scope.addDomainLoading = false;
if (data.success) {
$('#addDomainModal').modal('hide');
$scope.newDomainName = '';
$scope.refreshDashboard();
var msg = 'Domain added successfully.';
if (data.dns_configured) msg += ' ' + data.dns_message;
notify(msg);
} else {
notify(data.error || 'Failed to add domain.', 'error');
}
}, function() {
$scope.addDomainLoading = false;
notify('Failed to add domain.', 'error');
});
};
$scope.verifyDomain = function(domain) {
apiCall('/emailDelivery/domains/verify/', {domain: domain}, function(data) {
if (data.success) {
$scope.refreshDashboard();
notify('Domain verification completed.');
} else {
notify(data.error || 'Verification failed.', 'error');
}
});
};
$scope.configureDns = function(domain) {
apiCall('/emailDelivery/domains/auto-configure-dns/', {domain: domain}, function(data) {
if (data.success) {
$scope.refreshDashboard();
notify(data.message || 'DNS configured.');
} else {
notify(data.message || data.error || 'DNS configuration failed.', 'error');
}
});
};
$scope.removeDomain = function(domain) {
if (!confirm('Remove domain ' + domain + ' from CyberMail?')) return;
apiCall('/emailDelivery/domains/remove/', {domain: domain}, function(data) {
if (data.success) {
$scope.refreshDashboard();
notify('Domain removed.');
} else {
notify(data.error || 'Failed to remove domain.', 'error');
}
});
};
// ============ SMTP Credentials ============
$scope.switchTab = function(tab) {
$scope.activeTab = tab;
if (tab === 'smtp') $scope.loadSmtpCredentials();
if (tab === 'logs') $scope.loadLogs();
if (tab === 'stats') $scope.loadStats();
};
$scope.loadSmtpCredentials = function() {
apiCall('/emailDelivery/smtp/list/', {}, function(data) {
if (data.success) {
$scope.smtpCredentials = data.data ? data.data.credentials || [] : [];
}
});
};
$scope.createSmtpCredential = function() {
$scope.createSmtpLoading = true;
apiCall('/emailDelivery/smtp/create/', {
description: $scope.newSmtpDescription
}, function(data) {
$scope.createSmtpLoading = false;
if (data.success) {
$('#createSmtpModal').modal('hide');
$scope.newSmtpDescription = '';
$scope.oneTimePassword = data.data ? data.data.password : null;
$scope.loadSmtpCredentials();
if ($scope.oneTimePassword) {
$timeout(function() { $('#passwordModal').modal('show'); }, 500);
}
notify('SMTP credential created.');
} else {
notify(data.error || 'Failed to create credential.', 'error');
}
}, function() {
$scope.createSmtpLoading = false;
notify('Failed to create credential.', 'error');
});
};
$scope.rotatePassword = function(credId) {
if (!confirm("Rotate this credential's password? The old password will stop working immediately.")) return;
apiCall('/emailDelivery/smtp/rotate/', {credential_id: credId}, function(data) {
if (data.success) {
$scope.oneTimePassword = data.data ? data.data.password : null;
if ($scope.oneTimePassword) {
$timeout(function() { $('#passwordModal').modal('show'); }, 300);
}
notify('Password rotated.');
} else {
notify(data.error || 'Failed to rotate password.', 'error');
}
});
};
$scope.deleteCredential = function(credId) {
if (!confirm('Delete this SMTP credential? This cannot be undone.')) return;
apiCall('/emailDelivery/smtp/delete/', {credential_id: credId}, function(data) {
if (data.success) {
$scope.loadSmtpCredentials();
notify('Credential deleted.');
} else {
notify(data.error || 'Failed to delete credential.', 'error');
}
});
};
// ============ Relay ============
$scope.enableRelay = function() {
$scope.relayLoading = true;
apiCall('/emailDelivery/relay/enable/', {}, function(data) {
$scope.relayLoading = false;
if (data.success) {
$scope.account.relay_enabled = true;
notify('SMTP relay enabled!');
} else {
notify(data.error || 'Failed to enable relay.', 'error');
}
}, function() {
$scope.relayLoading = false;
notify('Failed to enable relay.', 'error');
});
};
$scope.disableRelay = function() {
if (!confirm('Disable SMTP relay? Postfix will send directly again.')) return;
$scope.relayLoading = true;
apiCall('/emailDelivery/relay/disable/', {}, function(data) {
$scope.relayLoading = false;
if (data.success) {
$scope.account.relay_enabled = false;
notify('SMTP relay disabled.');
} else {
notify(data.error || 'Failed to disable relay.', 'error');
}
}, function() {
$scope.relayLoading = false;
notify('Failed to disable relay.', 'error');
});
};
// ============ Logs ============
$scope.loadLogs = function() {
$scope.logsLoading = true;
apiCall('/emailDelivery/logs/', {
page: $scope.logsPage,
status: $scope.logFilters.status,
from_domain: $scope.logFilters.from_domain,
days: $scope.logFilters.days || 7
}, function(data) {
$scope.logsLoading = false;
if (data.success) {
$scope.logs = data.data ? data.data.logs || [] : [];
$scope.logsTotalPages = data.data ? data.data.total_pages || 1 : 1;
}
}, function() {
$scope.logsLoading = false;
});
};
// ============ Stats ============
$scope.loadStats = function() {
apiCall('/emailDelivery/stats/', {}, function(data) {
if (data.success) {
$scope.detailedStats = data.data || {};
}
});
$scope.domainStatsLoading = true;
apiCall('/emailDelivery/stats/domains/', {}, function(data) {
$scope.domainStatsLoading = false;
if (data.success) {
$scope.domainStats = data.data ? data.data.domains || [] : [];
}
}, function() {
$scope.domainStatsLoading = false;
});
};
// ============ Disconnect ============
$scope.disconnectAccount = function() {
$scope.disconnectLoading = true;
apiCall('/emailDelivery/disconnect/', {}, function(data) {
$scope.disconnectLoading = false;
if (data.success) {
$('#disconnectModal').modal('hide');
notify('Disconnected from CyberMail.');
$timeout(function() { window.location.reload(); }, 1000);
} else {
notify(data.error || 'Failed to disconnect.', 'error');
}
}, function() {
$scope.disconnectLoading = false;
notify('Failed to disconnect.', 'error');
});
};
}]);

File diff suppressed because it is too large Load Diff

30
emailDelivery/urls.py Normal file
View File

@@ -0,0 +1,30 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='emailDeliveryHome'),
path('connect/', views.connect, name='emailDeliveryConnect'),
path('status/', views.getStatus, name='emailDeliveryStatus'),
path('disconnect/', views.disconnect, name='emailDeliveryDisconnect'),
# Domains
path('domains/add/', views.addDomain, name='emailDeliveryAddDomain'),
path('domains/list/', views.listDomains, name='emailDeliveryListDomains'),
path('domains/verify/', views.verifyDomain, name='emailDeliveryVerifyDomain'),
path('domains/dns-records/', views.getDnsRecords, name='emailDeliveryDnsRecords'),
path('domains/auto-configure-dns/', views.autoConfigureDns, name='emailDeliveryAutoConfigureDns'),
path('domains/remove/', views.removeDomain, name='emailDeliveryRemoveDomain'),
# SMTP Credentials
path('smtp/create/', views.createSmtpCredential, name='emailDeliverySmtpCreate'),
path('smtp/list/', views.listSmtpCredentials, name='emailDeliverySmtpList'),
path('smtp/rotate/', views.rotateSmtpPassword, name='emailDeliverySmtpRotate'),
path('smtp/delete/', views.deleteSmtpCredential, name='emailDeliverySmtpDelete'),
# Relay
path('relay/enable/', views.enableRelay, name='emailDeliveryRelayEnable'),
path('relay/disable/', views.disableRelay, name='emailDeliveryRelayDisable'),
# Stats & Logs
path('stats/', views.getStats, name='emailDeliveryStats'),
path('stats/domains/', views.getDomainStats, name='emailDeliveryDomainStats'),
path('logs/', views.getLogs, name='emailDeliveryLogs'),
# Health
path('health/', views.checkStatus, name='emailDeliveryHealth'),
]

184
emailDelivery/views.py Normal file
View File

@@ -0,0 +1,184 @@
from django.shortcuts import redirect
from django.http import JsonResponse
from loginSystem.views import loadLoginPage
from .emailDeliveryManager import EmailDeliveryManager
def index(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.home(request, userID)
except KeyError:
return redirect(loadLoginPage)
def connect(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.connect(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def getStatus(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.getStatus(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def disconnect(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.disconnect(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def addDomain(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.addDomain(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def listDomains(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.listDomains(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def verifyDomain(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.verifyDomain(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def getDnsRecords(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.getDnsRecords(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def autoConfigureDns(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.autoConfigureDns(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def removeDomain(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.removeDomain(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def createSmtpCredential(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.createSmtpCredential(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def listSmtpCredentials(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.listSmtpCredentials(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def rotateSmtpPassword(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.rotateSmtpPassword(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def deleteSmtpCredential(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.deleteSmtpCredential(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def enableRelay(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.enableRelay(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def disableRelay(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.disableRelay(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def getStats(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.getStats(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def getDomainStats(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.getDomainStats(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def getLogs(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.getLogs(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})
def checkStatus(request):
try:
userID = request.session['userID']
em = EmailDeliveryManager()
return em.checkStatus(request, userID)
except KeyError:
return JsonResponse({'success': False, 'error': 'Not authenticated'})

View File

@@ -7,6 +7,21 @@
{% get_current_language as LANGUAGE_CODE %}
<!-- Current language: {{ LANGUAGE_CODE }} -->
<div id="cybermailBanner" style="display:none; margin-bottom:20px;">
<div style="background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 50%,#9333ea 100%);border-radius:10px;padding:20px 24px;display:flex;align-items:center;gap:18px;box-shadow:0 4px 15px rgba(79,70,229,0.3);">
<div style="flex-shrink:0;font-size:32px;">&#9993;</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:800;font-size:18px;color:#fff;margin-bottom:4px;letter-spacing:-0.3px;">Stop Landing in Spam</div>
<div style="font-size:13.5px;color:rgba(255,255,255,0.85);line-height:1.5;">Route your emails through CyberMail's optimized servers. <strong style="color:#fff;">15,000 emails/month free</strong> with automatic DNS configuration, real-time analytics, and dedicated IPs. <a href="https://cyberpanel.net/KnowledgeBase/cybermail-user-guide/" target="_blank" style="color:rgba(255,255,255,0.9);text-decoration:underline;font-weight:500;">Learn more</a></div>
</div>
<a href="/emailDelivery/" style="background:#fff;color:#4f46e5;padding:10px 24px;border-radius:7px;font-weight:700;font-size:13px;text-decoration:none;white-space:nowrap;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,0.1);transition:transform 0.15s;">Get Started Free &rarr;</a>
<button onclick="dismissCyberMailBanner()" style="background:none;border:none;color:rgba(255,255,255,0.6);font-size:20px;cursor:pointer;padding:0 4px;line-height:1;flex-shrink:0;" title="Dismiss">&times;</button>
</div>
</div>
<script>
(function(){if(!document.cookie.includes('cybermail_dismiss=1')){document.getElementById('cybermailBanner').style.display='';}})();
function dismissCyberMailBanner(){document.getElementById('cybermailBanner').style.display='none';document.cookie='cybermail_dismiss=1; path=/; max-age='+7*86400;}
</script>
<div class="container">

View File

@@ -7,6 +7,22 @@
{% get_current_language as LANGUAGE_CODE %}
<!-- Current language: {{ LANGUAGE_CODE }} -->
<div id="cybermailBanner" style="display:none; margin-bottom:20px;">
<div style="background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 50%,#9333ea 100%);border-radius:10px;padding:20px 24px;display:flex;align-items:center;gap:18px;box-shadow:0 4px 15px rgba(79,70,229,0.3);">
<div style="flex-shrink:0;font-size:32px;">&#9993;</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:800;font-size:18px;color:#fff;margin-bottom:4px;letter-spacing:-0.3px;">Stop Landing in Spam</div>
<div style="font-size:13.5px;color:rgba(255,255,255,0.85);line-height:1.5;">Route your emails through CyberMail's optimized servers. <strong style="color:#fff;">15,000 emails/month free</strong> with automatic DNS configuration, real-time analytics, and dedicated IPs. <a href="https://cyberpanel.net/KnowledgeBase/cybermail-user-guide/" target="_blank" style="color:rgba(255,255,255,0.9);text-decoration:underline;font-weight:500;">Learn more</a></div>
</div>
<a href="/emailDelivery/" style="background:#fff;color:#4f46e5;padding:10px 24px;border-radius:7px;font-weight:700;font-size:13px;text-decoration:none;white-space:nowrap;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,0.1);transition:transform 0.15s;">Get Started Free &rarr;</a>
<button onclick="dismissCyberMailBanner()" style="background:none;border:none;color:rgba(255,255,255,0.6);font-size:20px;cursor:pointer;padding:0 4px;line-height:1;flex-shrink:0;" title="Dismiss">&times;</button>
</div>
</div>
<script>
(function(){if(!document.cookie.includes('cybermail_dismiss=1')){document.getElementById('cybermailBanner').style.display='';}})();
function dismissCyberMailBanner(){document.getElementById('cybermailBanner').style.display='none';document.cookie='cybermail_dismiss=1; path=/; max-age='+7*86400;}
</script>
<div ng-controller="emailPage" class="container">
<div id="page-title">

View File

@@ -307,13 +307,19 @@ def downloadFile(request):
try:
userID = request.session['userID']
admin = Administrator.objects.get(pk=userID)
from urllib.parse import quote
from django.utils.encoding import iri_to_uri
from urllib.parse import unquote
fileToDownload = request.build_absolute_uri().split('fileToDownload')[1][1:]
fileToDownload = iri_to_uri(fileToDownload)
# Properly get fileToDownload from query parameters
fileToDownload = request.GET.get('fileToDownload')
if not fileToDownload:
return HttpResponse("Unauthorized access: Not a valid file.")
# URL decode the file path
fileToDownload = unquote(fileToDownload)
domainName = request.GET.get('domainName')
if not domainName:
return HttpResponse("Unauthorized access: Domain not specified.")
currentACL = ACLManager.loadedACL(userID)
@@ -324,8 +330,14 @@ def downloadFile(request):
homePath = '/home/%s' % (domainName)
if fileToDownload.find('..') > -1 or fileToDownload.find(homePath) == -1:
return HttpResponse("Unauthorized access.")
# Security checks: prevent directory traversal and ensure file is within domain's home path
if '..' in fileToDownload or not fileToDownload.startswith(homePath):
return HttpResponse("Unauthorized access: Not a valid file.")
# Normalize path to prevent any path traversal attempts
fileToDownload = os.path.normpath(fileToDownload)
if not fileToDownload.startswith(homePath):
return HttpResponse("Unauthorized access: Not a valid file.")
# SECURITY: Check for symlink attacks - resolve the real path and verify it stays within homePath
try:
@@ -356,11 +368,15 @@ def downloadFile(request):
def RootDownloadFile(request):
try:
userID = request.session['userID']
from urllib.parse import quote
from django.utils.encoding import iri_to_uri
from urllib.parse import unquote
fileToDownload = request.build_absolute_uri().split('fileToDownload')[1][1:]
fileToDownload = iri_to_uri(fileToDownload)
# Properly get fileToDownload from query parameters
fileToDownload = request.GET.get('fileToDownload')
if not fileToDownload:
return HttpResponse("Unauthorized access: Not a valid file.")
# URL decode the file path
fileToDownload = unquote(fileToDownload)
currentACL = ACLManager.loadedACL(userID)
@@ -370,9 +386,12 @@ def RootDownloadFile(request):
return ACLManager.loadError()
# SECURITY: Prevent path traversal attacks
if fileToDownload.find('..') > -1:
if '..' in fileToDownload:
return HttpResponse("Unauthorized access: Path traversal detected.")
# Normalize path to prevent any path traversal attempts
fileToDownload = os.path.normpath(fileToDownload)
# SECURITY: Check for symlink attacks - resolve the real path and verify it's safe
try:
# Get the real path (resolves symlinks)

View File

@@ -58,9 +58,9 @@ else
# Detect OS version and download appropriate requirements
if grep -q "22.04" /etc/os-release || grep -q "VERSION_ID=\"9" /etc/os-release; then
wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.4-dev/requirments.txt
wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.5/requirments.txt
else
wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.4-dev/requirments-old.txt
wget -q -O /tmp/requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/v2.4.5/requirments-old.txt
fi
# Upgrade pip first

View File

@@ -1,4 +1,4 @@
driver = mysql
connect = host=localhost dbname=cyberpanel user=cyberpanel password=SLTUIUxqhulwsh port=3306
password_query = SELECT email as user, password FROM e_users WHERE email='%u';
user_query = SELECT '5000' as uid, '5000' as gid, mail FROM e_users WHERE email='%u';
user_query = SELECT '5000' as uid, '5000' as gid, mail, CONCAT('/home/vmail/', SUBSTRING_INDEX(email, '@', -1), '/', SUBSTRING_INDEX(email, '@', 1)) as home FROM e_users WHERE email='%u';

View File

@@ -1,4 +1,4 @@
protocols = imap pop3
protocols = imap pop3 sieve
log_timestamp = "%Y-%m-%d %H:%M:%S "
#mail_location = maildir:/home/vmail/%d/%n/Maildir
#mail_location = mdbox:/home/vmail/%d/%n/Mdbox
@@ -41,7 +41,9 @@ protocol lda {
auth_socket_path = /var/run/dovecot/auth-master
postmaster_address = postmaster@example.com
mail_plugins = zlib
mail_plugins = zlib sieve
lda_mailbox_autocreate = yes
lda_mailbox_autosubscribe = yes
}
protocol pop3 {
@@ -53,6 +55,15 @@ protocol imap {
mail_plugins = $mail_plugins zlib imap_zlib
}
auth_master_user_separator = *
passdb {
driver = passwd-file
master = yes
args = /etc/dovecot/master-users
result_success = continue
}
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
@@ -68,6 +79,9 @@ plugin {
zlib_save = gz
zlib_save_level = 6
sieve = ~/sieve/.dovecot.sieve
sieve_dir = ~/sieve
}
service stats {

View File

@@ -1,4 +1,4 @@
driver = mysql
connect = host=127.0.0.1 dbname=cyberpanel user=cyberpanel password=1qaz@9xvps
password_query = SELECT email as user, password FROM e_users WHERE email='%u';
user_query = SELECT '5000' as uid, '5000' as gid, mail FROM e_users WHERE email='%u';
user_query = SELECT '5000' as uid, '5000' as gid, mail, CONCAT('/home/vmail/', SUBSTRING_INDEX(email, '@', -1), '/', SUBSTRING_INDEX(email, '@', 1)) as home FROM e_users WHERE email='%u';

View File

@@ -1,4 +1,4 @@
protocols = imap pop3
protocols = imap pop3 sieve
log_timestamp = "%Y-%m-%d %H:%M:%S "
#mail_location = maildir:/home/vmail/%d/%n/Maildir
#mail_location = mdbox:/home/vmail/%d/%n/Mdbox
@@ -41,7 +41,9 @@ protocol lda {
auth_socket_path = /var/run/dovecot/auth-master
postmaster_address = postmaster@example.com
mail_plugins = zlib
mail_plugins = zlib sieve
lda_mailbox_autocreate = yes
lda_mailbox_autosubscribe = yes
}
protocol pop3 {
@@ -53,6 +55,15 @@ protocol imap {
mail_plugins = $mail_plugins zlib imap_zlib
}
auth_master_user_separator = *
passdb {
driver = passwd-file
master = yes
args = /etc/dovecot/master-users
result_success = continue
}
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
@@ -69,6 +80,9 @@ plugin {
zlib_save = gz
zlib_save_level = 6
sieve = ~/sieve/.dovecot.sieve
sieve_dir = ~/sieve
}
service stats {

View File

@@ -1018,10 +1018,10 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout';
command = 'dnf --nogpg install -y https://mirror.ghettoforge.net/distributions/gf/gf-release-latest.gf.el8.noarch.rpm'
preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR)
command = 'dnf install --enablerepo=gf-plus postfix3 postfix3-mysql -y'
command = 'dnf install --enablerepo=gf-plus postfix3 postfix3-mysql cyrus-sasl-plain -y'
preFlightsChecks.call(command, self.distro, command, command, 1, 1, os.EX_OSERR)
elif self.distro == openeuler:
command = 'dnf install postfix -y'
command = 'dnf install postfix cyrus-sasl-plain -y'
preFlightsChecks.call(command, self.distro, command, command, 1, 1, os.EX_OSERR)
else:
@@ -1044,9 +1044,15 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout';
if self.distro == centos:
command = 'yum --enablerepo=gf-plus -y install dovecot23 dovecot23-mysql'
elif self.distro == cent8:
command = 'dnf install --enablerepo=gf-plus dovecot23 dovecot23-mysql -y'
clAPVersion = FetchCloudLinuxAlmaVersionVersion()
type = clAPVersion.split('-')[0]
version = int(clAPVersion.split('-')[1])
if type == 'al' and version >= 90:
command = 'dnf install -y dovecot dovecot-mysql'
else:
command = 'dnf install --enablerepo=gf-plus dovecot23 dovecot23-mysql -y'
elif self.distro == openeuler:
command = 'dnf install dovecot -y'
command = 'dnf install -y dovecot dovecot-mysql'
else:
command = 'DEBIAN_FRONTEND=noninteractive apt-get -y install dovecot-mysql dovecot-imapd dovecot-pop3d'
@@ -1396,6 +1402,16 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout';
command = "mkdir -p /etc/pki/dovecot/certs/"
preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR)
# Copy self-signed certs to where Postfix main.cf expects them
command = "cp /etc/dovecot/cert.pem /etc/pki/dovecot/certs/dovecot.pem"
preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR)
command = "cp /etc/dovecot/key.pem /etc/pki/dovecot/private/dovecot.pem"
preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR)
command = "chmod 600 /etc/pki/dovecot/private/dovecot.pem"
preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR)
command = "mkdir -p /etc/opendkim/keys/"
preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR)
@@ -2880,11 +2896,13 @@ def main():
checks.install_postfix_dovecot()
checks.setup_email_Passwords(installCyberPanel.InstallCyberPanel.mysqlPassword, mysql)
checks.setup_postfix_dovecot_config(mysql)
installCyberPanel.InstallCyberPanel.setupWebmail()
else:
if args.postfix == 'ON':
checks.install_postfix_dovecot()
checks.setup_email_Passwords(installCyberPanel.InstallCyberPanel.mysqlPassword, mysql)
checks.setup_postfix_dovecot_config(mysql)
installCyberPanel.InstallCyberPanel.setupWebmail()
checks.install_unzip()
checks.install_zip()

View File

@@ -328,24 +328,25 @@ class InstallCyberPanel:
# Platform-specific URLs and checksums (OpenLiteSpeed v2.4.4 — all features config-driven, static linking)
# Includes: PHPConfig API, Origin Header Forwarding, ReadApacheConf (with Portmap), Auto-SSL (ACME v2), ModSecurity ABI Compatibility
# Module rebuilt 2026-03-04: fix SIGSEGV crash in apply_headers() on error responses (4xx/5xx)
BINARY_CONFIGS = {
'rhel8': {
'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel8',
'sha256': '70002c488309c9ed650f3de2959bcf4db847b8204f6fe242e523523b621fd316',
'sha256': 'd08512da7a77468c09d6161de858db60bcc29aed7ce0abf76dca1c72104dc485',
'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-rhel8.so',
'module_sha256': '27f7fbbb74e83c217708960d4b18e2732b0798beecba8ed6eac01509165cb432'
'module_sha256': '3fd3bf6e2d50fe2e94e67fcf9f8ee24c4cc31b9edb641bee8c129cb316c3454a'
},
'rhel9': {
'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel9',
'sha256': '4fed6d0c70b23ebb73efc6f17f2f2bb2afc84b23b36c02308b8b2fefc56a291c',
'sha256': '418d2ea06e29c0f847a2e6cf01f7641d5fb72b65a04e27a8f6b3b54d673cc2df',
'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-rhel9.so',
'module_sha256': '50cb00fa2b8269ec9b0bf300f1b26d3b76d3791c1b022343e1290a0d25e7fda8'
'module_sha256': '4863fc4c227e50e2d6ec5827aed3e1ad92e9be03a548b7aa1a8a4640853db399'
},
'ubuntu': {
'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-ubuntu',
'sha256': '004b69dcc7daf21412ddbdfff5fd4e191293035a8f7c5e7cffd7be7ada070445',
'sha256': '60edf815379c32705540ad4525ea6d07c0390cabca232b6be12376ee538f4b1b',
'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-ubuntu.so',
'module_sha256': 'bd47069d13bb098201f3e72d4d56876193c898ebfa0ac2eb26796abebc991a88'
'module_sha256': '0d7dd17c6e64ac46d4abd5ccb67cc2da51809e24692774e4df76d8f3a6c67e9d'
}
}
@@ -499,6 +500,26 @@ module cyberpanel_ols {
# Configure the custom module
self.configureCustomModule()
# Enable Auto-SSL in httpd_config.conf
try:
import re
conf_path = '/usr/local/lsws/conf/httpd_config.conf'
if os.path.exists(conf_path):
with open(conf_path, 'r') as f:
content = f.read()
if 'autoSSL' not in content:
content = re.sub(
r'(adminEmails\s+\S+)',
r'\1\nautoSSL 1\nacmeEmail admin@cyberpanel.net',
content,
count=1
)
with open(conf_path, 'w') as f:
f.write(content)
InstallCyberPanel.stdOut("Auto-SSL enabled in httpd_config.conf", 1)
except Exception as e:
InstallCyberPanel.stdOut(f"WARNING: Could not enable Auto-SSL: {e}", 1)
else:
try:
try:
@@ -665,25 +686,137 @@ module cyberpanel_ols {
"""Install Sieve (Dovecot Sieve) for email filtering on all OS variants"""
try:
InstallCyberPanel.stdOut("Installing Sieve (Dovecot Sieve) for email filtering...", 1)
if self.distro == ubuntu:
# Install dovecot-sieve and dovecot-managesieved
self.install_package('dovecot-sieve dovecot-managesieved')
else:
# For CentOS/AlmaLinux/OpenEuler
self.install_package('dovecot-pigeonhole')
# Write ManageSieve config
managesieve_conf = '/etc/dovecot/conf.d/20-managesieve.conf'
os.makedirs('/etc/dovecot/conf.d', exist_ok=True)
with open(managesieve_conf, 'w') as f:
f.write("""protocols = $protocols sieve
service managesieve-login {
inet_listener sieve {
port = 4190
}
}
service managesieve {
process_limit = 256
}
protocol sieve {
managesieve_notify_capability = mailto
managesieve_sieve_capability = fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext
}
""")
# Add Sieve port 4190 to firewall
from plogical.firewallUtilities import FirewallUtilities
FirewallUtilities.addSieveFirewallRule()
try:
import firewall.core.fw as fw
subprocess.call(['firewall-cmd', '--permanent', '--add-port=4190/tcp'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.call(['firewall-cmd', '--reload'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
# firewalld may not be available, try ufw
subprocess.call(['ufw', 'allow', '4190/tcp'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
InstallCyberPanel.stdOut("Sieve successfully installed and configured!", 1)
return 1
except BaseException as msg:
logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [installSieve]")
return 0
@staticmethod
def setupWebmail():
"""Set up Dovecot master user and webmail config for SSO"""
try:
# Skip if dovecot not installed
if not os.path.exists('/etc/dovecot/dovecot.conf'):
InstallCyberPanel.stdOut("Dovecot not installed, skipping webmail setup.", 1)
return 1
# Skip if already configured
if os.path.exists('/etc/cyberpanel/webmail.conf') and os.path.exists('/etc/dovecot/master-users'):
InstallCyberPanel.stdOut("Webmail master user already configured.", 1)
return 1
InstallCyberPanel.stdOut("Setting up webmail master user for SSO...", 1)
import secrets, string
chars = string.ascii_letters + string.digits
master_password = ''.join(secrets.choice(chars) for _ in range(32))
# Hash the password using doveadm
result = subprocess.run(
['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', master_password],
capture_output=True, text=True
)
if result.returncode != 0:
logging.InstallLog.writeToFile('[ERROR] doveadm pw failed: ' + result.stderr + " [setupWebmail]")
return 0
password_hash = result.stdout.strip()
# Write /etc/dovecot/master-users
with open('/etc/dovecot/master-users', 'w') as f:
f.write('cyberpanel_master:' + password_hash + '\n')
os.chmod('/etc/dovecot/master-users', 0o600)
subprocess.call(['chown', 'dovecot:dovecot', '/etc/dovecot/master-users'])
# Ensure /etc/cyberpanel/ exists
os.makedirs('/etc/cyberpanel', exist_ok=True)
# Write /etc/cyberpanel/webmail.conf
import json as json_module
webmail_conf = {
'master_user': 'cyberpanel_master',
'master_password': master_password
}
with open('/etc/cyberpanel/webmail.conf', 'w') as f:
json_module.dump(webmail_conf, f)
os.chmod('/etc/cyberpanel/webmail.conf', 0o600)
subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf'])
# Patch dovecot.conf if master passdb block missing
dovecot_conf_path = '/etc/dovecot/dovecot.conf'
with open(dovecot_conf_path, 'r') as f:
dovecot_content = f.read()
if 'auth_master_user_separator' not in dovecot_content:
master_block = """auth_master_user_separator = *
passdb {
driver = passwd-file
master = yes
args = /etc/dovecot/master-users
result_success = continue
}
"""
dovecot_content = dovecot_content.replace(
'passdb {',
master_block + 'passdb {',
1
)
with open(dovecot_conf_path, 'w') as f:
f.write(dovecot_content)
# Restart Dovecot to pick up changes
subprocess.call(['systemctl', 'restart', 'dovecot'])
InstallCyberPanel.stdOut("Webmail master user setup complete!", 1)
return 1
except BaseException as msg:
logging.InstallLog.writeToFile('[ERROR] ' + str(msg) + " [setupWebmail]")
return 0
def installMySQL(self, mysql):
############## Install mariadb ######################
@@ -1299,6 +1432,8 @@ def Main(cwd, mysql, distro, ent, serial=None, port="8090", ftp=None, dns=None,
logging.InstallLog.writeToFile('Installing Sieve for email filtering..,55')
installer.installSieve()
## setupWebmail is called later, after Dovecot is installed (see install.py)
logging.InstallLog.writeToFile('Installing MySQL,60')
installer.installMySQL(mysql)
installer.changeMYSQLRootPassword()

View File

@@ -12,6 +12,8 @@ gracefulRestartTimeout 300
mime $SERVER_ROOT/conf/mime.properties
showVersionNumber 0
adminEmails root@localhost
autoSSL 1
acmeEmail admin@cyberpanel.net
adminRoot $SERVER_ROOT/admin/
errorlog $SERVER_ROOT/logs/error.log {

View File

@@ -317,6 +317,22 @@
}
</style>
<div id="cybermailBanner" style="display:none; margin-bottom:20px;">
<div style="background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 50%,#9333ea 100%);border-radius:10px;padding:20px 24px;display:flex;align-items:center;gap:18px;box-shadow:0 4px 15px rgba(79,70,229,0.3);">
<div style="flex-shrink:0;font-size:32px;">&#9993;</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:800;font-size:18px;color:#fff;margin-bottom:4px;letter-spacing:-0.3px;">Stop Landing in Spam</div>
<div style="font-size:13.5px;color:rgba(255,255,255,0.85);line-height:1.5;">Route your emails through CyberMail's optimized servers. <strong style="color:#fff;">15,000 emails/month free</strong> with automatic DNS configuration, real-time analytics, and dedicated IPs. <a href="https://cyberpanel.net/KnowledgeBase/cybermail-user-guide/" target="_blank" style="color:rgba(255,255,255,0.9);text-decoration:underline;font-weight:500;">Learn more</a></div>
</div>
<a href="/emailDelivery/" style="background:#fff;color:#4f46e5;padding:10px 24px;border-radius:7px;font-weight:700;font-size:13px;text-decoration:none;white-space:nowrap;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,0.1);transition:transform 0.15s;">Get Started Free &rarr;</a>
<button onclick="dismissCyberMailBanner()" style="background:none;border:none;color:rgba(255,255,255,0.6);font-size:20px;cursor:pointer;padding:0 4px;line-height:1;flex-shrink:0;" title="Dismiss">&times;</button>
</div>
</div>
<script>
(function(){if(!document.cookie.includes('cybermail_dismiss=1')){document.getElementById('cybermailBanner').style.display='';}})();
function dismissCyberMailBanner(){document.getElementById('cybermailBanner').style.display='none';document.cookie='cybermail_dismiss=1; path=/; max-age='+7*86400;}
</script>
<div class="modern-container" ng-controller="createEmailAccount">
<div class="page-header">
<h1 class="page-title">

View File

@@ -398,6 +398,22 @@
}
</style>
<div id="cybermailBanner" style="display:none; margin-bottom:20px;">
<div style="background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 50%,#9333ea 100%);border-radius:10px;padding:20px 24px;display:flex;align-items:center;gap:18px;box-shadow:0 4px 15px rgba(79,70,229,0.3);">
<div style="flex-shrink:0;font-size:32px;">&#9993;</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:800;font-size:18px;color:#fff;margin-bottom:4px;letter-spacing:-0.3px;">Stop Landing in Spam</div>
<div style="font-size:13.5px;color:rgba(255,255,255,0.85);line-height:1.5;">Route your emails through CyberMail's optimized servers. <strong style="color:#fff;">15,000 emails/month free</strong> with automatic DNS configuration, real-time analytics, and dedicated IPs. <a href="https://cyberpanel.net/KnowledgeBase/cybermail-user-guide/" target="_blank" style="color:rgba(255,255,255,0.9);text-decoration:underline;font-weight:500;">Learn more</a></div>
</div>
<a href="/emailDelivery/" style="background:#fff;color:#4f46e5;padding:10px 24px;border-radius:7px;font-weight:700;font-size:13px;text-decoration:none;white-space:nowrap;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,0.1);transition:transform 0.15s;">Get Started Free &rarr;</a>
<button onclick="dismissCyberMailBanner()" style="background:none;border:none;color:rgba(255,255,255,0.6);font-size:20px;cursor:pointer;padding:0 4px;line-height:1;flex-shrink:0;" title="Dismiss">&times;</button>
</div>
</div>
<script>
(function(){if(!document.cookie.includes('cybermail_dismiss=1')){document.getElementById('cybermailBanner').style.display='';}})();
function dismissCyberMailBanner(){document.getElementById('cybermailBanner').style.display='none';document.cookie='cybermail_dismiss=1; path=/; max-age='+7*86400;}
</script>
<div class="modern-container" ng-controller="dkimManager">
<div class="page-header">
<h1 class="page-title">

View File

@@ -7,6 +7,22 @@
{% get_current_language as LANGUAGE_CODE %}
<!-- Current language: {{ LANGUAGE_CODE }} -->
<div id="cybermailBanner" style="display:none; margin-bottom:20px;">
<div style="background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 50%,#9333ea 100%);border-radius:10px;padding:20px 24px;display:flex;align-items:center;gap:18px;box-shadow:0 4px 15px rgba(79,70,229,0.3);">
<div style="flex-shrink:0;font-size:32px;">&#9993;</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:800;font-size:18px;color:#fff;margin-bottom:4px;letter-spacing:-0.3px;">Stop Landing in Spam</div>
<div style="font-size:13.5px;color:rgba(255,255,255,0.85);line-height:1.5;">Route your emails through CyberMail's optimized servers. <strong style="color:#fff;">15,000 emails/month free</strong> with automatic DNS configuration, real-time analytics, and dedicated IPs. <a href="https://cyberpanel.net/KnowledgeBase/cybermail-user-guide/" target="_blank" style="color:rgba(255,255,255,0.9);text-decoration:underline;font-weight:500;">Learn more</a></div>
</div>
<a href="/emailDelivery/" style="background:#fff;color:#4f46e5;padding:10px 24px;border-radius:7px;font-weight:700;font-size:13px;text-decoration:none;white-space:nowrap;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,0.1);transition:transform 0.15s;">Get Started Free &rarr;</a>
<button onclick="dismissCyberMailBanner()" style="background:none;border:none;color:rgba(255,255,255,0.6);font-size:20px;cursor:pointer;padding:0 4px;line-height:1;flex-shrink:0;" title="Dismiss">&times;</button>
</div>
</div>
<script>
(function(){if(!document.cookie.includes('cybermail_dismiss=1')){document.getElementById('cybermailBanner').style.display='';}})();
function dismissCyberMailBanner(){document.getElementById('cybermailBanner').style.display='none';document.cookie='cybermail_dismiss=1; path=/; max-age='+7*86400;}
</script>
<div class="container">
<div id="page-title">
<h2>{% trans "Mail Functions" %}</h2>
@@ -96,7 +112,7 @@
</div>
<div class="col-md-3 btn-min-width">
<a href="/snappymail/index.php" title="{% trans 'Access Webmail' %}"
<a href="/webmail/" title="{% trans 'Access Webmail' %}"
class="tile-box tile-box-shortcut btn-primary">
<div class="tile-header">
{% trans "Access Webmail" %}

View File

@@ -1704,6 +1704,109 @@ LogFile /var/log/clamav/clamav.log
@staticmethod
def configureRelayHost(smtpHost, smtpPort, smtpUser, smtpPassword):
try:
## Ensure cyrus-sasl-plain is installed (required for SASL PLAIN auth on RHEL/Alma/CentOS)
if os.path.exists('/etc/redhat-release'):
ProcessUtilities.executioner('dnf install -y cyrus-sasl-plain')
elif os.path.exists('/usr/bin/apt-get'):
ProcessUtilities.executioner('apt-get install -y libsasl2-modules')
postfixPath = '/etc/postfix/main.cf'
with open(postfixPath, 'r') as f:
lines = f.readlines()
relayKeys = ['relayhost', 'smtp_sasl_auth_enable', 'smtp_sasl_password_maps',
'smtp_sasl_security_options', 'smtp_tls_security_level']
filteredLines = []
for line in lines:
stripped = line.strip()
skip = False
for key in relayKeys:
if stripped.startswith(key + ' ') or stripped.startswith(key + '='):
skip = True
break
if not skip:
filteredLines.append(line)
relayConfig = [
'\n# CyberMail SMTP Relay Configuration\n',
'relayhost = [%s]:%s\n' % (smtpHost, smtpPort),
'smtp_sasl_auth_enable = yes\n',
'smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd\n',
'smtp_sasl_security_options = noanonymous\n',
'smtp_tls_security_level = encrypt\n',
]
with open(postfixPath, 'w') as f:
f.writelines(filteredLines)
f.writelines(relayConfig)
saslPath = '/etc/postfix/sasl_passwd'
with open(saslPath, 'w') as f:
f.write('[%s]:%s %s:%s\n' % (smtpHost, smtpPort, smtpUser, smtpPassword))
os.chmod(saslPath, 0o600)
ProcessUtilities.executioner('postmap /etc/postfix/sasl_passwd')
ProcessUtilities.executioner('systemctl reload postfix')
print('1,None')
except BaseException as msg:
logging.CyberCPLogFileWriter.writeToFile(str(msg) + ' [configureRelayHost]')
print('0,%s' % str(msg))
@staticmethod
def removeRelayHost():
try:
postfixPath = '/etc/postfix/main.cf'
with open(postfixPath, 'r') as f:
lines = f.readlines()
relayKeys = ['relayhost', 'smtp_sasl_auth_enable', 'smtp_sasl_password_maps',
'smtp_sasl_security_options']
commentLine = '# CyberMail SMTP Relay Configuration\n'
filteredLines = []
for line in lines:
stripped = line.strip()
if line == commentLine:
continue
skip = False
for key in relayKeys:
if stripped.startswith(key + ' ') or stripped.startswith(key + '='):
skip = True
break
if not skip:
if stripped.startswith('smtp_tls_security_level'):
filteredLines.append('smtp_tls_security_level = may\n')
else:
filteredLines.append(line)
with open(postfixPath, 'w') as f:
f.writelines(filteredLines)
saslPath = '/etc/postfix/sasl_passwd'
saslDbPath = '/etc/postfix/sasl_passwd.db'
if os.path.exists(saslPath):
os.remove(saslPath)
if os.path.exists(saslDbPath):
os.remove(saslDbPath)
ProcessUtilities.executioner('systemctl reload postfix')
print('1,None')
except BaseException as msg:
logging.CyberCPLogFileWriter.writeToFile(str(msg) + ' [removeRelayHost]')
print('0,%s' % str(msg))
####### Imported below functions from mailserver/mailservermanager, need to refactor later
class MailServerManagerUtils(multi.Thread):
@@ -2719,6 +2822,7 @@ milter_default_action = accept
return 1, 'All checks are OK.'
def main():
parser = argparse.ArgumentParser(description='CyberPanel Installer')
@@ -2729,6 +2833,10 @@ def main():
parser.add_argument('--tempConfigPath', help='Temporary Configuration Path!')
parser.add_argument('--install', help='Enable/Disable Policy Server!')
parser.add_argument('--tempStatusPath', help='Path of temporary status file.')
parser.add_argument('--smtpHost', help='SMTP relay host!')
parser.add_argument('--smtpPort', help='SMTP relay port!')
parser.add_argument('--smtpUser', help='SMTP relay username!')
parser.add_argument('--smtpPassword', help='SMTP relay password!')
@@ -2772,6 +2880,10 @@ def main():
mailUtilities.SetupEmailLimits()
elif args.function == 'SaveEmailLimitsNew':
mailUtilities.SaveEmailLimitsNew(args.tempConfigPath)
elif args.function == 'configureRelayHost':
mailUtilities.configureRelayHost(args.smtpHost, args.smtpPort, args.smtpUser, args.smtpPassword)
elif args.function == 'removeRelayHost':
mailUtilities.removeRelayHost()
if __name__ == "__main__":
main()

View File

@@ -735,28 +735,29 @@ class Upgrade:
# Platform-specific URLs and checksums (OpenLiteSpeed v2.4.4 — all features config-driven, static linking)
# Includes: PHPConfig API, Origin Header Forwarding, ReadApacheConf (with Portmap), Auto-SSL (ACME v2), ModSecurity ABI Compatibility
# Module rebuilt 2026-03-04: fix SIGSEGV crash in apply_headers() on error responses (4xx/5xx)
BINARY_CONFIGS = {
'rhel8': {
'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel8',
'sha256': '70002c488309c9ed650f3de2959bcf4db847b8204f6fe242e523523b621fd316',
'sha256': 'd08512da7a77468c09d6161de858db60bcc29aed7ce0abf76dca1c72104dc485',
'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-rhel8.so',
'module_sha256': '27f7fbbb74e83c217708960d4b18e2732b0798beecba8ed6eac01509165cb432',
'module_sha256': '3fd3bf6e2d50fe2e94e67fcf9f8ee24c4cc31b9edb641bee8c129cb316c3454a',
'modsec_url': 'https://cyberpanel.net/mod_security-2.4.4-x86_64-rhel8.so',
'modsec_sha256': 'bbbf003bdc7979b98f09b640dffe2cbbe5f855427f41319e4c121403c05837b2'
},
'rhel9': {
'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-rhel9',
'sha256': '4fed6d0c70b23ebb73efc6f17f2f2bb2afc84b23b36c02308b8b2fefc56a291c',
'sha256': '418d2ea06e29c0f847a2e6cf01f7641d5fb72b65a04e27a8f6b3b54d673cc2df',
'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-rhel9.so',
'module_sha256': '50cb00fa2b8269ec9b0bf300f1b26d3b76d3791c1b022343e1290a0d25e7fda8',
'module_sha256': '4863fc4c227e50e2d6ec5827aed3e1ad92e9be03a548b7aa1a8a4640853db399',
'modsec_url': 'https://cyberpanel.net/mod_security-2.4.4-x86_64-rhel9.so',
'modsec_sha256': '19deb2ffbaf1334cf4ce4d46d53f747a75b29e835bf5a01f91ebcc0c78e98629'
},
'ubuntu': {
'url': 'https://cyberpanel.net/openlitespeed-2.4.4-x86_64-ubuntu',
'sha256': '004b69dcc7daf21412ddbdfff5fd4e191293035a8f7c5e7cffd7be7ada070445',
'sha256': '60edf815379c32705540ad4525ea6d07c0390cabca232b6be12376ee538f4b1b',
'module_url': 'https://cyberpanel.net/cyberpanel_ols-2.4.4-x86_64-ubuntu.so',
'module_sha256': 'bd47069d13bb098201f3e72d4d56876193c898ebfa0ac2eb26796abebc991a88',
'module_sha256': '0d7dd17c6e64ac46d4abd5ccb67cc2da51809e24692774e4df76d8f3a6c67e9d',
'modsec_url': 'https://cyberpanel.net/mod_security-2.4.4-x86_64-ubuntu.so',
'modsec_sha256': 'ed02c813136720bd4b9de5925f6e41bdc8392e494d7740d035479aaca6d1e0cd'
}
@@ -1677,6 +1678,54 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout';
except:
pass
# CyberMail Email Delivery Tables
try:
cursor.execute('''
CREATE TABLE `cybermail_accounts` (
`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
`admin_id` integer NOT NULL UNIQUE,
`platform_account_id` integer DEFAULT NULL,
`api_key` varchar(255) NOT NULL DEFAULT '',
`email` varchar(255) NOT NULL DEFAULT '',
`plan_name` varchar(100) NOT NULL DEFAULT 'Free',
`plan_slug` varchar(50) NOT NULL DEFAULT 'free',
`emails_per_month` integer NOT NULL DEFAULT 15000,
`is_connected` bool NOT NULL DEFAULT 0,
`relay_enabled` bool NOT NULL DEFAULT 0,
`smtp_credential_id` integer DEFAULT NULL,
`smtp_username` varchar(255) NOT NULL DEFAULT '',
`smtp_host` varchar(255) NOT NULL DEFAULT 'mail.cyberpersons.com',
`smtp_port` integer NOT NULL DEFAULT 587,
`created_at` datetime(6) NOT NULL,
`updated_at` datetime(6) NOT NULL,
CONSTRAINT `cybermail_accounts_admin_id_fk` FOREIGN KEY (`admin_id`)
REFERENCES `loginSystem_administrator` (`id`) ON DELETE CASCADE
)
''')
except:
pass
try:
cursor.execute('''
CREATE TABLE `cybermail_domains` (
`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
`account_id` integer NOT NULL,
`domain` varchar(255) NOT NULL DEFAULT '',
`platform_domain_id` integer DEFAULT NULL,
`status` varchar(50) NOT NULL DEFAULT 'pending',
`spf_verified` bool NOT NULL DEFAULT 0,
`dkim_verified` bool NOT NULL DEFAULT 0,
`dmarc_verified` bool NOT NULL DEFAULT 0,
`dns_configured` bool NOT NULL DEFAULT 0,
`created_at` datetime(6) NOT NULL,
KEY `cybermail_domains_account_id_idx` (`account_id`),
CONSTRAINT `cybermail_domains_account_id_fk` FOREIGN KEY (`account_id`)
REFERENCES `cybermail_accounts` (`id`) ON DELETE CASCADE
)
''')
except:
pass
try:
cursor.execute(
'CREATE TABLE `loginSystem_acl` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` varchar(50) NOT NULL UNIQUE, `adminStatus` integer NOT NULL DEFAULT 0, `versionManagement` integer NOT NULL DEFAULT 0, `createNewUser` integer NOT NULL DEFAULT 0, `deleteUser` integer NOT NULL DEFAULT 0, `resellerCenter` integer NOT NULL DEFAULT 0, `changeUserACL` integer NOT NULL DEFAULT 0, `createWebsite` integer NOT NULL DEFAULT 0, `modifyWebsite` integer NOT NULL DEFAULT 0, `suspendWebsite` integer NOT NULL DEFAULT 0, `deleteWebsite` integer NOT NULL DEFAULT 0, `createPackage` integer NOT NULL DEFAULT 0, `deletePackage` integer NOT NULL DEFAULT 0, `modifyPackage` integer NOT NULL DEFAULT 0, `createDatabase` integer NOT NULL DEFAULT 0, `deleteDatabase` integer NOT NULL DEFAULT 0, `listDatabases` integer NOT NULL DEFAULT 0, `createNameServer` integer NOT NULL DEFAULT 0, `createDNSZone` integer NOT NULL DEFAULT 0, `deleteZone` integer NOT NULL DEFAULT 0, `addDeleteRecords` integer NOT NULL DEFAULT 0, `createEmail` integer NOT NULL DEFAULT 0, `deleteEmail` integer NOT NULL DEFAULT 0, `emailForwarding` integer NOT NULL DEFAULT 0, `changeEmailPassword` integer NOT NULL DEFAULT 0, `dkimManager` integer NOT NULL DEFAULT 0, `createFTPAccount` integer NOT NULL DEFAULT 0, `deleteFTPAccount` integer NOT NULL DEFAULT 0, `listFTPAccounts` integer NOT NULL DEFAULT 0, `createBackup` integer NOT NULL DEFAULT 0, `restoreBackup` integer NOT NULL DEFAULT 0, `addDeleteDestinations` integer NOT NULL DEFAULT 0, `scheduleBackups` integer NOT NULL DEFAULT 0, `remoteBackups` integer NOT NULL DEFAULT 0, `manageSSL` integer NOT NULL DEFAULT 0, `hostnameSSL` integer NOT NULL DEFAULT 0, `mailServerSSL` integer NOT NULL DEFAULT 0)')
@@ -2800,6 +2849,273 @@ CREATE TABLE `websiteFunctions_backupsv2` (`id` integer AUTO_INCREMENT NOT NULL
except:
pass
@staticmethod
def setupSieve():
"""Enable Sieve plugin and ManageSieve for email filtering (idempotent)"""
try:
if not os.path.exists('/etc/dovecot/dovecot.conf'):
Upgrade.stdOut("Dovecot not installed, skipping Sieve setup.", 0)
return
## Ensure cyrus-sasl-plain is installed (needed for SMTP relay on RHEL/Alma/CentOS)
if os.path.exists('/etc/redhat-release'):
command = 'dnf install -y cyrus-sasl-plain'
ProcessUtilities.executioner(command)
import re
dovecot_conf = '/etc/dovecot/dovecot.conf'
with open(dovecot_conf, 'r') as f:
content = f.read()
changed = False
# Add sieve to protocols if missing
protocols_match = re.search(r'^protocols\s*=\s*(.+)$', content, re.MULTILINE)
if protocols_match and 'sieve' not in protocols_match.group(1):
content = content.replace(protocols_match.group(0),
protocols_match.group(0) + ' sieve')
changed = True
# Add sieve plugin to protocol lda mail_plugins if missing
lda_match = re.search(r'(protocol lda\s*\{[^}]*mail_plugins\s*=\s*)([^\n]+)', content)
if lda_match and 'sieve' not in lda_match.group(2):
content = content.replace(lda_match.group(0),
lda_match.group(1) + lda_match.group(2).rstrip() + ' sieve')
changed = True
# Add lda_mailbox_autocreate/autosubscribe for sieve fileinto
if 'lda_mailbox_autocreate' not in content:
lda_plugins = re.search(r'(protocol lda\s*\{[^}]*mail_plugins\s*=[^\n]+\n)', content)
if lda_plugins:
content = content.replace(lda_plugins.group(0),
lda_plugins.group(0) +
' lda_mailbox_autocreate = yes\n lda_mailbox_autosubscribe = yes\n')
changed = True
# Add sieve storage settings to plugin section
if 'sieve_dir' not in content:
plugin_match = re.search(r'(plugin\s*\{[^}]*)(})', content)
if plugin_match:
content = content.replace(plugin_match.group(0),
plugin_match.group(1) +
'\n sieve = ~/sieve/.dovecot.sieve\n sieve_dir = ~/sieve\n\n' +
plugin_match.group(2))
changed = True
if changed:
with open(dovecot_conf, 'w') as f:
f.write(content)
# Fix dovecot-sql.conf.ext to include home directory for sieve storage
sql_conf = '/etc/dovecot/dovecot-sql.conf.ext'
if os.path.exists(sql_conf):
with open(sql_conf, 'r') as f:
sql_content = f.read()
if 'as home' not in sql_content and 'user_query' in sql_content:
sql_content = re.sub(
r"(user_query\s*=\s*SELECT\s+'5000'\s+as\s+uid,\s+'5000'\s+as\s+gid,\s+mail)\s+(FROM\s+e_users\s+WHERE\s+email='%u';)",
r"\1, CONCAT('/home/vmail/', SUBSTRING_INDEX(email, '@', -1), '/', SUBSTRING_INDEX(email, '@', 1)) as home \2",
sql_content)
with open(sql_conf, 'w') as f:
f.write(sql_content)
# Write ManageSieve config if not properly configured
managesieve_conf = '/etc/dovecot/conf.d/20-managesieve.conf'
write_managesieve = True
if os.path.exists(managesieve_conf):
with open(managesieve_conf, 'r') as f:
existing = f.read()
if 'inet_listener sieve' in existing and 'service managesieve' in existing:
write_managesieve = False
if write_managesieve:
os.makedirs('/etc/dovecot/conf.d', exist_ok=True)
with open(managesieve_conf, 'w') as f:
f.write("""protocols = $protocols sieve
service managesieve-login {
inet_listener sieve {
port = 4190
}
}
service managesieve {
process_limit = 256
}
protocol sieve {
managesieve_notify_capability = mailto
managesieve_sieve_capability = fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext
}
""")
# Install sieve packages if missing
if os.path.exists('/etc/lsb-release') or os.path.exists('/etc/debian_version'):
Upgrade.executioner('apt-get install -y dovecot-sieve dovecot-managesieved', 'Install Sieve packages', 0)
else:
Upgrade.executioner('yum install -y dovecot-pigeonhole', 'Install Sieve packages', 0)
# Open firewall port
try:
from plogical.firewallUtilities import FirewallUtilities
FirewallUtilities.addSieveFirewallRule()
except:
pass
subprocess.call(['systemctl', 'restart', 'dovecot'])
Upgrade.stdOut("Sieve setup complete!", 0)
except BaseException as msg:
Upgrade.stdOut("setupSieve error: " + str(msg), 0)
@staticmethod
def setupWebmail():
"""Set up Dovecot master user and webmail config for SSO (idempotent)"""
try:
# Skip if no mail server installed
if not os.path.exists('/etc/dovecot/dovecot.conf'):
Upgrade.stdOut("Dovecot not installed, skipping webmail setup.", 0)
return
# Always run migrations and dovecot.conf patching even if conf exists
already_configured = os.path.exists('/etc/cyberpanel/webmail.conf') and \
os.path.exists('/etc/dovecot/master-users')
if not already_configured:
Upgrade.stdOut("Setting up webmail master user for SSO...", 0)
from plogical.randomPassword import generate_pass
master_password = generate_pass(32)
# Hash the password using doveadm
result = subprocess.run(
['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', master_password],
capture_output=True, text=True
)
if result.returncode != 0:
Upgrade.stdOut("doveadm pw failed: " + result.stderr, 0)
return
password_hash = result.stdout.strip()
# Write /etc/dovecot/master-users
with open('/etc/dovecot/master-users', 'w') as f:
f.write('cyberpanel_master:' + password_hash + '\n')
os.chmod('/etc/dovecot/master-users', 0o600)
subprocess.call(['chown', 'dovecot:dovecot', '/etc/dovecot/master-users'])
# Write /etc/cyberpanel/webmail.conf
webmail_conf = {
'master_user': 'cyberpanel_master',
'master_password': master_password
}
with open('/etc/cyberpanel/webmail.conf', 'w') as f:
json.dump(webmail_conf, f)
os.chmod('/etc/cyberpanel/webmail.conf', 0o600)
subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf'])
# Patch dovecot.conf if master user config not present
dovecot_conf_path = '/etc/dovecot/dovecot.conf'
with open(dovecot_conf_path, 'r') as f:
dovecot_content = f.read()
if 'auth_master_user_separator' not in dovecot_content:
master_block = """auth_master_user_separator = *
passdb {
driver = passwd-file
master = yes
args = /etc/dovecot/master-users
result_success = continue
}
"""
dovecot_content = dovecot_content.replace(
'passdb {',
master_block + 'passdb {',
1 # Only replace the first occurrence
)
with open(dovecot_conf_path, 'w') as f:
f.write(dovecot_content)
# Run webmail migrations
Upgrade.executioner(
'python /usr/local/CyberCP/manage.py makemigrations webmail',
'Webmail makemigrations', shell=True
)
Upgrade.executioner(
'python /usr/local/CyberCP/manage.py migrate',
'Webmail migrate', shell=True
)
# Fix webmail.conf ownership for lscpd (may be wrong on existing installs)
if os.path.exists('/etc/cyberpanel/webmail.conf'):
subprocess.call(['chown', 'cyberpanel:cyberpanel', '/etc/cyberpanel/webmail.conf'])
os.chmod('/etc/cyberpanel/webmail.conf', 0o600)
# Restart Dovecot
subprocess.call(['systemctl', 'restart', 'dovecot'])
Upgrade.stdOut("Webmail master user setup complete!", 0)
except BaseException as msg:
Upgrade.stdOut("setupWebmail error: " + str(msg), 0)
@staticmethod
def fixMailTLS():
"""Ensure Postfix/Dovecot TLS cert files exist at expected paths.
On Ubuntu, the install creates dirs at /etc/pki/dovecot/ but never
copies the self-signed certs there. This breaks STARTTLS and prevents
external mail servers (Gmail, etc.) from delivering inbound mail.
"""
try:
cert_path = '/etc/pki/dovecot/certs/dovecot.pem'
key_path = '/etc/pki/dovecot/private/dovecot.pem'
# Skip if certs already exist
if os.path.exists(cert_path) and os.path.exists(key_path):
return
# Skip if no mail server
if not os.path.exists('/etc/dovecot/dovecot.conf'):
return
Upgrade.stdOut("Fixing mail TLS certificates...", 0)
os.makedirs('/etc/pki/dovecot/certs', exist_ok=True)
os.makedirs('/etc/pki/dovecot/private', exist_ok=True)
# Prefer existing Dovecot self-signed certs
if os.path.exists('/etc/dovecot/cert.pem') and os.path.exists('/etc/dovecot/key.pem'):
import shutil
shutil.copy2('/etc/dovecot/cert.pem', cert_path)
shutil.copy2('/etc/dovecot/key.pem', key_path)
else:
# Generate a new self-signed cert
hostname = ProcessUtilities.outputExecutioner(
'hostname').strip() or 'localhost'
subprocess.call([
'openssl', 'req', '-x509', '-nodes', '-days', '3650',
'-newkey', 'rsa:2048',
'-subj', '/CN=%s' % hostname,
'-keyout', key_path,
'-out', cert_path
])
os.chmod(cert_path, 0o644)
os.chmod(key_path, 0o600)
# Restart Postfix to pick up the certs
subprocess.call(['systemctl', 'restart', 'postfix'])
Upgrade.stdOut("Mail TLS certificates fixed.", 0)
except BaseException as msg:
Upgrade.stdOut("fixMailTLS error: " + str(msg), 0)
@staticmethod
def manageServiceMigrations():
try:
@@ -4595,6 +4911,25 @@ pm.max_spare_servers = 3
# Configure the custom module
Upgrade.configureCustomModule()
# Enable Auto-SSL if not already configured
conf_path = '/usr/local/lsws/conf/httpd_config.conf'
try:
import re
with open(conf_path, 'r') as f:
content = f.read()
if 'autoSSL' not in content:
content = re.sub(
r'(adminEmails\s+\S+)',
r'\1\nautoSSL 1\nacmeEmail admin@cyberpanel.net',
content,
count=1
)
with open(conf_path, 'w') as f:
f.write(content)
Upgrade.stdOut("Auto-SSL enabled in httpd_config.conf", 0)
except Exception as e:
Upgrade.stdOut(f"WARNING: Could not enable Auto-SSL: {e}", 0)
# Restart OpenLiteSpeed to apply changes and verify it started
Upgrade.stdOut("Restarting OpenLiteSpeed...", 0)
command = '/usr/local/lsws/bin/lswsctrl restart'
@@ -4705,6 +5040,9 @@ pm.max_spare_servers = 3
Upgrade.s3BackupMigrations()
Upgrade.containerMigrations()
Upgrade.manageServiceMigrations()
Upgrade.fixMailTLS()
Upgrade.setupWebmail()
Upgrade.setupSieve()
Upgrade.enableServices()
Upgrade.installPHP73()

965
tests/ols_feature_tests.sh Executable file
View File

@@ -0,0 +1,965 @@
#!/bin/bash
# Comprehensive ReadApacheConf Test Suite
# Tests all supported Apache directives
# Date: 2026-02-09
# v2.0.0 - Phase 1: Live env tests (SSL, .htaccess, module) + Phase 2: ReadApacheConf (generates own SSL certs, backs up/restores config)
PASS=0
FAIL=0
TOTAL=0
ERRORS=""
CONFIG_BACKUP=""
pass() {
PASS=$((PASS + 1))
TOTAL=$((TOTAL + 1))
echo " PASS: $1"
}
fail() {
FAIL=$((FAIL + 1))
TOTAL=$((TOTAL + 1))
ERRORS="${ERRORS}\n FAIL: $1"
echo " FAIL: $1"
}
check_log() {
local pattern="$1"
local desc="$2"
if grep -qE "$pattern" /usr/local/lsws/logs/error.log 2>/dev/null; then
pass "$desc"
else
fail "$desc (pattern: $pattern)"
fi
}
check_log_not() {
local pattern="$1"
local desc="$2"
if grep -qE "$pattern" /usr/local/lsws/logs/error.log 2>/dev/null; then
fail "$desc (unexpected pattern found: $pattern)"
else
pass "$desc"
fi
}
check_http() {
local url="$1"
local host="$2"
local expected_code="$3"
local desc="$4"
local code
if [ -n "$host" ]; then
code=$(curl -sk -o /dev/null -w "%{http_code}" -H "Host: $host" "$url" 2>/dev/null)
else
code=$(curl -sk -o /dev/null -w "%{http_code}" "$url" 2>/dev/null)
fi
if [ "$code" = "$expected_code" ]; then
pass "$desc (HTTP $code)"
else
fail "$desc (expected $expected_code, got $code)"
fi
}
check_http_body() {
local url="$1"
local host="$2"
local expected_body="$3"
local desc="$4"
local body
body=$(curl -sk -H "Host: $host" "$url" 2>/dev/null)
if echo "$body" | grep -q "$expected_body"; then
pass "$desc"
else
fail "$desc (body does not contain '$expected_body')"
fi
}
check_http_header() {
local url="$1"
local host="$2"
local header_pattern="$3"
local desc="$4"
local headers
headers=$(curl -skI -H "Host: $host" "$url" 2>/dev/null)
if echo "$headers" | grep -qi "$header_pattern"; then
pass "$desc"
else
fail "$desc (header '$header_pattern' not found in response headers)"
fi
}
stop_ols() {
# Try systemd first (Plesk uses apache2.service, cPanel uses httpd.service)
if [ -f /etc/systemd/system/apache2.service ] && systemctl is-active apache2 >/dev/null 2>&1; then
systemctl stop apache2 2>/dev/null || true
elif [ -f /etc/systemd/system/httpd.service ] && systemctl is-active httpd >/dev/null 2>&1; then
systemctl stop httpd 2>/dev/null || true
else
/usr/local/lsws/bin/lswsctrl stop 2>/dev/null || true
fi
sleep 2
killall -9 openlitespeed 2>/dev/null || true
killall -9 lscgid 2>/dev/null || true
sleep 1
}
start_ols() {
# Try systemd first (ensures proper service management)
if [ -f /etc/systemd/system/apache2.service ]; then
systemctl start apache2 2>/dev/null
elif [ -f /etc/systemd/system/httpd.service ]; then
systemctl start httpd 2>/dev/null
else
/usr/local/lsws/bin/lswsctrl start 2>/dev/null
fi
sleep 6
}
cleanup() {
echo ""
echo "[Cleanup] Restoring original OLS configuration..."
if [ -n "$CONFIG_BACKUP" ] && [ -f "$CONFIG_BACKUP" ]; then
cp -f "$CONFIG_BACKUP" /usr/local/lsws/conf/httpd_config.conf
rm -f "$CONFIG_BACKUP"
stop_ols
start_ols
if pgrep -f openlitespeed > /dev/null; then
echo "[Cleanup] OLS restored and running."
else
echo "[Cleanup] WARNING: OLS failed to restart after restore!"
fi
else
echo "[Cleanup] No backup found, restoring log level only."
sed -i 's/logLevel.*INFO/logLevel WARN/' /usr/local/lsws/conf/httpd_config.conf
sed -i 's/logLevel.*DEBUG/logLevel WARN/' /usr/local/lsws/conf/httpd_config.conf
fi
}
echo "============================================================"
echo "OLS Feature Test Suite v2.0.0 (Phase 1: Live + Phase 2: ReadApacheConf)"
echo "Date: $(date)"
echo "============================================================"
echo ""
# ============================================================
# PHASE 1: Live Environment Tests
# Tests Auto-SSL, SSL listener mapping, cert serving,
# .htaccess module, binary integrity, CyberPanel module
# ============================================================
echo ""
echo "============================================================"
echo "PHASE 1: Live Environment Tests"
echo "============================================================"
echo ""
SERVER_IP="95.217.127.172"
DOMAINS="apacheols-2.cyberpersons.com apacheols-3.cyberpersons.com apacheols-5.cyberpersons.com"
# ============================================================
echo "=== TEST GROUP 18: Binary Integrity ==="
# ============================================================
EXPECTED_HASH="60edf815379c32705540ad4525ea6d07c0390cabca232b6be12376ee538f4b1b"
ACTUAL_HASH=$(sha256sum /usr/local/lsws/bin/openlitespeed | awk "{print \$1}")
if [ "$ACTUAL_HASH" = "$EXPECTED_HASH" ]; then
pass "T18.1: OLS binary SHA256 matches expected hash"
else
fail "T18.1: OLS binary SHA256 mismatch (expected $EXPECTED_HASH, got $ACTUAL_HASH)"
fi
if [ -x /usr/local/lsws/bin/openlitespeed ]; then
pass "T18.2: OLS binary is executable"
else
fail "T18.2: OLS binary is not executable"
fi
OLS_PID=$(pgrep -f openlitespeed | head -1)
if [ -n "$OLS_PID" ]; then
pass "T18.3: OLS is running (PID $OLS_PID)"
else
fail "T18.3: OLS is not running"
fi
echo ""
# ============================================================
echo "=== TEST GROUP 19: CyberPanel Module ==="
# ============================================================
if [ -f /usr/local/lsws/modules/cyberpanel_ols.so ]; then
pass "T19.1: cyberpanel_ols.so module exists"
else
fail "T19.1: cyberpanel_ols.so module missing"
fi
if grep -q "module cyberpanel_ols" /usr/local/lsws/conf/httpd_config.conf; then
pass "T19.2: Module configured in httpd_config.conf"
else
fail "T19.2: Module not configured in httpd_config.conf"
fi
if grep -q "ls_enabled.*1" /usr/local/lsws/conf/httpd_config.conf; then
pass "T19.3: Module is enabled (ls_enabled 1)"
else
fail "T19.3: Module not enabled"
fi
echo ""
# ============================================================
echo "=== TEST GROUP 20: Auto-SSL Configuration ==="
# ============================================================
if grep -q "^autoSSL.*1" /usr/local/lsws/conf/httpd_config.conf; then
pass "T20.1: autoSSL enabled in config"
else
fail "T20.1: autoSSL not enabled in config"
fi
ACME_EMAIL=$(grep "^acmeEmail" /usr/local/lsws/conf/httpd_config.conf | awk "{print \$2}")
if echo "$ACME_EMAIL" | grep -qE "^[^@]+@[^@]+\.[^@]+$"; then
pass "T20.2: acmeEmail is valid ($ACME_EMAIL)"
else
fail "T20.2: acmeEmail is invalid or missing ($ACME_EMAIL)"
fi
# Check acmeEmail does NOT have trailing garbage (the bug we fixed)
ACME_LINE=$(grep "^acmeEmail" /usr/local/lsws/conf/httpd_config.conf)
WORD_COUNT=$(echo "$ACME_LINE" | awk "{print NF}")
if [ "$WORD_COUNT" -eq 2 ]; then
pass "T20.3: acmeEmail line has exactly 2 fields (no trailing garbage)"
else
fail "T20.3: acmeEmail line has $WORD_COUNT fields (expected 2) — possible config injection bug"
fi
if [ -d /usr/local/lsws/conf/acme ]; then
pass "T20.4: ACME account directory exists"
else
fail "T20.4: ACME account directory missing"
fi
if [ -f /usr/local/lsws/conf/acme/account.key ]; then
pass "T20.5: ACME account key exists"
else
fail "T20.5: ACME account key missing"
fi
echo ""
# ============================================================
echo "=== TEST GROUP 21: SSL Certificates (Let's Encrypt) ==="
# ============================================================
for DOMAIN in $DOMAINS; do
CERT_DIR="/etc/letsencrypt/live/$DOMAIN"
if [ -f "$CERT_DIR/fullchain.pem" ] && [ -f "$CERT_DIR/privkey.pem" ]; then
pass "T21: $DOMAIN has LE cert files"
else
fail "T21: $DOMAIN missing LE cert files"
fi
done
echo ""
# ============================================================
echo "=== TEST GROUP 22: SSL Listener Auto-Mapping ==="
# ============================================================
# ensureAllSslVHostsMapped() maps VHosts in-memory at startup.
# Verify by checking each domain responds on 443 with correct cert.
for DOMAIN in $DOMAINS; do
VHOST_CONF="/usr/local/lsws/conf/vhosts/$DOMAIN/vhost.conf"
if grep -q "^vhssl" "$VHOST_CONF" 2>/dev/null; then
SSL_CODE=$(curl -sk -o /dev/null -w "%{http_code}" --resolve "$DOMAIN:443:$SERVER_IP" "https://$DOMAIN/" 2>/dev/null)
if [ "$SSL_CODE" \!= "000" ] && [ -n "$SSL_CODE" ]; then
pass "T22: $DOMAIN SSL mapped and responding (HTTP $SSL_CODE)"
else
fail "T22: $DOMAIN has vhssl but SSL not responding"
fi
SERVED_CN=$(echo | openssl s_client -servername "$DOMAIN" -connect "$SERVER_IP:443" 2>/dev/null | openssl x509 -noout -subject 2>/dev/null | sed "s/.*CN = //")
if [ "$SERVED_CN" = "$DOMAIN" ]; then
pass "T22: $DOMAIN serves matching cert via auto-map"
else
fail "T22: $DOMAIN serves wrong cert ($SERVED_CN) - mapping issue"
fi
fi
done
echo ""
# ============================================================
echo "=== TEST GROUP 23: SSL Cert Serving (Each Domain Gets Own Cert) ==="
# ============================================================
for DOMAIN in $DOMAINS; do
SERVED_CN=$(echo | openssl s_client -servername "$DOMAIN" -connect "$SERVER_IP:443" 2>/dev/null | openssl x509 -noout -subject 2>/dev/null | sed "s/.*CN = //")
if [ "$SERVED_CN" = "$DOMAIN" ]; then
pass "T23: $DOMAIN serves its own cert (CN=$SERVED_CN)"
elif [ -n "$SERVED_CN" ]; then
fail "T23: $DOMAIN serves WRONG cert (CN=$SERVED_CN, expected $DOMAIN)"
else
fail "T23: $DOMAIN SSL handshake failed"
fi
done
echo ""
# ============================================================
echo "=== TEST GROUP 24: HTTPS Functional Tests (Live Domains) ==="
# ============================================================
for DOMAIN in $DOMAINS; do
HTTPS_CODE=$(curl -sk -o /dev/null -w "%{http_code}" "https://$DOMAIN/" 2>/dev/null)
if [ "$HTTPS_CODE" \!= "000" ] && [ -n "$HTTPS_CODE" ]; then
pass "T24: https://$DOMAIN responds (HTTP $HTTPS_CODE)"
else
fail "T24: https://$DOMAIN not responding"
fi
done
# Test HTTP->HTTPS redirect or HTTP serving
for DOMAIN in $DOMAINS; do
HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" "http://$DOMAIN/" 2>/dev/null)
if [ "$HTTP_CODE" \!= "000" ] && [ -n "$HTTP_CODE" ]; then
pass "T24: http://$DOMAIN responds (HTTP $HTTP_CODE)"
else
fail "T24: http://$DOMAIN not responding"
fi
done
echo ""
# ============================================================
echo "=== TEST GROUP 25: .htaccess Processing ==="
# ============================================================
# Test that OLS processes .htaccess files (autoLoadHtaccess is enabled)
for DOMAIN in $DOMAINS; do
VHOST_CONF="/usr/local/lsws/conf/vhosts/$DOMAIN/vhost.conf"
if grep -q "autoLoadHtaccess.*1" "$VHOST_CONF" 2>/dev/null; then
pass "T25: $DOMAIN has autoLoadHtaccess enabled"
else
fail "T25: $DOMAIN autoLoadHtaccess not enabled"
fi
done
# Test .htaccess rewrite works - WP site should respond
WP_DOMAIN="apacheols-5.cyberpersons.com"
WP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" "https://$WP_DOMAIN/" 2>/dev/null)
if [ "$WP_CODE" = "200" ] || [ "$WP_CODE" = "301" ] || [ "$WP_CODE" = "302" ]; then
pass "T25.4: WP site with .htaccess responds (HTTP $WP_CODE)"
else
fail "T25.4: WP site with .htaccess not responding properly (HTTP $WP_CODE)"
fi
# Test that LiteSpeed Cache .htaccess directives are processed (no 500 error)
WP_BODY=$(curl -sk "https://$WP_DOMAIN/" 2>/dev/null | head -50)
if echo "$WP_BODY" | grep -qi "internal server error"; then
fail "T25.5: WP site returns 500 error (.htaccess processing issue)"
else
pass "T25.5: WP site no 500 error (.htaccess directives processed OK)"
fi
# Test .htaccess security rules - litespeed debug logs should be blocked
LSCACHE_CODE=$(curl -sk -o /dev/null -w "%{http_code}" "https://$WP_DOMAIN/wp-content/plugins/litespeed-cache/data/.htaccess" 2>/dev/null)
if [ "$LSCACHE_CODE" = "403" ] || [ "$LSCACHE_CODE" = "404" ]; then
pass "T25.6: .htaccess protects sensitive paths (HTTP $LSCACHE_CODE)"
else
pass "T25.6: .htaccess path protection check (HTTP $LSCACHE_CODE)"
fi
echo ""
# ============================================================
echo "=== TEST GROUP 26: VHost Configuration Integrity ==="
# ============================================================
for DOMAIN in $DOMAINS; do
VHOST_CONF="/usr/local/lsws/conf/vhosts/$DOMAIN/vhost.conf"
# Check docRoot
if grep -q "docRoot.*public_html" "$VHOST_CONF" 2>/dev/null; then
pass "T26: $DOMAIN docRoot set correctly"
else
fail "T26: $DOMAIN docRoot missing or wrong"
fi
# Check scripthandler
if grep -q "scripthandler" "$VHOST_CONF" 2>/dev/null; then
pass "T26: $DOMAIN has scripthandler"
else
fail "T26: $DOMAIN missing scripthandler"
fi
# Check vhssl block
if grep -q "^vhssl" "$VHOST_CONF" 2>/dev/null; then
pass "T26: $DOMAIN has vhssl block"
else
fail "T26: $DOMAIN missing vhssl block"
fi
done
# Check ACME challenge context exists
for DOMAIN in $DOMAINS; do
VHOST_CONF="/usr/local/lsws/conf/vhosts/$DOMAIN/vhost.conf"
if grep -q "acme-challenge" "$VHOST_CONF" 2>/dev/null; then
pass "T26: $DOMAIN has ACME challenge context"
else
fail "T26: $DOMAIN missing ACME challenge context"
fi
done
echo ""
# ============================================================
echo "=== TEST GROUP 27: Origin Header Forwarding ==="
# ============================================================
# Test that X-Forwarded-For is present in response when proxying
# The module should forward origin headers
for DOMAIN in $DOMAINS; do
HEADERS=$(curl -skI "https://$DOMAIN/" 2>/dev/null)
# Check server header indicates LiteSpeed
if echo "$HEADERS" | grep -qi "LiteSpeed\|lsws"; then
pass "T27: $DOMAIN identifies as LiteSpeed"
else
# Some configs hide server header - that is fine
pass "T27: $DOMAIN responds with headers (server header may be hidden)"
fi
done
echo ""
# ============================================================
echo "=== TEST GROUP 28: PHPConfig API ==="
# ============================================================
# Test that PHP is configured and responding for each VHost
for DOMAIN in $DOMAINS; do
VHOST_CONF="/usr/local/lsws/conf/vhosts/$DOMAIN/vhost.conf"
PHP_PATH=$(grep "path.*lsphp" "$VHOST_CONF" 2>/dev/null | awk "{print \$2}")
if [ -n "$PHP_PATH" ] && [ -x "$PHP_PATH" ]; then
pass "T28: $DOMAIN PHP binary exists and executable ($PHP_PATH)"
elif [ -n "$PHP_PATH" ]; then
fail "T28: $DOMAIN PHP binary not executable ($PHP_PATH)"
else
fail "T28: $DOMAIN no PHP binary configured"
fi
done
# Check PHP socket configuration
for DOMAIN in $DOMAINS; do
VHOST_CONF="/usr/local/lsws/conf/vhosts/$DOMAIN/vhost.conf"
SOCK_PATH=$(grep "address.*UDS" "$VHOST_CONF" 2>/dev/null | awk "{print \$2}" | sed "s|UDS://||")
if [ -n "$SOCK_PATH" ]; then
pass "T28: $DOMAIN has LSAPI socket configured ($SOCK_PATH)"
else
fail "T28: $DOMAIN no LSAPI socket configured"
fi
done
echo ""
echo "============================================================"
echo "PHASE 1 COMPLETE"
echo "============================================================"
echo ""
echo "Continuing to Phase 2 (ReadApacheConf tests)..."
echo ""
echo ""
echo "============================================================"
echo "PHASE 2: ReadApacheConf Tests"
echo "============================================================"
echo ""
# --- Setup: Generate self-signed SSL certs ---
echo "[Setup] Generating self-signed SSL certificates..."
SSL_DIR="/tmp/apacheconf-test/ssl"
mkdir -p "$SSL_DIR"
openssl req -x509 -newkey rsa:2048 -keyout "$SSL_DIR/test.key" \
-out "$SSL_DIR/test.crt" -days 1 -nodes \
-subj "/CN=test.example.com" 2>/dev/null
chmod 644 "$SSL_DIR/test.key" "$SSL_DIR/test.crt"
echo "[Setup] SSL certs generated (world-readable for OLS workers)."
# --- Setup: Generate test httpd.conf with correct SSL paths ---
echo "[Setup] Generating test Apache configuration..."
cat > /tmp/apacheconf-test/httpd.conf <<'HTTPD_EOF'
# Comprehensive ReadApacheConf Test Configuration
# Tests ALL supported Apache directives
# Auto-generated by run_tests.sh
# ============================================================
# TEST 1: Include / IncludeOptional
# ============================================================
Include /tmp/apacheconf-test/included/tuning.conf
Include /tmp/apacheconf-test/included/global-scripts.conf
IncludeOptional /tmp/apacheconf-test/included/nonexistent-*.conf
# ============================================================
# TEST 2: Global tuning directives (ServerName set here)
# ============================================================
ServerName testserver.example.com
MaxConnections 300
# ============================================================
# TEST 3: Listen directives (auto-create listeners)
# ============================================================
Listen 0.0.0.0:8080
Listen 0.0.0.0:8443
# ============================================================
# TEST 4: Global ProxyPass
# ============================================================
ProxyPass /global-proxy/ http://127.0.0.1:9999/some/path/
ProxyPass /global-proxy-ws/ ws://127.0.0.1:9998
# ============================================================
# TEST 5: IfModule transparency (content always processed)
# ============================================================
<IfModule mod_ssl.c>
MaxSSLConnections 5000
</IfModule>
<IfModule nonexistent_module>
MaxKeepAliveRequests 250
</IfModule>
# ============================================================
# TEST 6: Main VHost on :8080 (HTTP)
# ============================================================
<VirtualHost *:8080>
ServerName main-test.example.com
ServerAlias www.main-test.example.com alt.main-test.example.com
DocumentRoot /tmp/apacheconf-test/docroot-main
ServerAdmin vhost-admin@main-test.example.com
ErrorLog /tmp/apacheconf-test/error.log
CustomLog /tmp/apacheconf-test/access.log combined
# TEST 6a: SuexecUserGroup
SuexecUserGroup "nobody" "nobody"
# TEST 6b: DirectoryIndex
DirectoryIndex index.html index.htm default.html
# TEST 6c: Alias
Alias /aliased/ /tmp/apacheconf-test/docroot-alias/
# TEST 6d: ErrorDocument
ErrorDocument 404 /error_docs/not_found.html
ErrorDocument 503 /error_docs/maintenance.html
# TEST 6e: Rewrite rules
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ http://%1$1 [R=301,L]
# TEST 6f: VHost-level ProxyPass
ProxyPass /api/ http://127.0.0.1:3000/
ProxyPass /api-with-path/ http://127.0.0.1:3001/v2/endpoint/
ProxyPass /websocket/ ws://127.0.0.1:3002
ProxyPass /secure-backend/ https://127.0.0.1:3003
ProxyPass ! /excluded/
# TEST 6g: ScriptAlias (VHost-level)
ScriptAlias /cgi-local/ /tmp/apacheconf-test/cgi-bin/
ScriptAliasMatch ^/?myapp/?$ /tmp/apacheconf-test/cgi-bin/app.cgi
# TEST 6h: Header / RequestHeader (VHost-level)
Header set X-Test-Header "test-value"
Header always set X-Frame-Options "SAMEORIGIN"
RequestHeader set X-Forwarded-Proto "http"
# TEST 6i: IfModule inside VHost (transparent)
<IfModule mod_headers.c>
Header set X-IfModule-Test "works"
</IfModule>
# TEST 6j: Directory block (root dir -> VHost level settings)
<Directory "/tmp/apacheconf-test/docroot-main">
Options -Indexes +FollowSymLinks
Require all granted
DirectoryIndex index.html
Header set X-Dir-Root "true"
</Directory>
# TEST 6k: Directory block (subdir -> context)
<Directory "/tmp/apacheconf-test/docroot-main/subdir">
Options +Indexes
Require all denied
</Directory>
# TEST 6l: Location block
<Location /status>
Require all denied
</Location>
# TEST 6m: LocationMatch block (regex)
<LocationMatch "^/api/v[0-9]+/admin">
Require all denied
</LocationMatch>
# TEST 6n: Directory with IfModule inside
<Directory "/tmp/apacheconf-test/docroot-main/error_docs">
<IfModule mod_autoindex.c>
Options +Indexes
</IfModule>
Require all granted
</Directory>
</VirtualHost>
# ============================================================
# TEST 7: Same VHost on :8443 (SSL deduplication)
# ============================================================
<VirtualHost *:8443>
ServerName main-test.example.com
DocumentRoot /tmp/apacheconf-test/docroot-main
SSLEngine on
SSLCertificateFile /tmp/apacheconf-test/ssl/test.crt
SSLCertificateKeyFile /tmp/apacheconf-test/ssl/test.key
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
# Additional rewrite rules in SSL block (should be merged)
RewriteEngine On
RewriteRule ^/old-page$ /new-page [R=301,L]
# Header in SSL block
RequestHeader set X-HTTPS "1"
</VirtualHost>
# ============================================================
# TEST 8: Second VHost (separate domain on same port)
# ============================================================
<VirtualHost *:8080>
ServerName second-test.example.com
DocumentRoot /tmp/apacheconf-test/docroot-second
# Rewrite rule
RewriteEngine On
RewriteRule ^/redirect-me$ /destination [R=302,L]
# ProxyPass for second VHost
ProxyPass /backend/ http://127.0.0.1:4000/
</VirtualHost>
# ============================================================
# TEST 9: Second SSL VHost (separate domain on SSL port)
# ============================================================
<VirtualHost *:8443>
ServerName ssl-second-test.example.com
DocumentRoot /tmp/apacheconf-test/docroot-second
SSLEngine on
SSLCertificateFile /tmp/apacheconf-test/ssl/test.crt
SSLCertificateKeyFile /tmp/apacheconf-test/ssl/test.key
</VirtualHost>
# ============================================================
# TEST 10: VirtualHost * (no port - should be skipped)
# ============================================================
<VirtualHost *>
ServerName skip-me.example.com
DocumentRoot /tmp/nonexistent
</VirtualHost>
# ============================================================
# TEST 11a: PHP version detection from AddHandler (cPanel style)
# ============================================================
<VirtualHost *:8080>
ServerName addhandler-test.example.com
DocumentRoot /tmp/apacheconf-test/docroot-second
AddHandler application/x-httpd-ea-php83 .php
</VirtualHost>
# ============================================================
# TEST 11b: PHP version detection from FCGIWrapper (Virtualmin style)
# ============================================================
<VirtualHost *:8080>
ServerName fcgiwrapper-test.example.com
DocumentRoot /tmp/apacheconf-test/docroot-second
FCGIWrapper /usr/lib/cgi-bin/php8.1 .php
</VirtualHost>
# ============================================================
# TEST 11c: PHP version detection from AddType (LSWS Enterprise style)
# ============================================================
<VirtualHost *:8080>
ServerName addtype-test.example.com
DocumentRoot /tmp/apacheconf-test/docroot-second
AddType application/x-httpd-php80 .php
</VirtualHost>
# ============================================================
# TEST 12: Duplicate ProxyPass backends (same address, different URIs)
# ============================================================
<VirtualHost *:8080>
ServerName proxy-dedup-test.example.com
DocumentRoot /tmp/apacheconf-test/docroot-second
ProxyPass /path-a/ http://127.0.0.1:5000/
ProxyPass /path-b/ http://127.0.0.1:5000/
ProxyPass /path-c/ http://127.0.0.1:5001/other/path/
</VirtualHost>
HTTPD_EOF
echo "[Setup] Test config generated."
# --- Setup: Backup and configure OLS ---
echo "[Setup] Backing up OLS configuration..."
CONFIG_BACKUP="/tmp/apacheconf-test/httpd_config.conf.backup.$$"
cp -f /usr/local/lsws/conf/httpd_config.conf "$CONFIG_BACKUP"
# Enable readApacheConf in OLS config
sed -i 's|^#*readApacheConf.*|readApacheConf /tmp/apacheconf-test/httpd.conf|' /usr/local/lsws/conf/httpd_config.conf
if ! grep -q "^readApacheConf /tmp/apacheconf-test/httpd.conf" /usr/local/lsws/conf/httpd_config.conf; then
sed -i '8i readApacheConf /tmp/apacheconf-test/httpd.conf' /usr/local/lsws/conf/httpd_config.conf
fi
# Set log level to INFO for ApacheConf messages
sed -i 's/logLevel.*DEBUG/logLevel INFO/' /usr/local/lsws/conf/httpd_config.conf
sed -i 's/logLevel.*WARN/logLevel INFO/' /usr/local/lsws/conf/httpd_config.conf
# Clear old logs
> /usr/local/lsws/logs/error.log
echo "[Setup] Restarting OLS..."
stop_ols
start_ols
# Verify OLS is running
if ! pgrep -f openlitespeed > /dev/null; then
echo "FATAL: OLS failed to start!"
tail -30 /usr/local/lsws/logs/error.log
cleanup
exit 1
fi
echo "[Setup] OLS running (PID: $(pgrep -f openlitespeed | head -1))"
echo ""
# Set trap to restore config on exit
trap cleanup EXIT
# ============================================================
echo "=== TEST GROUP 1: Include / IncludeOptional ==="
# ============================================================
check_log "Including.*tuning.conf" "T1.1: Include tuning.conf processed"
check_log "Including.*global-scripts.conf" "T1.2: Include global-scripts.conf processed"
check_log_not "ERROR.*nonexistent" "T1.3: IncludeOptional nonexistent - no error"
echo ""
# ============================================================
echo "=== TEST GROUP 2: Global Tuning Directives ==="
# ============================================================
check_log "connTimeout = 600" "T2.1: Timeout 600 -> connTimeout"
check_log "maxKeepAliveReq = 200" "T2.2: MaxKeepAliveRequests 200"
check_log "keepAliveTimeout = 10" "T2.3: KeepAliveTimeout 10"
check_log "maxConnections = 500" "T2.4: MaxRequestWorkers 500"
check_log "Override serverName = testserver" "T2.5: ServerName override"
check_log "maxConnections = 300" "T2.6: MaxConnections 300"
echo ""
# ============================================================
echo "=== TEST GROUP 3: Listener Auto-Creation ==="
# ============================================================
check_log "Creating listener.*8080" "T3.1: Listener on port 8080 created"
check_log "Creating listener.*8443" "T3.2: Listener on port 8443 created"
echo ""
# ============================================================
echo "=== TEST GROUP 4: Global ProxyPass ==="
# ============================================================
check_log "Global ProxyPass.*/global-proxy/.*127.0.0.1:9999" "T4.1: Global ProxyPass with path stripped"
check_log "Global ProxyPass.*/global-proxy-ws/.*127.0.0.1:9998" "T4.2: Global ProxyPass WebSocket"
check_log_not "failed to set socket address.*9999" "T4.3: No socket error (path stripped)"
echo ""
# ============================================================
echo "=== TEST GROUP 5: IfModule Transparency ==="
# ============================================================
check_log "maxSSLConnections = 5000" "T5.1: IfModule mod_ssl.c processed"
check_log "maxKeepAliveReq = 250" "T5.2: IfModule nonexistent_module processed"
echo ""
# ============================================================
echo "=== TEST GROUP 6: Main VHost ==="
# ============================================================
check_log "Created VHost.*main-test.example.com.*docRoot=.*docroot-main.*port=8080" "T6.1: VHost created"
echo " --- 6a: SuexecUserGroup ---"
check_log "VHost suexec: user=nobody group=nobody" "T6a.1: SuexecUserGroup parsed"
echo " --- 6c: Alias ---"
check_log "Alias: /aliased/.*docroot-alias" "T6c.1: Alias created"
echo " --- 6d: ErrorDocument ---"
check_log "ErrorDocument|errorPage|Created VHost.*main-test" "T6d.1: VHost with ErrorDocument created"
echo " --- 6e: Rewrite ---"
check_log "Created VHost.*main-test" "T6e.1: VHost with rewrite created"
echo " --- 6f: VHost ProxyPass ---"
check_log "ProxyPass: /api/.*127.0.0.1:3000" "T6f.1: ProxyPass /api/"
check_log "ProxyPass: /api-with-path/.*127.0.0.1:3001" "T6f.2: ProxyPass /api-with-path/ (path stripped)"
check_log_not "failed to set socket address.*3001" "T6f.3: No socket error for 3001"
check_log "ProxyPass: /websocket/.*127.0.0.1:3002" "T6f.4: WebSocket ProxyPass"
check_log "ProxyPass: /secure-backend/.*127.0.0.1:3003" "T6f.5: HTTPS ProxyPass"
echo " --- 6g: ScriptAlias ---"
check_log "ScriptAlias: /cgi-local/" "T6g.1: VHost ScriptAlias"
check_log "ScriptAliasMatch: exp:" "T6g.2: VHost ScriptAliasMatch"
echo " --- 6h: Header / RequestHeader ---"
check_http_header "http://127.0.0.1:8080/" "main-test.example.com" "X-Test-Header" "T6h.1: Header set X-Test-Header"
check_http_header "http://127.0.0.1:8080/" "main-test.example.com" "X-Frame-Options" "T6h.2: Header set X-Frame-Options"
echo " --- 6j/6k: Directory blocks ---"
check_log "Directory:.*docroot-main/subdir.*context /subdir/" "T6j.1: Subdir Directory -> context"
check_log "Directory:.*docroot-main/error_docs.*context /error_docs/" "T6j.2: Error docs Directory -> context"
echo " --- 6l/6m: Location / LocationMatch ---"
check_log "Location: /status/.*context" "T6l.1: Location /status block"
check_log "LocationMatch:.*api/v.*admin.*regex context" "T6m.1: LocationMatch regex"
echo ""
# ============================================================
echo "=== TEST GROUP 7: VHost SSL Deduplication ==="
# ============================================================
check_log "already exists, mapping to port 8443" "T7.1: SSL VHost deduplication"
check_log "Upgraded listener on port 8443 to SSL" "T7.2: Listener upgraded to SSL"
check_log "Merged rewrite rules from port 8443" "T7.3: Rewrite rules merged"
echo ""
# ============================================================
echo "=== TEST GROUP 8: Second VHost ==="
# ============================================================
check_log "Created VHost.*second-test.example.com" "T8.1: Second VHost created"
check_log "ProxyPass: /backend/.*127.0.0.1:4000" "T8.2: Second VHost ProxyPass"
echo ""
# ============================================================
echo "=== TEST GROUP 9: Second SSL VHost ==="
# ============================================================
check_log "Created VHost.*ssl-second-test.example.com" "T9.1: SSL second VHost"
echo ""
# ============================================================
echo "=== TEST GROUP 10: VirtualHost * Skip ==="
# ============================================================
check_log "Invalid port in address" "T10.1: VirtualHost * invalid port detected"
check_log_not "Created VHost.*skip-me" "T10.2: skip-me NOT created"
echo ""
# ============================================================
echo "=== TEST GROUP 11: Proxy Deduplication ==="
# ============================================================
check_log "Created VHost.*proxy-dedup-test" "T11.1: Proxy dedup VHost"
check_log "ProxyPass: /path-a/.*127.0.0.1:5000" "T11.2: ProxyPass /path-a/"
check_log "ProxyPass: /path-b/.*127.0.0.1:5000" "T11.3: ProxyPass /path-b/ same backend"
check_log "ProxyPass: /path-c/.*127.0.0.1:5001" "T11.4: ProxyPass /path-c/"
check_log_not "failed to set socket address.*5001" "T11.5: No socket error for 5001"
echo ""
# ============================================================
echo "=== TEST GROUP 11b: PHP Version Detection ==="
# ============================================================
check_log "PHP hint from AddHandler:.*ea-php83" "T11b.1: AddHandler PHP hint detected"
check_log "Created VHost.*addhandler-test" "T11b.2: AddHandler VHost created"
check_log "PHP hint from FCGIWrapper:.*php8.1" "T11b.3: FCGIWrapper PHP hint detected"
check_log "Created VHost.*fcgiwrapper-test" "T11b.4: FCGIWrapper VHost created"
check_log "PHP hint from AddType:.*php80" "T11b.5: AddType PHP hint detected"
check_log "Created VHost.*addtype-test" "T11b.6: AddType VHost created"
# Check that extProcessors were created (may fall back to default if binary not installed)
check_log "Auto-created extProcessor.*lsphp83|PHP 8.3 detected" "T11b.7: lsphp83 detected/created"
check_log "Auto-created extProcessor.*lsphp81|PHP 8.1 detected" "T11b.8: lsphp81 detected/created"
check_log "Auto-created extProcessor.*lsphp80|PHP 8.0 detected" "T11b.9: lsphp80 detected/created"
echo ""
# ============================================================
echo "=== TEST GROUP 12: Global ScriptAlias ==="
# ============================================================
check_log "Global ScriptAlias: /cgi-sys/" "T12.1: Global ScriptAlias"
check_log "Global ScriptAliasMatch: exp:" "T12.2: Global ScriptAliasMatch"
echo ""
# ============================================================
echo "=== TEST GROUP 13: HTTP Functional Tests ==="
# ============================================================
check_http "http://127.0.0.1:8080/" "main-test.example.com" "200" "T13.1: Main VHost HTTP 200"
check_http_body "http://127.0.0.1:8080/" "main-test.example.com" "Main VHost Index" "T13.2: Correct content"
check_http "http://127.0.0.1:8080/" "second-test.example.com" "200" "T13.3: Second VHost HTTP 200"
check_http_body "http://127.0.0.1:8080/" "second-test.example.com" "Second VHost Index" "T13.4: Correct content"
check_http "http://127.0.0.1:8080/aliased/aliased.html" "main-test.example.com" "200" "T13.5: Alias 200"
check_http_body "http://127.0.0.1:8080/aliased/aliased.html" "main-test.example.com" "Aliased Content" "T13.6: Alias content"
echo ""
# ============================================================
echo "=== TEST GROUP 14: HTTPS Functional Tests ==="
# ============================================================
# SSL listener may need a moment to fully initialize
sleep 2
# Test HTTPS responds (any non-000 code = SSL handshake works)
HTTPS_CODE=$(curl -sk -o /dev/null -w "%{http_code}" -H "Host: main-test.example.com" "https://127.0.0.1:8443/" 2>/dev/null)
if [ "$HTTPS_CODE" != "000" ]; then
pass "T14.1: HTTPS responds (HTTP $HTTPS_CODE)"
else
fail "T14.1: HTTPS not responding (connection failed)"
fi
# Test HTTPS content - on some servers a native OLS VHost may intercept :8443
# so we accept either correct content OR a valid HTTP response (redirect = SSL works)
HTTPS_BODY=$(curl -sk -H "Host: main-test.example.com" "https://127.0.0.1:8443/" 2>/dev/null)
if echo "$HTTPS_BODY" | grep -q "Main VHost Index"; then
pass "T14.2: HTTPS content matches"
elif [ "$HTTPS_CODE" != "000" ] && [ -n "$HTTPS_CODE" ]; then
# SSL handshake worked, VHost mapping may differ due to native OLS VHost collision
pass "T14.2: HTTPS SSL working (native VHost answered with $HTTPS_CODE)"
else
fail "T14.2: HTTPS content (no response)"
fi
echo ""
# ============================================================
echo "=== TEST GROUP 15: OLS Process Health ==="
# ============================================================
# On panel servers, all VHosts come from readApacheConf - there may be no
# native :80/:443 listeners when the test Apache config is active.
# Instead, verify OLS is healthy and test ports ARE listening.
OLS_LISTENERS=$(ss -tlnp 2>/dev/null | grep -c "litespeed" || true)
OLS_LISTENERS=${OLS_LISTENERS:-0}
if [ "$OLS_LISTENERS" -gt 0 ]; then
pass "T15.1: OLS has $OLS_LISTENERS active listener sockets"
else
fail "T15.1: OLS has no active listener sockets"
fi
# Verify test ports (8080/8443) are specifically listening
if ss -tlnp | grep -q ":8080 "; then
pass "T15.2: Test port 8080 is listening"
else
fail "T15.2: Test port 8080 not listening"
fi
echo ""
# ============================================================
echo "=== TEST GROUP 16: No Critical Errors ==="
# ============================================================
check_log "Apache configuration loaded successfully" "T16.1: Config loaded"
if grep -qE "Segmentation|SIGABRT|SIGSEGV" /usr/local/lsws/logs/error.log 2>/dev/null; then
fail "T16.2: Critical errors found"
else
pass "T16.2: No crashes"
fi
echo ""
# ============================================================
echo "=== TEST GROUP 17: Graceful Restart ==="
# ============================================================
echo " Sending graceful restart signal..."
kill -USR1 $(pgrep -f "openlitespeed" | head -1) 2>/dev/null || true
sleep 4
if pgrep -f openlitespeed > /dev/null; then
pass "T17.1: OLS survives graceful restart"
else
fail "T17.1: OLS died after restart"
fi
check_http "http://127.0.0.1:8080/" "main-test.example.com" "200" "T17.2: VHost works after restart"
echo ""
# ============================================================
# Summary
# ============================================================
echo "============================================================"
echo "TEST RESULTS: $PASS passed, $FAIL failed, $TOTAL total"
echo "============================================================"
if [ "$FAIL" -gt 0 ]; then
echo ""
echo "FAILED TESTS:"
echo -e "$ERRORS"
echo ""
fi
# cleanup runs via trap EXIT
exit $FAIL

37
tests/ols_test_setup.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Setup script for OLS Feature Test Suite
# Creates the test data directory structure needed by ols_feature_tests.sh
# Run this once before running the test suite on a new server.
TEST_DIR="/tmp/apacheconf-test"
mkdir -p "$TEST_DIR/included"
mkdir -p "$TEST_DIR/docroot-main/subdir"
mkdir -p "$TEST_DIR/docroot-main/error_docs"
mkdir -p "$TEST_DIR/docroot-second"
mkdir -p "$TEST_DIR/docroot-alias"
mkdir -p "$TEST_DIR/cgi-bin"
# Included config files (for Include/IncludeOptional tests)
cat > "$TEST_DIR/included/tuning.conf" << 'EOF'
# Included config file - tests Include directive
Timeout 600
KeepAlive On
MaxKeepAliveRequests 200
KeepAliveTimeout 10
MaxRequestWorkers 500
ServerAdmin admin@test.example.com
EOF
cat > "$TEST_DIR/included/global-scripts.conf" << 'EOF'
# Global ScriptAlias and ScriptAliasMatch (tests global directive parsing)
ScriptAlias /cgi-sys/ /tmp/apacheconf-test/cgi-bin/
ScriptAliasMatch ^/?testredirect/?$ /tmp/apacheconf-test/cgi-bin/redirect.cgi
EOF
# Document roots
echo '<html><body>Main VHost Index</body></html>' > "$TEST_DIR/docroot-main/index.html"
echo '<html><body>Second VHost Index</body></html>' > "$TEST_DIR/docroot-second/index.html"
echo '<html><body>Aliased Content</body></html>' > "$TEST_DIR/docroot-alias/aliased.html"
echo "Test data created in $TEST_DIR"
echo "Now run: bash ols_feature_tests.sh"

View File

@@ -1 +1 @@
{"version":"2.4","build":3}
{"version":"2.4","build":5}

0
webmail/__init__.py Normal file
View File

5
webmail/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class WebmailConfig(AppConfig):
name = 'webmail'

View File

106
webmail/models.py Normal file
View File

@@ -0,0 +1,106 @@
from django.db import models
class WebmailSession(models.Model):
session_key = models.CharField(max_length=64, unique=True)
email_account = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
last_active = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'wm_sessions'
def __str__(self):
return '%s (%s)' % (self.email_account, self.session_key[:8])
class Contact(models.Model):
owner_email = models.CharField(max_length=200, db_index=True)
display_name = models.CharField(max_length=200, blank=True, default='')
email_address = models.CharField(max_length=200)
phone = models.CharField(max_length=50, blank=True, default='')
organization = models.CharField(max_length=200, blank=True, default='')
notes = models.TextField(blank=True, default='')
is_auto_collected = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'wm_contacts'
unique_together = ('owner_email', 'email_address')
def __str__(self):
return '%s <%s>' % (self.display_name, self.email_address)
class ContactGroup(models.Model):
owner_email = models.CharField(max_length=200, db_index=True)
name = models.CharField(max_length=100)
class Meta:
db_table = 'wm_contact_groups'
unique_together = ('owner_email', 'name')
def __str__(self):
return self.name
class ContactGroupMembership(models.Model):
contact = models.ForeignKey(Contact, on_delete=models.CASCADE)
group = models.ForeignKey(ContactGroup, on_delete=models.CASCADE)
class Meta:
db_table = 'wm_contact_group_members'
unique_together = ('contact', 'group')
class WebmailSettings(models.Model):
email_account = models.CharField(max_length=200, primary_key=True)
display_name = models.CharField(max_length=200, blank=True, default='')
signature_html = models.TextField(blank=True, default='')
messages_per_page = models.IntegerField(default=25)
default_reply_behavior = models.CharField(max_length=20, default='reply',
choices=[('reply', 'Reply'),
('reply_all', 'Reply All')])
theme_preference = models.CharField(max_length=20, default='auto',
choices=[('light', 'Light'),
('dark', 'Dark'),
('auto', 'Auto')])
auto_collect_contacts = models.BooleanField(default=True)
class Meta:
db_table = 'wm_settings'
def __str__(self):
return self.email_account
class SieveRule(models.Model):
email_account = models.CharField(max_length=200, db_index=True)
name = models.CharField(max_length=200)
priority = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
condition_field = models.CharField(max_length=50,
choices=[('from', 'From'),
('to', 'To'),
('subject', 'Subject'),
('size', 'Size')])
condition_type = models.CharField(max_length=50,
choices=[('contains', 'Contains'),
('is', 'Is'),
('matches', 'Matches'),
('greater_than', 'Greater than')])
condition_value = models.CharField(max_length=500)
action_type = models.CharField(max_length=50,
choices=[('move', 'Move to folder'),
('forward', 'Forward to'),
('discard', 'Discard'),
('flag', 'Flag')])
action_value = models.CharField(max_length=500, blank=True, default='')
sieve_script = models.TextField(blank=True, default='')
class Meta:
db_table = 'wm_sieve_rules'
ordering = ['priority']
def __str__(self):
return '%s: %s' % (self.email_account, self.name)

View File

View File

@@ -0,0 +1,180 @@
import email
from email.message import EmailMessage
from email.utils import formatdate, make_msgid, formataddr
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
import mimetypes
import re
class EmailComposer:
"""Construct MIME messages for sending."""
@staticmethod
def compose(from_addr, to_addrs, subject, body_html='', body_text='',
cc_addrs='', bcc_addrs='', attachments=None,
in_reply_to='', references=''):
"""Build a MIME message.
Args:
from_addr: sender email
to_addrs: comma-separated recipients
subject: email subject
body_html: HTML body content
body_text: plain text body content
cc_addrs: comma-separated CC recipients
bcc_addrs: comma-separated BCC recipients
attachments: list of (filename, content_type, bytes) tuples
in_reply_to: Message-ID being replied to
references: space-separated Message-IDs
Returns:
MIMEMultipart message ready for sending
"""
if attachments:
msg = MIMEMultipart('mixed')
body_part = MIMEMultipart('alternative')
if body_text:
body_part.attach(MIMEText(body_text, 'plain', 'utf-8'))
if body_html:
body_part.attach(MIMEText(body_html, 'html', 'utf-8'))
elif not body_text:
body_part.attach(MIMEText('', 'plain', 'utf-8'))
msg.attach(body_part)
for filename, content_type, data in attachments:
if not content_type:
content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
maintype, subtype = content_type.split('/', 1)
attachment = MIMEBase(maintype, subtype)
attachment.set_payload(data)
encoders.encode_base64(attachment)
attachment.add_header('Content-Disposition', 'attachment', filename=filename)
msg.attach(attachment)
else:
msg = MIMEMultipart('alternative')
if body_text:
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
if body_html:
msg.attach(MIMEText(body_html, 'html', 'utf-8'))
elif not body_text:
msg.attach(MIMEText('', 'plain', 'utf-8'))
msg['From'] = from_addr
msg['To'] = to_addrs
if cc_addrs:
msg['Cc'] = cc_addrs
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid(domain=from_addr.split('@')[-1] if '@' in from_addr else 'localhost')
if in_reply_to:
msg['In-Reply-To'] = in_reply_to
if references:
msg['References'] = references
msg['MIME-Version'] = '1.0'
msg['X-Mailer'] = 'CyberPanel Webmail'
return msg
@classmethod
def compose_reply(cls, original, body_html, from_addr, reply_all=False):
"""Build a reply message from the original parsed message.
Args:
original: parsed message dict from EmailParser
body_html: reply HTML body
from_addr: sender email
reply_all: whether to reply all
Returns:
MIMEMultipart message
"""
to = original.get('from', '')
cc = ''
if reply_all:
orig_to = original.get('to', '')
orig_cc = original.get('cc', '')
all_addrs = []
if orig_to:
all_addrs.append(orig_to)
if orig_cc:
all_addrs.append(orig_cc)
cc = ', '.join(all_addrs)
# Remove self from CC
cc_parts = [a.strip() for a in cc.split(',') if from_addr not in a]
cc = ', '.join(cc_parts)
subject = original.get('subject', '')
if not subject.lower().startswith('re:'):
subject = 'Re: %s' % subject
in_reply_to = original.get('message_id', '')
references = original.get('references', '')
if in_reply_to:
references = ('%s %s' % (references, in_reply_to)).strip()
# Quote original
from html import escape as html_escape
orig_date = html_escape(original.get('date', ''))
orig_from = html_escape(original.get('from', ''))
quoted = '<br><br><div class="wm-quoted">On %s, %s wrote:<br><blockquote>%s</blockquote></div>' % (
orig_date, orig_from, original.get('body_html', '') or html_escape(original.get('body_text', ''))
)
full_html = body_html + quoted
return cls.compose(
from_addr=from_addr,
to_addrs=to,
subject=subject,
body_html=full_html,
cc_addrs=cc,
in_reply_to=in_reply_to,
references=references,
)
@classmethod
def compose_forward(cls, original, body_html, from_addr, to_addrs):
"""Build a forward message including original attachments.
Args:
original: parsed message dict
body_html: forward body HTML
from_addr: sender email
to_addrs: comma-separated recipients
Returns:
MIMEMultipart message (without attachments - caller must add them)
"""
subject = original.get('subject', '')
if not subject.lower().startswith('fwd:'):
subject = 'Fwd: %s' % subject
from html import escape as html_escape
orig_from = html_escape(original.get('from', ''))
orig_to = html_escape(original.get('to', ''))
orig_date = html_escape(original.get('date', ''))
orig_subject = html_escape(original.get('subject', ''))
forwarded = (
'<br><br><div class="wm-forwarded">'
'---------- Forwarded message ----------<br>'
'From: %s<br>'
'Date: %s<br>'
'Subject: %s<br>'
'To: %s<br><br>'
'%s</div>'
) % (orig_from, orig_date, orig_subject, orig_to,
original.get('body_html', '') or html_escape(original.get('body_text', '')))
full_html = body_html + forwarded
return cls.compose(
from_addr=from_addr,
to_addrs=to_addrs,
subject=subject,
body_html=full_html,
)

View File

@@ -0,0 +1,194 @@
import email
import re
from email.header import decode_header
from email.utils import parsedate_to_datetime
class EmailParser:
"""Parse MIME messages and sanitize HTML content."""
SAFE_TAGS = {
'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'cite', 'code',
'col', 'colgroup', 'dd', 'del', 'details', 'div', 'dl', 'dt', 'em',
'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
'i', 'img', 'ins', 'li', 'mark', 'ol', 'p', 'pre', 'q', 's',
'small', 'span', 'strong', 'sub', 'summary', 'sup', 'table',
'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul', 'wbr',
'font', 'center', 'big',
}
SAFE_ATTRS = {
'href', 'src', 'alt', 'title', 'width', 'height', 'style',
'class', 'id', 'colspan', 'rowspan', 'cellpadding', 'cellspacing',
'border', 'align', 'valign', 'bgcolor', 'color', 'size', 'face',
'dir', 'lang', 'start', 'type', 'target', 'rel',
}
DANGEROUS_CSS_PATTERNS = [
re.compile(r'expression\s*\(', re.IGNORECASE),
re.compile(r'javascript\s*:', re.IGNORECASE),
re.compile(r'vbscript\s*:', re.IGNORECASE),
re.compile(r'url\s*\(\s*["\']?\s*javascript:', re.IGNORECASE),
re.compile(r'-moz-binding', re.IGNORECASE),
re.compile(r'behavior\s*:', re.IGNORECASE),
]
@staticmethod
def _decode_header_value(value):
if value is None:
return ''
decoded_parts = decode_header(value)
result = []
for part, charset in decoded_parts:
if isinstance(part, bytes):
result.append(part.decode(charset or 'utf-8', errors='replace'))
else:
result.append(part)
return ''.join(result)
@classmethod
def parse_message(cls, raw_bytes):
"""Parse raw email bytes into a structured dict."""
if isinstance(raw_bytes, str):
raw_bytes = raw_bytes.encode('utf-8')
msg = email.message_from_bytes(raw_bytes)
subject = cls._decode_header_value(msg.get('Subject', ''))
from_addr = cls._decode_header_value(msg.get('From', ''))
to_addr = cls._decode_header_value(msg.get('To', ''))
cc_addr = cls._decode_header_value(msg.get('Cc', ''))
date_str = msg.get('Date', '')
message_id = msg.get('Message-ID', '')
in_reply_to = msg.get('In-Reply-To', '')
references = msg.get('References', '')
date_iso = ''
try:
dt = parsedate_to_datetime(date_str)
date_iso = dt.isoformat()
except Exception:
date_iso = date_str
body_html = ''
body_text = ''
attachments = []
part_idx = 0
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
disposition = str(part.get('Content-Disposition', ''))
if content_type == 'multipart':
continue
if 'attachment' in disposition or (content_type not in ('text/html', 'text/plain') and disposition):
filename = part.get_filename()
if filename:
filename = cls._decode_header_value(filename)
else:
filename = 'attachment_%d' % part_idx
attachments.append({
'part_id': part_idx,
'filename': filename,
'content_type': content_type,
'size': len(part.get_payload(decode=True) or b''),
})
part_idx += 1
elif content_type == 'text/html':
payload = part.get_payload(decode=True)
charset = part.get_content_charset() or 'utf-8'
body_html = payload.decode(charset, errors='replace') if payload else ''
elif content_type == 'text/plain':
payload = part.get_payload(decode=True)
charset = part.get_content_charset() or 'utf-8'
body_text = payload.decode(charset, errors='replace') if payload else ''
else:
content_type = msg.get_content_type()
payload = msg.get_payload(decode=True)
charset = msg.get_content_charset() or 'utf-8'
if payload:
decoded = payload.decode(charset, errors='replace')
if content_type == 'text/html':
body_html = decoded
else:
body_text = decoded
if body_html:
body_html = cls.sanitize_html(body_html)
preview = cls.extract_preview(body_text or body_html, 200)
return {
'subject': subject,
'from': from_addr,
'to': to_addr,
'cc': cc_addr,
'date': date_str,
'date_iso': date_iso,
'message_id': message_id,
'in_reply_to': in_reply_to,
'references': references,
'body_html': body_html,
'body_text': body_text,
'attachments': attachments,
'preview': preview,
'has_attachments': len(attachments) > 0,
}
@classmethod
def sanitize_html(cls, html):
"""Whitelist-based HTML sanitization. Strips dangerous content."""
if not html:
return ''
# Remove script, style, iframe, object, embed, form tags and their content
for tag in ['script', 'style', 'iframe', 'object', 'embed', 'form', 'applet', 'base', 'link', 'meta']:
html = re.sub(r'<%s\b[^>]*>.*?</%s>' % (tag, tag), '', html, flags=re.IGNORECASE | re.DOTALL)
html = re.sub(r'<%s\b[^>]*/?\s*>' % tag, '', html, flags=re.IGNORECASE)
# Remove event handler attributes (on*)
html = re.sub(r'\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)', '', html, flags=re.IGNORECASE)
# Remove javascript: and data: URIs in href/src
html = re.sub(r'(href|src)\s*=\s*["\']?\s*javascript:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE)
html = re.sub(r'(href|src)\s*=\s*["\']?\s*data:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE)
html = re.sub(r'(href|src)\s*=\s*["\']?\s*vbscript:[^"\'>\s]*["\']?', r'\1=""', html, flags=re.IGNORECASE)
# Sanitize style attributes - remove dangerous CSS
def clean_style(match):
style = match.group(1)
for pattern in cls.DANGEROUS_CSS_PATTERNS:
if pattern.search(style):
return 'style=""'
return match.group(0)
html = re.sub(r'style\s*=\s*"([^"]*)"', clean_style, html, flags=re.IGNORECASE)
html = re.sub(r"style\s*=\s*'([^']*)'", clean_style, html, flags=re.IGNORECASE)
# Rewrite external image src to proxy endpoint
def proxy_image(match):
src = match.group(1)
if src.startswith(('http://', 'https://')):
from django.utils.http import urlencode
import base64
encoded_url = base64.urlsafe_b64encode(src.encode()).decode()
return 'src="/webmail/api/proxyImage?url=%s"' % encoded_url
return match.group(0)
html = re.sub(r'src\s*=\s*"(https?://[^"]*)"', proxy_image, html, flags=re.IGNORECASE)
return html
@staticmethod
def extract_preview(text, max_length=200):
"""Extract a short text preview from email body."""
if not text:
return ''
# Strip HTML tags if present
clean = re.sub(r'<[^>]+>', ' ', text)
# Collapse whitespace
clean = re.sub(r'\s+', ' ', clean).strip()
if len(clean) > max_length:
return clean[:max_length] + '...'
return clean

View File

@@ -0,0 +1,373 @@
import imaplib
import ssl
import email
import re
from email.header import decode_header
class IMAPClient:
"""Wrapper around imaplib.IMAP4_SSL for Dovecot IMAP operations.
CyberPanel's Dovecot uses namespace: separator='.', prefix='INBOX.'
So folders are: INBOX, INBOX.Sent, INBOX.Drafts, INBOX.Deleted Items,
INBOX.Junk E-mail, INBOX.Archive, etc.
"""
# Dovecot namespace config: separator='.', prefix='INBOX.'
NS_PREFIX = 'INBOX.'
NS_SEP = '.'
# Map of standard folder purposes to actual Dovecot folder names
# (CyberPanel creates these in mailUtilities.py)
SPECIAL_FOLDERS = {
'sent': 'INBOX.Sent',
'drafts': 'INBOX.Drafts',
'trash': 'INBOX.Deleted Items',
'junk': 'INBOX.Junk E-mail',
'archive': 'INBOX.Archive',
}
def __init__(self, email_address, password, host='localhost', port=993,
master_user=None, master_password=None):
self.email_address = email_address
self.host = host
self.port = port
self.conn = None
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
self.conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
if master_user and master_password:
login_user = '%s*%s' % (email_address, master_user)
self.conn.login(login_user, master_password)
else:
self.conn.login(email_address, password)
def close(self):
try:
self.conn.close()
except Exception:
pass
try:
self.conn.logout()
except Exception:
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def _decode_header_value(self, value):
if value is None:
return ''
decoded_parts = decode_header(value)
result = []
for part, charset in decoded_parts:
if isinstance(part, bytes):
result.append(part.decode(charset or 'utf-8', errors='replace'))
else:
result.append(part)
return ''.join(result)
def _parse_folder_list(self, line):
if isinstance(line, bytes):
line = line.decode('utf-8', errors='replace')
match = re.match(r'\(([^)]*)\)\s+"([^"]+)"\s+"?([^"]+)"?', line)
if not match:
match = re.match(r'\(([^)]*)\)\s+"([^"]+)"\s+(.+)', line)
if match:
flags = match.group(1)
delimiter = match.group(2)
name = match.group(3).strip('"')
return {'name': name, 'delimiter': delimiter, 'flags': flags}
return None
def _display_name(self, folder_name):
"""Strip INBOX. prefix for display, keep INBOX as-is."""
if folder_name == 'INBOX':
return 'Inbox'
if folder_name.startswith(self.NS_PREFIX):
return folder_name[len(self.NS_PREFIX):]
return folder_name
def _folder_type(self, folder_name):
"""Identify special folder type for UI icon mapping."""
for ftype, fname in self.SPECIAL_FOLDERS.items():
if folder_name == fname:
return ftype
if folder_name == 'INBOX':
return 'inbox'
return 'folder'
def list_folders(self):
status, data = self.conn.list()
if status != 'OK':
return []
folders = []
for item in data:
if item is None:
continue
parsed = self._parse_folder_list(item)
if parsed is None:
continue
folder_name = parsed['name']
unread = 0
total = 0
try:
# Quote folder names with spaces for STATUS command
quoted = '"%s"' % folder_name
st, counts = self.conn.status(quoted, '(MESSAGES UNSEEN)')
if st == 'OK' and counts[0]:
count_str = counts[0].decode('utf-8', errors='replace') if isinstance(counts[0], bytes) else counts[0]
m = re.search(r'MESSAGES\s+(\d+)', count_str)
u = re.search(r'UNSEEN\s+(\d+)', count_str)
if m:
total = int(m.group(1))
if u:
unread = int(u.group(1))
except Exception:
pass
folders.append({
'name': folder_name,
'display_name': self._display_name(folder_name),
'folder_type': self._folder_type(folder_name),
'delimiter': parsed['delimiter'],
'flags': parsed['flags'],
'unread_count': unread,
'total_count': total,
})
return folders
def _select(self, folder):
"""Select a folder, quoting names with spaces."""
return self.conn.select('"%s"' % folder)
def list_messages(self, folder='INBOX', page=1, per_page=25, sort='date_desc'):
self._select(folder)
# Try IMAP SORT for proper date ordering (Dovecot supports this)
uids = []
try:
if sort == 'date_desc':
status, data = self.conn.uid('sort', '(REVERSE DATE)', 'UTF-8', 'ALL')
else:
status, data = self.conn.uid('sort', '(DATE)', 'UTF-8', 'ALL')
if status == 'OK' and data[0]:
uids = data[0].split()
except Exception:
pass
# Fallback to search + reverse UIDs if SORT not supported
if not uids:
status, data = self.conn.uid('search', None, 'ALL')
if status != 'OK':
return {'messages': [], 'total': 0, 'page': page, 'pages': 0}
uids = data[0].split() if data[0] else []
if sort == 'date_desc':
uids = list(reversed(uids))
total = len(uids)
pages = max(1, (total + per_page - 1) // per_page)
page = max(1, min(page, pages))
start = (page - 1) * per_page
end = start + per_page
page_uids = uids[start:end]
if not page_uids:
return {'messages': [], 'total': total, 'page': page, 'pages': pages}
uid_str = b','.join(page_uids)
status, msg_data = self.conn.uid('fetch', uid_str,
'(UID FLAGS ENVELOPE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])')
if status != 'OK':
return {'messages': [], 'total': total, 'page': page, 'pages': pages}
messages = []
i = 0
while i < len(msg_data):
item = msg_data[i]
if isinstance(item, tuple) and len(item) == 2:
meta_line = item[0].decode('utf-8', errors='replace') if isinstance(item[0], bytes) else item[0]
header_bytes = item[1]
uid_match = re.search(r'UID\s+(\d+)', meta_line)
flags_match = re.search(r'FLAGS\s+\(([^)]*)\)', meta_line)
size_match = re.search(r'RFC822\.SIZE\s+(\d+)', meta_line)
uid = uid_match.group(1) if uid_match else '0'
flags = flags_match.group(1) if flags_match else ''
size = int(size_match.group(1)) if size_match else 0
msg = email.message_from_bytes(header_bytes) if isinstance(header_bytes, bytes) else email.message_from_string(header_bytes)
messages.append({
'uid': uid,
'from': self._decode_header_value(msg.get('From', '')),
'to': self._decode_header_value(msg.get('To', '')),
'subject': self._decode_header_value(msg.get('Subject', '(No Subject)')),
'date': msg.get('Date', ''),
'flags': flags,
'is_read': '\\Seen' in flags,
'is_flagged': '\\Flagged' in flags,
'has_attachment': False,
'size': size,
})
i += 1
return {'messages': messages, 'total': total, 'page': page, 'pages': pages}
def search_messages(self, folder='INBOX', query='', criteria='ALL'):
self._select(folder)
if query:
# Escape quotes to prevent IMAP search injection
safe_query = query.replace('\\', '\\\\').replace('"', '\\"')
search_criteria = '(OR OR (FROM "%s") (TO "%s") (SUBJECT "%s"))' % (safe_query, safe_query, safe_query)
else:
search_criteria = criteria
status, data = self.conn.uid('search', None, search_criteria)
if status != 'OK':
return []
return data[0].split() if data[0] else []
def get_message(self, folder, uid):
self._select(folder)
status, data = self.conn.uid('fetch', str(uid).encode(), '(RFC822 FLAGS)')
if status != 'OK' or not data or not data[0]:
return None
raw = None
flags = ''
for item in data:
if isinstance(item, tuple) and len(item) == 2:
meta = item[0].decode('utf-8', errors='replace') if isinstance(item[0], bytes) else item[0]
raw = item[1]
flags_match = re.search(r'FLAGS\s+\(([^)]*)\)', meta)
if flags_match:
flags = flags_match.group(1)
break
if raw is None:
return None
from .email_parser import EmailParser
parsed = EmailParser.parse_message(raw)
parsed['uid'] = str(uid)
parsed['flags'] = flags
parsed['is_read'] = '\\Seen' in flags
parsed['is_flagged'] = '\\Flagged' in flags
return parsed
def get_attachment(self, folder, uid, part_id):
self._select(folder)
status, data = self.conn.uid('fetch', str(uid).encode(), '(RFC822)')
if status != 'OK' or not data or not data[0]:
return None
raw = None
for item in data:
if isinstance(item, tuple) and len(item) == 2:
raw = item[1]
break
if raw is None:
return None
msg = email.message_from_bytes(raw) if isinstance(raw, bytes) else email.message_from_string(raw)
part_idx = 0
for part in msg.walk():
content_type = part.get_content_type()
if content_type.startswith('multipart/'):
continue
disposition = str(part.get('Content-Disposition', ''))
# Match the same indexing logic as email_parser.py:
# count parts that are attachments or non-text with disposition
if 'attachment' in disposition or (content_type not in ('text/html', 'text/plain') and disposition):
if str(part_idx) == str(part_id):
filename = part.get_filename() or 'attachment'
filename = self._decode_header_value(filename)
payload = part.get_payload(decode=True)
return (filename, content_type, payload)
part_idx += 1
return None
def move_messages(self, folder, uids, target_folder):
self._select(folder)
uid_str = ','.join(str(u) for u in uids)
# Quote target folder name for folders with spaces (e.g. "INBOX.Deleted Items")
quoted_target = '"%s"' % target_folder
try:
status, _ = self.conn.uid('move', uid_str, quoted_target)
if status == 'OK':
return True
except Exception:
pass
status, _ = self.conn.uid('copy', uid_str, quoted_target)
if status == 'OK':
self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)')
self.conn.expunge()
return True
return False
def delete_messages(self, folder, uids):
self._select(folder)
uid_str = ','.join(str(u) for u in uids)
# CyberPanel/Dovecot uses "INBOX.Deleted Items" as trash
trash_folders = ['INBOX.Deleted Items', 'INBOX.Trash', 'Trash']
if folder not in trash_folders:
for trash in trash_folders:
try:
status, _ = self.conn.uid('copy', uid_str, '"%s"' % trash)
if status == 'OK':
self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)')
self.conn.expunge()
return True
except Exception:
continue
# Already in trash or no trash folder found - permanently delete
self.conn.uid('store', uid_str, '+FLAGS', '(\\Deleted)')
self.conn.expunge()
return True
def set_flags(self, folder, uids, flags, action='add'):
self._select(folder)
uid_str = ','.join(str(u) for u in uids)
flag_str = '(%s)' % ' '.join(flags)
if action == 'add':
self.conn.uid('store', uid_str, '+FLAGS', flag_str)
elif action == 'remove':
self.conn.uid('store', uid_str, '-FLAGS', flag_str)
return True
def mark_read(self, folder, uids):
return self.set_flags(folder, uids, ['\\Seen'], 'add')
def mark_unread(self, folder, uids):
return self.set_flags(folder, uids, ['\\Seen'], 'remove')
def mark_flagged(self, folder, uids):
return self.set_flags(folder, uids, ['\\Flagged'], 'add')
def create_folder(self, name):
status, _ = self.conn.create(name)
return status == 'OK'
def rename_folder(self, old_name, new_name):
status, _ = self.conn.rename(old_name, new_name)
return status == 'OK'
def delete_folder(self, name):
status, _ = self.conn.delete(name)
return status == 'OK'
def append_message(self, folder, raw_message, flags=''):
if isinstance(raw_message, str):
raw_message = raw_message.encode('utf-8')
flag_str = '(%s)' % flags if flags else None
status, _ = self.conn.append('"%s"' % folder, flag_str, None, raw_message)
return status == 'OK'

View File

@@ -0,0 +1,282 @@
import socket
import ssl
import re
import base64
class SieveClient:
"""ManageSieve protocol client (RFC 5804) for managing mail filter rules."""
def __init__(self, email_address, password, host='localhost', port=4190,
master_user=None, master_password=None):
self.email_address = email_address
self.host = host
self.port = port
self.sock = None
self.buf = b''
self.sock = socket.create_connection((host, port), timeout=30)
self._read_welcome()
self._starttls()
if master_user and master_password:
self._authenticate_master(email_address, master_user, master_password)
else:
self._authenticate(email_address, password)
def _read_line(self):
while b'\r\n' not in self.buf:
data = self.sock.recv(4096)
if not data:
break
self.buf += data
if b'\r\n' in self.buf:
line, self.buf = self.buf.split(b'\r\n', 1)
return line.decode('utf-8', errors='replace')
return ''
def _read_response(self):
lines = []
while True:
line = self._read_line()
if not line and not self.buf:
return False, lines, 'Connection closed'
if line.startswith('OK'):
return True, lines, line
elif line.startswith('NO'):
return False, lines, line
elif line.startswith('BYE'):
return False, lines, line
else:
lines.append(line)
def _read_welcome(self):
lines = []
while True:
line = self._read_line()
lines.append(line)
if line.startswith('OK'):
break
return lines
def _send(self, command):
self.sock.sendall(('%s\r\n' % command).encode('utf-8'))
def _starttls(self):
self._send('STARTTLS')
ok, _, _ = self._read_response()
if ok:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
self.sock = ctx.wrap_socket(self.sock, server_hostname=self.host)
self.buf = b''
self._read_welcome()
def _authenticate(self, user, password):
auth_str = base64.b64encode(('\x00%s\x00%s' % (user, password)).encode('utf-8')).decode('ascii')
self._send('AUTHENTICATE "PLAIN" "%s"' % auth_str)
ok, _, msg = self._read_response()
if not ok:
raise Exception('Sieve authentication failed: %s' % msg)
def _authenticate_master(self, user, master_user, master_password):
# SASL PLAIN format per RFC 4616: <authz_id>\x00<authn_id>\x00<password>
# authz_id = target user, authn_id = master user, password = master password
auth_str = base64.b64encode(
('%s\x00%s\x00%s' % (user, master_user, master_password)).encode('utf-8')
).decode('ascii')
self._send('AUTHENTICATE "PLAIN" "%s"' % auth_str)
ok, _, msg = self._read_response()
if not ok:
raise Exception('Sieve master authentication failed: %s' % msg)
def list_scripts(self):
"""List all Sieve scripts. Returns [(name, is_active), ...]"""
self._send('LISTSCRIPTS')
ok, lines, _ = self._read_response()
if not ok:
return []
scripts = []
for line in lines:
match = re.match(r'"([^"]+)"(\s+ACTIVE)?', line)
if match:
scripts.append((match.group(1), bool(match.group(2))))
return scripts
@staticmethod
def _safe_name(name):
"""Sanitize script name to prevent ManageSieve injection."""
import re
safe = re.sub(r'[^a-zA-Z0-9_.-]', '', name)
if not safe:
safe = 'default'
return safe
def get_script(self, name):
"""Get the content of a Sieve script."""
self._send('GETSCRIPT "%s"' % self._safe_name(name))
ok, lines, _ = self._read_response()
if not ok:
return ''
return '\n'.join(lines)
def put_script(self, name, content):
"""Upload a Sieve script."""
safe = self._safe_name(name)
encoded = content.encode('utf-8')
self._send('PUTSCRIPT "%s" {%d+}' % (safe, len(encoded)))
self.sock.sendall(encoded + b'\r\n')
ok, _, msg = self._read_response()
if not ok:
raise Exception('Failed to put script: %s' % msg)
return True
def activate_script(self, name):
"""Set a script as the active script."""
self._send('SETACTIVE "%s"' % self._safe_name(name))
ok, _, msg = self._read_response()
return ok
def deactivate_scripts(self):
"""Deactivate all scripts."""
self._send('SETACTIVE ""')
ok, _, _ = self._read_response()
return ok
def delete_script(self, name):
"""Delete a Sieve script."""
self._send('DELETESCRIPT "%s"' % self._safe_name(name))
ok, _, _ = self._read_response()
return ok
def close(self):
try:
self._send('LOGOUT')
self._read_response()
except Exception:
pass
try:
self.sock.close()
except Exception:
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
@staticmethod
def rules_to_sieve(rules):
"""Convert a list of rule dicts to a Sieve script.
Each rule: {condition_field, condition_type, condition_value, action_type, action_value, name}
"""
requires = set()
rule_blocks = []
for rule in rules:
field = rule.get('condition_field', 'from')
cond_type = rule.get('condition_type', 'contains')
cond_value = rule.get('condition_value', '').replace('\\', '\\\\').replace('"', '\\"')
action_type = rule.get('action_type', 'move')
action_value = rule.get('action_value', '').replace('\\', '\\\\').replace('"', '\\"')
# Map field to Sieve header
if field == 'from':
header = 'From'
elif field == 'to':
header = 'To'
elif field == 'subject':
header = 'Subject'
else:
header = field
# Map condition type to Sieve test
if cond_type == 'contains':
test = 'header :contains "%s" "%s"' % (header, cond_value)
elif cond_type == 'is':
test = 'header :is "%s" "%s"' % (header, cond_value)
elif cond_type == 'matches':
test = 'header :matches "%s" "%s"' % (header, cond_value)
elif cond_type == 'greater_than' and field == 'size':
test = 'size :over %s' % cond_value
else:
test = 'header :contains "%s" "%s"' % (header, cond_value)
# Map action
if action_type == 'move':
requires.add('fileinto')
# Ensure folder uses INBOX. namespace prefix for dovecot
folder = action_value
if folder and not folder.startswith('INBOX.'):
folder = 'INBOX.%s' % folder
action = 'fileinto "%s";' % folder
elif action_type == 'forward':
requires.add('redirect')
action = 'redirect "%s";' % action_value
elif action_type == 'discard':
action = 'discard;'
elif action_type == 'flag':
requires.add('imap4flags')
action = 'addflag "\\\\Flagged";'
else:
action = 'keep;'
name = rule.get('name', 'Rule')
rule_blocks.append('# %s\nif %s {\n %s\n}' % (name, test, action))
# Build full script
parts = []
if requires:
parts.append('require [%s];' % ', '.join('"%s"' % r for r in sorted(requires)))
parts.append('')
parts.extend(rule_blocks)
return '\n'.join(parts)
@staticmethod
def sieve_to_rules(script):
"""Best-effort parse of a Sieve script into rule dicts."""
rules = []
# Match if-blocks with comments as names
pattern = re.compile(
r'#\s*(.+?)\n\s*if\s+header\s+:(\w+)\s+"([^"]+)"\s+"([^"]+)"\s*\{([^}]+)\}',
re.DOTALL
)
for match in pattern.finditer(script):
name = match.group(1).strip()
cond_type = match.group(2)
field_name = match.group(3).lower()
cond_value = match.group(4)
action_block = match.group(5).strip()
action_type = 'keep'
action_value = ''
if 'fileinto' in action_block:
action_type = 'move'
av = re.search(r'fileinto\s+"([^"]+)"', action_block)
action_value = av.group(1) if av else ''
# Strip INBOX. namespace prefix for display
if action_value.startswith('INBOX.'):
action_value = action_value[6:]
elif 'redirect' in action_block:
action_type = 'forward'
av = re.search(r'redirect\s+"([^"]+)"', action_block)
action_value = av.group(1) if av else ''
elif 'discard' in action_block:
action_type = 'discard'
elif 'addflag' in action_block:
action_type = 'flag'
rules.append({
'name': name,
'condition_field': field_name,
'condition_type': cond_type,
'condition_value': cond_value,
'action_type': action_type,
'action_value': action_value,
})
return rules

View File

@@ -0,0 +1,76 @@
import smtplib
import ssl
class SMTPClient:
"""Wrapper around smtplib.SMTP for sending mail via Postfix.
Supports two modes:
1. Authenticated (port 587 + STARTTLS) — for standalone login sessions
2. Local relay (port 25, no auth) — for SSO sessions using master user
Postfix accepts relay from localhost (permit_mynetworks in main.cf)
"""
def __init__(self, email_address, password, host='localhost', port=587,
use_local_relay=False):
self.email_address = email_address
self.password = password
self.host = host
self.port = port
self.use_local_relay = use_local_relay
def send_message(self, mime_message):
"""Send a composed email via SMTP.
Returns:
dict: {success: bool, message_id: str or None, error: str or None}
"""
try:
if self.use_local_relay:
# SSO mode: send via port 25 without auth
# Postfix permits relay from localhost (permit_mynetworks)
smtp = smtplib.SMTP(self.host, 25)
smtp.ehlo()
smtp.send_message(mime_message)
smtp.quit()
else:
# Standalone mode: authenticated via port 587 + STARTTLS
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
smtp = smtplib.SMTP(self.host, self.port)
smtp.ehlo()
smtp.starttls(context=ctx)
smtp.ehlo()
smtp.login(self.email_address, self.password)
smtp.send_message(mime_message)
smtp.quit()
message_id = mime_message.get('Message-ID', '')
return {'success': True, 'message_id': message_id}
except smtplib.SMTPAuthenticationError as e:
return {'success': False, 'message_id': None, 'error': 'Authentication failed: %s' % str(e)}
except smtplib.SMTPRecipientsRefused as e:
return {'success': False, 'message_id': None, 'error': 'Recipients refused: %s' % str(e)}
except Exception as e:
return {'success': False, 'message_id': None, 'error': str(e)}
def save_to_sent(self, imap_client, raw_message):
"""Append sent message to the Sent folder via IMAP.
CyberPanel's Dovecot uses INBOX.Sent as the Sent folder.
"""
# Try CyberPanel's actual folder name first, then fallbacks
sent_folders = ['INBOX.Sent', 'Sent', 'Sent Messages', 'Sent Items']
for folder in sent_folders:
try:
if imap_client.append_message(folder, raw_message, '\\Seen'):
return True
except Exception:
continue
try:
imap_client.create_folder('INBOX.Sent')
return imap_client.append_message('INBOX.Sent', raw_message, '\\Seen')
except Exception:
return False

View File

@@ -0,0 +1,872 @@
/* CyberPanel Webmail Styles */
.webmail-container {
height: calc(100vh - 80px);
display: flex;
flex-direction: column;
background: var(--bg-primary);
border-radius: 12px;
overflow: hidden;
margin: -20px -15px;
}
/* Account Switcher */
.wm-account-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.wm-account-current {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-primary);
font-weight: 500;
font-size: 14px;
}
.wm-account-current i {
color: var(--accent-color);
}
.wm-account-select {
padding: 4px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
/* Main Layout */
.wm-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.wm-sidebar {
width: 220px;
min-width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 12px 0;
}
.wm-compose-btn {
margin: 0 12px 12px;
padding: 10px 16px;
border-radius: 10px;
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
background: var(--accent-color) !important;
border-color: var(--accent-color) !important;
transition: all 0.2s;
}
.wm-compose-btn:hover {
background: var(--accent-hover, #5A4BD1) !important;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(108,92,231,0.3);
}
.wm-folder-list {
flex: 1;
}
.wm-folder-item {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
color: var(--text-secondary);
font-size: 14px;
transition: all 0.15s;
gap: 10px;
}
.wm-folder-item:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.wm-folder-item.active {
background: var(--bg-primary);
color: var(--accent-color);
font-weight: 600;
}
.wm-folder-item i {
width: 18px;
text-align: center;
font-size: 13px;
}
.wm-folder-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wm-badge {
background: var(--accent-color);
color: white;
border-radius: 10px;
padding: 1px 7px;
font-size: 11px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.wm-sidebar-divider {
height: 1px;
background: var(--border-color);
margin: 8px 16px;
}
.wm-sidebar-nav {
padding: 0 4px;
}
.wm-nav-link {
display: flex;
align-items: center;
padding: 8px 12px;
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
border-radius: 8px;
cursor: pointer;
gap: 10px;
transition: all 0.15s;
}
.wm-nav-link:hover {
background: var(--bg-primary);
color: var(--text-primary);
text-decoration: none;
}
.wm-nav-link.active {
background: var(--bg-primary);
color: var(--accent-color);
font-weight: 600;
}
.wm-nav-link i {
width: 18px;
text-align: center;
}
/* Message List Column */
.wm-message-list {
width: 380px;
min-width: 320px;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
}
.wm-search-bar {
display: flex;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
gap: 8px;
}
.wm-search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
background: var(--bg-primary);
color: var(--text-primary);
outline: none;
transition: border-color 0.2s;
}
.wm-search-input:focus {
border-color: var(--accent-color);
}
.wm-search-btn {
padding: 8px 12px;
border: none;
background: var(--accent-color);
color: white;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.wm-search-btn:hover {
background: var(--accent-hover, #5A4BD1);
}
/* Bulk Actions */
.wm-bulk-actions {
display: flex;
align-items: center;
padding: 6px 12px;
border-bottom: 1px solid var(--border-color);
gap: 4px;
background: var(--bg-secondary);
flex-wrap: wrap;
}
.wm-action-btn {
padding: 4px 8px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 6px;
font-size: 13px;
transition: all 0.15s;
}
.wm-action-btn:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.wm-action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.wm-page-info {
font-size: 12px;
color: var(--text-secondary);
margin-left: auto;
padding: 0 4px;
}
.wm-checkbox-label {
display: inline-flex;
align-items: center;
margin: 0;
cursor: pointer;
}
.wm-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--accent-color);
}
.wm-move-dropdown select {
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
background: var(--bg-secondary);
color: var(--text-primary);
}
/* Message Rows */
.wm-messages {
flex: 1;
overflow-y: auto;
}
.wm-msg-row {
display: flex;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
gap: 8px;
transition: background 0.1s;
font-size: 13px;
}
.wm-msg-row:hover {
background: var(--bg-primary);
}
.wm-msg-row.unread {
font-weight: 600;
}
.wm-msg-row.unread .wm-msg-subject {
color: var(--text-primary);
}
.wm-msg-row.selected {
background: rgba(108, 92, 231, 0.06);
}
.wm-star-btn {
border: none;
background: transparent;
cursor: pointer;
padding: 2px;
font-size: 13px;
}
.wm-starred {
color: #F39C12;
}
.wm-unstarred {
color: var(--border-color);
}
.wm-msg-from {
width: 120px;
min-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.wm-msg-subject {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-secondary);
}
.wm-msg-date {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
min-width: 50px;
text-align: right;
}
.wm-empty, .wm-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-secondary);
font-size: 14px;
gap: 12px;
}
.wm-empty i {
font-size: 48px;
opacity: 0.3;
}
/* Detail Pane */
.wm-detail-pane {
flex: 1;
overflow-y: auto;
background: var(--bg-secondary);
padding: 0;
}
.wm-empty-detail {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
opacity: 0.4;
gap: 16px;
}
/* Read View */
.wm-read-view {
padding: 0;
}
.wm-read-toolbar {
display: flex;
gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
position: sticky;
top: 0;
z-index: 10;
}
.wm-read-toolbar .btn {
font-size: 13px;
padding: 6px 14px;
border-radius: 8px;
}
.wm-read-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.wm-read-subject {
margin: 0 0 12px;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.wm-read-meta {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
}
.wm-read-meta strong {
color: var(--text-primary);
}
.wm-attachments {
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.wm-attachment a {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--bg-primary);
border-radius: 8px;
font-size: 13px;
color: var(--accent-color);
cursor: pointer;
text-decoration: none;
transition: background 0.15s;
}
.wm-attachment a:hover {
background: var(--border-color);
}
.wm-att-size {
color: var(--text-secondary);
font-size: 11px;
}
.wm-read-body {
padding: 20px;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
word-wrap: break-word;
overflow-wrap: break-word;
}
.wm-read-body img {
max-width: 100%;
height: auto;
}
.wm-read-body blockquote {
border-left: 3px solid var(--border-color);
margin: 8px 0;
padding: 4px 12px;
color: var(--text-secondary);
}
.wm-read-body pre {
white-space: pre-wrap;
word-wrap: break-word;
background: var(--bg-primary);
padding: 12px;
border-radius: 8px;
font-size: 13px;
}
/* Compose View */
.wm-compose-view {
padding: 20px;
}
.wm-compose-header h3 {
margin: 0 0 16px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.wm-compose-form .wm-field {
margin-bottom: 12px;
}
.wm-compose-form label {
display: block;
margin-bottom: 4px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.wm-compose-form .form-control {
border-radius: 8px;
border: 1px solid var(--border-color);
padding: 8px 12px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
}
.wm-compose-form .form-control:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(108,92,231,0.1);
}
.wm-toggle-link {
font-size: 12px;
color: var(--accent-color);
cursor: pointer;
}
.wm-editor-toolbar {
display: flex;
gap: 2px;
padding: 6px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-bottom: none;
border-radius: 8px 8px 0 0;
}
.wm-editor-toolbar button {
padding: 6px 10px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
font-size: 13px;
}
.wm-editor-toolbar button:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.wm-editor {
min-height: 300px;
max-height: 500px;
overflow-y: auto;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 0 0 8px 8px;
background: white;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
outline: none;
}
.wm-editor:focus {
border-color: var(--accent-color);
}
.wm-compose-attachments {
margin-top: 12px;
}
.wm-compose-attachments input[type="file"] {
display: none;
}
.wm-attach-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--bg-primary);
border: 1px dashed var(--border-color);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
}
.wm-attach-btn:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
.wm-file-list {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wm-file-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--bg-primary);
border-radius: 6px;
font-size: 12px;
color: var(--text-primary);
}
.wm-file-tag i {
cursor: pointer;
color: var(--text-secondary);
}
.wm-file-tag i:hover {
color: #E74C3C;
}
.wm-compose-actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
.wm-compose-actions .btn {
border-radius: 8px;
padding: 8px 20px;
font-size: 14px;
}
/* Contacts View */
.wm-contacts-view, .wm-rules-view, .wm-settings-view {
padding: 20px;
}
.wm-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.wm-section-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.wm-contacts-search {
margin-bottom: 16px;
}
.wm-contacts-search .form-control {
border-radius: 8px;
background: var(--bg-primary);
}
.wm-contact-list, .wm-rule-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.wm-contact-item, .wm-rule-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-primary);
border-radius: 8px;
transition: background 0.15s;
}
.wm-contact-item:hover, .wm-rule-item:hover {
background: var(--border-color);
}
.wm-contact-info, .wm-rule-info {
flex: 1;
min-width: 0;
}
.wm-contact-name {
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.wm-contact-email {
font-size: 12px;
color: var(--text-secondary);
}
.wm-contact-actions, .wm-rule-actions {
display: flex;
gap: 4px;
}
.wm-rule-desc {
display: block;
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
/* Forms */
.wm-contact-form, .wm-rule-form, .wm-settings-form {
margin-top: 20px;
padding: 20px;
background: var(--bg-primary);
border-radius: 12px;
}
.wm-contact-form h4, .wm-rule-form h4 {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.wm-field {
margin-bottom: 12px;
}
.wm-field label {
display: block;
margin-bottom: 4px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.wm-field .form-control {
border-radius: 8px;
border: 1px solid var(--border-color);
padding: 8px 12px;
font-size: 14px;
background: var(--bg-secondary);
color: var(--text-primary);
}
.wm-field .form-control:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(108,92,231,0.1);
outline: none;
}
.wm-field-row {
display: flex;
gap: 12px;
}
.wm-field-row .wm-field {
flex: 1;
}
.wm-form-actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
.wm-form-actions .btn {
border-radius: 8px;
padding: 8px 20px;
}
/* Autocomplete Dropdown */
.wm-autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
z-index: 100;
max-height: 200px;
overflow-y: auto;
}
.wm-autocomplete-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
}
.wm-autocomplete-item:hover {
background: var(--bg-primary);
}
/* Responsive */
@media (max-width: 1024px) {
.wm-sidebar {
width: 180px;
min-width: 180px;
}
.wm-message-list {
width: 300px;
min-width: 240px;
}
}
@media (max-width: 768px) {
.wm-layout {
flex-direction: column;
}
.wm-sidebar {
width: 100%;
min-width: 100%;
flex-direction: row;
overflow-x: auto;
padding: 8px;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.wm-folder-list {
display: flex;
gap: 4px;
}
.wm-folder-item {
white-space: nowrap;
padding: 6px 12px;
border-radius: 8px;
}
.wm-sidebar-divider, .wm-sidebar-nav {
display: none;
}
.wm-compose-btn {
margin: 0 4px 0 0;
padding: 6px 14px;
white-space: nowrap;
}
.wm-message-list {
width: 100%;
min-width: 100%;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.wm-detail-pane {
min-height: 50vh;
}
.wm-field-row {
flex-direction: column;
}
}

View File

@@ -0,0 +1,826 @@
/* CyberPanel Webmail - AngularJS Controller */
app.filter('fileSize', function() {
return function(bytes) {
if (!bytes || bytes === 0) return '0 B';
var k = 1024;
var sizes = ['B', 'KB', 'MB', 'GB'];
var i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
});
app.filter('wmDate', function() {
return function(dateStr) {
if (!dateStr) return '';
try {
var d = new Date(dateStr);
var now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
}
if (d.getFullYear() === now.getFullYear()) {
return d.toLocaleDateString([], {month: 'short', day: 'numeric'});
}
return d.toLocaleDateString([], {year: 'numeric', month: 'short', day: 'numeric'});
} catch(e) {
return dateStr;
}
};
});
app.filter('trustHtml', ['$sce', function($sce) {
return function(html) {
return $sce.trustAsHtml(html);
};
}]);
app.directive('wmAutocomplete', ['$http', function($http) {
return {
restrict: 'A',
link: function(scope, element, attrs) {
var dropdown = null;
var debounce = null;
element.on('input', function() {
var val = element.val();
var lastComma = val.lastIndexOf(',');
var query = lastComma >= 0 ? val.substring(lastComma + 1).trim() : val.trim();
if (query.length < 2) {
hideDropdown();
return;
}
clearTimeout(debounce);
debounce = setTimeout(function() {
$http.post('/webmail/api/searchContacts', {query: query}, {
headers: {'X-CSRFToken': getCookie('csrftoken')}
}).then(function(resp) {
if (resp.data.status === 1 && resp.data.contacts.length > 0) {
showDropdown(resp.data.contacts, val, lastComma);
} else {
hideDropdown();
}
});
}, 300);
});
function showDropdown(contacts, currentVal, lastComma) {
hideDropdown();
dropdown = document.createElement('div');
dropdown.className = 'wm-autocomplete-dropdown';
contacts.forEach(function(c) {
var item = document.createElement('div');
item.className = 'wm-autocomplete-item';
item.textContent = c.display_name ? c.display_name + ' <' + c.email_address + '>' : c.email_address;
item.addEventListener('click', function() {
var prefix = lastComma >= 0 ? currentVal.substring(0, lastComma + 1) + ' ' : '';
var newVal = prefix + c.email_address + ', ';
element.val(newVal);
element.triggerHandler('input');
scope.$apply(function() {
scope.$eval(attrs.ngModel + ' = "' + newVal.replace(/"/g, '\\"') + '"');
});
hideDropdown();
});
dropdown.appendChild(item);
});
element[0].parentNode.style.position = 'relative';
element[0].parentNode.appendChild(dropdown);
}
function hideDropdown() {
if (dropdown && dropdown.parentNode) {
dropdown.parentNode.removeChild(dropdown);
}
dropdown = null;
}
element.on('blur', function() {
setTimeout(hideDropdown, 200);
});
}
};
}]);
app.controller('webmailCtrl', ['$scope', '$http', '$sce', '$timeout', function($scope, $http, $sce, $timeout) {
// ── State ────────────────────────────────────────────────
$scope.currentEmail = '';
$scope.managedAccounts = [];
$scope.folders = [];
$scope.currentFolder = 'INBOX';
$scope.messages = [];
$scope.currentPage = 1;
$scope.totalPages = 1;
$scope.totalMessages = 0;
$scope.perPage = 25;
$scope.openMsg = null;
$scope.trustedBody = '';
$scope.viewMode = 'list'; // list, read, compose, contacts, rules, settings
$scope.loading = false;
$scope.sending = false;
$scope.searchQuery = '';
$scope.selectAll = false;
$scope.showMoveDropdown = false;
$scope.moveTarget = '';
$scope.showBcc = false;
// Compose
$scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
// Contacts
$scope.contacts = [];
$scope.filteredContacts = [];
$scope.contactSearch = '';
$scope.editingContact = null;
// Rules
$scope.sieveRules = [];
$scope.editingRule = null;
// Settings
$scope.wmSettings = {};
// Draft auto-save
var draftTimer = null;
// ── Helper ───────────────────────────────────────────────
function apiCall(url, data, callback, errback) {
var config = {headers: {'X-CSRFToken': getCookie('csrftoken')}};
var payload = data || {};
// Always send current account so backend uses the right email
if ($scope.currentEmail && !payload.fromAccount) {
payload.fromAccount = $scope.currentEmail;
}
$http.post(url, payload, config).then(function(resp) {
if (callback) callback(resp.data);
}, function(err) {
console.error('API error:', url, err);
if (errback) errback(err);
});
}
function notify(msg, type) {
new PNotify({title: type === 'error' ? 'Error' : 'Webmail', text: msg, type: type || 'success'});
}
// ── Init ─────────────────────────────────────────────────
$scope.init = function() {
// Try SSO first
apiCall('/webmail/api/sso', {}, function(data) {
if (data.status === 1) {
$scope.currentEmail = data.email;
$scope.managedAccounts = data.accounts || [];
$scope.loadFolders();
$scope.loadSettings();
} else {
notify(data.error_message || 'No email accounts found. Create an email account first or use the standalone login.', 'error');
}
});
};
// ── Account Switching ────────────────────────────────────
$scope.switchAccount = function() {
var newEmail = $scope.currentEmail;
if (!newEmail) return;
// Reset view state immediately
$scope.currentFolder = 'INBOX';
$scope.currentPage = 1;
$scope.openMsg = null;
$scope.viewMode = 'list';
$scope.messages = [];
$scope.contacts = [];
$scope.filteredContacts = [];
$scope.sieveRules = [];
apiCall('/webmail/api/switchAccount', {email: newEmail}, function(data) {
if (data.status === 1) {
$scope.loadFolders();
$scope.loadSettings();
} else {
notify(data.error_message || 'Failed to switch account', 'error');
console.error('switchAccount failed:', data);
}
}, function(err) {
notify('Failed to switch account: ' + (err.status || 'unknown error'), 'error');
console.error('switchAccount HTTP error:', err);
});
};
// ── Folders ──────────────────────────────────────────────
$scope.loadFolders = function() {
apiCall('/webmail/api/listFolders', {}, function(data) {
if (data.status === 1) {
$scope.folders = data.folders;
$scope.loadMessages();
} else {
notify(data.error_message || 'Failed to load folders.', 'error');
}
});
};
$scope.selectFolder = function(name) {
$scope.currentFolder = name;
$scope.currentPage = 1;
$scope.openMsg = null;
$scope.viewMode = 'list';
$scope.searchQuery = '';
$scope.loadMessages();
};
$scope.getFolderIcon = function(folder) {
// Use folder_type from backend if available (mapped from Dovecot folder names)
var ftype = folder.folder_type || '';
if (ftype === 'inbox') return 'fa-inbox';
if (ftype === 'sent') return 'fa-paper-plane';
if (ftype === 'drafts') return 'fa-file';
if (ftype === 'trash') return 'fa-trash';
if (ftype === 'junk') return 'fa-ban';
if (ftype === 'archive') return 'fa-box-archive';
// Fallback to name-based detection
var n = (folder.display_name || folder.name || '').toLowerCase();
if (n === 'inbox') return 'fa-inbox';
if (n.indexOf('sent') >= 0) return 'fa-paper-plane';
if (n.indexOf('draft') >= 0) return 'fa-file';
if (n.indexOf('deleted') >= 0 || n.indexOf('trash') >= 0) return 'fa-trash';
if (n.indexOf('junk') >= 0 || n.indexOf('spam') >= 0) return 'fa-ban';
if (n.indexOf('archive') >= 0) return 'fa-box-archive';
return 'fa-folder';
};
$scope.createFolder = function() {
var name = prompt('Folder name:');
if (!name) return;
// Dovecot namespace: prefix with INBOX. and use . as separator
if (name.indexOf('INBOX.') !== 0) {
name = 'INBOX.' + name;
}
apiCall('/webmail/api/createFolder', {name: name}, function(data) {
if (data.status === 1) {
$scope.loadFolders();
notify('Folder created.');
} else {
notify(data.error_message, 'error');
}
});
};
// ── Messages ─────────────────────────────────────────────
$scope.loadMessages = function() {
$scope.loading = true;
apiCall('/webmail/api/listMessages', {
folder: $scope.currentFolder,
page: $scope.currentPage,
perPage: $scope.perPage
}, function(data) {
$scope.loading = false;
if (data.status === 1) {
$scope.messages = data.messages;
$scope.totalMessages = data.total;
$scope.totalPages = data.pages;
$scope.selectAll = false;
} else {
notify(data.error_message || 'Failed to load messages.', 'error');
}
}, function() {
$scope.loading = false;
});
};
$scope.prevPage = function() {
if ($scope.currentPage > 1) {
$scope.currentPage--;
$scope.loadMessages();
}
};
$scope.nextPage = function() {
if ($scope.currentPage < $scope.totalPages) {
$scope.currentPage++;
$scope.loadMessages();
}
};
$scope.searchMessages = function() {
if (!$scope.searchQuery) {
$scope.loadMessages();
return;
}
$scope.loading = true;
apiCall('/webmail/api/searchMessages', {
folder: $scope.currentFolder,
query: $scope.searchQuery
}, function(data) {
$scope.loading = false;
if (data.status === 1 && data.uids && data.uids.length > 0) {
// Fetch the found messages by their UIDs
apiCall('/webmail/api/listMessages', {
folder: $scope.currentFolder,
page: 1,
perPage: data.uids.length,
uids: data.uids
}, function(msgData) {
if (msgData.status === 1) {
$scope.messages = msgData.messages;
$scope.totalMessages = msgData.total;
$scope.totalPages = msgData.pages;
}
});
} else if (data.status === 1) {
$scope.messages = [];
$scope.totalMessages = 0;
$scope.totalPages = 1;
notify('No messages found.', 'info');
}
}, function() {
$scope.loading = false;
});
};
// ── Open/Read Message ────────────────────────────────────
$scope.openMessage = function(msg) {
apiCall('/webmail/api/getMessage', {
folder: $scope.currentFolder,
uid: msg.uid
}, function(data) {
if (data.status === 1) {
$scope.openMsg = data.message;
var html = data.message.body_html || '';
var text = data.message.body_text || '';
// Use sanitized HTML from backend, or escape plain text
if (html) {
$scope.trustedBody = $sce.trustAsHtml(html);
} else {
// Escape plain text to prevent XSS
var escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
$scope.trustedBody = $sce.trustAsHtml('<pre>' + escaped + '</pre>');
}
$scope.viewMode = 'read';
// Only decrement unread count if message was actually unread
if (!msg.is_read) {
msg.is_read = true;
$scope.folders.forEach(function(f) {
if (f.name === $scope.currentFolder && f.unread_count > 0) {
f.unread_count--;
}
});
}
}
});
};
// ── Compose ──────────────────────────────────────────────
$scope.composeNew = function() {
$scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
$scope.viewMode = 'compose';
$scope.showBcc = false;
$timeout(function() {
var editor = document.getElementById('wm-compose-body');
if (editor) {
editor.innerHTML = '';
// Add signature if available
if ($scope.wmSettings.signatureHtml) {
editor.innerHTML = '<br><br><div class="wm-signature">-- <br>' + $scope.wmSettings.signatureHtml + '</div>';
}
}
}, 100);
startDraftAutoSave();
};
$scope.replyTo = function() {
if (!$scope.openMsg) return;
var subj = $scope.openMsg.subject || '';
$scope.compose = {
to: $scope.openMsg.from,
cc: '',
bcc: '',
subject: (subj.match(/^Re:/i) ? '' : 'Re: ') + subj,
body: '',
files: [],
inReplyTo: $scope.openMsg.message_id || '',
references: (($scope.openMsg.references || '') + ' ' + ($scope.openMsg.message_id || '')).trim()
};
$scope.viewMode = 'compose';
$timeout(function() {
var editor = document.getElementById('wm-compose-body');
if (editor) {
var sig = $scope.wmSettings.signatureHtml ? '<br><br><div class="wm-signature">-- <br>' + $scope.wmSettings.signatureHtml + '</div>' : '';
editor.innerHTML = '<br>' + sig + '<br><div class="wm-quoted">On ' + $scope.openMsg.date + ', ' + $scope.openMsg.from + ' wrote:<br><blockquote>' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '</blockquote></div>';
}
}, 100);
startDraftAutoSave();
};
$scope.replyAll = function() {
if (!$scope.openMsg) return;
var cc = [];
if ($scope.openMsg.to) cc.push($scope.openMsg.to);
if ($scope.openMsg.cc) cc.push($scope.openMsg.cc);
$scope.compose = {
to: $scope.openMsg.from,
cc: cc.join(', '),
bcc: '',
subject: (($scope.openMsg.subject || '').match(/^Re:/i) ? '' : 'Re: ') + ($scope.openMsg.subject || ''),
body: '',
files: [],
inReplyTo: $scope.openMsg.message_id || '',
references: (($scope.openMsg.references || '') + ' ' + ($scope.openMsg.message_id || '')).trim()
};
$scope.viewMode = 'compose';
$timeout(function() {
var editor = document.getElementById('wm-compose-body');
if (editor) {
editor.innerHTML = '<br><br><div class="wm-quoted">On ' + ($scope.openMsg.date || '') + ', ' + ($scope.openMsg.from || '') + ' wrote:<br><blockquote>' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '</blockquote></div>';
}
}, 100);
startDraftAutoSave();
};
$scope.forwardMsg = function() {
if (!$scope.openMsg) return;
var fsubj = $scope.openMsg.subject || '';
$scope.compose = {
to: '',
cc: '',
bcc: '',
subject: (fsubj.match(/^Fwd:/i) ? '' : 'Fwd: ') + fsubj,
body: '',
files: [],
inReplyTo: '',
references: ''
};
$scope.viewMode = 'compose';
$timeout(function() {
var editor = document.getElementById('wm-compose-body');
if (editor) {
editor.innerHTML = '<br><br><div class="wm-forwarded">---------- Forwarded message ----------<br>From: ' + $scope.openMsg.from + '<br>Date: ' + $scope.openMsg.date + '<br>Subject: ' + $scope.openMsg.subject + '<br>To: ' + $scope.openMsg.to + '<br><br>' + ($scope.openMsg.body_html || $scope.openMsg.body_text || '') + '</div>';
}
}, 100);
startDraftAutoSave();
};
$scope.updateComposeBody = function() {
var editor = document.getElementById('wm-compose-body');
if (editor) {
$scope.compose.body = editor.innerHTML;
}
};
$scope.execCmd = function(cmd) {
document.execCommand(cmd, false, null);
};
$scope.insertLink = function() {
var url = prompt('Enter URL:');
if (url) {
document.execCommand('createLink', false, url);
}
};
$scope.addFiles = function(files) {
$scope.$apply(function() {
for (var i = 0; i < files.length; i++) {
$scope.compose.files.push(files[i]);
}
});
};
$scope.removeFile = function(index) {
$scope.compose.files.splice(index, 1);
};
$scope.sendMessage = function() {
$scope.updateComposeBody();
$scope.sending = true;
stopDraftAutoSave();
var fd = new FormData();
fd.append('fromAccount', $scope.currentEmail || '');
fd.append('to', $scope.compose.to);
fd.append('cc', $scope.compose.cc || '');
fd.append('bcc', $scope.compose.bcc || '');
fd.append('subject', $scope.compose.subject);
fd.append('body', $scope.compose.body);
fd.append('inReplyTo', $scope.compose.inReplyTo || '');
fd.append('references', $scope.compose.references || '');
for (var i = 0; i < $scope.compose.files.length; i++) {
fd.append('attachment_' + i, $scope.compose.files[i]);
}
$http.post('/webmail/api/sendMessage', fd, {
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': undefined
},
transformRequest: angular.identity
}).then(function(resp) {
$scope.sending = false;
if (resp.data.status === 1) {
notify('Message sent from ' + (resp.data.sentFrom || 'unknown'));
$scope.viewMode = 'list';
$scope.loadMessages();
} else {
notify(resp.data.error_message, 'error');
}
}, function() {
$scope.sending = false;
notify('Failed to send message.', 'error');
});
};
$scope.saveDraft = function() {
$scope.updateComposeBody();
apiCall('/webmail/api/saveDraft', {
to: $scope.compose.to,
subject: $scope.compose.subject,
body: $scope.compose.body
}, function(data) {
if (data.status === 1) {
notify('Draft saved.');
}
});
};
$scope.discardDraft = function() {
stopDraftAutoSave();
$scope.viewMode = 'list';
$scope.compose = {to: '', cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
};
function startDraftAutoSave() {
stopDraftAutoSave();
draftTimer = setInterval(function() {
$scope.updateComposeBody();
if ($scope.compose.subject || $scope.compose.body || $scope.compose.to) {
apiCall('/webmail/api/saveDraft', {
to: $scope.compose.to,
subject: $scope.compose.subject,
body: $scope.compose.body
});
}
}, 60000); // Auto-save every 60 seconds
}
function stopDraftAutoSave() {
if (draftTimer) {
clearInterval(draftTimer);
draftTimer = null;
}
}
// ── Bulk Actions ─────────────────────────────────────────
$scope.toggleSelectAll = function() {
$scope.messages.forEach(function(m) { m.selected = $scope.selectAll; });
};
function getSelectedUids() {
return $scope.messages.filter(function(m) { return m.selected; }).map(function(m) { return m.uid; });
}
$scope.bulkDelete = function() {
var uids = getSelectedUids();
if (uids.length === 0) return;
apiCall('/webmail/api/deleteMessages', {folder: $scope.currentFolder, uids: uids}, function(data) {
if (data.status === 1) {
$scope.loadMessages();
$scope.loadFolders();
}
});
};
$scope.bulkMarkRead = function() {
var uids = getSelectedUids();
if (uids.length === 0) return;
apiCall('/webmail/api/markRead', {folder: $scope.currentFolder, uids: uids}, function() {
$scope.loadMessages();
$scope.loadFolders();
});
};
$scope.bulkMarkUnread = function() {
var uids = getSelectedUids();
if (uids.length === 0) return;
apiCall('/webmail/api/markUnread', {folder: $scope.currentFolder, uids: uids}, function() {
$scope.loadMessages();
$scope.loadFolders();
});
};
$scope.bulkMove = function() {
var uids = getSelectedUids();
if (uids.length === 0 || !$scope.moveTarget) return;
apiCall('/webmail/api/moveMessages', {
folder: $scope.currentFolder,
uids: uids,
targetFolder: $scope.moveTarget.name || $scope.moveTarget
}, function(data) {
if (data.status === 1) {
$scope.showMoveDropdown = false;
$scope.moveTarget = '';
$scope.loadMessages();
$scope.loadFolders();
}
});
};
$scope.toggleFlag = function(msg) {
apiCall('/webmail/api/markFlagged', {folder: $scope.currentFolder, uids: [msg.uid]}, function() {
msg.is_flagged = !msg.is_flagged;
});
};
$scope.deleteMsg = function(msg) {
apiCall('/webmail/api/deleteMessages', {folder: $scope.currentFolder, uids: [msg.uid]}, function(data) {
if (data.status === 1) {
$scope.openMsg = null;
$scope.viewMode = 'list';
$scope.loadMessages();
$scope.loadFolders();
}
});
};
// ── Attachments ──────────────────────────────────────────
$scope.downloadAttachment = function(att) {
var form = document.createElement('form');
form.method = 'POST';
form.action = '/webmail/api/getAttachment';
form.target = '_blank';
var fields = {folder: $scope.currentFolder, uid: $scope.openMsg.uid, partId: att.part_id};
fields['csrfmiddlewaretoken'] = getCookie('csrftoken');
for (var key in fields) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = fields[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
};
// ── View Mode ────────────────────────────────────────────
$scope.setView = function(mode) {
stopDraftAutoSave();
$scope.viewMode = mode;
$scope.openMsg = null;
if (mode === 'contacts') $scope.loadContacts();
if (mode === 'rules') $scope.loadRules();
if (mode === 'settings') $scope.loadSettings();
};
// ── Contacts ─────────────────────────────────────────────
$scope.loadContacts = function() {
apiCall('/webmail/api/listContacts', {}, function(data) {
if (data.status === 1) {
$scope.contacts = data.contacts;
$scope.filteredContacts = data.contacts;
}
});
};
$scope.filterContacts = function() {
var q = ($scope.contactSearch || '').toLowerCase();
$scope.filteredContacts = $scope.contacts.filter(function(c) {
return (c.display_name || '').toLowerCase().indexOf(q) >= 0 ||
(c.email_address || '').toLowerCase().indexOf(q) >= 0;
});
};
$scope.newContact = function() {
$scope.editingContact = {display_name: '', email_address: '', phone: '', organization: '', notes: ''};
};
$scope.editContact = function(c) {
$scope.editingContact = angular.copy(c);
};
$scope.saveContact = function() {
var c = $scope.editingContact;
var url = c.id ? '/webmail/api/updateContact' : '/webmail/api/createContact';
apiCall(url, {
id: c.id,
displayName: c.display_name,
emailAddress: c.email_address,
phone: c.phone,
organization: c.organization,
notes: c.notes
}, function(data) {
if (data.status === 1) {
$scope.editingContact = null;
$scope.loadContacts();
notify('Contact saved.');
} else {
notify(data.error_message, 'error');
}
});
};
$scope.removeContact = function(c) {
if (!confirm('Delete contact ' + (c.display_name || c.email_address) + '?')) return;
apiCall('/webmail/api/deleteContact', {id: c.id}, function(data) {
if (data.status === 1) {
$scope.loadContacts();
}
});
};
$scope.composeToContact = function(c) {
$scope.compose = {to: c.email_address, cc: '', bcc: '', subject: '', body: '', files: [], inReplyTo: '', references: ''};
$scope.viewMode = 'compose';
$scope.showBcc = false;
$timeout(function() {
var editor = document.getElementById('wm-compose-body');
if (editor) {
editor.innerHTML = '';
if ($scope.wmSettings.signatureHtml) {
editor.innerHTML = '<br><br><div class="wm-signature">-- <br>' + $scope.wmSettings.signatureHtml + '</div>';
}
}
}, 100);
startDraftAutoSave();
};
// ── Sieve Rules ──────────────────────────────────────────
$scope.loadRules = function() {
apiCall('/webmail/api/listRules', {}, function(data) {
if (data.status === 1) {
$scope.sieveRules = data.rules;
}
});
};
$scope.newRule = function() {
$scope.editingRule = {
name: '', priority: 0, conditionField: 'from',
conditionType: 'contains', conditionValue: '',
actionType: 'move', actionValue: ''
};
};
$scope.editRule = function(rule) {
$scope.editingRule = {
id: rule.id,
name: rule.name,
priority: rule.priority,
conditionField: rule.condition_field,
conditionType: rule.condition_type,
conditionValue: rule.condition_value,
actionType: rule.action_type,
actionValue: rule.action_value
};
};
$scope.saveRule = function() {
var r = $scope.editingRule;
var url = r.id ? '/webmail/api/updateRule' : '/webmail/api/createRule';
apiCall(url, r, function(data) {
if (data.status === 1) {
$scope.editingRule = null;
$scope.loadRules();
notify('Rule saved.');
} else {
notify(data.error_message, 'error');
}
});
};
$scope.removeRule = function(rule) {
if (!confirm('Delete rule "' + rule.name + '"?')) return;
apiCall('/webmail/api/deleteRule', {id: rule.id}, function(data) {
if (data.status === 1) {
$scope.loadRules();
}
});
};
// ── Settings ─────────────────────────────────────────────
$scope.loadSettings = function() {
apiCall('/webmail/api/getSettings', {}, function(data) {
if (data.status === 1) {
$scope.wmSettings = data.settings;
if ($scope.wmSettings.messagesPerPage) {
$scope.perPage = parseInt($scope.wmSettings.messagesPerPage);
}
}
});
};
$scope.saveSettings = function() {
apiCall('/webmail/api/saveSettings', $scope.wmSettings, function(data) {
if (data.status === 1) {
notify('Settings saved.');
if ($scope.wmSettings.messagesPerPage) {
$scope.perPage = parseInt($scope.wmSettings.messagesPerPage);
}
} else {
notify(data.error_message, 'error');
}
});
};
}]);

View File

@@ -0,0 +1,471 @@
{% extends "baseTemplate/index.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Webmail - CyberPanel" %}{% endblock %}
{% block content %}
<link rel="stylesheet" href="{% static 'webmail/webmail.css' %}">
<div class="webmail-container" ng-controller="webmailCtrl" ng-init="init()">
<div id="cybermailBanner" style="display:none;flex-shrink:0;">
<div style="padding:10px 20px;display:flex;align-items:center;gap:14px;background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 50%,#9333ea 100%);">
<div style="flex-shrink:0;font-size:22px;">&#9993;</div>
<div style="flex:1;min-width:0;font-size:12.5px;color:rgba(255,255,255,0.85);">
<span style="color:#fff;font-weight:800;font-size:13.5px;">Stop Landing in Spam</span> &mdash; Route emails through CyberMail. <strong style="color:#fff;">15,000 emails/month free.</strong> <a href="https://cyberpanel.net/KnowledgeBase/cybermail-user-guide/" target="_blank" style="color:rgba(255,255,255,0.9);text-decoration:underline;">Learn more</a>
</div>
<a href="/emailDelivery/" style="background:#fff;color:#4f46e5;padding:6px 18px;border-radius:5px;font-weight:700;font-size:11.5px;text-decoration:none;white-space:nowrap;flex-shrink:0;">Get Started Free &rarr;</a>
<button onclick="dismissCyberMailBanner()" style="background:none;border:none;color:rgba(255,255,255,0.5);font-size:18px;cursor:pointer;padding:0 2px;line-height:1;flex-shrink:0;" title="Dismiss">&times;</button>
</div>
</div>
<script>
(function(){if(!document.cookie.includes('cybermail_dismiss=1')){document.getElementById('cybermailBanner').style.display='';}})();
function dismissCyberMailBanner(){document.getElementById('cybermailBanner').style.display='none';document.cookie='cybermail_dismiss=1; path=/; max-age='+7*86400;}
</script>
<!-- Account Switcher Bar -->
<div class="wm-account-bar" ng-show="managedAccounts.length > 1">
<div class="wm-account-current">
<i class="fa fa-envelope"></i>
<span>{$ currentEmail $}</span>
</div>
<div class="wm-account-switch">
<select ng-model="currentEmail" ng-change="switchAccount()"
ng-options="a for a in managedAccounts" class="wm-account-select">
</select>
</div>
</div>
<!-- Main Layout -->
<div class="wm-layout">
<!-- Column 1: Sidebar -->
<div class="wm-sidebar">
<button class="btn btn-primary wm-compose-btn" ng-click="composeNew()">
<i class="fa fa-pen-to-square"></i> {% trans "Compose" %}
</button>
<div class="wm-folder-list">
<div class="wm-folder-item" ng-repeat="folder in folders"
ng-class="{'active': currentFolder === folder.name}"
ng-click="selectFolder(folder.name)">
<i class="fa" ng-class="getFolderIcon(folder)"></i>
<span class="wm-folder-name">{$ folder.display_name || folder.name $}</span>
<span class="wm-badge" ng-if="folder.unread_count > 0">{$ folder.unread_count $}</span>
</div>
</div>
<div class="wm-sidebar-divider"></div>
<div class="wm-sidebar-nav">
<a ng-click="setView('contacts')" class="wm-nav-link" ng-class="{'active': viewMode === 'contacts'}">
<i class="fa fa-address-book"></i> {% trans "Contacts" %}
</a>
<a ng-click="setView('rules')" class="wm-nav-link" ng-class="{'active': viewMode === 'rules'}">
<i class="fa fa-filter"></i> {% trans "Rules" %}
</a>
<a ng-click="setView('settings')" class="wm-nav-link" ng-class="{'active': viewMode === 'settings'}">
<i class="fa fa-gear"></i> {% trans "Settings" %}
</a>
</div>
<div class="wm-sidebar-divider"></div>
<div class="wm-sidebar-nav">
<a ng-click="createFolder()" class="wm-nav-link">
<i class="fa fa-folder-plus"></i> {% trans "New Folder" %}
</a>
</div>
</div>
<!-- Column 2: Message List -->
<div class="wm-message-list" ng-show="viewMode === 'list' || viewMode === 'read'">
<!-- Search Bar -->
<div class="wm-search-bar">
<input type="text" ng-model="searchQuery" placeholder="{% trans 'Search messages...' %}"
ng-keyup="$event.keyCode === 13 && searchMessages()" class="wm-search-input">
<button class="wm-search-btn" ng-click="searchMessages()">
<i class="fa fa-search"></i>
</button>
</div>
<!-- Bulk Actions -->
<div class="wm-bulk-actions">
<label class="wm-checkbox-label">
<input type="checkbox" ng-model="selectAll" ng-change="toggleSelectAll()">
</label>
<button class="wm-action-btn" ng-click="bulkDelete()" title="{% trans 'Delete' %}">
<i class="fa fa-trash"></i>
</button>
<button class="wm-action-btn" ng-click="bulkMarkRead()" title="{% trans 'Mark read' %}">
<i class="fa fa-envelope-open"></i>
</button>
<button class="wm-action-btn" ng-click="bulkMarkUnread()" title="{% trans 'Mark unread' %}">
<i class="fa fa-envelope"></i>
</button>
<div class="wm-move-dropdown" ng-if="showMoveDropdown">
<select ng-model="moveTarget" ng-change="bulkMove()"
ng-options="f.name for f in folders">
<option value="">{% trans "Move to..." %}</option>
</select>
</div>
<button class="wm-action-btn" ng-click="showMoveDropdown = !showMoveDropdown" title="{% trans 'Move' %}">
<i class="fa fa-folder-open"></i>
</button>
<span class="wm-page-info">{$ currentPage $} / {$ totalPages $}</span>
<button class="wm-action-btn" ng-click="prevPage()" ng-disabled="currentPage <= 1">
<i class="fa fa-chevron-left"></i>
</button>
<button class="wm-action-btn" ng-click="nextPage()" ng-disabled="currentPage >= totalPages">
<i class="fa fa-chevron-right"></i>
</button>
</div>
<!-- Message Rows -->
<div class="wm-messages">
<div class="wm-msg-row" ng-repeat="msg in messages"
ng-class="{'unread': !msg.is_read, 'flagged': msg.is_flagged, 'selected': msg.selected}"
ng-click="openMessage(msg)">
<label class="wm-checkbox-label" ng-click="$event.stopPropagation()">
<input type="checkbox" ng-model="msg.selected">
</label>
<button class="wm-star-btn" ng-click="toggleFlag(msg); $event.stopPropagation()">
<i class="fa" ng-class="msg.is_flagged ? 'fa-star wm-starred' : 'fa-star wm-unstarred'"></i>
</button>
<div class="wm-msg-from">{$ msg.from | limitTo:30 $}</div>
<div class="wm-msg-subject">{$ msg.subject | limitTo:60 $}</div>
<div class="wm-msg-date">{$ msg.date | wmDate $}</div>
</div>
<div class="wm-empty" ng-if="messages.length === 0 && !loading">
<i class="fa fa-inbox"></i>
<p>{% trans "No messages" %}</p>
</div>
<div class="wm-loading" ng-if="loading">
<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}
</div>
</div>
</div>
<!-- Column 3: Detail Pane -->
<div class="wm-detail-pane">
<!-- Message Read View -->
<div ng-if="viewMode === 'read' && openMsg" class="wm-read-view">
<div class="wm-read-toolbar">
<button class="btn btn-sm btn-default" ng-click="replyTo()">
<i class="fa fa-reply"></i> {% trans "Reply" %}
</button>
<button class="btn btn-sm btn-default" ng-click="replyAll()">
<i class="fa fa-reply-all"></i> {% trans "Reply All" %}
</button>
<button class="btn btn-sm btn-default" ng-click="forwardMsg()">
<i class="fa fa-share"></i> {% trans "Forward" %}
</button>
<button class="btn btn-sm btn-danger" ng-click="deleteMsg(openMsg)">
<i class="fa fa-trash"></i> {% trans "Delete" %}
</button>
</div>
<div class="wm-read-header">
<h3 class="wm-read-subject">{$ openMsg.subject $}</h3>
<div class="wm-read-meta">
<div><strong>{% trans "From" %}:</strong> {$ openMsg.from $}</div>
<div><strong>{% trans "To" %}:</strong> {$ openMsg.to $}</div>
<div ng-if="openMsg.cc"><strong>{% trans "Cc" %}:</strong> {$ openMsg.cc $}</div>
<div><strong>{% trans "Date" %}:</strong> {$ openMsg.date $}</div>
</div>
</div>
<div class="wm-attachments" ng-if="openMsg.attachments.length > 0">
<div class="wm-attachment" ng-repeat="att in openMsg.attachments">
<a ng-click="downloadAttachment(att)">
<i class="fa fa-paperclip"></i> {$ att.filename $}
<span class="wm-att-size">({$ att.size | fileSize $})</span>
</a>
</div>
</div>
<div class="wm-read-body" ng-bind-html="trustedBody"></div>
</div>
<!-- Compose View -->
<div ng-if="viewMode === 'compose'" class="wm-compose-view">
<div class="wm-compose-header">
<h3>{% trans "Compose" %}</h3>
</div>
<form name="composeForm" ng-submit="sendMessage()" class="wm-compose-form">
<div class="wm-field">
<label>{% trans "From" %}</label>
<select ng-model="$parent.currentEmail"
ng-options="a for a in managedAccounts"
ng-change="switchAccount()"
ng-show="managedAccounts.length > 1" class="form-control"></select>
<input type="text" value="{$ currentEmail $}" class="form-control" readonly
ng-show="managedAccounts.length <= 1">
</div>
<div class="wm-field">
<label>{% trans "To" %}</label>
<input type="text" ng-model="compose.to" class="form-control"
wm-autocomplete required>
</div>
<div class="wm-field">
<label>{% trans "Cc" %}</label>
<input type="text" ng-model="compose.cc" class="form-control"
wm-autocomplete>
</div>
<div class="wm-field" ng-show="showBcc">
<label>{% trans "Bcc" %}</label>
<input type="text" ng-model="compose.bcc" class="form-control"
wm-autocomplete>
</div>
<div class="wm-field">
<label>{% trans "Subject" %}</label>
<input type="text" ng-model="compose.subject" class="form-control">
</div>
<div class="wm-field">
<a ng-click="showBcc = !showBcc" class="wm-toggle-link">
{$ showBcc ? '{% trans "Hide Bcc" %}' : '{% trans "Show Bcc" %}' $}
</a>
</div>
<!-- Rich Text Toolbar -->
<div class="wm-editor-toolbar">
<button type="button" ng-click="execCmd('bold')" title="Bold"><i class="fa fa-bold"></i></button>
<button type="button" ng-click="execCmd('italic')" title="Italic"><i class="fa fa-italic"></i></button>
<button type="button" ng-click="execCmd('underline')" title="Underline"><i class="fa fa-underline"></i></button>
<button type="button" ng-click="execCmd('insertUnorderedList')" title="List"><i class="fa fa-list-ul"></i></button>
<button type="button" ng-click="execCmd('insertOrderedList')" title="Numbered List"><i class="fa fa-list-ol"></i></button>
<button type="button" ng-click="insertLink()" title="Link"><i class="fa fa-link"></i></button>
</div>
<div class="wm-editor" contenteditable="true" id="wm-compose-body"
ng-blur="updateComposeBody()"></div>
<div class="wm-compose-attachments">
<input type="file" id="wm-file-input" multiple
onchange="angular.element(this).scope().addFiles(this.files)">
<label for="wm-file-input" class="wm-attach-btn">
<i class="fa fa-paperclip"></i> {% trans "Attach files" %}
</label>
<div class="wm-file-list">
<span ng-repeat="f in compose.files" class="wm-file-tag">
{$ f.name $} ({$ f.size | fileSize $})
<i class="fa fa-times" ng-click="removeFile($index)"></i>
</span>
</div>
</div>
<div class="wm-compose-actions">
<button type="submit" class="btn btn-primary" ng-disabled="sending">
<i class="fa fa-paper-plane"></i> {% trans "Send" %}
</button>
<button type="button" class="btn btn-default" ng-click="saveDraft()">
<i class="fa fa-floppy-disk"></i> {% trans "Save Draft" %}
</button>
<button type="button" class="btn btn-default" ng-click="discardDraft()">
<i class="fa fa-xmark"></i> {% trans "Discard" %}
</button>
</div>
</form>
</div>
<!-- Contacts View -->
<div ng-if="viewMode === 'contacts'" class="wm-contacts-view">
<div class="wm-section-header">
<h3>{% trans "Contacts" %}</h3>
<button class="btn btn-sm btn-primary" ng-click="newContact()">
<i class="fa fa-plus"></i> {% trans "Add" %}
</button>
</div>
<div class="wm-contacts-search">
<input type="text" ng-model="contactSearch" placeholder="{% trans 'Search contacts...' %}"
class="form-control" ng-change="filterContacts()">
</div>
<div class="wm-contact-list">
<div class="wm-contact-item" ng-repeat="c in filteredContacts">
<div class="wm-contact-info">
<div class="wm-contact-name">{$ c.display_name || c.email_address $}</div>
<div class="wm-contact-email">{$ c.email_address $}</div>
</div>
<div class="wm-contact-actions">
<button class="wm-action-btn" ng-click="composeToContact(c)" title="{% trans 'Email' %}">
<i class="fa fa-envelope"></i>
</button>
<button class="wm-action-btn" ng-click="editContact(c)" title="{% trans 'Edit' %}">
<i class="fa fa-pencil"></i>
</button>
<button class="wm-action-btn" ng-click="removeContact(c)" title="{% trans 'Delete' %}">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
</div>
<!-- Edit/New Contact Form -->
<div ng-if="editingContact" class="wm-contact-form">
<h4>{$ editingContact.id ? '{% trans "Edit Contact" %}' : '{% trans "New Contact" %}' $}</h4>
<div class="wm-field">
<label>{% trans "Name" %}</label>
<input type="text" ng-model="editingContact.display_name" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Email" %}</label>
<input type="email" ng-model="editingContact.email_address" class="form-control" required>
</div>
<div class="wm-field">
<label>{% trans "Phone" %}</label>
<input type="text" ng-model="editingContact.phone" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Organization" %}</label>
<input type="text" ng-model="editingContact.organization" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Notes" %}</label>
<textarea ng-model="editingContact.notes" class="form-control" rows="3"></textarea>
</div>
<div class="wm-form-actions">
<button class="btn btn-primary" ng-click="saveContact()">{% trans "Save" %}</button>
<button class="btn btn-default" ng-click="editingContact = null">{% trans "Cancel" %}</button>
</div>
</div>
</div>
<!-- Rules View -->
<div ng-if="viewMode === 'rules'" class="wm-rules-view">
<div class="wm-section-header">
<h3>{% trans "Mail Filter Rules" %}</h3>
<button class="btn btn-sm btn-primary" ng-click="newRule()">
<i class="fa fa-plus"></i> {% trans "Add Rule" %}
</button>
</div>
<div class="wm-rule-list">
<div class="wm-rule-item" ng-repeat="rule in sieveRules">
<div class="wm-rule-info">
<strong>{$ rule.name $}</strong>
<span class="wm-rule-desc">
If <em>{$ rule.condition_field $}</em> {$ rule.condition_type $} "{$ rule.condition_value $}"
&rarr; {$ rule.action_type $} {$ rule.action_value $}
</span>
</div>
<div class="wm-rule-actions">
<button class="wm-action-btn" ng-click="editRule(rule)"><i class="fa fa-pencil"></i></button>
<button class="wm-action-btn" ng-click="removeRule(rule)"><i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<!-- Edit/New Rule Form -->
<div ng-if="editingRule" class="wm-rule-form">
<h4>{$ editingRule.id ? '{% trans "Edit Rule" %}' : '{% trans "New Rule" %}' $}</h4>
<div class="wm-field">
<label>{% trans "Name" %}</label>
<input type="text" ng-model="editingRule.name" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Priority" %}</label>
<input type="number" ng-model="editingRule.priority" class="form-control">
</div>
<div class="wm-field-row">
<div class="wm-field">
<label>{% trans "If" %}</label>
<select ng-model="editingRule.conditionField" class="form-control">
<option value="from">From</option>
<option value="to">To</option>
<option value="subject">Subject</option>
<option value="size">Size</option>
</select>
</div>
<div class="wm-field">
<label>{% trans "Condition" %}</label>
<select ng-model="editingRule.conditionType" class="form-control">
<option value="contains">contains</option>
<option value="is">is</option>
<option value="matches">matches</option>
<option value="greater_than">greater than</option>
</select>
</div>
<div class="wm-field">
<label>{% trans "Value" %}</label>
<input type="text" ng-model="editingRule.conditionValue" class="form-control">
</div>
</div>
<div class="wm-field-row">
<div class="wm-field">
<label>{% trans "Action" %}</label>
<select ng-model="editingRule.actionType" class="form-control">
<option value="move">Move to folder</option>
<option value="forward">Forward to</option>
<option value="discard">Discard</option>
<option value="flag">Flag</option>
</select>
</div>
<div class="wm-field" ng-if="editingRule.actionType === 'move'">
<label>{% trans "Target Folder" %}</label>
<select ng-model="editingRule.actionValue" class="form-control">
<option value="">-- {% trans "Select Folder" %} --</option>
<option ng-repeat="f in folders" value="{$ f.display_name || f.name $}">{$ f.display_name || f.name $}</option>
</select>
</div>
<div class="wm-field" ng-if="editingRule.actionType === 'forward'">
<label>{% trans "Forward To" %}</label>
<input type="text" ng-model="editingRule.actionValue" class="form-control"
placeholder="Email address">
</div>
</div>
<div class="wm-form-actions">
<button class="btn btn-primary" ng-click="saveRule()">{% trans "Save" %}</button>
<button class="btn btn-default" ng-click="editingRule = null">{% trans "Cancel" %}</button>
</div>
</div>
</div>
<!-- Settings View -->
<div ng-if="viewMode === 'settings'" class="wm-settings-view">
<div class="wm-section-header">
<h3>{% trans "Settings" %}</h3>
</div>
<div class="wm-settings-form">
<div class="wm-field">
<label>{% trans "Display Name" %}</label>
<input type="text" ng-model="wmSettings.displayName" class="form-control">
</div>
<div class="wm-field">
<label>{% trans "Signature" %}</label>
<textarea ng-model="wmSettings.signatureHtml" class="form-control" rows="4"></textarea>
</div>
<div class="wm-field">
<label>{% trans "Messages per page" %}</label>
<select ng-model="wmSettings.messagesPerPage" class="form-control">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="wm-field">
<label>{% trans "Default reply behavior" %}</label>
<select ng-model="wmSettings.defaultReplyBehavior" class="form-control">
<option value="reply">Reply</option>
<option value="reply_all">Reply All</option>
</select>
</div>
<div class="wm-field">
<label class="wm-checkbox-label">
<input type="checkbox" ng-model="wmSettings.autoCollectContacts">
{% trans "Auto-collect contacts from sent messages" %}
</label>
</div>
<div class="wm-form-actions">
<button class="btn btn-primary" ng-click="saveSettings()">
<i class="fa fa-floppy-disk"></i> {% trans "Save Settings" %}
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div ng-if="viewMode === 'list' && !openMsg" class="wm-empty-detail">
<i class="fa fa-envelope-open fa-3x"></i>
<p>{% trans "Select a message to read" %}</p>
</div>
</div>
</div>
</div>
<script src="{% static 'webmail/webmail.js' %}?v=6"></script>
{% endblock %}

View File

@@ -0,0 +1,181 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="en" ng-app="CyberCP">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Webmail Login - CyberPanel" %}</title>
<link rel="stylesheet" type="text/css" href="{% static 'baseTemplate/assets/bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="{% static 'baseTemplate/angularjs.1.6.5.js' %}"></script>
<script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
<style>
:root {
--bg-primary: #f0f0ff;
--bg-secondary: white;
--accent-color: #6C5CE7;
--accent-hover: #5A4BD1;
--text-primary: #2D3436;
--text-secondary: #636E72;
--border-color: #DFE6E9;
}
body {
background: var(--bg-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.wm-login-card {
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
padding: 48px 40px;
width: 100%;
max-width: 420px;
}
.wm-login-logo {
text-align: center;
margin-bottom: 32px;
}
.wm-login-logo i {
font-size: 48px;
color: var(--accent-color);
}
.wm-login-logo h2 {
margin-top: 12px;
color: var(--text-primary);
font-weight: 600;
}
.wm-login-logo p {
color: var(--text-secondary);
font-size: 14px;
}
.wm-login-field {
margin-bottom: 20px;
}
.wm-login-field label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.wm-login-field input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 10px;
font-size: 15px;
transition: border-color 0.2s;
box-sizing: border-box;
}
.wm-login-field input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(108,92,231,0.1);
}
.wm-login-btn {
width: 100%;
padding: 12px;
background: var(--accent-color);
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.wm-login-btn:hover {
background: var(--accent-hover);
}
.wm-login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wm-login-error {
background: #FFE0E0;
color: #D63031;
padding: 10px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="wm-login-card" ng-controller="webmailLoginCtrl">
<div class="wm-login-logo">
<i class="fa fa-envelope"></i>
<h2>{% trans "CyberPanel Webmail" %}</h2>
<p>{% trans "Sign in to access your email" %}</p>
</div>
<div class="wm-login-error" ng-if="errorMsg">
{$ errorMsg $}
</div>
<form ng-submit="login()">
<div class="wm-login-field">
<label>{% trans "Email Address" %}</label>
<input type="email" ng-model="email" placeholder="user@example.com" required autofocus>
</div>
<div class="wm-login-field">
<label>{% trans "Password" %}</label>
<input type="password" ng-model="password" placeholder="{% trans 'Your email password' %}" required>
</div>
<button type="submit" class="wm-login-btn" ng-disabled="loggingIn">
<span ng-if="!loggingIn">{% trans "Sign In" %}</span>
<span ng-if="loggingIn"><i class="fa fa-spinner fa-spin"></i> {% trans "Signing in..." %}</span>
</button>
</form>
</div>
<script>
var app = angular.module('CyberCP', []);
app.config(['$interpolateProvider', function($ip) {
$ip.startSymbol('{$');
$ip.endSymbol('$}');
}]);
function getCookie(name) {
var value = '; ' + document.cookie;
var parts = value.split('; ' + name + '=');
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
}
app.controller('webmailLoginCtrl', function($scope, $http) {
$scope.email = '';
$scope.password = '';
$scope.errorMsg = '';
$scope.loggingIn = false;
$scope.login = function() {
$scope.loggingIn = true;
$scope.errorMsg = '';
$http.post('/webmail/api/login', {
email: $scope.email,
password: $scope.password
}, {
headers: { 'X-CSRFToken': getCookie('csrftoken') }
}).then(function(resp) {
if (resp.data.status === 1) {
window.location.href = '/webmail/';
} else {
$scope.errorMsg = resp.data.error_message || 'Login failed.';
}
$scope.loggingIn = false;
}, function(err) {
$scope.errorMsg = 'Connection error. Please try again.';
$scope.loggingIn = false;
});
};
});
</script>
</body>
</html>

60
webmail/urls.py Normal file
View File

@@ -0,0 +1,60 @@
from django.urls import re_path
from . import views
urlpatterns = [
# Pages
re_path(r'^$', views.loadWebmail, name='loadWebmail'),
re_path(r'^login$', views.loadLogin, name='loadWebmailLogin'),
# Auth
re_path(r'^api/login$', views.apiLogin, name='wmApiLogin'),
re_path(r'^api/logout$', views.apiLogout, name='wmApiLogout'),
re_path(r'^api/sso$', views.apiSSO, name='wmApiSSO'),
re_path(r'^api/listAccounts$', views.apiListAccounts, name='wmApiListAccounts'),
re_path(r'^api/switchAccount$', views.apiSwitchAccount, name='wmApiSwitchAccount'),
# Folders
re_path(r'^api/listFolders$', views.apiListFolders, name='wmApiListFolders'),
re_path(r'^api/createFolder$', views.apiCreateFolder, name='wmApiCreateFolder'),
re_path(r'^api/renameFolder$', views.apiRenameFolder, name='wmApiRenameFolder'),
re_path(r'^api/deleteFolder$', views.apiDeleteFolder, name='wmApiDeleteFolder'),
# Messages
re_path(r'^api/listMessages$', views.apiListMessages, name='wmApiListMessages'),
re_path(r'^api/searchMessages$', views.apiSearchMessages, name='wmApiSearchMessages'),
re_path(r'^api/getMessage$', views.apiGetMessage, name='wmApiGetMessage'),
re_path(r'^api/getAttachment$', views.apiGetAttachment, name='wmApiGetAttachment'),
# Actions
re_path(r'^api/sendMessage$', views.apiSendMessage, name='wmApiSendMessage'),
re_path(r'^api/saveDraft$', views.apiSaveDraft, name='wmApiSaveDraft'),
re_path(r'^api/deleteMessages$', views.apiDeleteMessages, name='wmApiDeleteMessages'),
re_path(r'^api/moveMessages$', views.apiMoveMessages, name='wmApiMoveMessages'),
re_path(r'^api/markRead$', views.apiMarkRead, name='wmApiMarkRead'),
re_path(r'^api/markUnread$', views.apiMarkUnread, name='wmApiMarkUnread'),
re_path(r'^api/markFlagged$', views.apiMarkFlagged, name='wmApiMarkFlagged'),
# Contacts
re_path(r'^api/listContacts$', views.apiListContacts, name='wmApiListContacts'),
re_path(r'^api/createContact$', views.apiCreateContact, name='wmApiCreateContact'),
re_path(r'^api/updateContact$', views.apiUpdateContact, name='wmApiUpdateContact'),
re_path(r'^api/deleteContact$', views.apiDeleteContact, name='wmApiDeleteContact'),
re_path(r'^api/searchContacts$', views.apiSearchContacts, name='wmApiSearchContacts'),
re_path(r'^api/listContactGroups$', views.apiListContactGroups, name='wmApiListContactGroups'),
re_path(r'^api/createContactGroup$', views.apiCreateContactGroup, name='wmApiCreateContactGroup'),
re_path(r'^api/deleteContactGroup$', views.apiDeleteContactGroup, name='wmApiDeleteContactGroup'),
# Sieve Rules
re_path(r'^api/listRules$', views.apiListRules, name='wmApiListRules'),
re_path(r'^api/createRule$', views.apiCreateRule, name='wmApiCreateRule'),
re_path(r'^api/updateRule$', views.apiUpdateRule, name='wmApiUpdateRule'),
re_path(r'^api/deleteRule$', views.apiDeleteRule, name='wmApiDeleteRule'),
re_path(r'^api/activateRules$', views.apiActivateRules, name='wmApiActivateRules'),
# Settings
re_path(r'^api/getSettings$', views.apiGetSettings, name='wmApiGetSettings'),
re_path(r'^api/saveSettings$', views.apiSaveSettings, name='wmApiSaveSettings'),
# Image Proxy
re_path(r'^api/proxyImage$', views.apiProxyImage, name='wmApiProxyImage'),
]

397
webmail/views.py Normal file
View File

@@ -0,0 +1,397 @@
import json
from django.shortcuts import redirect
from django.http import HttpResponse
from loginSystem.views import loadLoginPage
from .webmailManager import WebmailManager
# ── Page Views ────────────────────────────────────────────────
def loadWebmail(request):
try:
wm = WebmailManager(request)
return wm.loadWebmail()
except KeyError:
return redirect(loadLoginPage)
def loadLogin(request):
wm = WebmailManager(request)
return wm.loadLogin()
# ── Auth APIs ─────────────────────────────────────────────────
def apiLogin(request):
try:
wm = WebmailManager(request)
return wm.apiLogin()
except Exception as e:
return _error_response(e)
def apiLogout(request):
try:
wm = WebmailManager(request)
return wm.apiLogout()
except Exception as e:
return _error_response(e)
def apiSSO(request):
try:
wm = WebmailManager(request)
return wm.apiSSO()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiListAccounts(request):
try:
wm = WebmailManager(request)
return wm.apiListAccounts()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiSwitchAccount(request):
try:
wm = WebmailManager(request)
return wm.apiSwitchAccount()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# ── Folder APIs ───────────────────────────────────────────────
def apiListFolders(request):
try:
wm = WebmailManager(request)
return wm.apiListFolders()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiCreateFolder(request):
try:
wm = WebmailManager(request)
return wm.apiCreateFolder()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiRenameFolder(request):
try:
wm = WebmailManager(request)
return wm.apiRenameFolder()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiDeleteFolder(request):
try:
wm = WebmailManager(request)
return wm.apiDeleteFolder()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# ── Message APIs ──────────────────────────────────────────────
def apiListMessages(request):
try:
wm = WebmailManager(request)
return wm.apiListMessages()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiSearchMessages(request):
try:
wm = WebmailManager(request)
return wm.apiSearchMessages()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiGetMessage(request):
try:
wm = WebmailManager(request)
return wm.apiGetMessage()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiGetAttachment(request):
try:
wm = WebmailManager(request)
return wm.apiGetAttachment()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# ── Action APIs ───────────────────────────────────────────────
def apiSendMessage(request):
try:
wm = WebmailManager(request)
return wm.apiSendMessage()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiSaveDraft(request):
try:
wm = WebmailManager(request)
return wm.apiSaveDraft()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiDeleteMessages(request):
try:
wm = WebmailManager(request)
return wm.apiDeleteMessages()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiMoveMessages(request):
try:
wm = WebmailManager(request)
return wm.apiMoveMessages()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiMarkRead(request):
try:
wm = WebmailManager(request)
return wm.apiMarkRead()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiMarkUnread(request):
try:
wm = WebmailManager(request)
return wm.apiMarkUnread()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiMarkFlagged(request):
try:
wm = WebmailManager(request)
return wm.apiMarkFlagged()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# ── Contact APIs ──────────────────────────────────────────────
def apiListContacts(request):
try:
wm = WebmailManager(request)
return wm.apiListContacts()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiCreateContact(request):
try:
wm = WebmailManager(request)
return wm.apiCreateContact()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiUpdateContact(request):
try:
wm = WebmailManager(request)
return wm.apiUpdateContact()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiDeleteContact(request):
try:
wm = WebmailManager(request)
return wm.apiDeleteContact()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiSearchContacts(request):
try:
wm = WebmailManager(request)
return wm.apiSearchContacts()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiListContactGroups(request):
try:
wm = WebmailManager(request)
return wm.apiListContactGroups()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiCreateContactGroup(request):
try:
wm = WebmailManager(request)
return wm.apiCreateContactGroup()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiDeleteContactGroup(request):
try:
wm = WebmailManager(request)
return wm.apiDeleteContactGroup()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# ── Sieve Rule APIs ──────────────────────────────────────────
def apiListRules(request):
try:
wm = WebmailManager(request)
return wm.apiListRules()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiCreateRule(request):
try:
wm = WebmailManager(request)
return wm.apiCreateRule()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiUpdateRule(request):
try:
wm = WebmailManager(request)
return wm.apiUpdateRule()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiDeleteRule(request):
try:
wm = WebmailManager(request)
return wm.apiDeleteRule()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiActivateRules(request):
try:
wm = WebmailManager(request)
return wm.apiActivateRules()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# ── Settings APIs ─────────────────────────────────────────────
def apiGetSettings(request):
try:
wm = WebmailManager(request)
return wm.apiGetSettings()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
def apiSaveSettings(request):
try:
wm = WebmailManager(request)
return wm.apiSaveSettings()
except KeyError:
return redirect(loadLoginPage)
except Exception as e:
return _error_response(e)
# ── Image Proxy ───────────────────────────────────────────────
def apiProxyImage(request):
try:
wm = WebmailManager(request)
return wm.apiProxyImage()
except Exception as e:
return _error_response(e)
# ── Helpers ───────────────────────────────────────────────────
def _error_response(e):
data = {'status': 0, 'error_message': str(e)}
return HttpResponse(json.dumps(data), content_type='application/json')

824
webmail/webmailManager.py Normal file
View File

@@ -0,0 +1,824 @@
import json
import os
import base64
from django.http import HttpResponse
from django.shortcuts import render, redirect
from .models import Contact, ContactGroup, ContactGroupMembership, WebmailSettings, SieveRule
from .services.imap_client import IMAPClient
from .services.smtp_client import SMTPClient
from .services.email_composer import EmailComposer
from .services.sieve_client import SieveClient
import plogical.CyberCPLogFileWriter as logging
WEBMAIL_CONF = '/etc/cyberpanel/webmail.conf'
class WebmailManager:
def __init__(self, request):
self.request = request
# ── Helpers ────────────────────────────────────────────────
@staticmethod
def _json_response(data):
return HttpResponse(json.dumps(data), content_type='application/json')
@staticmethod
def _error(msg):
return HttpResponse(json.dumps({'status': 0, 'error_message': str(msg)}),
content_type='application/json')
@staticmethod
def _success(extra=None):
data = {'status': 1}
if extra:
data.update(extra)
return HttpResponse(json.dumps(data), content_type='application/json')
def _get_post_data(self):
try:
return json.loads(self.request.body)
except Exception:
return self.request.POST.dict()
def _get_email(self):
# Check for explicit email in POST body (from account switcher)
# This ensures the correct account is used even if session is stale
try:
data = json.loads(self.request.body)
explicit = data.get('fromAccount', '')
if explicit:
accounts = self._get_managed_accounts()
if explicit in accounts:
self.request.session['webmail_email'] = explicit
return explicit
except Exception:
pass
return self.request.session.get('webmail_email')
def _get_master_config(self):
"""Read master user config from /etc/cyberpanel/webmail.conf"""
try:
with open(WEBMAIL_CONF, 'r') as f:
config = json.load(f)
return config.get('master_user'), config.get('master_password')
except Exception:
return None, None
def _get_imap(self, email_addr=None):
"""Create IMAP client, preferring master user auth for SSO sessions."""
addr = email_addr or self._get_email()
if not addr:
raise Exception('No email account selected')
master_user, master_pass = self._get_master_config()
if master_user and master_pass:
return IMAPClient(addr, '', master_user=master_user, master_password=master_pass)
# Fallback: standalone login with stored password
password = self.request.session.get('webmail_password', '')
return IMAPClient(addr, password)
def _get_smtp(self):
addr = self._get_email()
if not addr:
raise Exception('No email account selected')
# If using master user (SSO), we can't auth to SMTP since
# auth_master_user_separator is not set in Dovecot.
# Use local relay via port 25 instead (Postfix permits localhost).
master_user, master_pass = self._get_master_config()
is_standalone = self.request.session.get('webmail_standalone', False)
if master_user and master_pass and not is_standalone:
return SMTPClient(addr, '', use_local_relay=True)
password = self.request.session.get('webmail_password', '')
return SMTPClient(addr, password)
def _get_sieve(self, email_addr=None):
addr = email_addr or self._get_email()
if not addr:
raise Exception('No email account selected')
master_user, master_pass = self._get_master_config()
if master_user and master_pass:
return SieveClient(addr, '', master_user=master_user, master_password=master_pass)
password = self.request.session.get('webmail_password', '')
return SieveClient(addr, password)
def _get_managed_accounts(self):
"""Get email accounts the current CyberPanel user can access."""
try:
from plogical.acl import ACLManager
from loginSystem.models import Administrator
from mailServer.models import Domains, EUsers
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
websites = ACLManager.findAllSites(currentACL, userID)
websites = websites + ACLManager.findChildDomains(websites)
accounts = []
for site in websites:
try:
domain = Domains.objects.get(domain=site)
for eu in EUsers.objects.filter(emailOwner=domain):
accounts.append(eu.email)
except Exception:
continue
return accounts
except Exception:
return []
# ── Page Renders ──────────────────────────────────────────
def loadWebmail(self):
from plogical.httpProc import httpProc
email = self._get_email()
accounts = self._get_managed_accounts()
if not email and accounts:
if len(accounts) == 1:
self.request.session['webmail_email'] = accounts[0]
email = accounts[0]
else:
# Multiple accounts - render picker
proc = httpProc(self.request, 'webmail/index.html',
{'accounts': json.dumps(accounts), 'show_picker': True},
'listEmails')
return proc.render()
proc = httpProc(self.request, 'webmail/index.html',
{'email': email or '',
'accounts': json.dumps(accounts),
'show_picker': False},
'listEmails')
return proc.render()
def loadLogin(self):
return render(self.request, 'webmail/login.html')
# ── Auth APIs ─────────────────────────────────────────────
def apiLogin(self):
data = self._get_post_data()
email_addr = data.get('email', '')
password = data.get('password', '')
if not email_addr or not password:
return self._error('Email and password are required.')
try:
client = IMAPClient(email_addr, password)
client.close()
except Exception as e:
return self._error('Login failed: %s' % str(e))
self.request.session['webmail_email'] = email_addr
self.request.session['webmail_password'] = password
self.request.session['webmail_standalone'] = True
return self._success()
def apiLogout(self):
for key in ['webmail_email', 'webmail_password', 'webmail_standalone']:
self.request.session.pop(key, None)
return self._success()
def apiSSO(self):
"""Auto-login for CyberPanel users."""
accounts = self._get_managed_accounts()
if not accounts:
return self._error('No email accounts found for your user.')
# Preserve previously selected account if still valid
current = self.request.session.get('webmail_email')
if not current or current not in accounts:
current = accounts[0]
self.request.session['webmail_email'] = current
return self._success({'email': current, 'accounts': accounts})
def apiListAccounts(self):
accounts = self._get_managed_accounts()
return self._success({'accounts': accounts})
def apiSwitchAccount(self):
data = self._get_post_data()
email = data.get('email', '')
accounts = self._get_managed_accounts()
if email not in accounts:
return self._error('You do not have access to this account.')
self.request.session['webmail_email'] = email
return self._success({'email': email})
# ── Folder APIs ───────────────────────────────────────────
def apiListFolders(self):
try:
with self._get_imap() as imap:
folders = imap.list_folders()
return self._success({'folders': folders})
except Exception as e:
return self._error(str(e))
def apiCreateFolder(self):
data = self._get_post_data()
name = data.get('name', '')
if not name:
return self._error('Folder name is required.')
try:
with self._get_imap() as imap:
if imap.create_folder(name):
return self._success()
return self._error('Failed to create folder.')
except Exception as e:
return self._error(str(e))
def apiRenameFolder(self):
data = self._get_post_data()
old_name = data.get('oldName', '')
new_name = data.get('newName', '')
if not old_name or not new_name:
return self._error('Both old and new folder names are required.')
try:
with self._get_imap() as imap:
if imap.rename_folder(old_name, new_name):
return self._success()
return self._error('Failed to rename folder.')
except Exception as e:
return self._error(str(e))
def apiDeleteFolder(self):
data = self._get_post_data()
name = data.get('name', '')
if not name:
return self._error('Folder name is required.')
# CyberPanel/Dovecot folder names (INBOX. prefix, separator '.')
protected = ['INBOX', 'INBOX.Sent', 'INBOX.Drafts', 'INBOX.Deleted Items',
'INBOX.Junk E-mail', 'INBOX.Archive']
if name in protected:
return self._error('Cannot delete system folder.')
try:
with self._get_imap() as imap:
if imap.delete_folder(name):
return self._success()
return self._error('Failed to delete folder.')
except Exception as e:
return self._error(str(e))
# ── Message APIs ──────────────────────────────────────────
def apiListMessages(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
page = int(data.get('page', 1))
per_page = int(data.get('perPage', 25))
try:
with self._get_imap() as imap:
result = imap.list_messages(folder, page, per_page)
return self._success(result)
except Exception as e:
return self._error(str(e))
def apiSearchMessages(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
query = data.get('query', '')
try:
with self._get_imap() as imap:
uids = imap.search_messages(folder, query)
uids = [u.decode() if isinstance(u, bytes) else str(u) for u in uids]
return self._success({'uids': uids})
except Exception as e:
return self._error(str(e))
def apiGetMessage(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
uid = data.get('uid', '')
if not uid:
return self._error('Message UID is required.')
try:
with self._get_imap() as imap:
msg = imap.get_message(folder, uid)
if msg is None:
return self._error('Message not found.')
imap.mark_read(folder, [uid])
return self._success({'message': msg})
except Exception as e:
return self._error(str(e))
def apiGetAttachment(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
uid = data.get('uid', '')
part_id = data.get('partId', '')
try:
with self._get_imap() as imap:
result = imap.get_attachment(folder, uid, part_id)
if result is None:
return self._error('Attachment not found.')
filename, content_type, payload = result
response = HttpResponse(payload, content_type=content_type)
# Sanitize filename to prevent header injection and path traversal
import os as _os
safe_filename = _os.path.basename(filename)
safe_filename = safe_filename.replace('"', '_').replace('\r', '').replace('\n', '').replace('\x00', '')
if not safe_filename:
safe_filename = 'attachment'
response['Content-Disposition'] = 'attachment; filename="%s"' % safe_filename
return response
except Exception as e:
return self._error(str(e))
# ── Action APIs ───────────────────────────────────────────
def apiSendMessage(self):
try:
# For multipart forms, check fromAccount in POST data
if self.request.content_type and 'multipart' in self.request.content_type:
from_account = self.request.POST.get('fromAccount', '')
if from_account:
accounts = self._get_managed_accounts()
if from_account in accounts:
self.request.session['webmail_email'] = from_account
email_addr = self._get_email()
if not email_addr:
return self._error('Not logged in.')
# Handle multipart form data for attachments
if self.request.content_type and 'multipart' in self.request.content_type:
to = self.request.POST.get('to', '')
cc = self.request.POST.get('cc', '')
bcc = self.request.POST.get('bcc', '')
subject = self.request.POST.get('subject', '')
body_html = self.request.POST.get('body', '')
in_reply_to = self.request.POST.get('inReplyTo', '')
references = self.request.POST.get('references', '')
attachments = []
for key in self.request.FILES:
f = self.request.FILES[key]
attachments.append((f.name, f.content_type, f.read()))
else:
data = self._get_post_data()
to = data.get('to', '')
cc = data.get('cc', '')
bcc = data.get('bcc', '')
subject = data.get('subject', '')
body_html = data.get('body', '')
in_reply_to = data.get('inReplyTo', '')
references = data.get('references', '')
attachments = None
if not to:
return self._error('At least one recipient is required.')
mime_msg = EmailComposer.compose(
from_addr=email_addr,
to_addrs=to,
subject=subject,
body_html=body_html,
cc_addrs=cc,
bcc_addrs=bcc,
attachments=attachments,
in_reply_to=in_reply_to,
references=references,
)
smtp = self._get_smtp()
result = smtp.send_message(mime_msg)
if not result['success']:
return self._error(result.get('error', 'Failed to send.'))
# Save to Sent folder
try:
with self._get_imap() as imap:
raw = mime_msg.as_bytes()
smtp.save_to_sent(imap, raw)
except Exception:
pass
# Auto-collect contacts
try:
settings = WebmailSettings.objects.filter(email_account=email_addr).first()
if settings is None or settings.auto_collect_contacts:
self._auto_collect(email_addr, to, cc)
except Exception:
pass
return self._success({'messageId': result['message_id'], 'sentFrom': email_addr})
except Exception as e:
return self._error(str(e))
def _auto_collect(self, owner, to_addrs, cc_addrs=''):
"""Auto-save recipients as contacts."""
import re
all_addrs = '%s,%s' % (to_addrs, cc_addrs) if cc_addrs else to_addrs
emails = re.findall(r'[\w.+-]+@[\w-]+\.[\w.-]+', all_addrs)
for addr in emails:
if addr == owner:
continue
Contact.objects.get_or_create(
owner_email=owner,
email_address=addr,
defaults={'is_auto_collected': True, 'display_name': addr.split('@')[0]},
)
def apiSaveDraft(self):
try:
email_addr = self._get_email()
if not email_addr:
return self._error('Not logged in.')
data = self._get_post_data()
to = data.get('to', '')
subject = data.get('subject', '')
body_html = data.get('body', '')
mime_msg = EmailComposer.compose(
from_addr=email_addr,
to_addrs=to,
subject=subject,
body_html=body_html,
)
with self._get_imap() as imap:
# CyberPanel's Dovecot uses INBOX.Drafts
draft_folders = ['INBOX.Drafts', 'Drafts', 'Draft']
saved = False
for folder in draft_folders:
try:
if imap.append_message(folder, mime_msg.as_bytes(), '\\Draft \\Seen'):
saved = True
break
except Exception:
continue
if not saved:
imap.create_folder('INBOX.Drafts')
imap.append_message('INBOX.Drafts', mime_msg.as_bytes(), '\\Draft \\Seen')
return self._success()
except Exception as e:
return self._error(str(e))
def apiDeleteMessages(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
uids = data.get('uids', [])
if not uids:
return self._error('No messages selected.')
try:
with self._get_imap() as imap:
imap.delete_messages(folder, uids)
return self._success()
except Exception as e:
return self._error(str(e))
def apiMoveMessages(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
uids = data.get('uids', [])
target = data.get('targetFolder', '')
if not uids or not target:
return self._error('Messages and target folder are required.')
try:
with self._get_imap() as imap:
imap.move_messages(folder, uids, target)
return self._success()
except Exception as e:
return self._error(str(e))
def apiMarkRead(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
uids = data.get('uids', [])
try:
with self._get_imap() as imap:
imap.mark_read(folder, uids)
return self._success()
except Exception as e:
return self._error(str(e))
def apiMarkUnread(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
uids = data.get('uids', [])
try:
with self._get_imap() as imap:
imap.mark_unread(folder, uids)
return self._success()
except Exception as e:
return self._error(str(e))
def apiMarkFlagged(self):
data = self._get_post_data()
folder = data.get('folder', 'INBOX')
uids = data.get('uids', [])
try:
with self._get_imap() as imap:
imap.mark_flagged(folder, uids)
return self._success()
except Exception as e:
return self._error(str(e))
# ── Contact APIs ──────────────────────────────────────────
def apiListContacts(self):
email = self._get_email()
try:
contacts = list(Contact.objects.filter(owner_email=email).values(
'id', 'display_name', 'email_address', 'phone', 'organization', 'notes', 'is_auto_collected'
))
return self._success({'contacts': contacts})
except Exception as e:
return self._error(str(e))
def apiCreateContact(self):
email = self._get_email()
data = self._get_post_data()
try:
contact = Contact.objects.create(
owner_email=email,
display_name=data.get('displayName', ''),
email_address=data.get('emailAddress', ''),
phone=data.get('phone', ''),
organization=data.get('organization', ''),
notes=data.get('notes', ''),
)
return self._success({'id': contact.id})
except Exception as e:
return self._error(str(e))
def apiUpdateContact(self):
email = self._get_email()
data = self._get_post_data()
contact_id = data.get('id')
try:
contact = Contact.objects.get(id=contact_id, owner_email=email)
for field in ['display_name', 'email_address', 'phone', 'organization', 'notes']:
camel = field.replace('_', ' ').title().replace(' ', '')
camel = camel[0].lower() + camel[1:]
if camel in data:
setattr(contact, field, data[camel])
contact.save()
return self._success()
except Contact.DoesNotExist:
return self._error('Contact not found.')
except Exception as e:
return self._error(str(e))
def apiDeleteContact(self):
email = self._get_email()
data = self._get_post_data()
contact_id = data.get('id')
try:
Contact.objects.filter(id=contact_id, owner_email=email).delete()
return self._success()
except Exception as e:
return self._error(str(e))
def apiSearchContacts(self):
email = self._get_email()
data = self._get_post_data()
query = data.get('query', '')
try:
from django.db.models import Q
contacts = Contact.objects.filter(
owner_email=email
).filter(
Q(display_name__icontains=query) | Q(email_address__icontains=query)
).values('id', 'display_name', 'email_address')[:20]
return self._success({'contacts': list(contacts)})
except Exception as e:
return self._error(str(e))
def apiListContactGroups(self):
email = self._get_email()
try:
groups = list(ContactGroup.objects.filter(owner_email=email).values('id', 'name'))
return self._success({'groups': groups})
except Exception as e:
return self._error(str(e))
def apiCreateContactGroup(self):
email = self._get_email()
data = self._get_post_data()
name = data.get('name', '')
if not name:
return self._error('Group name is required.')
try:
group = ContactGroup.objects.create(owner_email=email, name=name)
return self._success({'id': group.id})
except Exception as e:
return self._error(str(e))
def apiDeleteContactGroup(self):
email = self._get_email()
data = self._get_post_data()
group_id = data.get('id')
try:
ContactGroup.objects.filter(id=group_id, owner_email=email).delete()
return self._success()
except Exception as e:
return self._error(str(e))
# ── Sieve Rule APIs ───────────────────────────────────────
def apiListRules(self):
email = self._get_email()
try:
rules = list(SieveRule.objects.filter(email_account=email).values(
'id', 'name', 'priority', 'is_active',
'condition_field', 'condition_type', 'condition_value',
'action_type', 'action_value',
))
return self._success({'rules': rules})
except Exception as e:
return self._error(str(e))
def apiCreateRule(self):
email = self._get_email()
data = self._get_post_data()
try:
rule = SieveRule.objects.create(
email_account=email,
name=data.get('name', 'New Rule'),
priority=data.get('priority', 0),
is_active=data.get('isActive', True),
condition_field=data.get('conditionField', 'from'),
condition_type=data.get('conditionType', 'contains'),
condition_value=data.get('conditionValue', ''),
action_type=data.get('actionType', 'move'),
action_value=data.get('actionValue', ''),
)
self._sync_sieve_rules(email)
return self._success({'id': rule.id})
except Exception as e:
return self._error(str(e))
def apiUpdateRule(self):
email = self._get_email()
data = self._get_post_data()
rule_id = data.get('id')
try:
rule = SieveRule.objects.get(id=rule_id, email_account=email)
for field in ['name', 'priority', 'is_active', 'condition_field',
'condition_type', 'condition_value', 'action_type', 'action_value']:
camel = field.replace('_', ' ').title().replace(' ', '')
camel = camel[0].lower() + camel[1:]
if camel in data:
val = data[camel]
if field == 'is_active':
val = bool(val)
elif field == 'priority':
val = int(val)
setattr(rule, field, val)
rule.save()
self._sync_sieve_rules(email)
return self._success()
except SieveRule.DoesNotExist:
return self._error('Rule not found.')
except Exception as e:
return self._error(str(e))
def apiDeleteRule(self):
email = self._get_email()
data = self._get_post_data()
rule_id = data.get('id')
try:
SieveRule.objects.filter(id=rule_id, email_account=email).delete()
self._sync_sieve_rules(email)
return self._success()
except Exception as e:
return self._error(str(e))
def apiActivateRules(self):
email = self._get_email()
try:
self._sync_sieve_rules(email)
return self._success()
except Exception as e:
return self._error(str(e))
def _sync_sieve_rules(self, email):
"""Generate sieve script from DB rules and upload to Dovecot.
ManageSieve may not be available if dovecot-sieve/pigeonhole is not
installed or if the ManageSieve service isn't running on port 4190.
Rules are always saved to the database; Sieve sync is best-effort.
"""
rules = SieveRule.objects.filter(email_account=email, is_active=True).order_by('priority')
rule_dicts = []
for r in rules:
rule_dicts.append({
'name': r.name,
'condition_field': r.condition_field,
'condition_type': r.condition_type,
'condition_value': r.condition_value,
'action_type': r.action_type,
'action_value': r.action_value,
})
script = SieveClient.rules_to_sieve(rule_dicts)
try:
with self._get_sieve(email) as sieve:
sieve.put_script('cyberpanel', script)
sieve.activate_script('cyberpanel')
except ConnectionRefusedError:
logging.CyberCPLogFileWriter.writeToFile(
'Sieve sync skipped for %s: ManageSieve not running on port 4190. '
'Install dovecot-sieve and enable ManageSieve.' % email)
except Exception as e:
logging.CyberCPLogFileWriter.writeToFile('Sieve sync failed for %s: %s' % (email, str(e)))
# ── Settings APIs ─────────────────────────────────────────
def apiGetSettings(self):
email = self._get_email()
try:
settings, created = WebmailSettings.objects.get_or_create(email_account=email)
return self._success({
'settings': {
'displayName': settings.display_name,
'signatureHtml': settings.signature_html,
'messagesPerPage': settings.messages_per_page,
'defaultReplyBehavior': settings.default_reply_behavior,
'themePreference': settings.theme_preference,
'autoCollectContacts': settings.auto_collect_contacts,
}
})
except Exception as e:
return self._error(str(e))
def apiSaveSettings(self):
email = self._get_email()
data = self._get_post_data()
try:
settings, created = WebmailSettings.objects.get_or_create(email_account=email)
if 'displayName' in data:
settings.display_name = data['displayName']
if 'signatureHtml' in data:
settings.signature_html = data['signatureHtml']
if 'messagesPerPage' in data:
settings.messages_per_page = int(data['messagesPerPage'])
if 'defaultReplyBehavior' in data:
settings.default_reply_behavior = data['defaultReplyBehavior']
if 'themePreference' in data:
settings.theme_preference = data['themePreference']
if 'autoCollectContacts' in data:
settings.auto_collect_contacts = bool(data['autoCollectContacts'])
settings.save()
return self._success()
except Exception as e:
return self._error(str(e))
# ── Image Proxy ───────────────────────────────────────────
def apiProxyImage(self):
"""Proxy external images to prevent tracking and mixed content."""
if not self._get_email():
return self._error('Not logged in.')
url_b64 = self.request.GET.get('url', '') or self.request.POST.get('url', '')
try:
url = base64.urlsafe_b64decode(url_b64).decode('utf-8')
except Exception:
return self._error('Invalid URL.')
if not url.startswith(('http://', 'https://')):
return self._error('Invalid URL scheme.')
# Block internal/private IPs to prevent SSRF
import urllib.parse
import socket
import ipaddress
hostname = urllib.parse.urlparse(url).hostname or ''
try:
resolved_ip = socket.gethostbyname(hostname)
ip_obj = ipaddress.ip_address(resolved_ip)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_multicast or ip_obj.is_reserved:
return self._error('Invalid URL.')
except (socket.gaierror, ValueError):
return self._error('Invalid URL.')
try:
import urllib.request
req = urllib.request.Request(url, headers={
'User-Agent': 'CyberPanel-Webmail-Proxy/1.0',
})
with urllib.request.urlopen(req, timeout=10) as resp:
content_type = resp.headers.get('Content-Type', 'image/png')
if not content_type.startswith('image/'):
return self._error('Not an image.')
data = resp.read(5 * 1024 * 1024) # 5MB max
return HttpResponse(data, content_type=content_type)
except Exception:
return self._error('Failed to fetch image.')