mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-03-09 22:00:14 +01:00
Merge branch 'v2.4.5' into stable
This commit is contained in:
@@ -75,6 +75,8 @@ INSTALLED_APPS = [
|
||||
'CLManager',
|
||||
'IncBackups',
|
||||
'aiScanner',
|
||||
'webmail',
|
||||
'emailDelivery',
|
||||
# 'WebTerminal'
|
||||
]
|
||||
|
||||
|
||||
@@ -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')),
|
||||
]
|
||||
|
||||
23
README.md
23
README.md
@@ -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**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,7 +29,7 @@ import pwd
|
||||
# Create your views here.
|
||||
|
||||
VERSION = '2.4'
|
||||
BUILD = 4
|
||||
BUILD = 5
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
|
||||
266
docs/CYBERMAIL_SETUP_GUIDE.md
Normal file
266
docs/CYBERMAIL_SETUP_GUIDE.md
Normal 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
|
||||
393
docs/CYBERMAIL_TECHNICAL_REFERENCE.md
Normal file
393
docs/CYBERMAIL_TECHNICAL_REFERENCE.md
Normal 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 |
|
||||
188
docs/CYBERMAIL_USER_GUIDE.md
Normal file
188
docs/CYBERMAIL_USER_GUIDE.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
0
emailDelivery/__init__.py
Normal file
0
emailDelivery/__init__.py
Normal file
6
emailDelivery/apps.py
Normal file
6
emailDelivery/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EmaildeliveryConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'emailDelivery'
|
||||
747
emailDelivery/emailDeliveryManager.py
Normal file
747
emailDelivery/emailDeliveryManager.py
Normal 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)})
|
||||
0
emailDelivery/migrations/__init__.py
Normal file
0
emailDelivery/migrations/__init__.py
Normal file
44
emailDelivery/models.py
Normal file
44
emailDelivery/models.py
Normal 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}"
|
||||
326
emailDelivery/static/emailDelivery/emailDelivery.js
Normal file
326
emailDelivery/static/emailDelivery/emailDelivery.js
Normal 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');
|
||||
});
|
||||
};
|
||||
|
||||
}]);
|
||||
1095
emailDelivery/templates/emailDelivery/index.html
Normal file
1095
emailDelivery/templates/emailDelivery/index.html
Normal file
File diff suppressed because it is too large
Load Diff
30
emailDelivery/urls.py
Normal file
30
emailDelivery/urls.py
Normal 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
184
emailDelivery/views.py
Normal 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'})
|
||||
@@ -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;">✉</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 →</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">×</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">
|
||||
|
||||
|
||||
@@ -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;">✉</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 →</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">×</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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;">✉</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 →</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">×</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">
|
||||
|
||||
@@ -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;">✉</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 →</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">×</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">
|
||||
|
||||
@@ -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;">✉</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 →</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">×</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" %}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
965
tests/ols_feature_tests.sh
Executable 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
37
tests/ols_test_setup.sh
Executable 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"
|
||||
@@ -1 +1 @@
|
||||
{"version":"2.4","build":3}
|
||||
{"version":"2.4","build":5}
|
||||
0
webmail/__init__.py
Normal file
0
webmail/__init__.py
Normal file
5
webmail/apps.py
Normal file
5
webmail/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WebmailConfig(AppConfig):
|
||||
name = 'webmail'
|
||||
0
webmail/migrations/__init__.py
Normal file
0
webmail/migrations/__init__.py
Normal file
106
webmail/models.py
Normal file
106
webmail/models.py
Normal 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)
|
||||
0
webmail/services/__init__.py
Normal file
0
webmail/services/__init__.py
Normal file
180
webmail/services/email_composer.py
Normal file
180
webmail/services/email_composer.py
Normal 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,
|
||||
)
|
||||
194
webmail/services/email_parser.py
Normal file
194
webmail/services/email_parser.py
Normal 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
|
||||
373
webmail/services/imap_client.py
Normal file
373
webmail/services/imap_client.py
Normal 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'
|
||||
282
webmail/services/sieve_client.py
Normal file
282
webmail/services/sieve_client.py
Normal 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
|
||||
76
webmail/services/smtp_client.py
Normal file
76
webmail/services/smtp_client.py
Normal 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
|
||||
872
webmail/static/webmail/webmail.css
Normal file
872
webmail/static/webmail/webmail.css
Normal 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;
|
||||
}
|
||||
}
|
||||
826
webmail/static/webmail/webmail.js
Normal file
826
webmail/static/webmail/webmail.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
$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');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
}]);
|
||||
471
webmail/templates/webmail/index.html
Normal file
471
webmail/templates/webmail/index.html
Normal 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;">✉</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> — 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 →</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">×</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 $}"
|
||||
→ {$ 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 %}
|
||||
181
webmail/templates/webmail/login.html
Normal file
181
webmail/templates/webmail/login.html
Normal 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
60
webmail/urls.py
Normal 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
397
webmail/views.py
Normal 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
824
webmail/webmailManager.py
Normal 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.')
|
||||
Reference in New Issue
Block a user