mirror of
https://github.com/sudoxnym/dashd.git
synced 2026-04-14 11:36:23 +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