mirror of
https://github.com/sudoxnym/dashd.git
synced 2026-04-14 19:46:21 +00:00
initial release - dashd cyberpunk infrastructure dashboard
features: - grid-locked card positioning with drag/resize - youtube widgets (click-to-play) - user authentication with server-side storage - per-user localStorage caching - docker deployment ready 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
57c920b57f
11 changed files with 5619 additions and 0 deletions
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# data
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# docker
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# os
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# install nginx first
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends nginx && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# install python deps
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# copy app files
|
||||||
|
COPY backend.py .
|
||||||
|
COPY mail_proxy.py .
|
||||||
|
COPY browser_proxy.py .
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY dashboard.html ./static/
|
||||||
|
|
||||||
|
# create data dir
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
# start script
|
||||||
|
COPY start.sh .
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
|
||||||
|
EXPOSE 8085
|
||||||
|
|
||||||
|
CMD ["./start.sh"]
|
||||||
|
COPY dashd_icon.png ./static/
|
||||||
232
backend.py
Normal file
232
backend.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
dashd backend - user auth + settings persistence
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from functools import wraps
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get('DASHD_DB', '/data/dashd.db')
|
||||||
|
JWT_SECRET = os.environ.get('DASHD_SECRET', secrets.token_hex(32))
|
||||||
|
JWT_EXPIRY_DAYS = 30
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
services TEXT DEFAULT '[]',
|
||||||
|
card_positions TEXT DEFAULT '{}',
|
||||||
|
card_sizes TEXT DEFAULT '{}',
|
||||||
|
grid_size TEXT DEFAULT '{}',
|
||||||
|
machines TEXT DEFAULT '[]',
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def hash_password(password):
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
||||||
|
return salt + ':' + hashed.hex()
|
||||||
|
|
||||||
|
def verify_password(password, stored):
|
||||||
|
salt, hashed = stored.split(':')
|
||||||
|
check = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
||||||
|
return check.hex() == hashed
|
||||||
|
|
||||||
|
def create_token(user_id, username):
|
||||||
|
payload = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'username': username,
|
||||||
|
'exp': datetime.utcnow() + timedelta(days=JWT_EXPIRY_DAYS)
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||||
|
|
||||||
|
def verify_token(token):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||||
|
return payload
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class APIHandler(BaseHTTPRequestHandler):
|
||||||
|
def send_json(self, data, status=200):
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(data).encode())
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def get_user(self):
|
||||||
|
auth = self.headers.get('Authorization', '')
|
||||||
|
if auth.startswith('Bearer '):
|
||||||
|
token = auth[7:]
|
||||||
|
return verify_token(token)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read_body(self):
|
||||||
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
|
if length:
|
||||||
|
return json.loads(self.rfile.read(length).decode())
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
|
||||||
|
if path == '/api/auth/register':
|
||||||
|
data = self.read_body()
|
||||||
|
username = data.get('username', '').strip().lower()
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return self.send_json({'error': 'username and password required'}, 400)
|
||||||
|
if len(username) < 3:
|
||||||
|
return self.send_json({'error': 'username too short'}, 400)
|
||||||
|
if len(password) < 6:
|
||||||
|
return self.send_json({'error': 'password too short'}, 400)
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
cur = conn.execute(
|
||||||
|
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
|
||||||
|
(username, hash_password(password))
|
||||||
|
)
|
||||||
|
user_id = cur.lastrowid
|
||||||
|
conn.execute('INSERT INTO settings (user_id) VALUES (?)', (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
token = create_token(user_id, username)
|
||||||
|
self.send_json({'token': token, 'username': username})
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
self.send_json({'error': 'username taken'}, 400)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
elif path == '/api/auth/login':
|
||||||
|
data = self.read_body()
|
||||||
|
username = data.get('username', '').strip().lower()
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
user = conn.execute(
|
||||||
|
'SELECT id, password_hash FROM users WHERE username = ?', (username,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if user and verify_password(password, user['password_hash']):
|
||||||
|
token = create_token(user['id'], username)
|
||||||
|
self.send_json({'token': token, 'username': username})
|
||||||
|
else:
|
||||||
|
self.send_json({'error': 'invalid credentials'}, 401)
|
||||||
|
|
||||||
|
elif path == '/api/settings/save':
|
||||||
|
user = self.get_user()
|
||||||
|
if not user:
|
||||||
|
return self.send_json({'error': 'unauthorized'}, 401)
|
||||||
|
|
||||||
|
data = self.read_body()
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute('''
|
||||||
|
UPDATE settings SET
|
||||||
|
services = ?,
|
||||||
|
card_positions = ?,
|
||||||
|
card_sizes = ?,
|
||||||
|
grid_size = ?,
|
||||||
|
machines = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?
|
||||||
|
''', (
|
||||||
|
json.dumps(data.get('services', [])),
|
||||||
|
json.dumps(data.get('cardPositions', {})),
|
||||||
|
json.dumps(data.get('cardSizes', {})),
|
||||||
|
json.dumps(data.get('gridSize', {})),
|
||||||
|
json.dumps(data.get('machines', [])),
|
||||||
|
user['user_id']
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
self.send_json({'success': True})
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.send_json({'error': 'not found'}, 404)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
|
||||||
|
if path == '/api/settings/load':
|
||||||
|
user = self.get_user()
|
||||||
|
if not user:
|
||||||
|
return self.send_json({'error': 'unauthorized'}, 401)
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
settings = conn.execute(
|
||||||
|
'SELECT * FROM settings WHERE user_id = ?', (user['user_id'],)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if settings:
|
||||||
|
self.send_json({
|
||||||
|
'services': json.loads(settings['services']),
|
||||||
|
'cardPositions': json.loads(settings['card_positions']),
|
||||||
|
'cardSizes': json.loads(settings['card_sizes']),
|
||||||
|
'gridSize': json.loads(settings['grid_size']),
|
||||||
|
'machines': json.loads(settings['machines'])
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
self.send_json({})
|
||||||
|
|
||||||
|
elif path == '/api/auth/verify':
|
||||||
|
user = self.get_user()
|
||||||
|
if user:
|
||||||
|
self.send_json({'valid': True, 'username': user['username']})
|
||||||
|
else:
|
||||||
|
self.send_json({'valid': False}, 401)
|
||||||
|
|
||||||
|
elif path == '/health':
|
||||||
|
self.send_json({'status': 'ok'})
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.send_json({'error': 'not found'}, 404)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # silent logging
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_db()
|
||||||
|
port = int(os.environ.get('PORT', 8086))
|
||||||
|
server = HTTPServer(('0.0.0.0', port), APIHandler)
|
||||||
|
print(f'dashd backend running on port {port}')
|
||||||
|
server.serve_forever()
|
||||||
71
browser_proxy.py
Normal file
71
browser_proxy.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
browser proxy for dashd - strips X-Frame-Options to allow embedding any page
|
||||||
|
runs on port 8088
|
||||||
|
usage: /proxy?url=https://example.com
|
||||||
|
"""
|
||||||
|
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import ssl
|
||||||
|
import re
|
||||||
|
|
||||||
|
class ProxyHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.path.startswith('/proxy'):
|
||||||
|
self.send_error(404, 'use /proxy?url=...')
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
if 'url' not in params:
|
||||||
|
self.send_error(400, 'missing url parameter')
|
||||||
|
return
|
||||||
|
|
||||||
|
target_url = params['url'][0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
req = urllib.request.Request(target_url, headers={
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
})
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
|
||||||
|
content_type = response.headers.get('Content-Type', 'text/html')
|
||||||
|
body = response.read()
|
||||||
|
|
||||||
|
if 'text/html' in content_type:
|
||||||
|
body_str = body.decode('utf-8', errors='replace')
|
||||||
|
base_tag = '<base href="' + target_url + '">'
|
||||||
|
if '<head>' in body_str.lower():
|
||||||
|
body_str = re.sub(r'(<head[^>]*>)', r'\1' + base_tag, body_str, count=1, flags=re.IGNORECASE)
|
||||||
|
elif '<html>' in body_str.lower():
|
||||||
|
body_str = re.sub(r'(<html[^>]*>)', r'\1<head>' + base_tag + '</head>', body_str, count=1, flags=re.IGNORECASE)
|
||||||
|
body = body_str.encode('utf-8')
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', content_type)
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Content-Length', len(body))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
self.send_error(e.code, str(e.reason))
|
||||||
|
except Exception as e:
|
||||||
|
self.send_error(500, str(e))
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
port = 8088
|
||||||
|
server = HTTPServer(('0.0.0.0', port), ProxyHandler)
|
||||||
|
print(f'browser proxy running on port {port}')
|
||||||
|
server.serve_forever()
|
||||||
4837
dashboard.html
Normal file
4837
dashboard.html
Normal file
File diff suppressed because it is too large
Load diff
BIN
dashd_icon.png
Normal file
BIN
dashd_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
services:
|
||||||
|
dashd:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8085:8085"
|
||||||
|
volumes:
|
||||||
|
- dashd_data:/data
|
||||||
|
environment:
|
||||||
|
- DASHD_SECRET=${DASHD_SECRET:-}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dashd_data:
|
||||||
354
mail_proxy.py
Executable file
354
mail_proxy.py
Executable file
|
|
@ -0,0 +1,354 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
mail proxy for dashd - fetches emails via IMAP, sends via SMTP
|
||||||
|
endpoints:
|
||||||
|
POST /api/mail/inbox - list emails
|
||||||
|
POST /api/mail/read/<id> - get full email + thread
|
||||||
|
POST /api/mail/send - send reply
|
||||||
|
"""
|
||||||
|
|
||||||
|
import imaplib
|
||||||
|
import smtplib
|
||||||
|
import email
|
||||||
|
from email.header import decode_header
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class MailProxyHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
|
||||||
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
content_length = int(self.headers.get('Content-Length', 0))
|
||||||
|
body = self.rfile.read(content_length).decode('utf-8')
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
server = data.get('server', '')
|
||||||
|
user = data.get('user', '')
|
||||||
|
password = data.get('pass', '')
|
||||||
|
|
||||||
|
if self.path.startswith('/api/mail/inbox'):
|
||||||
|
limit = 10
|
||||||
|
if 'limit=' in self.path:
|
||||||
|
try:
|
||||||
|
limit = int(self.path.split('limit=')[-1].split('&')[0])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
messages = fetch_emails(server, user, password, limit)
|
||||||
|
self.send_json(messages)
|
||||||
|
|
||||||
|
elif self.path.startswith('/api/mail/read/'):
|
||||||
|
msg_id = self.path.split('/api/mail/read/')[-1].split('?')[0]
|
||||||
|
email_data = fetch_full_email(server, user, password, msg_id)
|
||||||
|
self.send_json(email_data)
|
||||||
|
|
||||||
|
elif self.path.startswith('/api/mail/thread/'):
|
||||||
|
msg_id = self.path.split('/api/mail/thread/')[-1].split('?')[0]
|
||||||
|
thread = fetch_thread(server, user, password, msg_id)
|
||||||
|
self.send_json(thread)
|
||||||
|
|
||||||
|
elif self.path == '/api/mail/send':
|
||||||
|
to = data.get('to', '')
|
||||||
|
subject = data.get('subject', '')
|
||||||
|
body_text = data.get('body', '')
|
||||||
|
in_reply_to = data.get('in_reply_to', '')
|
||||||
|
references = data.get('references', '')
|
||||||
|
result = send_email(server, user, password, to, subject, body_text, in_reply_to, references)
|
||||||
|
self.send_json(result)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.send_error(404, 'Not found')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def send_json(self, data, status=200):
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(data).encode())
|
||||||
|
|
||||||
|
def send_error(self, code, message):
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({'error': message}).encode())
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
|
||||||
|
|
||||||
|
def decode_mime_header(header):
|
||||||
|
if not header:
|
||||||
|
return ''
|
||||||
|
decoded = decode_header(header)
|
||||||
|
result = []
|
||||||
|
for part, charset in decoded:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
try:
|
||||||
|
result.append(part.decode(charset or 'utf-8', errors='replace'))
|
||||||
|
except:
|
||||||
|
result.append(part.decode('utf-8', errors='replace'))
|
||||||
|
else:
|
||||||
|
result.append(part)
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
def get_email_body(msg):
|
||||||
|
"""extract plain text body from email message"""
|
||||||
|
body = ''
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
content_disposition = str(part.get('Content-Disposition', ''))
|
||||||
|
if content_type == 'text/plain' and 'attachment' not in content_disposition:
|
||||||
|
try:
|
||||||
|
charset = part.get_content_charset() or 'utf-8'
|
||||||
|
body = part.get_payload(decode=True).decode(charset, errors='replace')
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
elif content_type == 'text/html' and not body and 'attachment' not in content_disposition:
|
||||||
|
try:
|
||||||
|
charset = part.get_content_charset() or 'utf-8'
|
||||||
|
html = part.get_payload(decode=True).decode(charset, errors='replace')
|
||||||
|
# strip html tags for plain display
|
||||||
|
body = re.sub('<[^<]+?>', '', html)
|
||||||
|
body = re.sub(r'\s+', ' ', body).strip()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
charset = msg.get_content_charset() or 'utf-8'
|
||||||
|
body = msg.get_payload(decode=True).decode(charset, errors='replace')
|
||||||
|
except:
|
||||||
|
body = str(msg.get_payload())
|
||||||
|
return body.strip()
|
||||||
|
|
||||||
|
def fetch_emails(server, user, password, limit=10):
|
||||||
|
messages = []
|
||||||
|
try:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
imap = imaplib.IMAP4_SSL(server, 993, ssl_context=context)
|
||||||
|
imap.login(user, password)
|
||||||
|
imap.select('INBOX')
|
||||||
|
|
||||||
|
status, data = imap.search(None, 'ALL')
|
||||||
|
if status != 'OK':
|
||||||
|
return messages
|
||||||
|
|
||||||
|
message_ids = data[0].split()
|
||||||
|
recent_ids = message_ids[-limit:] if len(message_ids) > limit else message_ids
|
||||||
|
recent_ids = list(reversed(recent_ids))
|
||||||
|
|
||||||
|
for msg_id in recent_ids:
|
||||||
|
status, msg_data = imap.fetch(msg_id, '(FLAGS RFC822.HEADER)')
|
||||||
|
if status != 'OK':
|
||||||
|
continue
|
||||||
|
|
||||||
|
flags_data = msg_data[0][0].decode() if msg_data[0][0] else ''
|
||||||
|
is_read = '\\Seen' in flags_data
|
||||||
|
|
||||||
|
header_data = msg_data[0][1]
|
||||||
|
msg = email.message_from_bytes(header_data)
|
||||||
|
|
||||||
|
from_addr = decode_mime_header(msg.get('From', ''))
|
||||||
|
if '<' in from_addr:
|
||||||
|
from_addr = from_addr.split('<')[0].strip().strip('"')
|
||||||
|
|
||||||
|
subject = decode_mime_header(msg.get('Subject', '(no subject)'))
|
||||||
|
date_str = msg.get('Date', '')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
dt = parsedate_to_datetime(date_str)
|
||||||
|
date_display = dt.strftime('%b %d')
|
||||||
|
except:
|
||||||
|
date_display = date_str[:10] if date_str else ''
|
||||||
|
|
||||||
|
messages.append({
|
||||||
|
'id': msg_id.decode(),
|
||||||
|
'from': from_addr[:30] + '...' if len(from_addr) > 30 else from_addr,
|
||||||
|
'subject': subject[:50] + '...' if len(subject) > 50 else subject,
|
||||||
|
'date': date_display,
|
||||||
|
'read': is_read
|
||||||
|
})
|
||||||
|
|
||||||
|
imap.logout()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"IMAP error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def fetch_full_email(server, user, password, msg_id):
|
||||||
|
"""fetch complete email with body"""
|
||||||
|
try:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
imap = imaplib.IMAP4_SSL(server, 993, ssl_context=context)
|
||||||
|
imap.login(user, password)
|
||||||
|
imap.select('INBOX')
|
||||||
|
|
||||||
|
status, msg_data = imap.fetch(msg_id.encode(), '(RFC822)')
|
||||||
|
if status != 'OK':
|
||||||
|
return {'error': 'message not found'}
|
||||||
|
|
||||||
|
raw_email = msg_data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw_email)
|
||||||
|
|
||||||
|
from_addr = decode_mime_header(msg.get('From', ''))
|
||||||
|
from_email = ''
|
||||||
|
if '<' in from_addr:
|
||||||
|
match = re.search(r'<([^>]+)>', from_addr)
|
||||||
|
if match:
|
||||||
|
from_email = match.group(1)
|
||||||
|
from_name = from_addr.split('<')[0].strip().strip('"')
|
||||||
|
else:
|
||||||
|
from_email = from_addr
|
||||||
|
from_name = from_addr
|
||||||
|
|
||||||
|
to_addr = decode_mime_header(msg.get('To', ''))
|
||||||
|
subject = decode_mime_header(msg.get('Subject', ''))
|
||||||
|
date_str = msg.get('Date', '')
|
||||||
|
message_id = msg.get('Message-ID', '')
|
||||||
|
references = msg.get('References', '')
|
||||||
|
in_reply_to = msg.get('In-Reply-To', '')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
dt = parsedate_to_datetime(date_str)
|
||||||
|
date_display = dt.strftime('%b %d, %Y at %I:%M %p')
|
||||||
|
except:
|
||||||
|
date_display = date_str
|
||||||
|
|
||||||
|
body = get_email_body(msg)
|
||||||
|
|
||||||
|
# mark as read
|
||||||
|
imap.store(msg_id.encode(), '+FLAGS', '\\Seen')
|
||||||
|
imap.logout()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': msg_id,
|
||||||
|
'from_name': from_name,
|
||||||
|
'from_email': from_email,
|
||||||
|
'to': to_addr,
|
||||||
|
'subject': subject,
|
||||||
|
'date': date_display,
|
||||||
|
'body': body,
|
||||||
|
'message_id': message_id,
|
||||||
|
'references': references,
|
||||||
|
'in_reply_to': in_reply_to
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"fetch error: {e}")
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
def fetch_thread(server, user, password, msg_id):
|
||||||
|
"""fetch email thread based on references/subject"""
|
||||||
|
try:
|
||||||
|
# first get the target message to find thread references
|
||||||
|
target = fetch_full_email(server, user, password, msg_id)
|
||||||
|
if 'error' in target:
|
||||||
|
return [target]
|
||||||
|
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
imap = imaplib.IMAP4_SSL(server, 993, ssl_context=context)
|
||||||
|
imap.login(user, password)
|
||||||
|
imap.select('INBOX')
|
||||||
|
|
||||||
|
thread = []
|
||||||
|
subject_base = re.sub(r'^(Re:\s*|Fwd:\s*)+', '', target['subject'], flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
|
# search by subject
|
||||||
|
search_subject = subject_base.replace('"', '\\"')[:50]
|
||||||
|
status, data = imap.search(None, f'SUBJECT "{search_subject}"')
|
||||||
|
|
||||||
|
if status == 'OK' and data[0]:
|
||||||
|
thread_ids = data[0].split()[-10:] # limit to 10 messages
|
||||||
|
for tid in thread_ids:
|
||||||
|
status, msg_data = imap.fetch(tid, '(RFC822.HEADER)')
|
||||||
|
if status != 'OK':
|
||||||
|
continue
|
||||||
|
|
||||||
|
header_data = msg_data[0][1]
|
||||||
|
msg = email.message_from_bytes(header_data)
|
||||||
|
|
||||||
|
from_addr = decode_mime_header(msg.get('From', ''))
|
||||||
|
if '<' in from_addr:
|
||||||
|
from_addr = from_addr.split('<')[0].strip().strip('"')
|
||||||
|
|
||||||
|
date_str = msg.get('Date', '')
|
||||||
|
try:
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
dt = parsedate_to_datetime(date_str)
|
||||||
|
date_display = dt.strftime('%b %d %I:%M %p')
|
||||||
|
timestamp = dt.timestamp()
|
||||||
|
except:
|
||||||
|
date_display = date_str[:16]
|
||||||
|
timestamp = 0
|
||||||
|
|
||||||
|
thread.append({
|
||||||
|
'id': tid.decode(),
|
||||||
|
'from': from_addr,
|
||||||
|
'date': date_display,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'current': tid.decode() == msg_id
|
||||||
|
})
|
||||||
|
|
||||||
|
imap.logout()
|
||||||
|
|
||||||
|
# sort by timestamp
|
||||||
|
thread.sort(key=lambda x: x.get('timestamp', 0))
|
||||||
|
return thread
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"thread error: {e}")
|
||||||
|
return [{'error': str(e)}]
|
||||||
|
|
||||||
|
def send_email(server, user, password, to, subject, body, in_reply_to='', references=''):
|
||||||
|
"""send email via SMTP"""
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['From'] = user
|
||||||
|
msg['To'] = to
|
||||||
|
msg['Subject'] = subject
|
||||||
|
|
||||||
|
if in_reply_to:
|
||||||
|
msg['In-Reply-To'] = in_reply_to
|
||||||
|
if references:
|
||||||
|
msg['References'] = references + ' ' + in_reply_to if in_reply_to else references
|
||||||
|
|
||||||
|
msg.attach(MIMEText(body, 'plain'))
|
||||||
|
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(server, 465, context=context) as smtp:
|
||||||
|
smtp.login(user, password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
return {'success': True, 'message': 'email sent'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"SMTP error: {e}")
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
port = 8087
|
||||||
|
server = HTTPServer(('0.0.0.0', port), MailProxyHandler)
|
||||||
|
print(f"mail proxy running on port {port}")
|
||||||
|
print("endpoints:")
|
||||||
|
print(f" POST /api/mail/inbox?limit=N")
|
||||||
|
print(f" POST /api/mail/read/<id>")
|
||||||
|
print(f" POST /api/mail/thread/<id>")
|
||||||
|
print(f" POST /api/mail/send")
|
||||||
|
server.serve_forever()
|
||||||
53
nginx.conf
Normal file
53
nginx.conf
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
worker_processes 1;
|
||||||
|
events { worker_connections 1024; }
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8085;
|
||||||
|
server_name localhost;
|
||||||
|
root /app/static;
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
return 301 /dashboard.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/auth {
|
||||||
|
proxy_pass http://127.0.0.1:8086;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/settings {
|
||||||
|
proxy_pass http://127.0.0.1:8086;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/mail {
|
||||||
|
proxy_pass http://127.0.0.1:8087;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /proxy {
|
||||||
|
proxy_pass http://127.0.0.1:8088;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:8086;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
PyJWT>=2.8.0
|
||||||
7
start.sh
Normal file
7
start.sh
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# start all services
|
||||||
|
|
||||||
|
python3 /app/backend.py &
|
||||||
|
python3 /app/mail_proxy.py &
|
||||||
|
python3 /app/browser_proxy.py &
|
||||||
|
nginx -g 'daemon off;'
|
||||||
Loading…
Reference in a new issue